bs-virtualdom

1.0.5 • Public • Published

Virtual DOM

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.

License: MIT

Table of contents

Introduction

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:

  • h function to create virtual nodes (or vnodes for short).
  • component function to create independent, reusable components.
  • thunk function to cache directives.
  • wedge function to insert multiple directives.
  • keyed function to insert multiple keyed directives.
  • a number of directives that adds attributes, classes, events, properties, and styles to a virtual node.

In addition, mount is what you use to attach the main view function to an existing DOM element.

Below is an increment/decrement app. We've also implemented the To-Do App – see source code.

open Virtualdom
 
type message =
  Delay of message * int
| Decrement
| Increase
 
let update state notify = function
  Delay (message, msecs) ->
  Js.Global.setTimeout (fun () -> notify message) msecs |. ignore;
  state
| Decrement -> state - 1
| Increment -> state + 1
 
let 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 in
  match Document.getElementById "container" document with
    Some 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 <div id="container"></div>.

The onClick handler receives a native JavaScript browser event (unused in the example, hence the underscore) and returns a message which is a type variable that's bound by the update function (in the example this is the message type).

The 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.

Creating elements

The 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.

Using keys

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 keyed function.

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.

Hooks

  • onInsert

    The onInsert hook 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.

Directives

You can mix and match directives; some affect the element itself such as attr and className while h and text add child nodes:

Directive Description Example
attr Sets an attribute attr "href" "#"
className Sets a class name className ("field-" ^ name)
component Inserts a component component view handler state
cond Conditionally use directive cond (className "hidden") hidden
h Adds child (See example above.)
keyed Inserts multiple, keyed directive keyed children
maybe Optionally use directive maybe f option
prop Sets a property prop "value" "42"
removeTransition Adds a remove transition stage removeTransition directives
style Sets a style style ~important:true "border" "none"
text Adds a text node text "Hello"
thunk Caches a directive thunk f 42
wedge Inserts multiple directives wedge children

Additional documentation can be found in the module signature.

Events

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 in
    Some (
      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.

Transitions

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.)

The 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.

Structuring applications

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

Components are added using the component directive.

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 'c into '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: int, float, string, bool – these all behave exactly as you would imagine.
  • Lists, records, and objects – these are compared using reference equality. In Javascript, this is the === 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.

Thunks

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.

For example:

let renderItem item =
  h "div" [|
    className ("item-" ^ item.name);
    wedge (renderSubItems item.children)
  |]
 
let view state =
  h "div" (
    Array.map (thunk renderItem) state.items
  )

The renderItem function is defined outside the rendering loop.

Static nodes

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.

FAQ

  1. Why isn't onClick getting 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 ParentNode.prepend method.

Package Sidebar

Install

npm i bs-virtualdom

Weekly Downloads

8

Version

1.0.5

License

MIT

Unpacked Size

70.6 kB

Total Files

13

Last publish

Collaborators

  • malthe