https://mp.weixin.qq.com/s/B0...css
前言 文章开篇,咱们先思考一个问题,你们都说 virtual dom 这,virtual dom 那的,那么 virtual dom 究竟是啥? 首先,咱们得明确一点,所谓的 virtual dom,也就是虚拟节点。它经过 JS 的 Object 对象模拟 DOM 中的节点,而后再经过特定的 render 方法将其渲染成真实的 DOM 节点。 其次咱们还得知道一点,那就是 virtual dom 作的一件事情究竟是啥。咱们知道的对于页面的从新渲染通常的作法是经过操做 dom,重置 innerHTML 去完成这样一件事情。而 virtual dom 则是经过 JS 层面的计算,返回一个 patch 对象,即补丁对象,在经过特定的操做解析 patch 对象,完成页面的从新渲染。具体 virtual dom 渲染的一个流程如图所示 接下来,我会老规矩,边上代码,边解析,带着小伙伴们一块儿实现一个virtual dom && diff。具体步骤以下 实现一个 utils 方法库 实现一个 Element(virtual dom) 实现 diff 算法 实现 patch 1、实现一个 utils 方法库 俗话说的好,磨刀不废砍柴功,为了后面的方便,我会在这先带着你们实现后面常常用到的一些方法,毕竟要是每次都写一遍用的方法,岂不得疯,由于代码简单,因此这里我就直接贴上代码了 const \_ = exports \_.setAttr = function setAttr (node, key, value) { switch (key) { case 'style': node.style.cssText = value break; case 'value': let tagName = node.tagName || '' tagName = tagName.toLowerCase() if ( tagName === 'input' || tagName === 'textarea' ) { node.value = value } else { // 若是节点不是 input 或者 textarea, 则使用 \`setAttribute\` 去设置属性 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 Object.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 } 2、实现一个 Element 这里咱们须要作的一件事情很 easy ,那就是实现一个 Object 去模拟 DOM 节点的展现形式。真实节点以下 <ul id="list"> <li class="item">item1</li> <li class="item">item2</li> <li class="item">item3</li> </ul> 咱们须要完成一个 Element 模拟上面的真实节点,形式以下 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'\] }, \] } 看到这里,咱们能够看到的是 el 对象中的 tagName,attrs,children 均可以提取出来到 Element 中去,即 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; 那么上面的ul就能够用更简化的方式进行书写了,即 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 则是 Element 对象,如图 OK,到这咱们 Element 算是实现一半,剩下的通常则是提供一个 render 函数,将 Element 对象渲染成真实的 DOM 节点。完整的 Element 的代码以下 import \_ from './utils' /\*\* \* @class Element Virtrual Dom \* @param { String } tagName \* @param { Object } attrs Element's attrs, 如: { id: 'list' } \* @param { Array <Element|String> } 能够是Element对象,也能够只是字符串,即textNode \*/ class Element { constructor(tagName, attrs, children) { // 若是只有两个参数 if (\_.isArray(attrs)) { children = attrs attrs = {} } this.tagName = tagName this.attrs = attrs || {} this.children = children // 设置this.key属性,为了后面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) { // 设置节点的DOM属性 let attrValue = attrs\[attrName\] \_.setAttr(el, attrName, attrValue) } let children = this.children || \[\] children.forEach(child => { let childEl = child instanceof Element ? child.render() // 若子节点也是虚拟节点,递归进行构建 : document.createTextNode(child) // 如果字符串,直接构建文本节点 el.appendChild(childEl) }) return el } } function el (tagName, attrs, children) { return new Element(tagName, attrs, children) } module.exports = el; 这个时候咱们执行写好的 render 方法,将 Element 对象渲染成真实的节点 let ulRoot = ul.render() document.body.appendChild(ulRoot); 效果如图 至此,咱们的 Element 便得以实现了。 3、实现 diff 算法 这里咱们作的就是实现一个 diff 算法进行虚拟节点 Element 的对比,并返回一个 patch 对象,用来存储两个节点不一样的地方。这也是整个 virtual dom 实现最核心的一步。而 diff 算法又包含了两个不同的算法,一个是 O(n),一个则是 O(max(m, n)) 一、同层级元素比较(O(n)) 首先,咱们的知道的是,若是元素之间进行彻底的一个比较,即新旧 Element 对象的父元素,自己,子元素之间进行一个混杂的比较,其实现的时间复杂度为 O(n^3)。可是在咱们前端开发中,不多会出现跨层级处理节点,因此这里咱们会作一个同级元素之间的一个比较,则其时间复杂度则为 O(n)。算法流程如图所示 在这里,咱们作同级元素比较时,可能会出现四种状况 整个元素都不同,即元素被 replace 掉 元素的 attrs 不同 元素的 text 文本不同 元素顺序被替换,即元素须要 reorder 上面列举第四种状况属于 diff 的第二种算法,这里咱们先不讨论,咱们在后面再进行详细的讨论 针对以上四种状况,咱们先设置四个常量进行表示。diff 入口方法及四种状态以下 const REPLACE = 0 // replace => 0 const ATTRS = 1 // attrs => 1 const TEXT = 2 // text => 2 const REORDER = 3 // reorder => 3 // diff 入口,比较新旧两棵树的差别 function diff (oldTree, newTree) { let index = 0 let patches = {} // 用来记录每一个节点差别的补丁对象 walk(oldTree, newTree, index, patches) return patches } OK,状态定义好了,接下来开搞。咱们一个一个实现,获取到每一个状态的不一样。这里须要注意的一点就是,咱们这里的 diff 比较只会和上面的流程图显示的同样,只会两两之间进行比较,若是有节点 remove 掉,这里会 pass 掉,直接走 list diff。 a、首先咱们先从最顶层的元素依次往下进行比较,直到最后一层元素结束,并把每一个层级的差别存到 patch 对象中去,即实现walk方法 /\*\* \* walk 遍历查找节点差别 \* @param { Object } oldNode \* @param { Object } newNode \* @param { Number } index - currentNodeIndex \* @param { Object } patches - 记录节点差别的对象 \*/ function walk (oldNode, newNode, index, patches) { let currentPatch = \[\] // 若是oldNode被remove掉了 if (newNode === null || newNode === undefined) { // 先不作操做, 具体交给 list diff 处理 } // 比较文本之间的不一样 else if (\_.isString(oldNode) && \_.isString(newNode)) { if (newNode !== oldNode) currentPatch.push({ type: TEXT, content: newNode }) } // 比较attrs的不一样 else if ( oldNode.tagName === newNode.tagName && oldNode.key === newNode.key ) { let attrsPatches = diffAttrs(oldNode, newNode) if (attrsPatches) { currentPatch.push({ type: ATTRS, attrs: attrsPatches }) } // 递归进行子节点的diff比较 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 = {} // 若是存在不一样的 attrs for (key in oldAttrs) { value = oldAttrs\[key\] // 若是 oldAttrs 移除掉一些 attrs, newAttrs\[key\] === undefined if (newAttrs\[key\] !== value) { count++ attrsPatches\[key\] = newAttrs\[key\] } } // 若是存在新的 attr for (key in newAttrs) { value = newAttrs\[key\] if (!oldAttrs.hasOwnProperty(key)) { count++ attrsPatches\[key\] = value } } if (count === 0) { return null } return attrsPatches } b、实际上咱们须要对新旧元素进行一个深度的遍历,为每一个节点加上一个惟一的标记,具体流程如图所示 如上图,咱们接下来要作的一件事情就很明确了,那就是在作深度遍历比较差别的时候,将每一个元素节点,标记上一个惟一的标识。具体作法以下 // 设置节点惟一标识 let key\_id = 0 // diff with children function diffChildren (oldChildren, newChildren, index, patches) { // 存放当前node的标识,初始化值为 0 let currentNodeIndex = index oldChildren.forEach((child, i) => { key\_id++ let newChild = newChildren\[i\] currentNodeIndex = key\_id // 递归继续比较 walk(child, newChild, currentNodeIndex, patches) }) } OK,这一步偶了。咱调用一下看下效果,看看两个不一样的 Element 对象比较会返回一个哪一种形式的 patch 对象 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); 控制台结果如图 完整的 diff 代码以下(包含了调用 list diff 的方法,若是你在跟着文章踩坑的话,把里面一些代码注释掉便可) import \_ from './utils' import listDiff from './list-diff' const REPLACE = 0 const ATTRS = 1 const TEXT = 2 const REORDER = 3 // diff 入口,比较新旧两棵树的差别 function diff (oldTree, newTree) { let index = 0 let patches = {} // 用来记录每一个节点差别的补丁对象 walk(oldTree, newTree, index, patches) return patches } /\*\* \* walk 遍历查找节点差别 \* @param { Object } oldNode \* @param { Object } newNode \* @param { Number } index - currentNodeIndex \* @param { Object } patches - 记录节点差别的对象 \*/ function walk (oldNode, newNode, index, patches) { let currentPatch = \[\] // 若是oldNode被remove掉了,即 newNode === null的时候 if (newNode === null || newNode === undefined) { // 先不作操做, 具体交给 list diff 处理 } // 比较文本之间的不一样 else if (\_.isString(oldNode) && \_.isString(newNode)) { if (newNode !== oldNode) currentPatch.push({ type: TEXT, content: newNode }) } // 比较attrs的不一样 else if ( oldNode.tagName === newNode.tagName && oldNode.key === newNode.key ) { let attrsPatches = diffAttrs(oldNode, newNode) if (attrsPatches) { currentPatch.push({ type: ATTRS, attrs: attrsPatches }) } // 递归进行子节点的diff比较 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 = {} // 若是存在不一样的 attrs for (key in oldAttrs) { value = oldAttrs\[key\] // 若是 oldAttrs 移除掉一些 attrs, newAttrs\[key\] === undefined if (newAttrs\[key\] !== value) { count++ attrsPatches\[key\] = newAttrs\[key\] } } // 若是存在新的 attr for (key in newAttrs) { value = newAttrs\[key\] if (!oldAttrs.hasOwnProperty(key)) { attrsPatches\[key\] = value } } if (count === 0) { return null } return attrsPatches } // 设置节点惟一标识 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) } // 存放当前node的标识,初始化值为 0 let currentNodeIndex = index oldChildren.forEach((child, i) => { key\_id++ let newChild = newChildren\[i\] currentNodeIndex = key\_id // 递归继续比较 walk(child, newChild, currentNodeIndex, patches) }) } module.exports = diff 看到这里的小伙伴们,若是以为只看到 patch 对象而看不到 patch 解析后页面从新渲染的操做而以为比较无聊的话,能够先跳过 list diff 这一章节,直接跟着 patch 方法实现那一章节进行强怼,可能会比较带劲吧!也但愿小伙伴们能够和我达成共识(由于我本身原来好像也是这样干的)。 二、listDiff实现 O(m\*n) => O(max(m, n)) 首先咱们得明确一下为何须要 list diff 这种算法的存在,list diff 作的一件事情是怎样的,而后它又是如何作到这么一件事情的。 举个栗子,我有新旧两个 Element 对象,分别为 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'\]) \]) 若是要进行 diff 比较的话,咱们直接用上面的方法就能比较出来,但咱们能够看出来这里只作了一次节点的 move。若是直接按照上面的 diff 进行比较,而且经过后面的 patch 方法进行 patch 对象的解析渲染,那么将须要操做三次 DOM 节点才能完成视图最后的 update。 固然,若是只有三个节点的话那还好,咱们的浏览器还能吃的消,看不出啥性能上的区别。那么问题来了,若是有 N 多节点,而且这些节点只是作了一小部分 remove,insert,move 的操做,那么若是咱们仍是按照一一对应的 DOM 操做进行 DOM 的从新渲染,那岂不是操做太昂贵? 因此,才会衍生出 list diff 这种算法,专门进行负责收集 remove,insert,move 操做,固然对于这个操做咱们须要提早在节点的 attrs 里面申明一个 DOM 属性,表示该节点的惟一性。另外上张图说明一下 list diff 的时间复杂度,小伙伴们能够看图了解一下 OK,接下来咱们举个具体的例子说明一下 list diff 具体如何进行操做的,代码以下 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'\]) \]) 对于上面例子中的新旧节点的差别对比,若是我说直接让小伙伴们看代码捋清楚节点操做的流程,估计你们都会说我耍流氓。因此我整理了一幅流程图,解释了 list diff 具体如何进行计算节点差别的,以下 咱们看图说话,list diff 作的事情就很简单明了啦。 第一步,newChildren 向 oldChildren 的形式靠近进行操做(移动操做,代码中作法是直接遍历 oldChildren 进行操做),获得 simulateChildren = \[key1, 无key, null, key3\] step1. oldChildren 第一个元素 key1 对应 newChildren 中的第二个元素 step2. oldChildren 第二个元素 无key 对应 newChildren 中的第三个元素 step3. oldChildren 第三个元素 key2 在 newChildren 中找不到,直接设为 null step4. oldChildren 第四个元素 key3 对应 newChildren 中的第一个元素 第二步,稍微处理一下得出的 simulateChildren,将 null 元素以及 newChildren 中的新元素加入,获得 simulateChildren = \[key1, 无key, key3, key4\] 第三步,将得出的 simulateChildren 向 newChildren 的形式靠近,并将这里的移动操做所有记录下来(注:元素的 move 操做这里会当成 remove 和 insert 操做的结合)。因此最后咱们得出上图中的一个 moves 数组,存储了全部节点移动类的操做。 OK,总体流程咱们捋清楚了,接下来要作的事情就会简单不少了。咱们只须要用代码把上面列出来要作的事情得以实现便可。(注:这里原本我是想分步骤一步一步实现,可是每一步牵扯到的东西有点多,怕到时贴出来的代码太多,我仍是直接把 list diff 全部代码写上注释贴上吧) /\*\* \* Diff two list in O(N). \* @param {Array} oldList - 原始列表 \* @param {Array} newList - 通过一些操做的得出的新列表 \* @return {Object} - {moves: <Array>} \* - moves list操做记录的集合 \*/ 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 // 记录全部move操做 let moves = \[\] // a simulate list let children = \[\] let i = 0 let item let itemKey let freeIndex = 0 // newList 向 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) // 移除列表中一些不存在的元素 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 { // 若是移除掉当前的 simulateItem 可让 item在一个正确的位置,那么直接移除 let nextItemKey = getItemKey(simulateList\[j + 1\], key) if (nextItemKey === itemKey) { remove(i) removeSimulate(j) j++ // 移除后,当前j的值是正确的,直接自加进入下一循环 } else { // 不然直接将item 执行 insert insert(i, item) } } // 若是是新的 item, 直接执行 inesrt } 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++; // } // 记录remove操做 function remove (index) { let move = {index: index, type: 0} moves.push(move) } // 记录insert操做 function insert (index, item) { let move = {index: index, item: item, type: 1} moves.push(move) } // 移除simulateList中对应实际list中remove掉节点的元素 function removeSimulate (index) { simulateList.splice(index, 1) } // 返回全部操做记录 return { moves: moves, children: children } } /\*\* \* 将 list转变成 key-item keyIndex 对象的形式进行展现. \* @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) } } // 返回 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、实现 patch,解析 patch 对象 相信仍是有很多小伙伴会直接从前面的章节跳过来,为了看到 diff 后页面的从新渲染。 若是你是仔仔细细看完了 diff 同层级元素比较以后过来的,那么其实这里的操做仍是蛮简单的。由于他和前面的操做思路基本一致,前面是遍历 Element,给其惟一的标识,那么这里则是顺着 patch 对象提供的惟一的键值进行解析的。直接给你们上一些深度遍历的代码 function patch (rootNode, patches) { let walker = { index: 0 } walk(rootNode, walker, patches) } function walk (node, walker, patches) { let currentPatches = patches\[walker.index\] // 从patches取出当前节点的差别 let len = node.childNodes ? node.childNodes.length : 0 for (let i = 0; i < len; i++) { // 深度遍历子节点 let child = node.childNodes\[i\] walker.index++ walk(child, walker, patches) } if (currentPatches) { dealPatches(node, currentPatches) // 对当前节点进行DOM操做 } } 历史老是惊人的类似,如今小伙伴应该知道以前深度遍历给 Element 每一个节点加上惟一标识的好处了吧。OK,接下来咱们根据不一样类型的差别对当前节点进行操做 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) } }) } 具体的 setAttrs 和 reorder 的实现以下 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 = {} // 存储含有key特殊字段的节点 staticNodeList.forEach(node => { // 若是当前节点是ElementNode,经过maps将含有key字段的节点进行存储 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) } }) } 到这,咱们的 patch 方法也得以实现了,virtual dom && diff 也算完成了,终于能够松一口气了。可以看到这里的小伙伴们,给大家一个大大的赞。 总结 文章先从 Element 模拟 DOM 节点开始,而后经过 render 方法将 Element 还原成真实的 DOM 节点。而后再经过完成 diff 算法,比较新旧 Element 的不一样,并记录在 patch 对象中。最后在完成 patch 方法,将 patch 对象解析,从而完成 DOM 的 update。