turbine: Why Turbine doesn't use virtual DOM

@dmitriz

Don’t use virtual DOM. Instead, the created view listens directly to streams and updates the DOM accordingly. This avoids the overhead of virtual DOM and allows for a very natural style where streams are inserted directly into the view.

Could you explain this? I’ve thought the point of virtual DOM was to avoid the overhead of the real one 😃

In my opinion, virtual DOM solves a problem that we can completely avoid by using FRP.

Note: In this post, I’m using the words “stream” as a catch-all term for what different libraries call “stream” or “observable” and for what Turbine calls a “behavior”.

Frameworks that use virtual DOM almost always represents their views as a function that takes plain state and returns virtual DOM. For instance, the render method on a React component is a function that returns a vnode tree from the components state. This is nice because it means that our view doesn’t need to know which parts of our state changed and figure out what changes it then has to make to the DOM. It can just take the entire state and build a new virtual DOM. Then the virtual DOM library will find the differences and figure out how to precisely update the DOM.

When we use FRP the situation is quite different. We represent our state as streams. We can observe them and precisely know when pieces of our state changes. Based on this we can know exactly what changes to make to the DOM. We don’t need a virtual DOM library to figure it out.

What I see almost all FRP/observable based frameworks do instead is something like the following: They start out with a bunch of different streams in their model, they then combine those into a single state stream and finally map their view function over the stream. So they go from state in many streams to state in a single stream to a stream of vnodes.

One very minor immediate downside to this approach is that the user has to write a bit of boilerplate code to merge the streams. Flimflam cleverly eliminates this boilerplate by automatically merging streams from an object.

The much bigger issue to the approach is that it throws information away that it then later has to get back by using virtual DOM diffing. Not only that, throwing the information away has a small overhead (the merging) and getting it back has an even bigger overhead (virtual DOM diffing).

To understand the problem let me give an example. Assume we have a model with three values, A, B and C. We express all of these as streams. We want a view with three p tags that each show one of the values.

<p>value of A here</p>
<p>value of B here</p>
<p>value of C here</p>

Whenever one of the streams changes we will have to modify the content of the p tag that it belongs to. What the above mentioned approach will do is something like the following.

const stateStream = combine(aStream, bStream, cStream);
const vnodeStream = stateStream.map(viewFunction);
// apply virtual DOM diffing to vnodeStream 

Note that when we have three streams we can observe each one of them individually and know when they change. But, when we combine them into a single stream we can only observe when any of them changes and don’t know which one.

Therefore, when we apply our view function to the combined stream, it too doesn’t know which value changed and it will calculate an entirely new vnode tree. Then the virtual DOM library will compare the new vnode tree with the last one it saw. It will then figure out if it was A, B or C that changed and update the view accordingly.

The virtual DOM library goes through the entire diffing process to figure out whether A, B, or C changed. But, we had that information originally before we combined the streams! If we hadn’t merged the streams in the first place we wouldn’t have needed that at all.

A smart enough view could simply know where each stream belongs in the DOM, observe that stream and update the DOM whenever that one changes. That is what Turbine does. In the above example, all three streams would be given to the view function and they would be inserted directly into the data structure describing the view. Then when the view is created Turbine will do something equivalent to the following

aStream.observe((a) => update first p element);
bStream.observe((a) => update second p element);
cStream.observe((a) => update third p element);

This updates the DOM just as efficiently as what a virtual DOM library would do. But it eliminates the overhead of virtual DOM diffing completely. And virtual DOM diffing does have an overhead. As an indication of that, consider React Fiber. It’s a very complex solution to solve, among other things, the problem that virtual DOM diffing can in some cases be so expensive that it takes more than 16ms and causes frames to drop.

We don’t have any benchmark yet. But we think the approach Turbine takes can give very good performance. In particular in cases where virtual DOM is ill-suited. I.e. animations and similar things that have to update frequently.

For instance, if you want to implement drag-and-drop in Turbine. We can represent the position of the mouse as a stream and hook that stream directly into the dragged element. Then whenever the mouse moves we will update the element position very efficiently.

To sum it up. Turbine doesn’t use virtual DOM because it is unnecessary thanks to FRP. And since not using it leads to a concise way of expressing views where streams do not have to be combined but where they instead can be inserted directly into the view.

Please let me know if the above explanation makes sense and what you think about the rationale.

About this issue

  • Original URL
  • State: open
  • Created 7 years ago
  • Reactions: 35
  • Comments: 22 (11 by maintainers)

Most upvoted comments

@trusktr

I can easily imagine how mapping streams to element attributes works, but I’m not sure I can imagine how dynamically generated arrays of children work without vdom yet. Can you describe how a Turbine component figures out how to add/remove DOM nodes based on dynamic arrays of Turbine component children?

For example, there’s list() which is used for this, but how does that work considering that the components in the list can have deep tree structures? What’s in place of vdom for this? (vdom starts to seem like merely the easy way of making it work internally)

That is a very good question. I asked myself the same thing when I had to implement the first dynamic list in Turbine 🤣. You are completely right that it’s easier to imagine how it works with attributes.

But the answer is that in this case, we do the very same thing that a virtual DOM library would do. In fact what I think any framework would have to do. We do what is called children reconciliation. Let me try and explain.

First, it’s important to point out that virtual DOM libraries will only efficiently reorder a list of elements if the user supplies some sort of key property for each vdom node. For instance, if we diff this.

<ul><li>b</li><li>c</li></ul>

With this

<ul><li>a</li><li>b</li><li>c</li></ul>

One may think that a vdom library would realize that in this case it just has to insert an element in the beginning of the list. But I don’t know of any vdom library that actually does that. React or Snabbdom will do the following naive set of modifications: 1/ modify the text in the first li from “b” to “a”, modify the text in the second li from “c” to “b”. 3/ insert a new li elment with text “c”. Obviously, that is not the most efficient way to update the DOM.

To solve the problem users have to give their vdom library unique keys on each element. When keys are present the vdom library will essentially stop doing vdom diffing and instead do “key diffing”. It will look at two arrays of keys and figure out what to reorder based on those keys. The list function in Turbine does the very same thing.

I think it’s interesting to consider how poor virtual DOM libraries actually are at diffing. They pretty much only find the minimal amount of DOM manipulating when one is changing simple attributes and in the key-diffing case. But in practice, it works out just fine because that actually includes most of the changes that one makes to the DOM. Doing a more comprehensive diffing isn’t worth it because such “deep-diffing” algorithms are very costly.

The cases where a vdom library is actually efficient with the DOM can both neatly be expressed in Turbine using behaviors as attributes and the list function (but do note that we haven’t implemented a proper key-diffing algorithm in list yet).

FWIW, @adamhaile uses a similar approach in surplus by tying FRP signals directly to DOM nodes. Benchmarks show exceptional performance.

@dmitriz You’re very welcome. Inferno is very cool. I don’t know much about it. But, my understanding is that they compile JSX so that they know exactly where data is being inserted. Then that can diff data instead of diffing DOM.

In Turbine we don’t even need to diff data since behaviors tells us exactly when they change. Also the Turbine approach doesn’t rely on JSX and compilation.

We need a virtual DOM library to figure it out.

Missing don’t in OP

💰 I’m sold.