In the current, fast-changing JavaScript scene, there are two projects I am following more closely than the rest: Elm and Ember.js. Recently I had the opportunity to experiment with both at once, as I worked out how to use Glimmer (Ember.js’s rendering engine) as a view layer for Elm code.
This is a write up of my solution. It is actually divided in two parts:
- Using an external view layer with Elm.
- Messaging to/from a Glimmer application.
This will not be a comprehensive walkthrough, but rather will cover the most important details of how to build the “glue” between these two pieces. You can check out the complete example on Github if you want to know more or simply play with the result.
Using an external view layer with Elm
Elm’s own view layer
Something that can be surprising to Elm newcomers is how to work with its view layer. Instead of writing the HTML templates that are commonplace in other environments, developers and designers are expected to use the Elm language itself to describe the output. For example, where we normally would have something like the following:
<tr>
<td class="item-id">#{{item.id}}</td>
<td class="item-name">{{item.name}}</td>
</tr>
With Elm we would define the following function instead:
itemView : Item -> Html Msg
itemView item =
tr []
[ td [ class "item-id" ] [ text ("#" ++ item.id) ]
, td [ class "item-name" ] [ text item.name ]
]
This gives our templates the benefits of Elm’s compile-time type checking, helping us to identify many errors in our code immediately. On the other hand, it is a bit more verbose, arguably inconvenient, and it can be difficult to handle when converting existing HTML, or generally liaising with members of the team implementing the views.
Whether the tradeoff is acceptable or not is for your team to decide. It is worth pointing out though that Elm’s type checking is a major boon of the language, and you should not disregard it lightly.
Having said that, are there alternatives to this? Is it possible to work with Elm while keeping our “classic” HTML-based templates? The answer is yes. At least two options, actually.
Alternative view layers
The first option would be to write an Elm library to handle this. For example,
the core library elm-lang/html
implements Html.program
. This expects (among
other things) a view
argument that is the basis of Elm-based templates like
the one in the example above. A custom library would handle things differently,
capturing DOM events, notifying the update
function, and refreshing our view
layer when the model changes. We could call it foobar/template
and provide
Template.program
.
The problem with this approach is that it requires certain knowledge of Elm’s internal APIs, or at least enough to write a library of this type. I do not possess this knowledge, so I had to look for another, simpler option.
This second option is: instead of using Html.program
, completely eschew the
HTML library and create a “headless” program using
Platform.program
,
which takes the usual arguments except for view
. You can then communicate with
your view layer using Elm ports.
Example headless Elm program
Let’s try put something together. This would be an example Elm program without a view layer. It expects messages to come in through a port. When the model changes, the program will send it out via another port:
port module Main exposing (main)
import Json.Decode -- A bug in Elm forces us to explicitly require this
type alias Model =
Int
port increment : ( Int -> msg ) -> Sub msg
port reset : ( () -> msg ) -> Sub msg
port render : Model -> Cmd msg
type Msg
= Increment Int
| Reset
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ increment (\amount -> Increment amount)
, reset (\_ -> Reset)
]
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
let
newModel =
case msg of
Increment amount ->
model + amount
Reset ->
0
in
(newModel, render newModel)
main : Program Never Model Msg
main =
Platform.program
{ init = (0, Cmd.none)
, subscriptions = subscriptions
, update = update
}
This program exposes these ports:
render
(outbound): will be passed the model when this changes.reset
(inbound): will set the model to 0.increment Int
(inbound): will increment the model by the given amount.
We can now write some HTML and JS that load the above Elm program and interacts
with it. For a simple start, let’s just not render anything. We can use the
browser console for now. If the Elm code is compiled into a file called
elm.js
, we can load it as follows:
<html>
<head>
<title>Elm experiment</title>
</head>
<body>
<script type="text/javascript" src="elm.js"></script>
<script>
var elmApp = Elm.Main.worker();
window.elmApp = elmApp;
elmApp.ports.render.subscribe(function(model) {
console.log("RENDER", model);
});
function actionIncrement(value) {
elmApp.ports.increment.send(value);
};
function actionReset() {
elmApp.ports.reset.send(null);
};
actionReset();
</script>
</body>
</html>
We open this on a browser (as a file://
is ok) and get a beautiful,
exciting… blank page. But remember we were not rendering anything yet. We will
work with the console. Opening the inspector will reveal some output present
already: RENDER 0
. We can interact with the Elm program using the interface we
created. This is an example session:
RENDER 0
> actionIncrement(1)
RENDER 1
> actionIncrement(2)
RENDER 3
> actionIncrement(-7)
RENDER -4
> actionReset()
RENDER 0
We now have a clear interface to send messages to Elm, as well as for Elm to tell us about changes in the model. We can integrate this with any JavaScript code, including templating libraries. Time to wire one up.
Communication to/from a Glimmer application.
A basic Glimmer setup
I will not go into detail on how to build an Elm and a Glimmer applications side
to side on the same project. You can check the example app to see
a simple way of doing it. For now, let’s just assume we have a working project
where both applications are building correctly. At a minimum, it will involve
altering the HTML above with a change like the following, where app.js
is the
Glimmer code, which in turn expects a container element with id app
where
results will be rendered:
<body>
+ <div id="app"></div>
+
+ <script type="text/javascript" src="app.js"></script>
<script type="text/javascript" src="elm.js"></script>
Messaging
At the time of writing, standalone Glimmer (as opposed to integrated into Ember) is beta software and there are some outstanding issues. The one that is important to us now is that Glimmer does not provide a way for external code to communicate with its components: we cannot pass data or notify of changes that occurred in the outside.
This should be addressed at some point this year, and in fact there’s a pull request in the works as I write these lines. But until such time that this makes it into mainline Glimmer, there is a web API that we can use for this purpose.
To work around Glimmer’s limitation, I have been using
window.postMessage
successfully. If you are familiar with this Web API, you will know that it is
commonly used to send messages to other frames or windows in the app, but it can
also be used to send messages within the current window, to any code that may be
listening to them.
From Elm to Glimmer
Let’s see how it would work for us. First, from our HTML glue code we can send the model to the template when Elm notifies us of a change:
elmApp.ports.render.subscribe(function(model) {
- console.log("RENDER", model);
+ var msg = {
+ origin: 'core',
+ target: 'view',
+ action: 'update',
+ payload: { model },
+ };
+ window.postMessage(msg, window.location.origin);
});
Then, in the top-most component of our Glimmer app, we can set a listener for
postMessage
notifications:
import Component, { tracked } from '@glimmer/component';
export default class ModelDisplay extends Component {
@tracked model = null;
// ...other component code here...
didInsertElement() {
window.addEventListener('message', (evt) => {
if (evt.origin !== window.location.origin) {
return false;
}
let data = evt.data;
if (data.origin !== 'core' && data.target !== 'view') {
return false;
}
let { action, payload } = data;
if (action === 'update') {
this.model = payload.model;
}
});
}
}
This is enough to get a first render. If your Glimmer component has a representation for the initial model of the Elm app, it should appear on screen now.
From Glimmer to Elm
Next, we enable communications in the opposite direction: from the Glimmer
component to the Elm app. To do this, we use very similar code, only reversed.
For example, if our Glimmer component has two actions increment
and
decrement
, these will look like follows:
// ...
increment() {
this.postIncrement(1);
}
decrement() {
this.postIncrement(-1);
}
postIncrement(amount) {
let msg = {
origin: 'view',
target: 'core',
action: 'increment',
payload: { amount },
}
window.postMessage(msg, window.location.origin);
}
// ...
As you trigger the actions (pressing buttons or whatever is appropriate), the “glue” code will receive these messages. From there we will forward them to the Elm app through its inbound ports. The code will mirror the one receiving messages in the component:
window.addEventListener('message', function(evt) {
if (evt.origin !== window.location.origin) {
return false;
}
var data = evt.data;
if (data.origin !== 'view' && data.target !== 'core') {
return false;
}
var action = data.action;
if (action === 'increment') {
actionIncrement(data.payload.amount);
}
});
That is enough to get the increment/decrement actions going. Implementing the reset is very similar and left as an exercise to the reader ;-)
Performance
I wondered about how efficient this would be, sending these messages across using an asynchronous API and all. For a simple benchmark, I built a simple app that shows a large table and updates random rows at the push of a button. I actually wrote three different simplementations: pure Elm, pure Glimmer and hybrid. Check it out and compare yourself.
From what I can see, there is no significant difference between the Elm and hybrid versions, but the Glimmer-only version performs way better. But take all this with a pinch of salt anyway.