vue2-diff算法手写一步步增加功能

前面

vue2 diff算法虽然只有50行,但4个指针思想很难理解,因此便有了这篇文章。
我的想法一步步实现这个diff,然后发现问题,解决问题,慢慢向源码实现靠拢。



vue2 diff算法思想

  • 尽可能复用原来的老节点
  • 比如老节点头尾跟新节点头尾看看是不是同一个节点,是的话打个补丁, 没有找到可复用的就重新创建)。


vue patch工作流程简单版本

示例代码

copy
<div class="app" @click="count++">{{count}}</div> new Vue({ data(){ return { count: 0 } } })

1. 首次$mount

  • 首次$mount的时候, vue会根据模板生成对应的VNode
  • 发现第一次渲染,直接根据vnode全量生成dom即可
copy
// 第一次渲染的VNode { tag: "div", attrs: {'class': 'app'}, children: [ {tag: undefined, text: 0} ] }

2. count变化的时候

  • 当count变化,会重新生成一次VNode
  • 与上一份的VNode进行比较,发现不一样的就去修改对应的dom,这时候一个优秀的diff算法就很重要了,优秀的diff算法可以最小化减少dom性能的消耗。
copy
// count变化时候渲染的VNode { tag: "div", attrs: {'class': 'app'}, children: [ {tag: undefined, text: 1} ] }

3. vue2 diff算法原则

  • 以组件为一个整体,如果发现根标签不是同一个元素, 会销毁所有的dom,全部重新创建。相关源码如下
    image
  • diff算法是只同层比较, 深度优先。先创建完元素,然后最后再删除不需要的。


开始实现

1. 先实现一个h函数,专门生成VNode的。(类似于vue2的render函数)

copy
function h(tag, attrs, children){ // h('div', {}, [h(undefined, {}, '111')]) if (tag){ const node = { tag, attrs, children } return node; } return { tag, attrs, text: children } } // <div>111</div> h('div', {}, [h(undefined, {}, '111')])
  • 这样就可以生成我们想要的Vnode了。如下图所示

image


2. 实现第一次渲染

第一次渲染根据vnode直接全量生成vnode即可。

copy
// 这是dom <div class="app">占位</div> function h(tag, attrs, children){ // h('div', {}, [h(undefined, {}, '111')]) if (tag){ const node = { tag, attrs, children } return node; } return { tag, attrs, text: children } } function createElm(parentDom, vnode){ const {tag, attrs, children, text} = vnode; let dom; if (tag){ // 元素节点 dom = document.createElement(tag); Object.keys(attrs).forEach(key=>{ dom.setAttribute(key, attrs[key]); }); // 子元素 children && children.forEach(child=>createElm(dom, child)); }else{ // 文本节点 dom = document.createTextNode(text); } parentDom.appendChild(dom); } function patch(vnode){ const elm = document.querySelector('.app'); // dom挂载点 const container = elm.parentNode; createElm(container, vnode); // 删除占位的 elm.remove(); } patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world')]));

3. patch vnode

如果数据变化了,vue会重新生成一个vnode,然后那这个vnode跟之前的老的vnode对比。在对比的过程中对dom进行修改。

copy
// 实现目标 patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world')])); patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world-changed')])); // dom修改为 hello world-changed

3.1. 直接暴力修改,删掉原来的,然后根据最新的vnode生成。

copy
<div class="app">占位</div> function h(tag, attrs, children){ // h('div', {}, [h(undefined, {}, '111')]) if (tag){ const node = { tag, attrs, children } return node; } return { tag, attrs, text: children } } function createElm(parentDom, vnode){ const {tag, attrs, children, text} = vnode; let dom; if (tag){ // 元素节点 dom = document.createElement(tag); Object.keys(attrs).forEach(key=>{ dom.setAttribute(key, attrs[key]); }); // 子元素 children && children.forEach(child=>createElm(dom, child)); }else{ // 文本节点 dom = document.createTextNode(text); } // 这里是为了后面diff的时候可以直接对该vnode进行dom操作。 vnode.elm = dom; parentDom.appendChild(dom); } // 判断两个节点是否是一样的,因为每次的虚拟节点都是直接生成的,看着对象是一样的,但其实并不是一个对象。(对象引用) function sameNode(a, b){ // 这里的策略是 return a.tag === b.tag && a.tag !== undefined; } function updateChildren(parentDom, oldCh, ch){ for (let i=0; i<ch.length; i++){ // 暴力根据新节点进行递归创建 createElm(parentDom, ch[i]); } // 删除所有的老节点 for (let i=0; i<oldCh.length; i++){ oldCh[i].elm.remove(); } } function patchVnode(old, vnode){ // 一样的,就不用对比了。 if (old === vnode) return; // 本节点的处理,更新attrs,props,class之类的,这边先不实现此功能。 const ch = vnode.children; const oldCh = old.children; vnode.elm = old.elm; const elm = vnode.elm; if (ch && oldCh){ if (ch !== oldCh){ // 都有子节点,可以进行下一步diff操作,其他的情况下直接删除对应dom然后重新创建; updateChildren(elm, oldCh, ch); } } } // 保存新老节点 let prev, current; function patch(vnode){ if(!prev || !sameNode(prev, vnode)){ // 第一次挂载或者发现根节点的元素都不一样了,那就全量生成 prev = vnode; const elm = document.querySelector('.app'); // dom挂载点 const container = elm.parentNode; createElm(container, vnode); elm.remove(); return; } const old = prev; current = vnode; patchVnode(old, current); // patch完毕后,这里的vnode就成为老节点了,等待下一次新的vnode进行对比 prev = current; } patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world')])); patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world -changed')]));

3.2. 优化-复用老节点

考虑如下情况

copy
patch('div', {'class':'app'}, [h('a', {}, [h(undefined, {}, 'hello world')])]); patch('div', {'class':'app'}, [h('a', {}, [h(undefined, {}, 'hello world-changed-a')])]); /* 像这里的 a标签之前老节点是有的,因此不需要删除重建整个a标签. 我们只需要给a标签新建一个 文本节点 hello world-changed-a,然后 删除之前的文本节点 hello world 即可。 这样可比上面那种暴力删除a标签,然后重新创建a标签节省很多性能。 */

具体实现

copy
function createElm(parentDom, vnode, ref){ const {tag, attrs, children, text} = vnode; let dom; if (tag){ // 元素节点 dom = document.createElement(tag); Object.keys(attrs).forEach(key=>{ dom.setAttribute(key, attrs[key]); }); // 子元素递归创建 children && children.forEach(child=>createElm(dom, child)); }else{ // 文本节点 dom = document.createTextNode(text); } // 这里是为了后面diff的时候可以直接对该vnode进行dom操作。 vnode.elm = dom; if (ref){ parentDom.insertBefore(dom, ref); }else{ parentDom.appendChild(dom); } } // 从老节点进行搜索,看看tag是否一样,是一样的tag,可以直接返回该老节点了。 function getOldIndex(oldCh, vnode){ for (let i=0; i<oldCh.length; i++){ if(oldCh[i]&&sameNode(oldCh[i], vnode)){ return i; } } } function updateChildren(parentDom, oldCh, ch){ // 这个值是为了确定从哪里位置开始删oldCh let oldStart = 0; // 这个值是确定从哪个位置增加元素 let newStart = 0; let oldStartNode = oldCh[oldStart]; // oldCh与当前的dom是完全对应的。 for (let i=0; i<ch.length; i++){ // 遇到之前已经被复用过的节点了。那就跳到下一个呗。 // 为啥while循环,因为可能可能会出现连续undefined的情况。 while(oldStartNode === undefined){ oldStart++; oldStartNode = oldCh[oldStart] } // 暴力根据新节点进行递归创建 const oldIndex = getOldIndex(oldCh, ch[i]); if (oldIndex === undefined){ // 没有在老的vnode找到,只能重新创建该节点了。 createElm(parentDom, ch[i], oldStartNode.elm); }else{ const vnodeToRemove = oldCh[oldIndex]; /* patch('div', {'class':'app'}, [ h('a', {}, [h(undefined, {}, 'hello world') ]); patch('div', {'class':'app'}, [ h('li', {}, [h(undefined, {}, 'lilili')]), h('a', {}, [h(undefined, {}, 'hello world-changed-a')]) ]); 当执行到 updateChildren(parentDom, [h('a'...)], [h('li'...), h('a')]) 时 发现 'li'标签在老的节点上找不到,所以重新创建了下li元素,不过这里需要注意的是,li元素应该从头部开始插入 因为都得按照最新的vnode的顺序来,第一次插入0号位置,第二次就是插入1号位置,以此类推 */ // 把这个老 dom更新成最新的状态了 patchVnode(vnodeToRemove, ch[i]); // 移动老dom位置挪到正确的位置。 parentDom.insertBefore(vnodeToRemove, oldStartNode.elm); // 这个老dom不应该再次被复用了(下次遍历时候 getOldIndex 函数不会再次扫到该vnode) oldCh[oldIndex] = undefined; } newStart++; } // 如果新的子节点大于旧的说明需要创建 /* 旧 [h(undefined, {}, '111'), h(undefined, {}, '222')] 新 [h(undefined, {}, '111-1'), h(undefined, {}, '2222'), h(undefined, {}, '3333')] */ const oldLength = oldCh.length; const newLength = ch.length; if (newLength>oldLength){ // 直接appendChild即可 for(let i=oldLength; i<newLength; i++){ createElm(parentDom, ch[i].elm); } }else{ // 旧的大于新的,那就要移除不需要的。 for(let i=newLength; i<oldLength; i++){ oldCh[i].elm.remove(); } } }

3.3. 增加循环停止条件

  • 测试上面的代码, 会发现当新的vnode子节点小于老节点时候正常,但是当新的vnode子节点大于老节点时,会出现异常情况。
  • 考虑如下情况
copy
// 老节点 [h(undefined, {}, '111')] // 新节点 [h(undefined, {}, '111'), h(undefined, {}, '222'), h(undefined, {}, '222')]
  • 这种情况就应该看看 oldStartNode 是否已经超出 oldCh
  • 不能通过undefined方式判断,因为patch的时候会将 oldch里的vnode置为 undefined
  • 应该判断 oldStart是否大于等于老节点的长度即可。若大于,停止循环。

3.3.1 代码实现

copy
// 从老节点进行搜索,看看tag是否一样,是一样的tag,可以直接返回该老节点了。 function getOldIndex(oldCh, vnode){ for (let i=0; i<oldCh.length; i++){ if(oldCh[i]&&sameNode(oldCh[i], vnode)){ return i; } } } function updateChildren(parentDom, oldCh, ch){ // 这个值是为了确定从哪里位置开始删oldCh let oldStart = 0; // 这个值是确定从哪个位置增加元素 let newStart = 0; let oldStartNode = oldCh[oldStart]; // oldCh与当前的dom是完全对应的。 foo:for (let i=0; i<ch.length; i++){ // 遇到之前已经被复用过的节点了。那就跳到下一个呗。 // 为啥while循环,因为可能可能会出现连续undefined的情况。 while(oldStartNode === undefined){ oldStart++; if (oldStart>=oldCh.length) break foo; oldStartNode = oldCh[oldStart] } // 暴力根据新节点进行递归创建 const oldIndex = getOldIndex(oldCh, ch[i]); if (oldIndex === undefined){ // 没有在老的vnode找到,只能重新创建该节点了。 createElm(parentDom, ch[i], oldStartNode.elm); }else{ const vnodeToRemove = oldCh[oldIndex]; /* patch('div', {'class':'app'}, [ h('a', {}, [h(undefined, {}, 'hello world') ]); patch('div', {'class':'app'}, [ h('li', {}, [h(undefined, {}, 'lilili')]), h('a', {}, [h(undefined, {}, 'hello world-changed-a')]) ]); 当执行到 updateChildren(parentDom, [h('a'...)], [h('li'...), h('a')]) 时 发现 'li'标签在老的节点上找不到,所以重新创建了下li元素,不过这里需要注意的是,li元素应该从头部开始插入 因为都得按照最新的vnode的顺序来,第一次插入0号位置,第二次就是插入1号位置,以此类推 */ // 把这个老 dom更新成最新的状态了 patchVnode(vnodeToRemove, ch[i]); // 移动老dom位置挪到正确的位置。 parentDom.insertBefore(vnodeToRemove, oldStartNode.elm); // 这个老dom不应该再次被复用了(下次遍历时候 getOldIndex 函数不会再次扫到该vnode) oldCh[oldIndex] = undefined; } newStart++; } // oldStart 从哪里开始删, 要是等于oldCh的长度,那说明也不用删了 // newStart 从哪里开始增加,要是等于ch的长度,那就不用增加了 // 如果新的子节点大于旧的说明需要创建 /* 旧 [h(undefined, {}, '111'), h(undefined, {}, '222')] 新 [h(undefined, {}, '111-1'), h(undefined, {}, '2222'), h(undefined, {}, '3333')] */ for(let i=oldStart; i<oldCh.length; i++){ oldCh[i].elm.remove(); } for (let i=newStart; i<ch.length; i++){ createElm(parentDom, ch[i].elm); } }

3.4 增加预判

  • 上面的实现代码时间复杂度还是比较高的, 为 n^2。
  • 这是因为遍历了 ch的新节点时,拿着当前的vnode在所有的老节点进行查找。运气好的会在老节点第一个vnode找到,运气差的话会遍历整个老节点。

如果我们在遍历新节点的时候先尝试看看以下方式

  • 老节点第一个元素跟新节点第一个元素是否相同
  • 老节点第一个元素跟新节点最后一个元素是否相同
  • 老节点最后一个元素跟新节点第一个元素是否相同
  • 老节点最后一个元素跟新节点最后一个元素是否相同
  • 以上不符合才走遍历的方式(也就是我们上面的代码实现方式)

3.4.1 先实现第一个预判, 也就是老头与新头是否相同。

copy
function updateChildren(parentDom, oldCh, ch){ let oldStart = 0; let newStart = 0; let oldStartNode = oldCh[oldStart]; foo:for (let i=0; i<ch.length; i++){ while(oldStartNode === undefined){ oldStart++; if (oldStart>=oldCh.length) break foo; oldStartNode = oldCh[oldStart] } if (sameNode(oldStartNode, ch[i])){ // patch下,因为都是同一个位置,因此无需移动dom patchVnode(oldStartNode, ch[i]); oldStart++; if (oldStart>=oldCh.length) break foo; oldStartNode = oldCh[oldStart]; }else{ const oldIndex = getOldIndex(oldCh, ch[i]); if (oldIndex === undefined){ createElm(parentDom, ch[i], oldStartNode.elm); }else{ const vnodeToRemove = oldCh[oldIndex]; patchVnode(vnodeToRemove, ch[i]); parentDom.insertBefore(vnodeToRemove, oldStartNode.elm); oldCh[oldIndex] = undefined; } } newStart++; } for(let i=oldStart; i<oldCh.length; i++){ oldCh[i].elm.remove(); } for (let i=newStart; i<ch.length; i++){ createElm(parentDom, ch[i].elm); } }

3.4.2 全部实现

copy
function updateChildren(parentDom, oldCh, ch){ // 这个值是为了确定从哪里位置开始删oldCh let oldStart = 0; // 这个值是确定从哪个位置增加元素 let newStart = 0; let oldStartNode = oldCh[oldStart]; // oldCh与当前的dom是完全对应的。 let oldEnd = oldCh.length - 1; let oldEndNode = oldCh[oldEnd]; let newStartNode = ch[newStart]; let newEnd = ch.length - 1; let newEndNode = ch[newEnd]; while(newStart<=newEnd && oldStart <=oldEnd){ // // 遇到之前已经被复用过的节点了。那就跳到下一个呗。 // // 为啥while循环,因为可能可能会出现连续undefined的情况。 // while(oldStartNode === undefined){ // oldStart++; // if (oldStart>=oldCh.length) break foo; // oldStartNode = oldCh[oldStart] // } if (oldStartNode === undefined){ oldStart++; oldStartNode = oldCh[oldStart]; } // 新头与老头对比,位置一样,不用变 else if (sameNode(newStartNode, oldStartNode)){ patchVnode(oldStartNode, newStartNode); newStart++; oldStart++; oldStartNode = oldCh[oldStart]; newStartNode = ch[newStart]; }else if (sameNode(newEndNode, oldEndNode)){ // 新尾与老尾对比,位置一样,不用变 patchVnode(oldEndNode, newEndNode); oldEnd --; newEnd --; oldEndNode = oldCh[oldEnd]; newEndNode = ch[newEnd]; }else if (sameNode(newStartNode, oldEndNode)){ // 新头与老尾对比,位置不同 左移 patchVnode(oldEndNode, newStartNode); newStart ++; oldEnd --; newStartNode = ch[newStart]; oldEndNode = oldCh[oldEnd]; parentDom.insertBefore(newStartNode.elm, oldStartNode.elm) }else if (sameNode(newEndNode, oldStartNode)){ // 右移 /* [div-1, aa-1, bb-1, cc-1] [div-1, div-2, aa-1] */ patchVnode(oldStartNode, newEndNode); oldStart ++; newEnd --; newEndNode = ch[newEnd]; oldStart = oldCh[oldStart]; parentDom.insertBefore(newStartNode.elm, oldStartNode.elm.nextSibling) }else{ const oldIndex = getOldIndex(oldCh, newStartNode); if (oldIndex === undefined){ // 没有在老的vnode找到,只能重新创建该节点了。 createElm(parentDom, newStartNode, oldStartNode.elm); }else{ const vnodeToRemove = oldCh[oldIndex]; /* patch('div', {'class':'app'}, [ h('a', {}, [h(undefined, {}, 'hello world') ]); patch('div', {'class':'app'}, [ h('li', {}, [h(undefined, {}, 'lilili')]), h('a', {}, [h(undefined, {}, 'hello world-changed-a')]) ]); 当执行到 updateChildren(parentDom, [h('a'...)], [h('li'...), h('a')]) 时 发现 'li'标签在老的节点上找不到,所以重新创建了下li元素,不过这里需要注意的是,li元素应该从头部开始插入 因为都得按照最新的vnode的顺序来,第一次插入0号位置,第二次就是插入1号位置,以此类推 */ // 把这个老 dom更新成最新的状态了 patchVnode(vnodeToRemove,newStartNode); // 移动老dom位置挪到正确的位置。 parentDom.insertBefore(vnodeToRemove, oldStartNode.elm); // 这个老dom不应该再次被复用了(下次遍历时候 getOldIndex 函数不会再次扫到该vnode) oldCh[oldIndex] = undefined; } newStart++; newStartNode = ch[newStart]; } } // oldStart 从哪里开始删, 要是等于oldCh的长度,那说明也不用删了 // newStart 从哪里开始增加,要是等于ch的长度,那就不用增加了 // 如果新的子节点大于旧的说明需要创建 /* 旧 [h(undefined, {}, '111'), h(undefined, {}, '222')] 新 [h(undefined, {}, '111-1'), h(undefined, {}, '2222'), h(undefined, {}, '3333')] */ if (oldStart > oldEnd){ for (let i=newStart; i<=newEnd; i++){ createElm(parentDom, ch[i].elm); } }else if (newStart > newEnd){ // oldEnd 初始值为 oldCh.length 因此是 <= for(let i=oldStart; i<=oldEnd; i++){ oldCh[i].elm.remove(); } } }

总结

  • 这样的实现已经与vue2实现的diff算法差不多了
  • v-for的key并没有实现哈。不过可以看到key的重要性(只获取一次并缓存起来, 下次查询key时间复杂度均为1)。

posted @   re大法好  阅读(40)  评论(0编辑  收藏  举报
相关博文:
点击右上角即可分享
微信分享提示
💬
评论
📌
收藏
💗
关注
👍
推荐
🚀
回顶
收起