Eason

React-less Virtual DOM with Snabbdom : – Yassine Elouafi – Medium

Eason · 2017-06-12推荐 · 36阅读 原文链接

React was a true addition to the JavaScript community. While one can find JSX — the HTML like syntax inside JavaScript — controversial. It is not the same for the Virtual DOM concept.

For the non familiar, Virtual DOM is a simplified memory representation of the real DOM state at a certain moment. The idea is : instead of directly updating the real DOM with imperative statements, you build a virtual DOMeach time your UI state changes, and the underlying library updates the physical DOM accordingly

It’s important to note that the update operation doesn’t replace the whole DOM tree (like setting innerHTML with an HTML string), but only replaces the parts of the DOM that actually changed (modify Node properties, adding a child element…). i.e. incremental updates are inferred by diffing the old and the new Virtual DOM versions., then patching the real DOM to reflect the new version.

The Virtual DOM concept has been often emphasized for its speedy performance. But there is also an equally important — if not more important — property. The concept makes it possible to represent the UI as a function of its state. This makes it possible to explore new ways of writing web applications.

In this post we examine how the concept of Virtual DOM fits in a web application. W’ll start by simple examples then present an architecture to write virtual DOM based applications (hint: if you love pure functions, you won’t be deceived).

For that, we will opt for a standalone and plain JavaScript (no JSX) virtual DOM library, because we need to examine the smallest basic puzzles.

There are a few good standalone virtual DOM libraries around, for my post i will use snabbdom (paldepind/snabbdom), a small lib written by Simon Friis Vindum. But the examples can be implemented in any other similar lib like the Matt Esch’s virtual-dom.

Quick snabbdom tutorial

Snabbdom is provided as a set of CommonJs modules. So we need a client side module bundler like browserify or webpack.

First let’s examine how to bootstrap a basic application

Above we ‘init’ the core snabbdom module with a set of extensions. In snabbdom, functionalities like toggling classes, styles and even setting properties on DOM elements are delegated to separate modules. For our case, we will just use the default modules.

The core module exposes only one function ‘patch’, which is returned by the init method. We use it to create the initial DOM as well as updating it later.

Here is what a basic Hello example looks like in snabbdom

‘h’ is a helper function to create virtual nodes. W’ll discover the usage in the rest of this post, for now just remember it takes 3 inputs:

  1. a selector like ‘‘div#id.class’

  2. an optional data object which specifies properties of the virtual node (class, styles, events …) as w’ll see later.

  3. Optionally, a string or an array of child virtual nodes.

The first time, patch expect a placeholder DOM element and an initial virtual node. It then creates the initial DOM tree that mirrors the virtual one. In subsequent calls, we provide it with the previous and the new virtual nodes. It then diffs the 2 virtual nodes and patches the DOM with the necessary modifications to take it into a new state that mirrors the new virtual representation

In order to get things up quickly, I created a GitHub repository with the all basic stuff to start right away. So the first step is to clone the repository at (yelouafi/snabbdom-starter). After that install the dependencies with ‘npm install’. The kit uses Browserify for browser bundling, Watchify for automatic rebuilds on file change, and Babel to allow us writing nice ES6 code and transpile it to ES5 compatible code.

After installing type

npm run watch

This will start the Watchify module, which will create the browser bundle ‘build.js’ inside the ‘app’ folder. The module will also watch for changes we make in JavaScript files and rebuild the bundle automatically (for manual builds you can type ‘npm run build’).

To run the application, open the ‘app/index.html’ file in the browser. Normally, you should see a ‘Hello world’ message on the screen.

All the examples w’ll see in this post can be found on the GitHub repo, each example is implemented in a specific branch, w’ll link to each branch throughout the post; the README file of the repo contains links to all branches.

A dynamic view

Sources for this example in the dynamic-view branch

To highlight the dynamic aspect of the Virtual DOM, w’ll build a very simple clock. Change ‘app/js/main.js’ as below

The virtual node construction has been delegated to a separate function ‘view’, which takes as input the current state (the current date).

The example illustrates a typical pattern in virtual DOM based applications. At the very low level, the application behavior consists of constructing new virtual trees successively at different moments of interest (timer, user events, server events…) and then patching the actual DOM with the changes compared to the latest virtual node. In our example, we construct a new virtual tree every second and update the DOM with it.

Event reactivity

Sources for this example are in the event-reactivity branch

The next example introduces event handling through a basic greeting app

In snabbdom, we set element’s properties through the ‘props’ object, which get handled by the ‘props’ module. Similarly we set event listeners through the ‘on’ object which get handled by the ‘eventlisteners’ module.

In the above example, the ‘update’ function plays the same rule as ‘setInterval’ from the previous example : it constructs a new virtual tree, using the data extracted from the incoming event, then calls patch to update the DOM with the new created tree.

Structuring complex applications

The benefit of using a standalone virtual DOM library is that we become free to structure our application the way we like. You can adopt the old good MVC pattern, or opt for a ‘more modern’ architecture like Flux.

In this post i’ll present a less-known architectural pattern I’ve encountered in Elm (an FP language that compiles to JavaScript). The pattern is known among Elm developers as Elm Architecture, so w’ll stick to that name in the rest of this post. The main advantage is that it allows us to write the whole application as a set of pure functions.

But before, w’ll take a moment to reason about the overall behavior of our example above. In this process w’ll take some inspiration from this excellent talk of André Staltz’s.

The main loop

Let’s examine the overall process of the last example :

  1. evaluate the ‘view’ function with an initial state and construct our first virtual node. Within the ‘view’ function, a listener is attached to the ‘input’ event of the text input

  2. patch the DOM with the new virtual node; attaching BTW the input event listener to the real DOM

  3. waiting for user action : ticktock, ticktock…

  4. the user finally types something, triggering the input event which calls the ‘update’ function

  5. within ‘update’ we update the state (by constructing a new one)

  6. we evaluate again the view function, passing it the new state constructed by the handler (same as step 1)

  7. calling ‘patch’ again will repeat the above process again (same as step 2)

The steps above describe a cyclical process. By getting rid of the implementation details we can establish an abstract sequence of function calls.

‘user’ is an abstraction of user interactions. What we get is a circular sequence of function calls. Note that the ‘user’ function is asynchronous, otherwise w’ll have an infinite recursion.

Let’s turn the above process into real code

The ‘main’ function mirrors the cyclic process described above : given an initial state (our initial data), a DOM element and a top component (a pair view+update), ‘main’ evaluates the top component’s view function to construct a new virtual node from the current state, then patches the DOM with that virtual node.

Note the parameters passed to the ‘view’ function : first the current state and second the main event handler : a callback that handles events from the generated view. The handler is responsible for constructing a new state for the application and restarting the UI cycle with the new data (state + virtual node).

Construction of the new state is delegated to the top component’s ‘update’ function which is a simple (and pure) function : at each moment, given a current state and a current program input (event/action), it returns a new state for our application.

Note also that — beside the side effect in the patch method call — the main function has no mutated state.

The main function is somewhat similar to the ‘main’ event loop of low level GUI frameworks. The point here is to take back the control over the UI event dispatching process : in its actual state, the DOM API forces us into an Event Driven paradigm by adopting the Observer pattern. But we don't want to use the Observer pattern here as w’ll see in a moment.

Elm architecture

In an Elm-architecture based program, the basic building blocks are a set of modules, or, let’s say components. Each component has 2 basic functions : ‘update’ and ‘view’, along with a specific data contract : the ‘model’ type owned by the component and a set of actions that updates instances of that model.

  1. ‘update’ is a pure function that takes 2 arguments : the current state which is an instance of the ‘model’ owned by the component and the current ‘action’ which describes the update operation that needs to take place. It returns a new ‘model’ instance.

  2. ‘view’ takes also 2 inputs : the current ‘model’ instance and an event channel which can be any mechanism by which events are propagated, in our examples w’ll use a simple callback function (the ‘handler’ argument). The function then returns a new virtual node that will get applied to the real DOM.

As said above, Elm architecture walks away from the traditional Observer pattern that characterize typical event driven programs. Instead, the architecture favors a centralized model of dispatching (like in React/Flux), where any event : 1- bubbles up to the top component, 2- then tunnels down through a tree like hierarchy of components. During this phase, a component can choose to handle the action itself and/or forwards it to one or all of its children.

Another key property of the architecture is that the entire program state is held into one big data object. And each component in the tree is responsible for passing its children the part of state they own (w’ll see a concrete example of that).

In our demonstration w’ll use the same examples as in the Elm website as they perfectly illustrates the pattern.

Example 1 : a basic counter

Sources for this example are in the counter-1 branch

The counter component is defined in its own module ‘counter.js’

The counter component is defined by the following properties

  1. Model : a simple ‘Number’

  2. View : provides the user with 2 buttons in order to increment/decrement a counter , and a text that shows the current count.

  3. Update : sensible to 2 actions : INC and DEC that increments or decrements the counter value.

The first thing to note is that the view/update are both pure functions, they have no dependency on any external environment besides their input. The counter component itself doesn’t hold any state or variable, it just describes how to construct a view from a given state, and how to update a given state with a given action. Thanks to its purity, the counter component can be easily plugged into any environment that is able to supply it with its dependencies : a state (number) and an action.

Second note, the ‘handler.bind(null, action)’ expression on the click event listener for each button. We are translating the raw user event (mouse click) into a meaningful action to our program (Increment or Decrement). Using ES6 symbols is better than raw strings (avoids collisions in action names), but w’ll see later a better solution provided by the so-called union types.

To see how our component can be tested; here is an example using the ‘tape’ testing library

You can run the test using ‘babel-node’ command

babel-node test/counterTest.js

Example 2: a pair of counters

Sources for this example are in the counter-2 branch

W’ll follow the same path as in Elm tutorial and augment slightly the counter example, w’ll now have 2 counters. Moreover, w’ll provide also a ‘reset’ button to reset both counters to ‘0’;

First, we have to modify our counter component to provide support for the reset operation. For that w’ll introduce a new function ‘init’ whose role is to construct a new fresh state (a count) for the counter.

‘init’ can be very useful in many situations. For example, initializing the state with data from the server or the local storage. It can also create a rich data model from a plain JavaScript object (e.g. enriching the plain object with some prototype properties and methods).

‘init’ is different in purpose from ‘update’ : while the latter derives a new state from a state and an action that updates that state, the former construct a new fresh state, optionally from some input (default value, server data …etc) , and totally ignores the previous state.

Second, w’ll have to add another component that will manage the 2 counters. We implement the code in a separate module ‘twoCounters.js’.

First we need to define our model and its associated set of actions

The model exports 2 properties: ‘first’ and ‘second’ to hold the states of the 2 counters. We define 3 actions on : the first reset both counters to ‘0’. W’ll see the use of the 2 others in a moment.

The component has also an ‘init’ method to create a state from scratch

The view function is responsible for rendering the 2 counters as well as providing the user with a button to reset them.

The thing to note is the 2 parameters passed to the child views:

  1. Each view gets its relevant part (model.first/model.second) of the parent state.

  2. The dynamic handler parameter that’s passed down to the children’s views : For example, an action triggered from the first child counter will be wrapped in an ‘UPDATE_FIRST’ action, so when the parent’s update function is invoked, w’ll be able to forward the original action (stored in the ‘data’ attribute) to the correct counter.

The rest of the code implements the update function and exports the component’s properties

The update function handles 3 actions:

  1. the RESET action ‘init’ each counter to its default state.

  2. the UPDATE_FIRST and UPDATE_SECOND are, as we just saw, wrappers around a counter action. The function forwards the wrapped action to the concerned child counter along with its specific state.

Note the use of {…model, prop: val }; we are using the ES7 object spread properties (like Object.assign) which always returns a new extended instance. We never modify the state passed in the argument but always returns a new state object with the relevant modifications. This is important to ensure the purity of the update function.

The rest of the code in ‘main’ simply invokes the top component with an initial state

The code of ‘twoCounters’ illustrates the typical pattern in an application with nested components :

  1. The components of an application are organized in a tree like hierarchy.

  2. the main function invokes the top component’s view function with the global application state as parameter (and also the main handler).

  3. When rendering its view, each parent invokes the children’s view functions, passing them the relevant parts of the state.

  4. views translates raw DOM/user events into actions meaningful to the application.

  5. actions triggered from child components bubbles up through parents up to the top component. Unlike the DOM event bubbling, a parent can not handle the action in this phase. All it can do is adding information to the triggered action (see next).

  6. During the bubbling phase, the parent view function may intercept actions from its children in order to augment them with the needed information; typically information needed to identify the originating child when dispatching actions to the children.

  7. The action ends up in the main handler, which will triggers the dispatching phase by invoking the top component’s update function.

  8. each parent’s update function is responsible of dispatching the action to its children’s update functions.Typically using the information added during the action bubbling phase.

Example 3 : A list of counters

Sources for this example are in the counter-3 branch

Following the same path of Elm tutorial, w’ll expand further our example to manage an arbitrary list of counters. W’ll also provide the user with buttons to add new counters as well as remove existing counters.

The code for the ‘counter’ component will remain the same. W’ll have also to define a component ‘counterList’ to manage the array of counters.

As usual, we start by defining the model, the set of associated actions

The component’s model defines 2 properties

  1. a list of pairs (id, counter), the ‘id’ property plays the same role as the properties ‘first’ and ‘second’ in the previous example; it’ll allow the component to uniquely identify each child counter.

  2. a ‘nextID’ property to maintain an auto-increment value, we will use this value to assign new IDs to each newly added counter.

Next we define the ‘init’ method which constructs a default state (note that ‘init’ can also take an optional parameter in case we have a saved state)

Then we define the view function

The main view provides 2 buttons to trigger the ‘ADD’ and ‘RESET’ actions. The rendering of each counter is delegated to the ‘counterItemView’ function

The function adds a remove button to the item view and annotates the actions triggered by the ‘counter’ view with the id of the originating counter.

Next the update function

The code follows the same pattern in the previous example, children’s actions are forwarded to the originating child using the id information stored in the bubble phase. This is handled by UPDATE case which delegates to the ‘updateCounter’ function

The other actions are handled by the component itself (you can find the whole source on the GitHub repository).

The same pattern above can be applied to any tree of components and to any at degree of nesting. In spite of some overhead code that bubbles up actions and tunnels down updates, we get an uniform architecture for the whole application.

Representing Actions with union types

In the previous examples we used JavaScript ES6 Symbols to represent action types. And inside the views, we created objects carrying the action type as a well as additional information (id, wrapped child action).

In a real scenario w’d have to move the logic of action creation to a separate factory function (something like Action Creators in React/Flux). In the remaining of this post i’ll present an alternative that’s more in spirit of FP : union types. They are a subset of Algebraic Data Types used in FP languages like Haskell, you can think of them like Enumerations but with more powerful capabilities.

Concretely, an union type will provides us with the following features at once

  1. Define a type describing all possible values of our actions

  2. For each possible value provide us with a factory function

  3. Provide a control flow mechanism (called pattern matching) to handle all possible variants.

Unions types are not present natively in JavaScript, so w’ll use a library that emulates them. In our examples w’ll use union-type (github/union-type) a small and nice library written by the same author of snabbdom.

first we install the library by :

npm install --save union-type

Here is how we define the counter actions

‘Type’ is the only function exported by the library. We use it to define our union type ‘Action’ which contains the 2 possible actions.

the returned ‘Action’ type contains factory function to create all the possible actions

The view creates an Increment and a Decrement action.

The update function demonstrates the use of pattern matching against different variants

The ‘Action’ type exports a ‘case’ method which takes 2 arguments

  • An object of the form (variant name, handler function).

  • the value to match against

The case method then matches the provided actions against all specified cases and call the corresponding handler function. The return value is that returned by the matched handler.

Similarly, here is how ‘counterList’ actions are defined

‘Add’ and ‘Reset’ are _empty actions (i.e. they don’t have any field)_, ‘Remove’ has one field (the child counter’s id). Finally the ‘Update’ action has 2 fields : the child’s id and the action triggered by that child.

We use, as usual, pattern matching in the update function

Note that the Remove and Update handlers both take parameters. If a case is matched successfully, the ‘case’ method will extract the fields from the case instance and passes them to the handler function.

So the typical pattern is :

  • model the actions as an union type

  • inside the view function, create actions using the factory functions provided by the union type (Action creation can also be delegated to a separate function if the logic of creation is more complex).

  • inside the update function, use the ‘case’ method to match against the possible values of the union type.

TodoMVC example

In this repository (github/yelouafi/snabbdom-todomvc)you can find an implementation of the canonical todoMVC application using the concepts in this lesson. The application consists of 2 modules :

  • ‘task.js’ which define a component that renders a single task as well as updating its state

  • ‘todos.js’ which manages the task list as well as filtering and updating.

The filtering is implemented using a trivial router based on ‘hashchange’ events. To trap window events into the main UI cycle the example use the hook feature of snabbdom (embedded as a Snabbdom extension, see ‘app/js/snabbdom-modules/window-events.js).

Conclusion

W’ve seen how to write a virtual DOM based applications using a small and standalone library. This can be helpful when we don’t want to be forced into the choices made by the React framework (esp. classes) or when we need a small sized JavaScript library.

The Elm architecture provides a simple pattern to write complex virtual DOM applications with all the benefits of pure functions. This gives a simple and regular structure to our code. Using regular patterns makes the application easier to maintain especially in teams with members changing frequently. A new member can quickly grasp the overall architecture of the code.

Being also implemented exclusively with pure functions, w’re sure that changing a component won’t cause undesirable side effects as long as the component code respects its contract.

相关文章