Handwritten a virtual DOM library to completely let you understand the diff algorithm

Posted by mrphobos on Tue, 04 Jan 2022 13:46:20 +0100

The so-called virtual DOM is to use js objects to describe the real DOM, which is lighter than the native DOM, because the real DOM object comes with many attributes. In addition, with the diff algorithm of the virtual DOM, it can update the DOM with minimal operations. In addition, it can also enable the frameworks of Vue and React to support other platforms other than browsers. This paper will refer to the well-known snabbdom Library to hand write a simple version, with picture examples to complete the code step by step, which will make you thoroughly understand the patch and diff algorithm of virtual DOM.

Create a virtual DOM object

Virtual DOM (hereinafter referred to as VNode) uses the common objects of js to describe the types, attributes, sub elements and other information of DOM. It is generally created through a function named h. In order to purely understand the patch process of VNode, we do not consider the attributes, styles and events of elements, but only consider the node type and node content. Take a look at the VNode structure at this time:

{
    tag: '',// Element label
    children: [],// Child element
    text: '',// If the child element is a text node, save the text
    el: null// Corresponding real dom
}

The h function returns the object according to the received parameters:

export const h = (tag, children) => {
    let text = ''
    let el
    // Child elements are text nodes
    if (typeof children === 'string' || typeof children === 'number') {
        text = children
        children = undefined
    } else if (!Array.isArray(children)) {
        children = undefined
    }
    return {
        tag, // Element label
        children, // Child element
        text, // Text of text child node
        el// Real dom
    }
}

For example, we want to create a VNode of div, which can be used as follows:

h('div', 'I'm text')
h('div', [h('span')])

Explain the patch process in detail

The patch function is our main function. It is mainly used to compare the old and new vnodes and find the difference to update the actual dom. It receives two parameters. The first parameter can be a DOM element or VNode, representing the old VNode, and the second parameter represents the new VNode. Generally, the DOM element is transmitted only when it is called for the first time, If the first parameter is a DOM element, we directly ignore its child elements and turn it into a VNode:

export const patch = (oldVNode, newVNode) => {
    // dom element
    if (!oldVNode.tag) {
        let el = oldVNode
        el.innerHTML = ''
        oldVNode = h(oldVNode.tagName.toLowerCase())
        oldVNode.el = el
    }
}

Next, the old and new vnodes can be compared:

export const patch = (oldNode, newNode) => {
    // ...
    patchVNode(oldVNode, newVNode)
    // Returns the new vnode
      return newVNode
}

In the patchVNode method, we compare the old and new vnodes and update the DOM.

First, if the types of two vnodes are different, the new vnodes can be used to replace the old ones without comparison:

const patchVNode = (oldNode, newNode) => {
    if (oldVNode === newVNode) {
        return
    }
    // The element labels are the same, and patch
    if (oldVNode.tag === newVNode.tag) {
        // ...
    } else { // If the type is different, create a new dom node according to the new VNode, then insert the new node and remove the old node
        let newEl = createEl(newVNode)
        let parent = oldVNode.el.parentNode
        parent.insertBefore(newEl, oldVNode.el)
        parent.removeChild(oldVNode.el)
    }
}

The createEl method is used to recursively convert VNode into a real DOM node:

const createEl = (vnode) => {
    let el = document.createElement(vnode.tag)
    vnode.el = el
    // Create child node
    if (vnode.children && vnode.children.length > 0) {
        vnode.children.forEach((item) => {
            el.appendChild(createEl(item))
        })
    }
    // Create text node
    if (vnode.text) {
        el.appendChild(document.createTextNode(vnode.text))
    }
    return el
}

If the types are the same, you should judge which operation to perform according to the situation of its child nodes.

If the new node has only one text child node, remove all child nodes (if any) of the old node and create a text child node:

const patchVNode = (oldVNode, newVNode) => {
    // The element labels are the same, and patch
    if (oldVNode.tag === newVNode.tag) {
        // If the element types are the same, the old elements must be reused
        let el = newVNode.el = oldVNode.el
        // The child node of the new node is the text node
        if (newVNode.text) {
            // Remove child nodes of old nodes
            if (oldVNode.children) {
                oldVNode.children.forEach((item) => {
                    el.removeChild(item.el)
                })
            }
            // Update the text if the text content is different
            if (oldVNode.text !== newVNode.text) {
                el.textContent = newVNode.text
            }
        } else {
            // ...
        }
    } else { // Replace oldNode with newNode
        // ...
    }
}

If the child node of the new node is not a text node, there are several situations:

1. If the new node does not have child nodes, but the old node does, remove the child nodes of the old node;

2. If the new node does not have a child node and the old node has a text node, remove the text node;

3. If a child node exists in the new node and a text node exists in the old node, remove the text node and insert the new node;

4. If both old and new nodes have child nodes, you need to enter the diff stage;

const patchVNode = (oldVNode, newVNode) => {
    // The element labels are the same, and patch
    if (oldVNode.tag === newVNode.tag) {
        // ...
        // The child node of the new node is the text node
        if (newVNode.text) {
            // ...
        } else {// The new node does not have a text node
            // If both the old and new nodes have child nodes, diff is required
            if (oldVNode.children && newVNode.children) {
                diff(el, oldVNode.children, newVNode.children)
            } else if (oldVNode.children) {// If the new node does not have child nodes, remove all child nodes of the old node
                oldVNode.children.forEach((item) => {
                    el.removeChild(item.el)
                })
            } else if (newVNode.children) {// The new node has child nodes
                // If the old node has a text node, it will be removed
                if (oldVNode.text) {
                    el.textContent = ''
                }
                // Add child nodes of the new node
                newVNode.children.forEach((item) => {
                    el.appendChild(createEl(item))
                })
            } else if (oldVNode.text) {// There is nothing in the new node, and there is a text node in the old node
                el.textContent = ''
            }
        }
    } else { // Replace oldNode with newNode
        // ...
    }
}

If the old and new nodes have non textual child nodes, it will enter the famous diff stage. The purpose of diff algorithm is to reuse the old nodes as much as possible to reduce the overhead of DOM operation.

Graphical diff algorithm

First of all, the simplest diff is obviously a pairwise comparison of old and new nodes at the same location, but in the WEB scenario, reverse order, sorting and transposition are often possible, so the same location comparison is often inefficient and can not meet this common scenario. Various so-called diff algorithms are used to check these situations as much as possible and reuse them, The diff algorithm in snabbdom is a two terminal comparison strategy. At the same time, the comparison starts from the two ends of the old and new nodes to the middle. Each round will be compared four times, so four pointers are required, as shown in the following figure:

That is, the arrangement and combination of the above four positions: oldStartIdx and newStartIdx, oldStartIdx and newEndIdx, oldEndIdx and newStartIdx, oldEndIdx and newEndIdx. Whenever it is found that the two nodes compared may be reusable, patch and operate on the two nodes, update the pointer and enter the next round of comparison. How to judge whether the two nodes can be reused? This requires the use of key s, because it is not enough to just see whether the nodes are of the same type. Because the same list is basically of the same type, it is no different from the pairwise comparison from the beginning. First modify our h function:

export const h = (tag, data = {}, children) => {
    // ...
    let key
    // Text node
    // ...
    if (data && data.key) {
        key = data.key
    }
    return {
        // ...
        key
    }
}

Now when creating VNode, you can pass in the key:

h('div', {key: 1}, 'I'm text')

The termination conditions of the comparison are also obvious. One of the lists has been compared, that is, oldstartidx > oldendidx or newstartidx > newendidx. First write down the basic framework of the algorithm:

// Judge whether two nodes can be reused
const isSameNode = (a, b) => {
    return a.key === b.key && a.tag === b.tag
}

// diff
const diff = (el, oldChildren, newChildren) => {
    // Position pointer
    let oldStartIdx = 0
    let oldEndIdx = oldChildren.length - 1
    let newStartIdx = 0
    let newEndIdx = newChildren.length - 1
    // Node pointer
    let oldStartVNode = oldChildren[oldStartIdx]
    let oldEndVNode = oldChildren[oldEndIdx]
    let newStartVNode = newChildren[newStartIdx]
    let newEndVNode = newChildren[newEndIdx]

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {

        } else if (isSameNode(oldStartVNode, newEndVNode)) {

        } else if (isSameNode(oldEndVNode, newStartVNode)) {

        } else if (isSameNode(oldEndVNode, newEndVNode)) {

        }
    }
}

Four variables are added to save nodes in four locations. Next, take the above figure as an example to improve the code.

In the first round, it will be found that oldEndVNode and newEndVNode are reusable nodes, so they are patch ed. Since they are in the last position, there is no need to move the DOM node and update the pointer:

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {
            patchVNode(oldEndVNode, newEndVNode)
            // Update pointer
            oldEndVNode = oldChildren[--oldEndIdx]
            newEndVNode = newChildren[--newEndIdx]
        }
    }
}

The location information is as follows:

In the next round, it will be found that oldStartIdx and newEndIdx are reusable nodes. Then, patch the oldStartVNode and newEndVNode nodes. At the same time, the position of the node in the new list is the last in the current comparison interval. Therefore, it is necessary to move the real DOM of oldStartIdx to the last in the current comparison interval of the old column, that is, after oldEndVNode:

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {
            patchVNode(oldStartVNode, newEndVNode)
            // After moving the node to oldEndVNode
            el.insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling)
            // Update pointer
            oldStartVNode = oldChildren[++oldStartIdx]
            newEndVNode = newChildren[--newEndIdx]
        } 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
    }
}

The position after this round is as follows:

In the next round of comparison, it is obvious that oldStartVNode and newStartVNode are reusable nodes. patch them because they are in the first position, so there is no need to move the node and update the pointer:

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {
            patchVNode(oldStartVNode, newStartVNode)
            // Update pointer
            oldStartVNode = oldChildren[++oldStartIdx]
            newStartVNode = newChildren[++newStartIdx]
        } 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
    }
}

The position after this round is as follows:

In the next round, it will be found that oldEndVNode and newStartVNode are reusable nodes. In the new list, the position becomes the first in the current comparison interval. Therefore, after the patch, you need to move the node to the front of oldStartVNode:

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {
            patchVNode(oldEndVNode, newStartVNode)
            // Move the oldEndVNode node to the front of oldStartVNode
            el.insertBefore(oldEndVNode.el, oldStartVNode.el)
            // Update pointer
            oldEndVNode = oldChildren[--oldEndIdx]
            newStartVNode = newChildren[++newStartIdx]
        } 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
    }
}

The rear position of this wheel is as follows:

In the next round, we will find that no reusable nodes are found in the four comparisons. What should we do? Because finally, we need to turn the old list into a new list, so if the current newStartVNode does not find reusable nodes in the old list, we need to directly create a new node and insert it, but we can see that there is a c node in the old node at a glance, Except for the four positions in this round of comparison, we can directly search in the old list, patch when found, and move the node to the first in the current comparison interval, that is, oldStartIdx. Before this position is empty, it is set to null, and then skip after traversing. If it is not found, it indicates that this node is really new, Create this node directly before inserting it into oldStartIdx:

// Find reusable nodes in the list
const findSameNode = (list, node) => {
    return list.findIndex((item) => {
        return item && isSameNode(item, node)
    })
}

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // The node at a location is null. Skip this round of comparison and only update the pointer
        if (oldStartVNode === null) {
            oldStartVNode = oldChildren[++oldStartIdx]
        } else if (oldEndVNode === null) {
            oldEndVNode = oldChildren[--oldEndIdx]
        } else if (newStartVNode === null) {
            newStartVNode = oldChildren[++newStartIdx]
        } else if (newEndVNode === null) {
            newEndVNode = oldChildren[--newEndIdx]
        }
        else if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
        else {
            let findIndex = findSameNode(oldChildren, newStartVNode)
            // If the newStartVNode does not exist in the old list, it is a new node. Create and insert it
            if (findIndex === -1) {
                el.insertBefore(createEl(newStartVNode), oldStartVNode.el)
            } else {// If it exists in the old list, patch it and move it to the front of oldStartVNode
                let oldVNode = oldChildren[findIndex]
                patchVNode(oldVNode, newStartVNode)
                el.insertBefore(oldVNode.el, oldStartVNode.el)
                // The original location is empty and set to null
                oldChildren[findIndex] = null
            }
            // Update pointer
            newStartVNode = newChildren[++newStartIdx]
        }
    }
}

For our example, we found it in the old list, so the location information after this round is as follows:

The next round of comparison is the same as the previous round. It will enter the search branch and find d, so it is also path plus mobile node. After this round, it is as follows:

Because newStartIdx is greater than newEndIdx, the while loop ends, but we find that there are more g and h nodes in the old list, which are not in the new list, so we need to remove them. Conversely, if there are more nodes in the new list that are not in the old list, create and insert them:

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
        else {}
    }
    // Nodes in the old list but not in the new list need to be deleted
    if (oldStartIdx <= oldEndIdx) {
        for(let i = oldStartIdx; i <= oldEndIdx; i++) {
            oldChildren[i] && el.removeChild(oldChildren[i].el)
        }
    } else if (newStartIdx <= newEndIdx) {// There are nodes in the new list that are not in the old list. Create and insert them
        // Insert before the next node of newEndVNode. If the next node does not exist, the insertBefore method will perform the operation of appendChild
        let before = newChildren[newEndIdx + 1] ? newChildren[newEndIdx + 1].el : null
        for(let i = newStartIdx; i <= newEndIdx; i++) {
            el.insertBefore(createEl(newChildren[i]), before)
        }
    }
}

The above is the whole process of double ended diff. isn't it quite simple? It's very easy to understand by drawing a diagram.

Property update

Other attributes are passed in through the data parameter. First modify the h function:

export const h = (tag, data = {}, children) => {
  // ...
  return {
    // ...
    data
  }
}

Class name

The class name is passed through the class field of the data option, for example:

h('div',{
    class: {
        btn: true
    }
}, 'text')

The class name is updated in the patchVNode method. When the types of two nodes are the same, the class name is updated. If it is replaced, it is equivalent to setting the class name:

// Update node class name
const updateClass = (el, newVNode) => {
    el.className = ''
    if (newVNode.data && newVNode.data.class) {
        let className = ''
        Object.keys(newVNode.data.class).forEach((cla) => {
            if (newVNode.data.class[cla]) {
                className += cla + ' '
            }
        })
        el.className = className
    }
}

const patchVNode = (oldVNode, newVNode) => {
    // ...
    // The element labels are the same, and patch
    if (oldVNode.tag === newVNode.tag) {
        let el = newVNode.el = oldVNode.el
        // Update class name
        updateClass(el, newVNode)
        // ...
    } else { // Replace oldNode with newNode
        let newEl = createEl(newVNode)
        // Update class name
        updateClass(newEl, newVNode)
        // ...
    }
}

The logic is very simple. Directly replace the class name of the old node with the class name of newVNode.

style

The style attribute is passed in using the style field of data:

h('div',{
    style: {
        fontSize: '30px'
    }
}, 'text')

The update time is consistent with the location of the class name:

// Update node style
const updateStyle = (el, oldVNode, newVNode) => {
  let oldStyle = oldVNode.data.style || {}
  let newStyle = newVNode.data.style || {}
  // Remove styles that exist in the old node and do not exist in the new node
  Object.keys(oldStyle).forEach((item) => {
    if (newStyle[item] === undefined || newStyle[item] === '') {
      el.style[item] = ''
    }
  })
  // Add a new style where the old node does not exist
  Object.keys(newStyle).forEach((item) => {
    if (oldStyle[item] !== newStyle[item]) {
      el.style[item] = newStyle[item]
    }
  })
}

const patchVNode = (oldVNode, newVNode) => {
    // ...
    // The element labels are the same, and patch
    if (oldVNode.tag === newVNode.tag) {
        let el = newVNode.el = oldVNode.el
        // Update Style
        updateStyle(el, oldVNode, newVNode)
        // ...
    } else {
        let newEl = createEl(newVNode)
        // Update Style
        updateStyle(el, null, newVNode)
        // ...
    }
}

Other properties

Other attributes are saved in the attr field of data, and the update method, location and style are exactly the same:

// Update node properties
const updateAttr = (el, oldVNode, newVNode) => {
    let oldAttr = oldVNode && oldVNode.data.attr ? oldVNode.data.attr : {}
    let newAttr = newVNode.data.attr || {}
    // Remove the attributes that exist in the old node and do not exist in the new node
    Object.keys(oldAttr).forEach((item) => {
        if (newAttr[item] === undefined || newAttr[item] === '') {
            el.removeAttribute(item)
        }
    })
    // Add a new attribute that does not exist in the old node
    Object.keys(newAttr).forEach((item) => {
        if (oldAttr[item] !== newAttr[item]) {
            el.setAttribute(item, newAttr[item])
        }
    })
}

const patchVNode = (oldVNode, newVNode) => {
    // ...
    // The element labels are the same, and patch
    if (oldVNode.tag === newVNode.tag) {
        let el = newVNode.el = oldVNode.el
        // Update properties
        updateAttr(el, oldVNode, newVNode)
        // ...
    } else {
        let newEl = createEl(newVNode)
        // Update properties
        updateAttr(el, null, newVNode)
        // ...
    }
}

event

Finally, let's take a look at the update of events. The difference between events and other attributes is that if you delete a node, you need to unbind all its events first, otherwise there may be a memory leak problem, so you need to unbind the events at each node removal time:

// Remove all events of the dom corresponding to a VNode
const removeEvent = (oldVNode) => {
  if (oldVNode && oldVNode.data && oldVNode.data.event) {
    Object.keys(oldVNode.data.event).forEach((item) => {
      oldVNode.el.removeEventListener(item, oldVNode.data.event[item])
    })
  }
}

// Update node events
const updateEvent = (el, oldVNode, newVNode) => {
  let oldEvent = oldVNode && oldVNode.data.event ? oldVNode.data.event : {}
  let newEvent = newVNode.data.event || {}
  // Unbind events that are no longer needed
  Object.keys(oldEvent).forEach((item) => {
    if (newEvent[item] === undefined || oldEvent[item] !== newEvent[item]) {
      el.removeEventListener(item, oldEvent[item])
    }
  })
  // Bind new events where the old node does not exist
  Object.keys(newEvent).forEach((item) => {
    if (oldEvent[item] !== newEvent[item]) {
      el.addEventListener(item, newEvent[item])
    }
  })
}

const patchVNode = (oldVNode, newVNode) => {
    // ...
    // The element labels are the same, and patch
    if (oldVNode.tag === newVNode.tag) {
        // If the element types are the same, the old elements must be reused
        let el = newVNode.el = oldVNode.el
        // Update event
        updateEvent(el, oldVNode, newVNode)
        // ...
    } else {
        let newEl = createEl(newVNode)
        // Remove all events from the old node
        removeEvent(oldNode)
        // Update event
        updateEvent(newEl, null, newVNode)
        // ...
    }
}
// There are several other places where removeEvent() needs to be added. If you are interested, please see the source code

The update logic of the above attributes is rough and is only for reference snabbdom The source code is self-improvement.

summary

The above code implements a simple virtual DOM library, which decomposes the patch process and diff process in detail. If it needs to be used on non browser platforms, just abstract DOM related operations into interfaces, and use different interfaces on different platforms. The complete code is in https://github.com/wanglin2/VNode-Demo.