41

Tour of an Open-Source Elm SPA (updated for Elm 0.19!)

 6 years ago
source link: https://www.tuicool.com/articles/hit/QJZJz2I
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Back in May 2017, I made an open-source Elm SPA andwrote about it. The response has been super positive!

In the course of updating it for Elm 0.19, I spotted some opportunities to refactor it based on various Elm techniques I've learned since I first wrote it. Some of the changes were seriously impactful...so I thought I'd do a tour of the updated design!

Fair warning:This is not a gentle introduction to Elm. I built this to be something I'd like to maintain, and did not hold back. This is how I'd build this application with the full power of Elm at my fingertips.

If you're looking for a less drink-from-the-firehose introduction to Elm, I can recommend a book , a video tutorial , and of course the Official Guide .

Overview

Here's the link to the updated version:

elm-spa-example updated for Elm 0.19 .

If you're looking for the previous version, it's tagged as 0.18 .

Module Structure

I used to see modules as a tool for code organization, whereas now I see them primarily as a tool for creating guarantees. Thinking this way revealed some pretty great guarantees that I didn't have before, and the code organization followed naturally from pursuing those guarantees!

With few exceptions, each module ended up revolving around a particular data structure. I've been really happy following the advice in this talk, which I highly recommend:

:movie_camera: The Life of a File by Evan Czaplicki

Here's one useful guarantee I ended up with:

The only way to get a Cred value (short for “Authenticated Credentials”) is to do one of the following:

login
flags
port

Because the module containing type Cred exposes those three ways to obtain one, but not any other ways to obtain one, I know that any function that requires Cred as an argument only works if you are logged in!

This is especially useful on pages like the Profile page, which do different things depending on whether you're logged in. (For example, the Follow and Favorite buttons shouldn't be rendered.)

Routing for User Experience

I went with a routing design that optimizes for user experience. I considered three use cases, illustrated in this gif:

rY7jQnU.gif

The use cases:

  1. The user has a fast connection
  2. The user has a slow connection
  3. The user is offline

Fast Connection

On fast connections, I want users to transition from one page to another seamlessly, without seeing a flash of a partially-loaded page in between.

To accomplish this, I had each page expose init : Task PageLoadError Model . When the router receives a request to transition to a new page, it doesn't transition immediately; instead, it first calls Task.attempt on this init task to fetch the data the new page needs.

If the task fails, the resulting PageLoadError tells the router what error message to show the user. If the task succeeds, the resulting Model serves as the initial model necessary to render 100% of the new page right away.

No flash of partially-loaded page necessary!

Slow Connection

On slow connections, I want users to see a loading spinner, to reassure them that there's something happening even though it's taking a bit.

To do this, I'm rendering a loading spinner in the header as soon as the user attempts to transition to a new page. It stays there while the Task is in-flight, and then as soon as it resolves (either to the new page or to an error page), the spinner goes away.

For a bit of polish, I prevented the spinner from flashing into view on fast connections by adding a CSS animation-delay to the spinner's animation. This meant I could add it to the DOM as soon as the user clicked the link to transition (and remove it again once the destination page rendered), but the spinner would not become visible to the user unless a few hundred milliseconds of delay had elapsed in between.

Offline

I'd like at least some things to work while the user is offline.

I didn't go as far as to use Service Worker (or for that matter App Cache, for those of us who went down that bumpy road ), but I did want users to be able to visit pages like New Post which could be loaded without fetching data from the network.

For them, init returned a Model instead of a Task PageLoadError Model . That was all it took.

Module Structure

bMBzMfB.png!web

We have over 100,000 lines of Elm code in production at NoRedInk , and we've learned a lot along the way! (We don't have a SPA, so our routing logic lives on the server, but the rest is the same.) Naturally every application is different, but I've been really happy with how well our code base has scaled, so I drew on our organizational scheme when building this app.

Keep in mind that although using exposing to create guarantees by restricting what modules expose is an important technique (which I used often here), the actual file structure is a lot less important. Remember, if you change your mind and want to rename some files or shift directories around, Elm's compiler will have your back. It'll be okay!

Here's how I organized this application's modules.

The Page.* modules

Examples: Page.Home , Page.Article , Page.Article.Editor

These modules hold the logic for the individual pages in the app.

Pages that require data from the server expose an init function, which returns a Task responsible for loading that data. This lets the routing system wait for a page's data to finish loading before switching to it.

The Views.* modules

Examples: Views.Form , Views.Errors , Views.User.Follow

These modules hold reusable views which multiple Page modules import.

Some, like Views.User , are very simple. Others, like Views.Article.Feed , are very complex. Each exposes an appropriate API for its particular requirements.

The Views.Page module exposes a frame function which wraps each page in a header and footer.

The Data.* modules

Examples: Data.User , Data.Article , Data.Article.Comment

These modules describe common data structures, and expose ways to translate them into other data structures. Data.User describes a User , as well as the encoders and decoders that serialize and deserialize a User to and from JSON.

Identifiers such as CommentId , Username , and Slug - which are used to uniquely identify comments, users, and articles, respectively - are implemented as union types. If we used e.g. type alias Username = String , we could mistakenly pass a Username to an API call expecting a Slug , and it would still compile. We can rule bugs like that out by implementing identifiers as union types.

The Request.* modules

Examples: Request.User , Request.Article , Request.Article.Comments

These modules expose functions to make HTTP requests to the app server. They expose Http.Request values so that callers can combine them together, for example on pages which need to hit multiple endpoints to load all their data.

I don't use raw API endpoint URL strings anywhere outside these modules. Only Request.* modules should know about actual endpoint URLs.

The Route module

This exposes functions to translate URLs in the browser's Location bar to logical "pages" in the application, as well as functions to effect Location bar changes.

Similarly to how Request modules never expose raw API URL strings, this module never exposes raw Location bar URL strings either. Instead it exposes a union type called Route which callers use to specify which page they want.

The Ports module

Centralizing all the ports in one port module makes it easier to keep track of them. Most large applications end up with more than just two ports, but in this application I only wanted two. See index.html for the 10 lines of JavaScript code they connect to.

At NoRedInk our policy for both ports and flags is to use Value to type any values coming in from JavaScript, and decode them in Elm. This way we have full control over how to deal with any surprises in the data. I followed that policy here.

The Main module

This kicks everything off, and calls Cmd.map and Html.map on the various Page modules to switch between them.

Based on discussions around how asset management features like code splitting and lazy loading have been shaping up, I expect most of this file to become unnecessary in a future release of Elm.

The Util module

These are miscellaneous helpers that are used in several other modules.

It might be more honest to call this Misc.elm . :sweat_smile:

Other Considerations

  • With server-side rendering, it's possible to offer a better user experience on first page load by using cookie-based authentication. There aresecurity risks on that path, though!
  • If I were making this from scratch, I'd use elm-css to style it. However, since Realworld provided so much markup, I ended up using html-to-elm to save myself a bunch of time instead.
  • There's a beta of elm-test in progress, and I'd like to use the latest and greatest for tests. I debated waiting until the new elm-test landed to publish this, but decided that even in its untested form it would be a useful resource.

I hope this has been useful to you!

And now, back to writing another chapter of Elm in Action . :wink:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK