vue的diff算法详解
Posted hans774882968
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了vue的diff算法详解相关的知识,希望对你有一定的参考价值。
为什么需要虚拟dom?
虚拟dom只是一个普通的js对象。
由于每次渲染视图都是先创建vnode,然后用它创建真实DOM插入到页面中,所以可以将上一次渲染视图所创建的vnode缓存起来。之后重新渲染视图,就可以对比oldVnode和vnode,基于新旧差异来更新DOM了。这样可以提升性能。(《深入浅出Vue.js》P55)
源码专有术语一览
-
新旧虚拟节点在源码里的变量名分别为:vnode和oldVnode。我们以vnode为基准,目标是把dom修改成vnode的样子,并且最小化dom操作次数。
-
如果
vnode
有text属性,意味着是“文本节点”,则无论旧节点的子节点是什么,都可以直接调用api.setTextContent
。具体地说,是api.setTextContent(el, vnode.text)
,el
是oldVnode
对应的真实dom节点。 -
sameVnode
:判定两个虚拟dom是否相同。 -
function sameVnode (a, b) { return ( a.key === b.key && // key值 a.tag === b.tag && // 标签名 a.isComment === b.isComment && // 是否为注释节点 // 是否都定义了data,data包含一些具体信息,例如onclick , style isDef(a.data) === isDef(b.data) && sameInputType(a, b) // 当标签是<input>的时候,type必须相同 ) }
-
el
:vnode
对应的真实dom节点。
太长不看的总结版本
- 优化点:dom操作尽量少、updateChildren尽量避免线性查找、同层级比较。
- patch用sameVnode判定是否有必要进行更细致的比较。如果不需要,直接更新dom,过程结束。否则调用patchVnode。
- dom插入操作的插入位置:任何情况下(包括线性查找,和patch函数的情况),插入位置一定是在“待处理区间”之外。
- patchVnode判定是否需要进行children的比较和更新。以下情况是不需要进行children比较的:vnode有text属性(即它是文本节点)且和oldVnode的不相同;vnode和oldVnode有至少一方没children,或都有children但children地址相同。需要进行children比较,才进入updateChildren。
- updateChildren是启发式算法,《深入浅出Vue.js》把它叫做“快捷查找”,可以大大减少线性查找的次数。该算法维护旧children和新children的待比较区间。当两个区间都不空,我们判定
oldStartIdx
和newStartIdx
等4种情况的相同性(用sameVnode判定)。
- 如有满足,则不用进行线性查找了,递归调用patchVnode、更新dom即可。然后缩小待比较区间。
- 否则需要线性查找。若没找到,新建节点即可。若找到,则需要先递归调用patchVnode,再把旧children数组对应位置的节点移动到
oldStartVnode.el
之前。情况2,总是只需要以newStartIdx对应的虚拟节点为基准,且缩小待比较区间的操作是++newStartIdx
(只考虑newEndIdx也行,二选一)。 - 移动操作需要
oldCh[idxInOld] = null
,表示该位置已废弃。而后续待比较区间的下标还有可能经过idxInOld,所以需要4个if语句判定当前下标是null,并跳过。 - updateChildren的循环结束后,如果是旧待比较区间先变为空,则需要插入操作;否则需要删除操作。
跟着函数走一遍
如果我们自己构思diff,多半直接一个dfs完事。但vue的diff算法是间接递归的。patch只需要在根处使用(原因将在下文解释),它调用了patchVnode;patchVnode调用了updateChildren;updateChildren递归调用了patchVnode。
patch
patch的一部分代码
function patch (oldVnode, vnode) {
// some code
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
} else {
const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
let parentEle = api.parentNode(oEl) // 父元素
createEle(vnode) // 根据Vnode生成新元素
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点
oldVnode = null
}
}
// some code
return vnode
}
值得注意的是,vue的diff算法是在同层级比较节点的相同性的。所以如果当前的两个虚拟dom不相同(sameVnode
为false),就没必要再dfs下去了。用vnode建出dom,插到父亲的children里,再把旧的el
删掉。
由此也可以得到,从根节点到vnode
的树上唯一路径,和从旧根节点到oldVnode
的树上唯一路径,它们的每个节点都满足sameVnode
为true。所以同层级比较,是通过树上唯一路径的每个点都满足sameVnode
为true,来实现的。
patchVnode
function patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.el
let i, oldCh = oldVnode.children, ch = vnode.children
if (oldVnode === vnode) return
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text)
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch)
}else if (ch){
createEle(vnode) //create el's children dom
}else if (oldCh){
api.removeChildren(el)
}
}
}
如果oldVnode
和vnode
指向同一个对象,就没必要更新(但不知道这种情况有没有可能发生)。
vnode
有text属性,意味着它是文本节点,则可以考虑直接api.setTextContent
。
接下来,如果oldVnode
和vnode
不是都有children(children自然也是虚拟dom节点),则也没必要进行复杂的updateChildren
。
- 如果vnode的ch不空(此时旧的为空),则需要新建节点(整个子树都随之新建)
- 如果oldVnode的ch不空(此时新的为空),则需要删除
el
。
如果新旧虚拟节点都有children,且不相等,则需要进行updateChildren的对比。
updateChildren
function updateChildren (parentElm, oldCh, newCh) {
let oldStartIdx = 0, newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { // 对于vnode.key的比较,会把oldVnode = null
oldStartVnode = oldCh[++oldStartIdx]
}else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
}else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
}else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}else {
// 使用key时的比较
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 由key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
}else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
重头戏!代码很长,但其实是纸老虎!
parentElm, oldCh, newCh分别表示vnode
对应的el
和旧、新children。
先看局部变量
let oldStartIdx = 0, newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx
let idxInOld
let elmToMove
let before
前8个变量,分别是旧children的待比较起始、结束下标、新children的待比较起始、结束下标,和它们对应的虚拟dom节点(oldStartVnode
等只是为了方便书写)。[oldStartIdx,oldEndIdx]
和[newStartIdx,newEndIdx]
分别表示旧、新children的待比较区间,左闭右闭。
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 由key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
oldKeyToIdx
表示旧children只取key属性、待比较区间的部分,生成的数组。idxInOld
则表示newStartVnode.key
在旧children的什么地方(所以key属性很重要啊)。
后面4个变量,只会在while循环的else部分用到。
updateChildren
的主体是一个while循环,其思想是逐步缩小待比较区间,直到新旧有一方待比较区间为空,即可收尾。
继续循环的条件:oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
,即新旧待比较区间都不为空。
if (oldStartVnode == null) { // 对于vnode.key的比较,会把oldVnode = null(赋值为空)
oldStartVnode = oldCh[++oldStartIdx]
}else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
}else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
}else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
}
后面的代码会移动线性查找所找到的节点,移动前的位置作废了,但指针可能还会经过这里,所以需要设为null并跳过。
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
这里是一个启发式的方法(《深入浅出Vue.js》把这个方法称为“快捷查找”,据说,这种方式能大大减少线性查找的耗时操作的次数),即所谓的人类经验(类似的有,并查集按秩合并size小的合并到size大的,复杂度能达到nlogn,避免了n^2)。
-
如果头和头相等,或尾和尾相等,只递归调用patchVnode即可,不用进行dom操作。
为什么不递归调用patch?回去看一下patch代码发现,调用patch会直接进入patchNode,只是徒增递归深度。因为此时已知这两个节点是值得比较的。这就是patch只需要在根处调用的原因了。
-
如果头和尾相等,或尾和头相等,则在
patchVnode
完成后,进行dom的插入,然后缩小区间。
insertBefore怎么用:document.getElementById("myList").insertBefore(newItem,existingItem);
sameVnode(oldStartVnode, newEndVnode)
,则把oldStartVnode.el
插到oldEndVnode.el
之后(它的nextSibling之前,也就是它之后)。sameVnode(oldEndVnode, newStartVnode)
,则把oldEndVnode.el
插到oldStartVnode.el
之前。
插入位置的推导,在《深入浅出Vue.js》P80有讲述。我们只需要记住:任何情况下(包括下面的线性查找,和patch函数的情况),插入位置一定是在“待处理区间”之外。
如果以上“人类经验”的情况不满足,则只好进行线性查找的耗时操作了。
else {
// 使用key时的比较
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 由key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
}else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
这一段代码,只考虑newStartIdx对应的虚拟dom节点要放到什么位置即可(只考虑newEndIdx也行,二选一即可)。
回顾一下变量定义:oldKeyToIdx
表示旧children只取key属性、待比较区间的部分,生成的数组(它只生成1次)。idxInOld
则表示newStartVnode.key
在旧children的下标。idxInOld
为0,表示newStartVnode.key
是新产生的,就插到oldStartVnode.el
之前。否则:
sel
属性不知道是个啥,不管啦!sel
相等,则需要先用patchVnode比较elmToMove和newStartVnode这两个子节点,然后把elmToMove.el插到oldStartVnode.el之前。oldCh[idxInOld] = null
则是删除操作,因为已经移动完成了,所以后续指针再次经过这里的时候应该跳过!
循环结束后的收尾工作
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
都是很自然的推导结果。
- 如果旧的区间先空了,就把新的区间的元素都插入
- 如果新的区间先空了,就把旧的区间的元素都删除
参考:https://www.cnblogs.com/wind-lanyan/p/9061684.html
以上是关于vue的diff算法详解的主要内容,如果未能解决你的问题,请参考以下文章