TodoMVC with Mithril
This page walks through the TodoMVC variant written with Mithril, a JavaScript library that knows nothing of Amber. It shows the same model as TodoMVC with Silk. The view is one Mithril component, written in Amber, and Mithril’s router puts it on the page. The variant is the guide to a plain npm library in an Amber project, as the libraries page starts it. This page shows what differs from the Silk variant, and what crosses the boundary between the two languages.
Taking Mithril in
Mithril is on npm. Install it, and tell the loader where its script is:
npm install mithril --save
{
"paths": {
"mithril": "mithril"
},
"shim": {
"mithril": {
"exports": "m"
}
}
}
This is mithril.amd.json, in the root of the project. Mithril’s mithril.js is no module. In a browser it sets window.m, so the shim says that the module exports m. grunt devel puts both into config.js. The package then imports the module, bound to a name:
Package
named: 'TodoMVC-Mithril'
imports: {'m' -> 'mithril'. 'amber-todomvc/TodoMVC-Model'}
transport: (AmdPackageTransport namespace: 'amber-todomvc')
The key of the association is the name, and the value is the module. m is Mithril’s own name for itself, so the code reads as Mithril code does. The test runner under Node.js loads no browser script. So Gruntfile.js gives it an empty mithril module, as it gives it an empty jquery. grunt deploy:mithril puts mithril.js into the bundle, which is 459 KB.
The page
mithril.html has an empty .todoapp section:
<section class="todoapp"></section>
The view renders the whole markup of the template into it. So the page holds none of it, and nothing has to be hidden before Amber starts. The rest of the page is as silk.html, with MithrilTodoApp._start().
The view
The view is the package TodoMVC-Mithril, with one class, MithrilTodoApp. It keeps the todos under todos-amber-mithril. It has three slots. todoList is the TodoList, root the element it is mounted on, and editing the todo being edited, or nil.
Virtual nodes
Mithril builds the page from virtual nodes, and m makes one: m("li", attrs, children). m is a function, so in Amber it is a block, and value:value:value: calls it. The selector carries the tag, its classes, its id and its type. The attributes are a #{ } literal, which is a plain JavaScript object, and a handler is a block:
main
^ m value: 'section.main' value: #{ 'hidden' -> todoList isEmpty } value: {
m value: 'input#toggle-all.toggle-all[type=checkbox]' value: #{
'checked' -> todoList allAreCompleted.
'onchange' -> [ :event | self markAll: event target checked ] }.
m value: 'label[for=toggle-all]' value: 'Mark all as complete'.
m value: 'ul.todo-list' value: (todoList shownTodos collect: [ :each | self item: each ]) }
view answers an array of three such nodes, the header, the main section and the footer. The header holds the heading and the input, with onkeydown bound to addOnEnter:. The footer holds the counter, the three links and the button. The hiding is an attribute, hidden, from the model’s answer. Mithril compares the new nodes with the old ones and changes the elements that differ.
The links come from the model. TodoFilter filters answers the three filters, and each has a route and a name:
filterLink: aTodoFilter
^ m value: 'li' value: (m value: 'a' value: #{
'href' -> aTodoFilter route.
'class' -> (aTodoFilter == todoList filter ifTrue: [ 'selected' ]) asJavaScriptObject } value: aTodoFilter name)
The class is selected or nothing. The nothing goes as nil asJavaScriptObject, for a reason the section on the boundary gives.
Mounting
A component is an object with a view function. Mithril mounts it on an element, and a test does that:
component
^ #{ 'view' -> [ self view ] }
attachTo: anElement
root := anElement.
self mithril mount: anElement value: self component
mithril answers JSObjectProxy on: m. A block understands Amber’s protocol alone. So the properties of m, such as m.mount, are reached through a proxy, as the JavaScript page says. mount:value: calls m.mount with the two arguments. The page mounts through the router instead:
routeIn: anElement
root := anElement.
(self mithril at: 'route') prefix: '#'.
self mithril route: anElement value: '/' value: #{ '/' -> self routeResolver. '/:filter' -> self routeResolver }
routeResolver
^ #{
'onmatch' -> [ :params :path | self routeTo: path ].
'render' -> [ self view ] }
m.route takes the element, the default route and the routes. Each route has a resolver. Its onmatch gets the path, /active say, and routeTo: sets the filter from it, through TodoFilter fromRoute:. Its render answers the view. The prefix is #. So the links of the template, #/active and the others, are the routes, and a reload keeps the filter. Mithril listens to the URL itself.
m.route is a function with properties of its own, prefix among them. A unary message to a proxy calls a property that is a function, so self mithril route would call m.route(). at: reads the property without calling it. What it answers is a function, and in Amber a block, which does not understand prefix:. So the class opts m.route and m.redraw out of Smalltalk once, when the package loads:
initialize
| mithril |
PlatformDom isFeasible ifTrue: [
mithril := JSObjectProxy on: m.
Smalltalk
optOut: (mithril at: 'route');
optOut: (mithril at: 'redraw') ]
From then on the two take messages as objects do, and prefix: sets m.route.prefix. Advanced JavaScript interop says what optOut: does. m itself stays a block, called for every node. The guard keeps the package loading under Node.js, where the test runner has no Mithril. The package imports amber/core/Platform-DOM for it.
Redrawing at once
Mithril redraws after a handler it bound, on the next animation frame. The view redraws at once instead, after every change:
toggle: aTodo
todoList toggle: aTodo.
self redraw
redraw
(self mithril at: 'redraw') sync
So a test reads the page right after an event, as it does for the other variants. The redraw that Mithril schedules then finds nothing to change.
One todo
item: builds the list item of the template. The todo’s id is its key, so that Mithril keeps the element of each todo as the list changes:
item: aTodo
^ m value: 'li' value: #{ 'key' -> aTodo id. 'class' -> (self itemClass: aTodo) asJavaScriptObject } value: {
m value: 'div.view' value: {
m value: 'input.toggle[type=checkbox]' value: #{
'checked' -> aTodo isCompleted.
'onchange' -> [ self toggle: aTodo ] }.
m value: 'label' value: #{ 'ondblclick' -> [ self startEditing: aTodo ] } value: aTodo title.
m value: 'button.destroy' value: #{ 'onclick' -> [ self remove: aTodo ] } }.
m value: 'input.edit' value: #{
'onupdate' -> [ :vnode | self focusEdit: vnode dom of: aTodo ].
'onblur' -> [ :event | editing == aTodo ifTrue: [ self finishEditing: aTodo with: event target value ] ].
'onkeydown' -> [ :event |
event key = 'Enter' ifTrue: [ self finishEditing: aTodo with: event target value ].
event key = 'Escape' ifTrue: [ self stopEditing ] ] } }
itemClass: answers completed, editing, both, or nil. The bindings do what those of SilkTodoItem do, and each ends in a redraw.
Editing
The item being edited is state of the view, in editing. startEditing: sets it and redraws, and the item gets the class editing. The input then needs the title and the focus. onupdate is a lifecycle method of Mithril: it runs after each redraw, with the node, whose dom is the element:
focusEdit: anInput of: aTodo
editing == aTodo ifFalse: [ ^ self ].
anInput = document activeElement ifTrue: [ ^ self ].
anInput value: aTodo title.
anInput focus
The input of the todo being edited gets the title and the focus once. It keeps what is typed through later redraws. finishEditing:with: clears editing, retitles and redraws. stopEditing clears it and redraws, for Escape. A blur saves only while editing is this todo. The redraw after Enter takes the class off the item, and the stylesheet hides the input. The blur that follows finds editing nil.
What crosses the boundary
Four things went from Amber to Mithril, and each has a rule:
- A function is a block.
mis called withvalue:value:value:. Its own properties are reached throughJSObjectProxy on: m. - A function used as an object is opted out.
m.routeandm.redraware read withat:, which calls nothing.Smalltalk optOut:opts them out. Thenprefix:andsyncreach them as messages. - A
#{ }literal is a plain object. The attributes, the component and the route resolver are such literals. Their functions are blocks. nilis notnull. Amber’s nil is an object of its own. Mithril takesnullas an attribute left out, and as no component to mount. Amber’s nil it takes as a value. An attribute that isselectedor nothing is(...) asJavaScriptObject, which answers the string ornull.detachunmounts withnil asJavaScriptObject. Without the conversion, an unselected link gets the class[object Object]. The unmount raises “m.mount expects a component, not a vnode”.
What came back from Mithril needed nothing. The event in a handler and the node in onupdate are JavaScript objects, which answer their properties as messages.
What each point costs
The table of TodoMVC with Silk, for this variant:
| The specification says | Where it is | What it takes |
|---|---|---|
| Hide main and footer with no todos | main, footer |
one hidden attribute each |
| Focus the input on load | header |
one oncreate |
| Enter adds, trimmed, empty ignored | header, addOnEnter:, TodoList >> add: |
one handler; the model trims |
| Mark all as complete | main, markAll: |
one checked attribute, one handler |
| Mark all cleared after Clear completed | main, TodoList >> allAreCompleted |
nothing; the model answers false for no todos |
Toggle, class completed |
item:, itemClass:, toggle: |
one handler, one class |
| Destroy button on hover | item:, remove: |
one handler; the stylesheet shows the button |
| Double-click edits | item:, startEditing:, focusEdit:of: |
one handler, one lifecycle method |
| Enter or blur saves, Escape cancels, empty destroys | item:, finishEditing:with:, stopEditing, TodoList >> retitle:to: |
two handlers, one guard; the model removes the todo |
| The counter, item or items | footer, TodoList >> activeCountLabel |
two nodes; the model words it |
| Clear completed, hidden when none | footer, clearCompleted |
one hidden attribute, one handler |
Persistence under todos-amber-mithril |
start, TodoStore |
one key; the model saves |
Routing, class selected, kept on reload |
routeIn:, routeResolver, routeTo:, filterLink: |
Mithril’s router, one fromRoute: |
The view has no rendering methods and no bindings, in the sense of the other variants. It answers the page from the model, and Mithril does the rest. The cost moved to the boundary. It is three asJavaScriptObject, value:value:value: on every node, a proxy on m, and two functions opted out.
The tests of the view
MithrilTodoAppTest, in TodoMVC-Mithril-Tests, answers attach:to: with a MithrilTodoApp mounted on the element, without the router. Mithril keeps every mounted element, so the test unmounts the app in tearDown, with detach. The 25 tests of TodoAppTest are the same as for the other variants. They drive the elements that Mithril rendered into the fixture. They read the page right after each event, since the view redraws at once.
Running it
Open http://localhost:4000/mithril.html, and try it as TodoMVC with Silk says. The todos are in localStorage under todos-amber-mithril. The test runner of Helios runs the 25 tests of MithrilTodoAppTest on the page.