A virtual DOM library written in OCaml/BuckleScript with a focus on ease-of-use, immutability and performance.
It's got a small footprint. Just over 7KB compressed.
Table of contents
The library was designed from the ground up to support a functional programming model based on immutability. The user provides input and you translate that to messages which are then processed in a central update handler to feed a new state to the rendering loop.
You'll have to try hard to shoot yourself in the foot!
The library provides the following building blocks which are also directives:
hfunction to create virtual nodes (or vnodes for short).
componentfunction to create independent, reusable components.
thunkfunction to cache directives.
wedgefunction to insert multiple directives.
keyedfunction to insert multiple keyed directives.
- a number of directives that adds attributes, classes, events, properties, and styles to a virtual node.
mount is what you use to attach the main view function to an existing DOM element.
open Virtualdomtype message =Delay of message * int| Decrement| Increaselet update state notify = functionDelay (message, msecs) ->Js.Global.setTimeout (fun () -> notify message) msecs |. ignore;state| Decrement -> state - 1| Increment -> state + 1let button title message =h "button" [|text title;onClick (fun _ -> Some message)|]let view state = [|h "p" [|text "Number: ";text (string_of_int state)|];button "Increase" Increment;button "Decrease" Decrement;button "Increase (after 1 second)" (Delay (Increase, 1000))|]let () =let open Webapi.Dom inmatch Document.getElementById "container" document withSome target ->let _ = mount target view update 0 in ()| None -> ()
In this example, the model is simply an integer counter, initially set to
0 (the last parameter in the
mount call). In a real application, this would be a more complex value. We're assuming that the document has an element with the id
container in it such as
update function will often have to deal with asynchronous logic. This is illustrated in the example with the
Delay message. It sets up a timer and when it finishes, uses the
notify method to hook back into the event loop.
h function creates a vnode from a selector such as
"div#main.app-like" and an array of child directives.
val h :?namespace:string ->?onInsert:(Dom.element -> 'a option) ->?onRemove:(Dom.element -> Dom.element -> unit) ->string ->'a directive array ->'a directive
The type variable
'a is the message type (see the example in the introduction). The return value is a virtual node, but it's also a directive in its own right. This is how we add child nodes:
let greeting =h "div" [|h "span" [|text "Hello, ";h "em" [| text "Zaphod Beeblebrox" |]|]|]
An overview of the directives is presented in the next section, but it's important to understand how virtual nodes become real DOM elements and how they're kept in sync.
This library uses a reconciliation algorithm similar to React, also known as "patch and diff". Basically, the library matches the old, attached tree with the new, detached tree and makes the required changes. Ideally, the minimum amount of changes required, but the algorithm is rather simple. To match an old node with a new one, it lines up the arrays of directives and makes at most one comparison. What this effectively means is that we need a special mechanism to deal with reorderings.
In a situation where we're reordering children and/or adding and removing them, we need to equip the patch and diff algorithm with a unique key for each child. The algorithm will still apply the reconciliation algorithm to keyed child nodes, but it will be able to do so without removing and creating the elements from scratch (why slows down our app and causes unnecessary reflowing.)
This mechanism is activated through the use of the
val keyed : (Js.Dict.key * 'a directive) array -> 'a directive
It's like a wedge, but lets you specify a string for each directive (typically a vnode). This string is then used as the key in a lookup table in order to (possibly) locate the old directive and match it with the new one.
onInserthook is called immediately after the patch cycle when an element is created and inserted into the DOM. The return value is an optional message. This is useful for example to start up an asynchronous initialization routine such as loading external data or starting an animation effect.
You can mix and match directives; some affect the element itself such as
text add child nodes:
||Sets an attribute||
||Sets a class name||
||Inserts a component||
||Conditionally use directive||
||Adds child||(See example above.)|
||Inserts multiple, keyed directive||
||Optionally use directive||
||Sets a property||
||Adds a remove transition stage||
||Sets a style||
||Adds a text node||
||Caches a directive||
||Inserts multiple directives||
Additional documentation can be found in the module signature.
The library comes with functions to bind to the most commonly used browser events.
val onClick :?passive:bool ->(Dom.mouseEvent -> 'a option) -> 'a t
They're named exactly like their browser counterpart except for the camel-casing. In order to actually pull out information from the events, you can use bs-webapi which is already pulled in as a dependency of this library.
The return value of the event handler is
'a option since not all browser events need to become user interface events. For example, if you're listening to the
keypress event, then you're probably only interested in a subset of key codes.
onClick (fun event ->let open Webapi.Dom inSome (Clicked (MouseEvent.target event |. EventTarget.unsafeAsElement))))
The example above assumes that
Clicked of Dom.element is a message that's understood by the update function.
Note that the event system uses the bubbling nature of browser events and attaches event listeners only to the mounted root element (lazily, when required). It uses an internal dispatching system to invoke the matching event handlers defined in the virtual tree.
In addition, the
mount function takes an optional
onPatch argument which gives an opportunity to react to DOM changes resulting from a rendering pass.
The library comes with support for staged remove transitions. Normally, the patch and diff algorithm simply removes elements that are no longer in the tree, but to improve the user experience, we often want to stage a transition first (or possibly multiple.)
removeTransition directive is bound to the internal event of an element being removed from the tree. The directive is similar to
wedge except it's only activated when the element is removed:
let node = h "div" [|text "Hello world";removeTransition [|style "opacity" "0";style "transition" "opacity 1.5s ease-out"|]|]
The element will be removed from the document only when the transition ends. In the case of multiple style properties, it's possibly to be explicit and provide a
~name argument, e.g.
~name:"opacity". The remove handler will then listen to specifically the end of a transition for the
opacity style property.
It's possible to nest
removeTransition directives to stage multi-layered transitions.
In a Virtual DOM application you'll usually have just one mounted tree. Thus, it's important to get the application structure right and use composition techniques to split up the codebase into logical modules.
There is some controversy on what's the right way to program this sort of application. The basic premise of the system is that an event always has to trickle up the tree in order to propagate changes in the other direction. But locally, where the event is fired, we often don't want the code to know too much about what's further up the tree. In other words, it's turtles all the way down, but each turtle shouldn't have to deal with the turtles before it.
Using components, we can hook into the event stream and use local state to transform a more specialized event into a more generic event. And conversely, a component also lets us specialize the data model in the other direction.
In addition, we get caching for free because of immutability.
Components are added using the
val component : ('b -> 'c directive) -> ('c -> 'a) -> 'b -> 'a directive
That is, a component is a directive of type
'a which takes a view function returning a directive of type
'c, a handler function that does one-way translation from
'a, and finally, a state parameter.
Using the "same input, same output" philosophy, the component is only evaluated when the input changes. Note that the view function must remain constant.
A note on equality
When are two values “the same” though?
- The basic types are easy:
bool– these all behave exactly as you would imagine.
===operator, which as you probably know has virtually no performance cost.
- Arrays and tuples – these are compared item by item using the same set of rules, recursively. Note that in both cases, we'll only attempt to make a comparison if the lengths are equal in the first place.
The strangely named
thunk directive is a simple caching mechanism.
val thunk : ('b -> 'a directive) -> 'b -> 'a directive
If the cache key (denoted by the type variable
'b) changes (strict equality), the view function is called. The
thunk directive is similar to Elm's Html.Lazy module.
The main difference between thunks and components is that components are able to lift the message type. Like components, the view function must remain constant.
let renderItem item =h "div" [|className ("item-" ^ item.name);wedge (renderSubItems item.children)|]let view state =h "div" (Array.map (thunk renderItem) state.items)
renderItem function is defined outside the rendering loop.
Using static nodes is the most simple way of optimizing your view functions. Simply predefine nodes outside of the rendering loop to avoid dynamic allocation altogether.
let button title message =h "button" [|text title;onClick (fun _ -> Some message)|]let signupButton = button "Signup" SignupClicked
When you're using this statically allocated signup button in your view code, the library knows that it can safely skip over it when patching.
onClickgetting called? Perhaps you've already detached the corresponding vnode from the tree. This would prevent the dispatcher from locating the event handler. Note that a remove transition does not prevent an element from being detached from the parent tree.
Possible related answer:
Note that in order to use this library, you may need to bundle a polyfill for the