Preface
This is a long article!!! Stick to see the last eggs!!!
At the beginning of the article, let's think about a question. Everyone says that virtual dom is what virtual dom is.
First of all, we have to make it clear that the so-called virtual dom is the virtual node. It simulates nodes in DOM through JS Object objects, and then renders them into real DOM nodes through specific render methods.
Secondly, we also know what virtual dom does. We know that the usual way to re-render a page is to do this by manipulating Dom and resetting innerHTML. virtual dom, on the other hand, returns a patch object, i.e. a patch object, by computing at the JS level, and parses the patch object through specific operations to complete the page re-rendering. A flow chart for specific virtual dom rendering is shown in the figure.
Next, I'll follow the old rules, code and parse, and bring my little buddies together to implement a virtual DOM & diff. The specific steps are as follows
- Implementing a utils method library
- Implementing an Element (virtual dom)
- Implementing diff algorithm
- Implementing patch
1. Implementing a utils method library
As the saying goes, sharpening knives does not waste chopping firewood. For the convenience of the latter, I will take some of the methods you often use to realize the latter. After all, if you write the method once every time, it will not be crazy, because the code is simple, so I will paste the code directly here.
const _ = exports _.setAttr = function setAttr (node, key, value) { switch (key) { case 'value': node.style.cssText = value break; case 'value': let tagName = node.tagName || '' tagName = tagName.toLowerCase() if ( tagName === 'input' || tagName === 'textarea' ) { node.value = value } else { // If the node is not input or textarea, use `setAttribute'to set properties node.setAttribute(key, value) } break; default: node.setAttribute(key, value) break; } } _.slice = function slice (arrayLike, index) { return Array.prototype.slice.call(arrayLike, index) } _.type = function type (obj) { return Array.prototype.toString.call(obj).replace(/\[object\s|\]/g, '') } _.isArray = function isArray (list) { return _.type(list) === 'Array' } _.toArray = function toArray (listLike) { if (!listLike) return [] let list = [] for (let i = 0, l = listLike.length; i < l; i++) { list.push(listLike[i]) } return list } _.isString = function isString (list) { return _.type(list) === 'String' } _.isElementNode = function (node) { return node.nodeType === 1 }
Implementing an Element
One thing we need to do here is to implement an Object to simulate the presentation of DOM nodes. The real nodes are as follows
<ul id="list"> <li class="item">item1</li> <li class="item">item2</li> <li class="item">item3</li> </ul>
We need to complete an Element simulation of the real node above in the form of
let ul = { tagName: 'ul', attrs: { id: 'list' }, children: [ { tagName: 'li', attrs: { class: 'item' }, children: ['item1'] }, { tagName: 'li', attrs: { class: 'item' }, children: ['item1'] }, { tagName: 'li', attrs: { class: 'item' }, children: ['item1'] }, ] }
Looking at this, we can see that tagName, attrs, children in el object can be extracted into Element, that is, tagName, attrs, and children.
class Element { constructor(tagName, attrs, children) { this.tagName = tagName this.attrs = attrs this.children = children } } function el (tagName, attrs, children) { return new Element(tagName, attrs, children) } module.exports = el;
So the ul above can be written in a simpler way, that is
let ul = el('ul', { id: 'list' }, [ el('li', { class: 'item' }, ['Item 1']), el('li', { class: 'item' }, ['Item 2']), el('li', { class: 'item' }, ['Item 3']) ])
ul is the Element object, as shown in the figure
OK, to this point, Element is half the implementation, and the rest is usually to provide a render function that renders Element objects into real DOM nodes. The complete Element code is as follows
import _ from './utils' /** * @class Element Virtrual Dom * @param { String } tagName * @param { Object } attrs Element's attrs, For example: {id:'list'} * @param { Array <Element|String> } It could be an Element object or just a string, textNode */ class Element { constructor(tagName, attrs, children) { // If there are only two parameters if (_.isArray(attrs)) { children = attrs attrs = {} } this.tagName = tagName this.attrs = attrs || {} this.children = children // Set this.key property to prepare for later list diff this.key = attrs ? attrs.key : void 0 } render () { let el = document.createElement(this.tagName) let attrs = this.attrs for (let attrName in attrs) { // Setting DOM properties of nodes let attrValue = attrs[attrName] _.setAttr(el, attrName, attrValue) } let children = this.children || [] children.forEach(child => { let childEl = child instanceof Element ? child.render() // If the sub-node is also a virtual node, it is constructed recursively. : document.createTextNode(child) // If it's a string, build the text node directly el.appendChild(childEl) }) return el } } function el (tagName, attrs, children) { return new Element(tagName, attrs, children) } module.exports = el;
At this point, we execute the written render method, rendering Element objects into real nodes.
let ulRoot = ul.render() document.body.appendChild(ulRoot);
The effect is shown in the figure.
So far, our Element has been implemented.
3. Implementing diff algorithm
What we do here is to implement a diff algorithm to compare virtual node Element s and return a patch object to store the differences between two nodes. This is also the core step of the whole virtual dom implementation. The diff algorithm contains two different algorithms, one is O(n), the other is O (max (m, n).
1. Comparison of Elements at the Same Level (O(n))
First of all, what we know is that if there is a complete comparison between elements, that is, the parent element of the old and new Element objects, and a hybrid comparison between the child elements, the time complexity of its implementation is O(n^3). But in our front-end development, there are few cross-level processing nodes, so here we will make a comparison between the same level elements, and its time complexity is O(n). The algorithm flow is shown in the figure.
Here, when we compare elements at the same level, there are four possible scenarios
- The whole element is different, that is, the element is replace d.
- Element attrs are different
- The text text text of the element is different
- The order of elements is replaced, that is, elements need reorder
The fourth case listed above belongs to the second algorithm of diff. We will not discuss it here, but we will discuss it in detail later.
For the above four cases, we first set four constants to express. The diff entry method and four states are as follows
const REPLACE = 0 // replace => 0 const ATTRS = 1 // attrs => 1 const TEXT = 2 // text => 2 const REORDER = 3 // reorder => 3 // diff entrance, comparing the difference between old and new trees function diff (oldTree, newTree) { let index = 0 let patches = {} // Patch objects used to record differences between each node walk(oldTree, newTree, index, patches) return patches }
OK, the status is defined, so let's go ahead. We implement it one by one, and we get different states. One thing to note here is that our diff comparison here will only compare between the two as shown in the flow chart above. If the node removes, pass will be removed here and list diff will be taken directly.
a. First, we compare the top-level elements down to the end of the last-level elements, and store the differences of each level in the patch object, that is, we implement the walk method.
/** * walk Traversing to Find Node Differences * @param { Object } oldNode * @param { Object } newNode * @param { Number } index - currentNodeIndex * @param { Object } patches - Objects that record node differences */ function walk (oldNode, newNode, index, patches) { let currentPatch = [] // If oldNode is remove d if (newNode === null || newNode === undefined) { // Do not do the operation first, and give it to list diff } // Compare the differences between texts else if (_.isString(oldNode) && _.isString(newNode)) { if (newNode !== oldNode) currentPatch.push({ type: TEXT, content: newNode }) } // Comparing attrs else if ( oldNode.tagName === newNode.tagName && oldNode.key === newNode.key ) { let attrsPatches = diffAttrs(oldNode, newNode) if (attrsPatches) { currentPatch.push({ type: ATTRS, attrs: attrsPatches }) } // Recursive diff comparison of sub-nodes diffChildren(oldNode.children, newNode.children, index, patches) } else { currentPatch.push({ type: REPLACE, node: newNode}) } if (currentPatch.length) { patches[index] = currentPatch } } function diffAttrs (oldNode, newNode) { let count = 0 let oldAttrs = oldNode.attrs let newAttrs = newNode.attrs let key, value let attrsPatches = {} // If there are different attrs for (key in oldAttrs) { value = oldAttrs[key] // If oldAttrs removes some attrs, newAttrs [key]==== undefined if (newAttrs[key] !== value) { count++ attrsPatches[key] = newAttrs[key] } } // If a new attr exists for (key in newAttrs) { value = newAttrs[key] if (!oldAttrs.hasOwnProperty(key)) { attrsPatches[key] = value } } if (count === 0) { return null } return attrsPatches }
b. In fact, we need to traverse the old and new elements in depth, adding a unique tag to each node. The specific flow is shown in the figure.
As shown above, the next thing we need to do is to mark each element node with a unique identifier while traversing the differences in depth. The specific methods are as follows
// Setting node unique identity let key_id = 0 // diff with children function diffChildren (oldChildren, newChildren, index, patches) { // Store the identity of the current node with an initialization value of 0 let currentNodeIndex = index oldChildren.forEach((child, i) => { key_id++ let newChild = newChildren[i] currentNodeIndex = key_id // Recursive Continuous Comparison walk(child, newChild, currentNodeIndex, patches) }) }
OK, this step is even. Let's call it and see what kind of patch object two different Element objects will return.
let ul = el('ul', { id: 'list' }, [ el('li', { class: 'item' }, ['Item 1']), el('li', { class: 'item' }, ['Item 2']) ]) let ul1 = el('ul', { id: 'list1' }, [ el('li', { class: 'item1' }, ['Item 4']), el('li', { class: 'item2' }, ['Item 5']) ]) let patches = diff(ul, ul1); console.log(patches);
The console results are shown in the figure.
The complete diff code is as follows (including the method of calling list diff, if you're following the article, just comment out some of the code)
import _ from './utils' import listDiff from './list-diff' const REPLACE = 0 const ATTRS = 1 const TEXT = 2 const REORDER = 3 // diff entrance, comparing the difference between old and new trees function diff (oldTree, newTree) { let index = 0 let patches = {} // Patch objects used to record differences between each node walk(oldTree, newTree, index, patches) return patches } /** * walk Traversing to Find Node Differences * @param { Object } oldNode * @param { Object } newNode * @param { Number } index - currentNodeIndex * @param { Object } patches - Objects that record node differences */ function walk (oldNode, newNode, index, patches) { let currentPatch = [] // If oldNode is remove d, that's when newNode === null if (newNode === null || newNode === undefined) { // Do not do the operation first, and give it to list diff } // Compare the differences between texts else if (_.isString(oldNode) && _.isString(newNode)) { if (newNode !== oldNode) currentPatch.push({ type: TEXT, content: newNode }) } // Comparing attrs else if ( oldNode.tagName === newNode.tagName && oldNode.key === newNode.key ) { let attrsPatches = diffAttrs(oldNode, newNode) if (attrsPatches) { currentPatch.push({ type: ATTRS, attrs: attrsPatches }) } // Recursive diff comparison of sub-nodes diffChildren(oldNode.children, newNode.children, index, patches, currentPatch) } else { currentPatch.push({ type: REPLACE, node: newNode}) } if (currentPatch.length) { patches[index] = currentPatch } } function diffAttrs (oldNode, newNode) { let count = 0 let oldAttrs = oldNode.attrs let newAttrs = newNode.attrs let key, value let attrsPatches = {} // If there are different attrs for (key in oldAttrs) { value = oldAttrs[key] // If oldAttrs removes some attrs, newAttrs [key]==== undefined if (newAttrs[key] !== value) { count++ attrsPatches[key] = newAttrs[key] } } // If a new attr exists for (key in newAttrs) { value = newAttrs[key] if (!oldAttrs.hasOwnProperty(key)) { attrsPatches[key] = value } } if (count === 0) { return null } return attrsPatches } // Setting node unique identity let key_id = 0 // diff with children function diffChildren (oldChildren, newChildren, index, patches, currentPatch) { let diffs = listDiff(oldChildren, newChildren, 'key') newChildren = diffs.children if (diffs.moves.length) { let reorderPatch = { type: REORDER, moves: diffs.moves } currentPatch.push(reorderPatch) } // Store the identity of the current node with an initialization value of 0 let currentNodeIndex = index oldChildren.forEach((child, i) => { key_id++ let newChild = newChildren[i] currentNodeIndex = key_id // Recursive Continuous Comparison walk(child, newChild, currentNodeIndex, patches) }) } module.exports = diff
If you think it's boring to see only the patch object but not the operation of re-rendering the page after parsing the patch, you can skip the list diff chapter and follow the patch method directly to implement that chapter. It may be more exciting. I also hope that my little friends can reach a consensus with me (because I seem to have done the same thing myself).
2. listDiff implements O (m*n) => O (max (m, n))
First of all, we have to make clear why we need the existence of list diff algorithm, what is one thing that list diff does, and then how it does such a thing.
For example, I have two Element objects, new and old, respectively.
let oldTree = el('ul', { id: 'list' }, [ el('li', { class: 'item1' }, ['Item 1']), el('li', { class: 'item2' }, ['Item 2']), el('li', { class: 'item3' }, ['Item 3']) ]) let newTree = el('ul', { id: 'list' }, [ el('li', { class: 'item3' }, ['Item 3']), el('li', { class: 'item1' }, ['Item 1']), el('li', { class: 'item2' }, ['Item 2']) ])
If we want to compare diff, we can directly use the above method to compare, but we can see that only one node move has been made here. If we compare the above diff directly and render the patch object analytically by the later patch method, we will need to operate three DOM nodes to complete the final update of the view.
Of course, if there are only three nodes, that's good. Our browser can eat and eat, and we can't see any difference in performance. Then the problem arises. If there are N multi-nodes, and these nodes only do a small part of remove, insert, move operations, then if we still re-render DOM according to one-to-one corresponding DOM operations, is it not too expensive to operate?
Therefore, the list diff arithmetic is derived, which is responsible for collecting remove, insert, move operations. Of course, for this operation, we need to declare a DOM attribute in the attrs of the node in advance to indicate the uniqueness of the node. In addition, the previous chart illustrates the time complexity of list diff, so the little buddies can look at it.
OK, let's give a concrete example to illustrate how list diff works. The code is as follows
let oldTree = el('ul', { id: 'list' }, [ el('li', { key: 1 }, ['Item 1']), el('li', {}, ['Item']), el('li', { key: 2 }, ['Item 2']), el('li', { key: 3 }, ['Item 3']) ]) let newTree = el('ul', { id: 'list' }, [ el('li', { key: 3 }, ['Item 3']), el('li', { key: 1 }, ['Item 1']), el('li', {}, ['Item']), el('li', { key: 4 }, ['Item 4']) ])
For the comparison of the differences between the old and new nodes in the above example, if I say to show the code directly to the small partners to understand the process of node operation, it is estimated that everyone will say that I am a hooligan. So I sorted out a flow chart to explain how list diff calculates node differences, as follows
Let's look at the pictures and talk. What Listdiff does is very simple and straightforward.
- In the first step, newChildren operates close to the old Children form (mobile operation, which is done by traversing the old Children directly in the code) and gets simulateChildren = [key1, no key, null, key3]
Step 1. Old Children's first element key1 corresponds to the second element in new Children
Step 2. Old Children's second element has no key corresponding to the third element in new Children
Step 3. Old Children's third element, key2, is not found in newChildren and is set directly to null.
Step 4. oldChildren's fourth element key3 corresponds to the first element in newChildren - The second step is to process the simulateChildren slightly and add null elements and new elements in the new Children to get simulateChildren = [key1, no key, key3, key4]
- The third step is to approach the simulate Children in the form of new Children and record all the mobile operations here. So at last we get an array of move s in the figure above, which stores the operations of all the mobile classes of the nodes.
OK, the whole process is clear, and the next thing will be much simpler. All we need to do is code the things listed above. (Note: I would like to implement it step by step, but there are a lot of things involved in each step. I'd like to write all the code in list diff and paste it with comments, for fear that there will be too much code posted at that time.)
/** * Diff two list in O(N). * @param {Array} oldList - Original list * @param {Array} newList - A new list of operations * @return {Object} - {moves: <Array>} * - moves list Collection of operation records */ function diff (oldList, newList, key) { let oldMap = getKeyIndexAndFree(oldList, key) let newMap = getKeyIndexAndFree(newList, key) let newFree = newMap.free let oldKeyIndex = oldMap.keyIndex let newKeyIndex = newMap.keyIndex // Record all move operations let moves = [] // a simulate list let children = [] let i = 0 let item let itemKey let freeIndex = 0 // newList operates close to the form of oldList while (i < oldList.length) { item = oldList[i] itemKey = getItemKey(item, key) if (itemKey) { if (!newKeyIndex.hasOwnProperty(itemKey)) { children.push(null) } else { let newItemIndex = newKeyIndex[itemKey] children.push(newList[newItemIndex]) } } else { let freeItem = newFree[freeIndex++] children.push(freeItem || null) } i++ } let simulateList = children.slice(0) // Remove some non-existent elements from the list i = 0 while (i < simulateList.length) { if (simulateList[i] === null) { remove(i) removeSimulate(i) } else { i++ } } // i => new list // j => simulateList let j = i = 0 while (i < newList.length) { item = newList[i] itemKey = getItemKey(item, key) let simulateItem = simulateList[j] let simulateItemKey = getItemKey(simulateItem, key) if (simulateItem) { if (itemKey === simulateItemKey) { j++ } else { // If removing the current simulateItem allows the item to be in the right place, remove it directly let nextItemKey = getItemKey(simulateList[j + 1], key) if (nextItemKey === itemKey) { remove(i) removeSimulate(j) j++ // After removal, the current value of j is correct, adding itself directly into the next loop } else { // Otherwise insert the item directly insert(i, item) } } // If it's a new item, execute inesrt directly } else { insert(i, item) } i++ } // if j is not remove to the end, remove all the rest item // let k = 0; // while (j++ < simulateList.length) { // remove(k + i); // k++; // } // Record remote operations function remove (index) { let move = {index: index, type: 0} moves.push(move) } // Record insert operations function insert (index, item) { let move = {index: index, item: item, type: 1} moves.push(move) } // remove the element removing the node in the simulateList corresponding to the actual list function removeSimulate (index) { simulateList.splice(index, 1) } // Returns all operation records return { moves: moves, children: children } } /** * The list is transformed into a key-item keyIndex object for presentation. * @param {Array} list * @param {String|Function} key */ function getKeyIndexAndFree (list, key) { let keyIndex = {} let free = [] for (let i = 0, len = list.length; i < len; i++) { let item = list[i] let itemKey = getItemKey(item, key) if (itemKey) { keyIndex[itemKey] = i } else { free.push(item) } } // Return key-item keyIndex return { keyIndex: keyIndex, free: free } } function getItemKey (item, key) { if (!item || !key) return void 0 return typeof key === 'string' ? item[key] : key(item) } module.exports = diff
4. Implementing patch and parsing patch objects
I believe that there are still many small partners who will jump directly from the previous chapters in order to see the re-rendering of the page after diff.
If you come after you have carefully looked at diff's comparison with hierarchical elements, the operation here is actually quite simple. Because he is basically in line with the previous idea of operation, which is to traverse Element and give it a unique identifier, then here it is parsed along the unique key value provided by the patch object. Give you some deep traversal code directly
function patch (rootNode, patches) { let walker = { index: 0 } walk(rootNode, walker, patches) } function walk (node, walker, patches) { let currentPatches = patches[walker.index] // Extract the difference of the current node from patches let len = node.childNodes ? node.childNodes.length : 0 for (let i = 0; i < len; i++) { // Depth traversal subnode let child = node.childNodes[i] walker.index++ walk(child, walker, patches) } if (currentPatches) { dealPatches(node, currentPatches) // DOM operation on the current node } }
History has always been strikingly similar, and now little buddies should know the benefits of deep traversal before adding a unique identifier to each Element node. OK, next we operate on the current node according to different types of differences
function dealPatches (node, currentPatches) { currentPatches.forEach(currentPatch => { switch (currentPatch.type) { case REPLACE: let newNode = (typeof currentPatch.node === 'string') ? document.createTextNode(currentPatch.node) : currentPatch.node.render() node.parentNode.replaceChild(newNode, node) break case REORDER: reorderChildren(node, currentPatch.moves) break case ATTRS: setProps(node, currentPatch.props) break case TEXT: if (node.textContent) { node.textContent = currentPatch.content } else { // for ie node.nodeValue = currentPatch.content } break default: throw new Error('Unknown patch type ' + currentPatch.type) } }) }
Specific setAttrs and reorder s are implemented as follows
function setAttrs (node, props) { for (let key in props) { if (props[key] === void 0) { node.removeAttribute(key) } else { let value = props[key] _.setAttr(node, key, value) } } } function reorderChildren (node, moves) { let staticNodeList = _.toArray(node.childNodes) let maps = {} // Storing nodes with key special fields staticNodeList.forEach(node => { // If the current node is ElementNode, the node containing the key field is stored through maps if (_.isElementNode(node)) { let key = node.getAttribute('key') if (key) { maps[key] = node } } }) moves.forEach(move => { let index = move.index if (move.type === 0) { // remove item if (staticNodeList[index] === node.childNodes[index]) { // maybe have been removed for inserting node.removeChild(node.childNodes[index]) } staticNodeList.splice(index, 1) } else if (move.type === 1) { // insert item let insertNode = maps[move.item.key] ? maps[move.item.key] // reuse old item : (typeof move.item === 'object') ? move.item.render() : document.createTextNode(move.item) staticNodeList.splice(index, 0, insertNode) node.insertBefore(insertNode, node.childNodes[index] || null) } }) }
At this point, our patch method has been implemented, and virtual DOM & diff has been completed. Finally, we can breathe a sigh of relief. I can see the little friends here and give you a big compliment.
summary
This paper begins with Element simulating DOM nodes, and then restores Element to real DOM nodes by render method. Then, by completing the diff algorithm, we compare the differences between the old and the new Elements and record them in the patch object. Finally, the patch method is completed, and the patch object is parsed to complete the update of DOM.
Note: My level is limited, and I have learned so much. If you find any imprecise places, you are welcome to point out that, of course, you can give a compliment (collection is not praised by hooligans)!
All of the above code is in my github overwrite project.
Full code portal
github-https://github.com/xuqiang521/overwrite
Code cloud- http://git.oschina.net/qiangdada_129/overwrite
If you like overwrite, you can star t a wave. What are you doing? The homepage of the project is introduced.
Finally, let's give a famous quote to our little companion.