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.