这是 聊diff 的第三篇文章,聊聊vue3的diff思路.思路主要来自 vue-design 项目
【第一篇】和面试官聊聊Diff___React
【第二篇】和面试官聊聊Diff___vue2
【第三篇】和面试官聊聊Diff___Vue3(本文)html
为了更好的阅读体验,建议从第一篇看起前端
我是一名前端的小学生。行文中对某些设计原理理解有误十分欢迎你们讨论指正😁😁😁,谢谢啦!固然有好的建议也谢谢提出来
(玩笑)vue
Let's startnode
本文注重的是patch过程,具体的细节和边界就没有考虑。react
==另 外 注 意==git
- 三篇文章 diff 的讲解,为了方便展现 节点复用, 用了
children
保存内容,实际上这是不合理的,由于children不一样还会递归补丁(patch)- diff也不是vue optimize的所有,只是其中一部分,例如compile时肯定节点类型,不一样类型 不一样的
mount/patch
处理方式等等。
Vue2.x的 diff 相对于 react 更好一些,避免了一些没必要要的比对。
我先假设有以下节点, key
是 Vnode
的 key
, children
表明该节点的内容github
// 之前的节点 const preNodes = [ {key: "k-1", children: "<span>old1</span>"}, {key: "k-2", children: "<span>old2</span>"}, {key: "k-3", children: "<span>old3</span>"}, {key: "k-4", children: "<span>old4</span>"}, {key: "k-5", children: "<span>old5</span>"}, {key: "k-6", children: "<span>old6</span>"}, ] // 新节点,最后更新的结果 const nextNodes = [ {key: "k-11", children: "<span>11</span>"}, {key: "k-0", children: "<span>0</span>"}, {key: "k-5", children: "<span>5</span>"}, {key: "k-13", children: "<span>13</span>"}, {key: "k-1", children: "<span>1</span>"}, {key: "k-7", children: "<span>7</span>"}, {key: "k-16", children: "<span>16</span>"}, {key: "k-3", children: "<span>3</span>"}, {key: "k-15", children: "<span>15</span>"}, {key: "k-17", children: "<span>7</span>"}, {key: "k-4", children: "<span>4</span>"}, {key: "k-6", children: "<span>6</span>"} ]
diff 是基于新旧的 diff, 先要明确这个大前提,若是刚刚开始没有节点,则会先 mount 而不会 patch。
最后指望的结果(老节点都获得了复用)面试
另外新产生节点(newNodes)是基于老节点的,因而算法
// 最终的节点数据,由于最终的节点是基于老节点的,这里作个模拟 let newNodes = JSON.parse(JSON.stringify(preNodes));
preNodes
: 老节点;nextNodes
: 新节点;newNodes
: 新产生节点,最后用于渲染为真实dom.其实vue2早期就是先彻底产生新节点,最后再渲染为真实dom.后面版本变为一次(patch)优化遍历时就更新相应的Dom.segmentfault中心思想提炼: 从老节点中找到与新节点中key相同的节点,进行复用。
详细思路:
1. 先找两端相同的节点(key相同),找到即再往中间找。
如图,J
从开头找, preEndIndex
和 nextEndIndex
分别对应老节点和新节点的末尾索引。找到就增长 J 或者减小 preEndIndex
和 nextEndIndex
。
代码以下
let j = 0; let preEndIndex = preNodes.length - 1; let nextEndIndex = nextNodes.length - 1; let preVNode = preNodes[j]; let nextVNode = nextNodes[j]; while(preVNode.key === nextVNode.key){ j++; preVNode = preNodes[j]; nextVNode = nextNodes[j]; } preVNode = preNodes[preEndIndex]; nextVNode = nextNodes[nextEndIndex]; while(preVNode.key === nextVNode.key){ preVNode = preNodes[--preEndIndex]; nextVNode = nextNodes[--nextEndIndex]; }
考虑到一种状况
上面的状况会出现老节点比对完了,新节点还存在,那么最后会形成 J > preEndIndex
[状况1],同理,老节点未比对完,新节点已经比对完,那么会出现 J > nextEndIdnex
[状况2].
针对这两种状况,
因而把原来代码改一下:
// .... outer: { while(preVNode.key === nextVNode.key){ j++; if(j> preEndIndex || j > newEndIndex) { break outer; } preVNode = preNodes[j]; nextVNode = nextNodes[j]; } preVNode = preNodes[preEndIndex]; nextVNode = nextNodes[nextEndIndex]; while(preVNode.key === nextVNode.key){ if(j> preEndIndex || j > nextEndIndex) { break outer; } preVNode = preNodes[--preEndIndex]; nextVNode = nextNodes[--nextEndIndex]; } } if(j > preEndIndex) { // 老节点遍历完了,新节点还存在,将新节点放入。 for(let i = j; i< nextEndIndex; i++){ const addedNode = nextNodes[i]; // 注意: 框架内部是利用appendchild 更新dom. newNodes.splice(i,0,addedNode); } } else if(j > nextEndIndex){ // 新节点遍历完了,老节点还存在,将老节点删除。 const deleteLen = preEndIndex - j; // 注意: 框架内部是重写removeChild 更新dom. newNodes.splice(i,deleteLen); }else { //均还有不一样节点时 }
大多数状况就像刚开始的例子同样: 中间都还存在不一样的节点,须要移动和新增。这个状况是patch
主要处理的地方,代码写在上面的 else
里面。
具体思路是怎样呢,
2. 产生一个老节点可复用节点的映射数组
先生成一个 每项为 -1
的数组noPatchedIndex
,长度为 新节点未遍历的节点长度。
遍历未比对的 老节点和新节点(这里有个优化细节: 新节点不用遍历,由于结构的特殊性,直接生成key
对应 index
的对象 keyInIndex
例如{k-1: 0, k-2: 1, ...}
,后面直接取)。
未比对老节点中若是存在未比对新节点相同的节点那么在 noPatchedIndex
相应位置保存起来它在老节点中的索引。
另外判断时,新节点中不存在还需删除老节点,而且得出是否须要移动元素(索引数组 noPatchedIndex
中存在非递增排序,即数组中当前项不能大于以后的项)
大概这个意思
新生成节点。k-2在新节点(nextNodes
)中不存在,因此被删除。
3. 处理须要移动的状况(复用节点处理)
大概有以下步骤:
noPatchedIndex
最大递增子序列 lisArr
索引数组针对①,这里有一个函数 lis
,
lis([3,1,5,4,2]) //[1, 4] | 1,2 为最大递增子序列 lis([1,2,3]) //[0, 1, 2] | 1,2,3 为最大递增子序列 lis([0,-1,8,6,10,7]) //[1, 3, 5] | -1,6,7 为最大递增子序列
lis
具体实现请参考个人另外一篇文章 [算法篇---寻找最大递增子序列]()
因而有
针对②,为何要找最大递增子序列 lisArr
呢,由于对于 lisArr
里面的项顺序是不用动的,新节点的未比对节点只须要在这些项先后插入便可。
具体实现就是遍历noPatchedIndex
与 lisArr
:
noPatchedIndex
项等于 -1
,表示,老节点中不存在的项,须要新增noPatchedIndex
索引 与 lisArr
不相等时,须要移动老节点到响应的位置noPatchedIndex
索引 与 lisArr
相等时,不作操做。须要注意:遍历都是从后向前遍历,目的是防止数组长度变换影响索引值进而影响节点取值,插入,删除。
为了直观的理解,下面来一波操做图:i
与 j
为 noPatchedIndex
与 lisArr
删除的为红色,新增的为绿色,复用的节点为灰色;
节点复用插入时调用的是DOM API [insertBefore]()这个是先回添加该节点若是存在重复是会删除原有节点的;
插入位置 为 节点nextNodes[nopatchedIndex[lisArr[j]]]
对应在oldNodes
的位置
1)i =10, j = 2
; 节点 k-4
复用
2)i=9, j=1
3)i=8, j=1
. 新增
4)i=7,j=1.
k-3
复用
5)i=6, j=0
6)i=5, j=0
7)i=4, j=0
.插入(涉及到增长和删除),这里由于插入的是老节点,而原节点(2)在插入位置(0)后面,因此新增以后删除的索引位置要减一(代码中会有体现)
8)i=3,j=0
新增
9)i=2, j=0
.插入(涉及到增长和删除),这里由于插入的是老节点,而原节点(9)在插入位置(0)后面,因此新增以后删除的索引位置要减一(代码中会有体现)
10)i=1,j=0
11)i=0, j=0
,新增
12) i= -1
, 循环结束。
最后结果来看,k-5
, k-1
, k-2
, k-4
, k-6
获得了复用, 那么k-2
到哪去了呢,新节点nextNodes不含该节点天然在移动(move)前就删除了!前面提到了。
至此,diff过程结束了相信其实看图也能够看明白
哎,画图太累了🤣,如今真心对那么文章配有图解说的博主 瑞思拜🙏🙏🙏(respect!!!)。absolute!!!
最后奉上所有代码。
// 老节点 const preNodes = [ {key: "k-1", children: "<span>old1</span>"}, {key: "k-2", children: "<span>old2</span>"}, {key: "k-3", children: "<span>old3</span>"}, {key: "k-4", children: "<span>old4</span>"}, {key: "k-5", children: "<span>old5</span>"}, {key: "k-6", children: "<span>old6</span>"}, ] // 新节点 const nextNodes = [ {key: "k-11", children: "<span>11</span>"}, {key: "k-0", children: "<span>0</span>"}, {key: "k-5", children: "<span>5</span>"}, {key: "k-13", children: "<span>13</span>"}, {key: "k-1", children: "<span>1</span>"}, {key: "k-7", children: "<span>7</span>"}, {key: "k-6", children: "<span>6</span>"}, {key: "k-3", children: "<span>3</span>"}, {key: "k-15", children: "<span>15</span>"}, {key: "k-17", children: "<span>7</span>"}, {key: "k-4", children: "<span>4</span>"}, {key: "k-6", children: "<span>6</span>"} ] // 最终的节点数据,由于最终的节点是基于老节点的,这里作个模拟 let newNodes = JSON.parse(JSON.stringify(preNodes)); //两个都从左边开始比对的索引 let j = 0; let preEndIndex = preNodes.length - 1; let nextEndIndex = nextNodes.length - 1; let preVNode = preNodes[j]; let nextVNode = nextNodes[j]; outer: { while(preVNode.key === nextVNode.key){ j++; if(j> preEndIndex || j > newEndIndex) { break outer; } preVNode = preNodes[j]; nextVNode = nextNodes[j]; } preVNode = preNodes[preEndIndex]; nextVNode = nextNodes[nextEndIndex]; while(preVNode.key === nextVNode.key){ if(j> preEndIndex || j > nextEndIndex) { break outer; } preVNode = preNodes[--preEndIndex]; nextVNode = nextNodes[--nextEndIndex]; } } if(j > preEndIndex) { // 老节点遍历完了,新节点还存在,将新节点放入。 for(let i = j; i< nextEndIndex; i++){ const addedNode = nextNodes[i]; // 注意: 框架内部是利用appendchild 更新dom. newNodes.splice(i,0,addedNode); } } else if(j > nextEndIndex){ // 新节点遍历完了,老节点还存在,将老节点删除。 const deleteLen = preEndIndex - j; // 注意: 框架内部是重写removeChild 更新dom. newNodes.splice(i,deleteLen); } else { //保存是否须要移动 let moved = false; const preStart = j; const nextStart = j; let pos = 0; //保存新节点key-index 的map, 避免屡次循环 const keyInIndex = {}; //{ k-1: 1, k-2: 2, k-3:3,... } //新节点未比对的节点长度 const newLength = nextEndIndex - nextStart + 1; for(let i = nextStart; i< newLength; i++) { keyInIndex[nextNodes[i].key] = i } const oldLength = preEndIndex - preStart + 1; //防止老节点比新节点多时,删除已找到的重复的节点 let patched = 0; // 产生新节点能复用的老节点的索引数组 const noPatchedIndex = Array(newLength).fill(-1); //-1状态保存 for(let i = preStart; i< oldLength; i++) { const preNode = preNodes[i]; if(patched <= nextEndIndex) { //保存老节点key在新节点中对应的index const k = keyInIndex[preNode.key]; if(typeof k !== 'undefined'){ let idx = k - preStart; noPatchedIndex[idx] = i; patched++; // 筛选出须要往前调换的元素 if(k < pos) moved = true; else pos = k; }else { // 动态查找删除项索引 const deleteIndex = newNodes.findIndex(node => node.key === preNodes[i].key) newNodes.splice(deleteIndex,1); } }else { newNodes.splice(i,1) } } //处理须要移动的状况 if(moved){ const newNodesCopy = JSON.parse(JSON.stringify(newNodes)) //最大递增子序列索引 const lisArr = lis(noPatchedIndex); let j = lisArr.length - 1; //遍历新节点中未比对的节点,从后面遍历,防止更新过程index非预期变化。 for(let i=newLength - 1; i>=0; i--){ const current = noPatchedIndex[i]; // 更新的实际位置 const pos = i+nextStart; let insertPos = newNodes.findIndex(node => node.key === nextNodes[nextStart+lisArr[j]].key); if(current === -1){// -1即为新增的状况 // 注意 [1,2,3].splice(0,4) => [4,1,2,3] newNodes.splice(insertPos+1, 0, nextNodes[pos]); continue; }else if(lisArr[j] !== i) {//能够复用非递增节点的状况 /* insertBefore 做用: 若是给定的子节点是对文档中现有节点的引用,insertBefore() 会将其从当前位置移动到新位置 如下的操做就是实现 insertBefore 方法。 */ //移动元素在新节点的位置 let oldPos = newNodes.findIndex(node => node.key === nextNodes[pos].key); //须要删除插入的节点对应原来的节点 //新节点中插入老节点对应的位置 newNodes.splice(insertPos+1, 0, newNodes[oldPos]); // 判断插入节点的位置在被插入位置的前面仍是后面,若是是后面就加1 oldPos = insertPos > oldPos ? oldPos : oldPos+1; newNodes.splice(oldPos, 1) }else { j--; } } } } console.log('newNodes: ', newNodes); // 寻找最大递增子序列 索引 // https://en.wikipedia.org/wiki/Longest_increasing_subsequence /* [3,1,5,4,2] => [1,2] */ function lis(arr) { const p = arr.slice(); const result = [0]; // 索引数组 let i; let j; let u; let v; let c; const len = arr.length; for (i = 0; i < len; i++) { const arrI = arr[i]; if (arrI !== 0) { // 取最后一个元素 j = result[result.length - 1]; if (arr[j] < arrI) { p[i] = j; result.push(i); continue; } u = 0; v = result.length - 1; //result长度大于1时 while (u < v) { // 取中位数 c = ((u + v) / 2) | 0; if (arr[result[c]] < arrI) { u = c + 1; } else { v = c; //result中位数大于等于 当前项。v取中位数 } } if (arrI < arr[result[u]]) { if (u > 0) { p[i] = result[u - 1]; } result[u] = i; } } } u = result.length; v = result[u - 1]; while (u-- > 0) { result[u] = v; v = p[v]; } return result; }
本文中例子只是为了更好理解diff思路, patch过程与真实状况还有些差别
insertbefore
、 delete
、add
。这些方法均是单独封装不能采用相对应的 Dom Api,由于 vue 不止用在浏览器环境。
Vue@3.2
⇲ 已经出来了,React@18
也快了,哎,框架学不完。仍是多看看不变的东西吧(js, 设计模式, 数据结构,算法...)哎哎哎,,同志,看完怎么不点赞,别看别人就说你呢,你几个意思?
站在别人肩膀能看的更远。
【推荐】vue-design
【掘金小册】剖析Vue.js内部运行机制
【Vue patch源码地址】vue-next ⇲
另外,大佬们正在翻译 vue3的 英文文档 docs-next-zh-cn
以上。