My previous post described how to create a very basic web application following the principles of functional programming. That’s fine, but I bet you’re not building basic apps. How to scale this approach? This (and the following) post will present some techniques you could use to solve common problems encountered when creating more complex applications.
Let me remind you that the end goal is not to convince you to ditch all JavaScript frameworks and build your own instead. My point is to explain the reasoning behind commonly used patterns and how they relate to functional programming. Source code for this article is available here.
Limiting DOM updates
Our little framework relies on view
function which translates state into DOM tree. The function is invoked on every state update. This means that on every state change we need to re-create the whole DOM tree and have it re-rendered by the browser. In a complex application with multiple actions and huge DOM tree this could have a huge impact on performance. Below you can find a screenshot illustrating the problem. The whole div
is updated even though clicking Complete should only affect two table rows.
Basically, we’d like to limit the amount of unnecessary DOM-related work. On the other hand, we want our code to stay declarative and functional so we have to avoid direct, imperative DOM manipulation.
Introducing virtual DOM
Virtual DOM is the answer to our problems! It is a clever technique where instead of creating actual DOM objects you operate on virtual DOM elements. Operations on virtual nodes are much faster than on actual nodes since there is no browser API involved. Obviously, at some point we need to update the actual DOM tree. Here is how we will do this:
view
function will return a virtual DOM tree- on every state update (every
app
invocation) we will compare the result of theview
call with the previous tree - the comparison results in a set of
patches
that represent minimal changes to the DOM patches
can be applied to the actual DOM tree; only relevant parts of the DOM are updated, not the whole tree
Below you can see the difference after enhancing the application with virtual DOM. Note that only relevant parts of the DOM are highlighted.
Show me the code
Let’s rework our application to take advantage of virtual DOM.
View function
The first step is to adjust the view
function to return virtual nodes instead of real DOM nodes. We are not going to implement the virtual DOM mechanism itself. It’s a highly non-trivial task and not in the scope of this article. Instead, let’s use one of existing virtual DOM libraries. Our library of choice is simply called virtual-dom. The best thing about it is that it’s compatible with hyperscript-helpers
. Remember how we wrapped hyperscript
with hyperscript-helpers
so that we were able to use functions such as div
, h2
, table
, etc.? This extra level of indirection will prove enormously useful now. Our view
function will continue using these functions. However, they will proxy to virtual-dom
instead of hyperscript
resulting in virtual DOM nodes instead of real DOM nodes.
1 | import h from 'virtual-dom/h'; |
That’s it! There are no more changes to view
function!
Engine
The next (and last) step is to adjust the app
function. It’s going to get a bit more complex. Before, all we had to do was to replace the old DOM tree with the new tree. However, view
returns a virtual tree now. We need to compare the new tree and with old one using diff
function provided by the library. The comparison will return a set of patches
which can be later applied on the actual DOM tree. The above procedure describes what happens on state update. However, we need an initial DOM tree to begin with! We can get one from the initial virtual tree by calling createElement
, also provided by the library. Below you can find the update code.
1 | import diff from 'virtual-dom/diff'; |
The app
function accepts a new parameter called previousView
. We need it to be able to compare updated virtual DOM with the previous version. When previousView
is null
, it means that app
is called for the first time (with initialState
) and that there is no real DOM tree in place yet. Therefore, we call createElement
and append the result to rootNode
. When previousView
is not empty, we should compare it with the new virtual tree (updatedView
) and apply patches on rootNode
‘s first child (because we initially attached the whole tree to rootNode
). Obviously, this part is not functional code. Patching the DOM is an imperative operation with side effects. However, this part of the code wasn’t pure in the first place. What’s important is that we’ve managed to preserve the purity of the rest of the code.
Virtual DOM in real world
The concept of virtual DOM is instrumental in React framework. This is actually what made React famous for its performance. By the way, if you are familiar with React, you might have noticed similarities between our application and the framework. Indeed, our view
function is nothing less than a React functional component! It’s interesting to see how other frameworks deal with limiting the amount of DOM operations while maintaining declarativeness. For example, Angular takes a different approach based on change detection. You can read more about it in one of my posts. This comparison between those two mechanisms seems very interesting to me and I asked a question on Reddit specifically about it. I’ve got an amazing reply from Rob Wolmard which details the pros and cons of both approaches:
The tradeoff (and part of the philosophy Angular is built around) is that templating allows Angular to deeply understand a template, and generate highly optimized code, in both pure CPU cycles, but low memory consumption and garbage collection. The flexibility of being able to return whatever from a JSX-style
render()
function means the framework has to be able to handle whatever, and each time a new virtual DOM representation is created, it can consume a fair amount of memory - especially important on low end devices.
Summary
In this post you’ve learned what virtual DOM is and how frameworks can use it to optimize performance while remaining declarative. I believe that this example nicely illustrates how trying to build your own framework can push you to learn new things and understand how existing frameworks work underneath.