Open stormqx opened 5 years ago
Virtual DOM这个概念相信大部分人都不陌生,它产生的前提之一是浏览器中的DOM操作是很“昂贵”的。Virtual DOM本质上在JavaScript和DOM之间做了一层缓存,可以拿CPU和硬盘来举例,CPU(JavaScript)只操作内存(Virtual DOM),最后再把变更写入硬盘(DOM)。
Virtual DOM主要有三个实现细节:
今天我们主要讨论的是不同框架的diff算法,即上述步骤2和步骤3。因此,diff算法是Virtual DOM的加速器,它会计算出Virtual DOM中真正变化的部分,然后对变化的部分进行远程DOM操作。
翻开react官方文档,里面描述了React diff算法的设计动机。
生成将一棵树转换成另一棵树的最小操作数,即使在最前沿的算法中,该算法的复杂程度为 O(n^3),其中 n 是树中元素的数量。
这个开销实在太过高昂,所以在三个(官方文档写的是两个)假设的基础上提出了启发式算法:
基于上述三个假设,业界各个框架的diff算法基本都使用三种策略:
基于层次进行遍历。
不同类型的元素,直接卸载原有的树,并建立新的树。
针对子节点(通常是两个列表),通过key来找到正确的映射。
渲染器Patch过程负责对新旧VNode(用来描述Virtual DOM),并以合适的方式更新DOM。
Patch
VNode(用来描述Virtual DOM)
下面vue2的Vnode部分结构:
vue2
Vnode
export default class VNode { tag: string | void; data: VNodeData | void; children: ?Array<VNode>; text: string | void; elm: Node | void; ns: string | void; context: Component | void; // rendered in this component's scope key: string | number | void; componentOptions: VNodeComponentOptions | void; componentInstance: Component | void; // component instance parent: VNode | void; // component placeholder node ... }
结合Vnode结构和上述三种策略,我们可以得出一次patch过程的主要流程:
patch
上述过程的思想其实就是基于策略1和策略2,逐层进行比对,判断新旧Vnode节点类型并执行对应更新操作。在进行完这些操作后,对于新旧两个节点来说,就剩下子节点的差异了。
子节点的更新策略十分朴素。如上图,当旧children没有子节点时,新children有两种情况:
children
如上图,当旧children有子节点时,新children有两种情况:
下面我们会基于不同框架,来分析他们对这种情况的处理方式。
diff算法的核心目的就是尽可能多地复用DOM元素来降低性能开销。因为key的存在,我们可以知道哪些节点是可以被复用的。所以对于DOM元素来说,应该有三种操作:
插入操作和删除操作都很容易理解,难点在于使用哪种策略来进行移动操作。下面我们会分别对三种框架的移动操作策略进行分析。
我们先来看一个例子,大家可以用几秒钟的时间观察上图中的新旧children节点的排列方式,能否发现某种规律呢?
直观上来看,我们发现节点B和节点C的在新旧children中的相对位置是没有发生变化的。这种不变的顺序映射到索引上该怎么定义?在寻找移动节点的过程中,如果两个节点在新旧children中的索引均呈递增趋势,则说明这两个节点在新旧children中的相对位置没有变化,也就不需要进行移动操作。
如上图,旧节点B的索引为1,旧节点A的索引为2,新节点B的索引为0,新节点C的索引为1, 则有B -> C(旧索引 1-> 2, 新索引0 ->1)均呈递增趋势,所以这两个节点不需要移动。
如上图,旧节点B的索引为1,旧节点A的索引为2,新节点B的索引为0,新节点C的索引为1,
则有B -> C(旧索引 1-> 2, 新索引0 ->1)均呈递增趋势,所以这两个节点不需要移动。
React@15借鉴了这个思路,维护了一个lastIndex变量,该变量用来存储在寻找过程中遇到的在旧children中最大索引值。一头雾水?我们下面逐步来分析React的做法。
前提:React使用从左到右单向遍历。
步骤:
我们可以得到下述代码:
// 用来存储在寻找过程中遇到的在旧 children 中最大索引值 let lastIndex = 0; // 遍历新的 children for(let i = 0; i < nextChildren.length; i++) { const nextVNode = nextChildren[i]; // 标示是否找到复用节点 let find = false; for(let j = 0; j < prevChildren.length; j++) { const prevVNode = prevChildren[j]; // 寻找可以复用的节点,即 key 相同。 // 调用 patch 函数更新该节点。 if(nextVNode.key === prevVNode.key) { find = true; patch(prevVNode, nextVNode, container); if(j >= lastIndex) { // 索引呈现递增趋势,不需要移动。 // 更新 lastIndex 即可 lastIndex = j; } else { // 需要移动 // refNode 是为了调用 insertBefore 函数准备的 // VNode的el属性为对应的DOM元素 const refNode = nextChildren[i-1].el.nextSibling; // 调用 insertBefore 函数移动 DOM,将该节点放在nextChildren[i-1]节点后面 container.insertBefore(prevVNode.el, refNode); } break; // 找到之后break } } // 插入新节点 if(!find) { // 找到refNode,将新节点插入到refNode前面 const refNode = i -1 < 0 ? prevChildren[0].el : nextChildren[i-1].el.nextSibling; mount(nextVNode, container, refNode) } // 移除不存在节点 // 遍历旧的节点 for(let i = 0; i < prevChildren.length; i++) { const prevVNode = prevChildren[i]; // 拿着旧 VNode 去新 children 中寻找相同节点 const has = nextChildren.find(nextVNode => nextVNode.key === prevVNode.key); if(!has) { // 如果没有找到相同节点,移除该节点 container.removeChild(prevVNode.el); } } }
React@15通过单向遍历,维护lastIndex变量试图寻找递增趋势节点,但是会有一些极端场景出现。
如上图所示,按照React@15的算法。执行步骤:
上述算法进行了三次移动操作。但其实根据我们的直觉来看,节点A、节点B和节点C的索引呈递增顺序,其实我们只需要将节点D移动到最前面即可。所以React@15的算法并不是最优解,一些极端情况下会执行一些不必要的移动操作。
lastIndex
vue@2底层同样使用了virtual DOM来实现,vue@2的virtual DOM部分直接整合了snabbdom库,可以看到,一款优秀的框架不代表所有的内容都要原创,而是在一个好的设计上吸收改进,使用更优秀的东西进化自己。vue@2采用了双端比较的算法。
snabbdom
所谓双端比较,就是从新旧children的两端开始,逐步向中间移动的比较方式。它是一种比较平衡的算法,可以一定程度上避免React的极端情况。
双端比较使用四个索引来寻找需要移动的节点:
代码如下:
let oldStartIdx = 0 let oldEndIdx = oldCh.length - 1 let newStartIdx = 0 let newEndIdx = newCh.length - 1 let oldStartVnode = oldch[0] let oldEndVnode = oldCh[oldEndIdx] let newStartVnode = newCh[0] let newEndVnode = newCh[newEndIdx]
我们接着使用React的极端例子来分析双端比较算法。
前提:Vue@2从两端向中间收敛遍历。
步骤一:
oldStartIdx
newStartIdx
oldVNode
newVNode
oldEndIdx
newEndIdx
oldEndIdx--; newStartIdx++
步骤二:
oldStartIdx++; newStartIdx++
步骤三:
步骤四:
oldStartIdx > oldEndIdx
newStartIdx > newEndIdx
分析:
vue@2的算法在这种情况下只进行了一次移动操作,而React@15却进行了三次移动操作。
通过上述实例分析,我们暂时可以得到下述四种情况:
sameVnode(oldStartVnode, newStartVnode)
sameVnode(oldEndVnode, newEndVnode)
oldEndIdx--; newEndIdx--
sameVnode(oldStartVnode, newEndVnode)
sameVnode(oldEndVnode, newStartVnode)
我们可以写出如下代码:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (sameVnode(oldStartVnode, newStartVnode)) { // 情况一 // 调用patch方法更新 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // 更新索引 oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { // 情况二 // 调用patch方法更新 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) // 更新索引 oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // 情况三 // 调用patch方法更新 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) // 将容器中第一个子节点移动到最后面 canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) // 更新索引 oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // 情况四 // 调用patch方法更新 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // 将容器中最后一个子节点移动到最前面,使其成为第一个子节点 canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) // 更新索引 oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } }
上述我们讨论的四种情况均为匹配时的处理方式,那么如果四种情况都没有找到相同节点,该如何处理呢?我们用下图中的例子进行分析。
通过①、②、③、④ 四种情况比对,仍未找到可以复用的节点,vue@2会判断newStartVnode节点是否在旧children中出现,如果成功找到,则将该节点对应的DOM元素直接移动到最前端。
newStartVnode
如上图所示,找到节点D在旧children中的位置idxInOld,将节点D对应的DOM元素移动到节点A前面,与此同时,将oldCh[idxInOld]设置为undefined,最后执行newStartIdx++。
idxInOld
oldCh[idxInOld]
undefined
newStartIdx++
将oldCh[idxInOld]设置为undefined的原因:后续当oldStartIdx索引或者oldEndIdx索引移动到该位置时,因为该节点已经被移动过,所以无需再进行上述四种情况判断,直接跳过即可。
‘
接下来,比较oldStartIdx和newStartIdx索引对应节点时,此时oldStartVNode为节点A,newStartVNode为节点A,两者相同,不需要移动节点,只需要oldStartIdx++; newStartIdx++。
oldStartVNode
newStartVNode
接下来,比较oldEndIdx和newStartIdx索引对应节点时,此时oldEndVNode为节点B,newStartVNode为节点B,两者相同,则说明节点B在新children的开始位置,将节点B移动到新children最前端,同时oldEndIdx--; newStartIdx++。
oldEndVNode
接下来,未成功匹配上述四种情况,尝试去寻找节点E在旧children中的位置idxInOld,因为没有找到可复用的节点,所以该节点为新增节点,直接插入新节点。同时newStartIdx++
此时由于不满足while循环条件退出循环体,但是我们发现仍然有节点C(需要被删除)被我们遗漏。所以在循环体结束后,仍然要判断旧children中是否有未删除的节点。同理,新children中也可能有新出现的节点被遗漏。在循环体结束后,也需要处理这种情况。如下图。
while
通过上述分析,我们可以得出普通情况的详细代码:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 判断节点是否为undefined,若是表示该节点已经被移动 if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { // 情况一,省略 } else if (sameVnode(oldEndVnode, newEndVnode)) { // 情况二,省略 } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // 情况三,省略 } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // 情况四,省略 } else { // 构建oldKeyToIdx哈希表 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 寻找旧children是否有newStartVnode idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) // 未找到可复用的节点,创建新节点 if (isUndef(idxInOld)) { // New element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { vnodeToMove = oldCh[idxInOld] // 找到可复用节点,判断是否是相同类型 if (sameVnode(vnodeToMove, newStartVnode)) { // 调用patch方法更新 patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // 设置旧children中移动位置值为undefined oldCh[idxInOld] = undefined canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // same key but different element. treat as new element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } newStartVnode = newCh[++newStartIdx] } } if (oldStartIdx > oldEndIdx) { // 判断新children中是否有新节点未插入 refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { // 判断旧children中是否有不存在节点未删除 removeVnodes(oldCh, oldStartIdx, oldEndIdx) }
Virtual DOM diff算法原理概述
Virtual DOM主要有三个实现细节:
今天我们主要讨论的是不同框架的diff算法,即上述步骤2和步骤3。因此,diff算法是Virtual DOM的加速器,它会计算出Virtual DOM中真正变化的部分,然后对变化的部分进行远程DOM操作。
三个假设
翻开react官方文档,里面描述了React diff算法的设计动机。
这个开销实在太过高昂,所以在三个(官方文档写的是两个)假设的基础上提出了启发式算法:
三种策略
基于上述三个假设,业界各个框架的diff算法基本都使用三种策略:
基于层次进行遍历。
不同类型的元素,直接卸载原有的树,并建立新的树。
针对子节点(通常是两个列表),通过key来找到正确的映射。
渲染器Patch过程
渲染器
Patch
过程负责对新旧VNode(用来描述Virtual DOM)
,并以合适的方式更新DOM。下面
vue2
的Vnode
部分结构:结合
Vnode
结构和上述三种策略,我们可以得出一次patch
过程的主要流程:上述过程的思想其实就是基于策略1和策略2,逐层进行比对,判断新旧
Vnode
节点类型并执行对应更新操作。在进行完这些操作后,对于新旧两个节点来说,就剩下子节点的差异了。更新子节点
子节点的更新策略十分朴素。如上图,当旧
children
没有子节点时,新children
有两种情况:如上图,当旧
children
有子节点时,新children
有两种情况:下面我们会基于不同框架,来分析他们对这种情况的处理方式。
diff核心算法(重点)
diff算法的核心目的就是尽可能多地复用DOM元素来降低性能开销。因为key的存在,我们可以知道哪些节点是可以被复用的。所以对于DOM元素来说,应该有三种操作:
插入操作和删除操作都很容易理解,难点在于使用哪种策略来进行移动操作。下面我们会分别对三种框架的移动操作策略进行分析。
React@15
寻找移动节点
我们先来看一个例子,大家可以用几秒钟的时间观察上图中的新旧children节点的排列方式,能否发现某种规律呢?
直观上来看,我们发现节点B和节点C的在新旧children中的相对位置是没有发生变化的。这种不变的顺序映射到索引上该怎么定义?在寻找移动节点的过程中,如果两个节点在新旧children中的索引均呈递增趋势,则说明这两个节点在新旧children中的相对位置没有变化,也就不需要进行移动操作。
React@15借鉴了这个思路,维护了一个lastIndex变量,该变量用来存储在寻找过程中遇到的在旧children中最大索引值。一头雾水?我们下面逐步来分析React的做法。
前提:React使用从左到右单向遍历。
步骤:
我们可以得到下述代码:
缺陷
React@15通过单向遍历,维护lastIndex变量试图寻找递增趋势节点,但是会有一些极端场景出现。
如上图所示,按照React@15的算法。执行步骤:
上述算法进行了三次移动操作。但其实根据我们的直觉来看,节点A、节点B和节点C的索引呈递增顺序,其实我们只需要将节点D移动到最前面即可。所以React@15的算法并不是最优解,一些极端情况下会执行一些不必要的移动操作。
总结
lastIndex
来简单模拟上述原理,但并非全局最优解,极端情况有优化空间(例如将最后的节点移动到最前)。Vue@2
vue@2底层同样使用了virtual DOM来实现,vue@2的virtual DOM部分直接整合了
snabbdom
库,可以看到,一款优秀的框架不代表所有的内容都要原创,而是在一个好的设计上吸收改进,使用更优秀的东西进化自己。vue@2采用了双端比较的算法。双端比较原理
双端比较使用四个索引来寻找需要移动的节点:
代码如下:
我们接着使用React的极端例子来分析双端比较算法。
前提:Vue@2从两端向中间收敛遍历。
步骤一:
oldStartIdx
和newStartIdx
索引对应节点,此时oldVNode
为节点A,newVNode
为节点D,两者不同,进入步骤二。oldEndIdx
和newEndIdx
索引对应节点,此时oldVNode
为节点D,newVNode
为节点C,两者不同,进入步骤三。oldStartIdx
和newEndIdx
索引对应节点,此时oldVNode
为节点A,newVNode
为节点D,两者不同,进入步骤四。oldEndIdx
和newStartIdx
索引对应节点,此时oldVNode
为节点D,newVNode
为节点D,两者相同,则说明节点D在新children的开始位置,将节点D移动到新children最前端,同时oldEndIdx--; newStartIdx++
。步骤二:
oldStartIdx
和newStartIdx
索引对应节点,此时oldVNode
为节点A,newVNode
为节点A,两者相同,则说明节点A在新children和旧children均在最前端,不需要移动节点,只需要oldStartIdx++; newStartIdx++
。步骤三:
oldStartIdx
和newStartIdx
索引对应节点,此时oldVNode
为节点B,newVNode
为节点B,两者相同,则说明节点B在新children和旧children均在最前端,不需要移动节点,只需要oldStartIdx++; newStartIdx++
。步骤四:
oldStartIdx
和newStartIdx
索引对应节点,此时oldVNode
为节点C,newVNode
为节点C,两者相同,则说明节点B在新children和旧children均在最前端,不需要移动节点,只需要oldStartIdx++; newStartIdx++
。此时发现oldStartIdx > oldEndIdx
,newStartIdx > newEndIdx
。退出遍历。分析:
vue@2的算法在这种情况下只进行了一次移动操作,而React@15却进行了三次移动操作。
通过上述实例分析,我们暂时可以得到下述四种情况:
sameVnode(oldStartVnode, newStartVnode)
=>oldStartIdx++; newStartIdx++
sameVnode(oldEndVnode, newEndVnode)
=>oldEndIdx--; newEndIdx--
sameVnode(oldStartVnode, newEndVnode)
=> 该节点移动到最后端sameVnode(oldEndVnode, newStartVnode)
=> 该节点移动到最前端我们可以写出如下代码:
普通情况处理方式
上述我们讨论的四种情况均为匹配时的处理方式,那么如果四种情况都没有找到相同节点,该如何处理呢?我们用下图中的例子进行分析。
通过①、②、③、④ 四种情况比对,仍未找到可以复用的节点,vue@2会判断
newStartVnode
节点是否在旧children中出现,如果成功找到,则将该节点对应的DOM元素直接移动到最前端。如上图所示,找到节点D在旧children中的位置
idxInOld
,将节点D对应的DOM元素移动到节点A前面,与此同时,将oldCh[idxInOld]
设置为undefined
,最后执行newStartIdx++
。‘
接下来,比较
oldStartIdx
和newStartIdx
索引对应节点时,此时oldStartVNode
为节点A,newStartVNode
为节点A,两者相同,不需要移动节点,只需要oldStartIdx++; newStartIdx++
。接下来,比较
oldEndIdx
和newStartIdx
索引对应节点时,此时oldEndVNode
为节点B,newStartVNode
为节点B,两者相同,则说明节点B在新children的开始位置,将节点B移动到新children最前端,同时oldEndIdx--; newStartIdx++
。接下来,未成功匹配上述四种情况,尝试去寻找节点E在旧children中的位置
idxInOld
,因为没有找到可复用的节点,所以该节点为新增节点,直接插入新节点。同时newStartIdx++
此时由于不满足
while
循环条件退出循环体,但是我们发现仍然有节点C(需要被删除)被我们遗漏。所以在循环体结束后,仍然要判断旧children中是否有未删除的节点。同理,新children中也可能有新出现的节点被遗漏。在循环体结束后,也需要处理这种情况。如下图。通过上述分析,我们可以得出普通情况的详细代码:
总结
Inferno
Inferno介绍
核心思想
双端比较
最长递增子序列
总结
相关链接