Vue中diff算法的理解

diff算法用来计算出Virtual DOM中改变的部分,然后针对该部分进行DOM操作,而不用重新渲染整个页面,渲染整个DOM结构的过程中开销是很大的,需要浏览器对DOM结构进行重绘与回流,而diff算法能够使得操作过程中只更新修改的那部分DOM结构而不更新整个DOM,这样能够最小化操作DOM结构,能够最大程度上减少浏览器重绘与回流的规模。

虚拟DOM

diff算法的基础是Virtual DOMVirtual DOM是一棵以JavaScript对象作为基础的树,每一个节点称为VNode,用对象属性来描述节点,实际上它是一层对真实DOM的抽象,最终可以通过渲染操作使这棵树映射到真实环境上,简单来说Virtual DOM就是一个Js对象,用以描述整个文档。
在浏览器中构建页面时需要使用DOM节点描述整个文档。

<div class="root" name="root"><p>1</p><div>11</div>
</div>

如果使用Js对象去描述上述的节点以及文档,那么便类似于下面的样子,当然这不是Vue中用以描述节点的对象,Vue中用以描述一个节点的对象包括大量属性,例如tagdatachildrentextelmnscontextkeycomponentOptionscomponentInstanceparentrawisStaticisRootInsertisCommentisCloned等等,具体的属性可以参阅Vue源码的/dev/src/core/vdom/vnode.js

{type: "tag",tagName: "div",attr: {className: "root"name: "root"},parent: null,children: [{type: "tag",tagName: "p",attr: {},parent: {} /* 父节点的引用 */, children: [{type: "text",tagName: "text",parent: {} /* 父节点的引用 */, content: "1"}]},{type: "tag",tagName: "div",attr: {},parent: {} /* 父节点的引用 */, children: [{type: "text",tagName: "text",parent: {} /* 父节点的引用 */, content: "11"}]}]
}

当选用diff算法进行部分更新的时候就需要比较旧DOM结构与新DOM结构的不同,此时就需要VNode来描述整个DOM结构,首先根据真实DOM生成Virtual DOM,当Virtual DOM某个节点的数据改变后会生成一个新的Vnode,然后通过newVNodeoldVNode进行对比,发现有不同之处便通过在VNodeelm属性相对应的真实DOM节点进行patch修改于真实DOM,然后使旧的Virtual DOM赋值为新的Virtual DOM

diff算法

当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher数据发生更新,订阅者就会调用patch进行比较,然后将相应的部分渲染到真实DOM结构。

时间复杂度

首先进行一次完整的diff需要O(n^3)的时间复杂度,这是一个最小编辑距离的问题,在比较字符串的最小编辑距离时使用动态规划的方案需要的时间复杂度是O(mn),但是对于DOM来说是一个树形结构,而树形结构的最小编辑距离问题的时间复杂度在30多年的演进中从O(m^3n^3)演进到了O(n^3),关于这个问题如果有兴趣的话可以研究一下论文https://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf
对于原本想要提高效率而引入的diff算法使用O(n^3)的时间复杂度显然是不太合适的,如果有1000个节点元素将需要进行十亿次比较,这是一个昂贵的算法,所以必须有一些妥协来加快速度,对比较通过一些策略进行简化,将时间复杂度缩小到O(n),虽然并不是最小编辑距离,但是作为编辑距离与时间性能的折中是一个比较好的解决方案。

diff策略

上边提到的O(n)时间复杂度是通过一定策略进行的,React中提到了两个假设,在Vue中同样适用:

  • 两个不同类型的元素将产生不同的树。
  • 通过渲染器附带key属性,开发者可以示意哪些子元素可能是稳定的。

通俗点说就是:

  • 只进行统一层级的比较,如果跨层级的移动则视为创建和删除操作。
  • 如果是不同类型的元素,则认为是创建了新的元素,而不会递归比较他们的孩子。
  • 如果是列表元素等比较相似的内容,可以通过key来唯一确定是移动还是创建或删除操作。

比较后会出现几种情况,然后进行相应的操作:

  • 此节点被添加或移除->添加或移除新的节点。
  • 属性被改变->旧属性改为新属性。
  • 文本内容被改变->旧内容改为新内容。
  • 节点tagkey是否改变->改变则移除后创建新元素。

分析

实现diff算法的部分在Vue源码中的dev/src/core/vdom/patch.js文件中,不过Vue源码的实现比较复杂,文章分析比较核心的代码部分,精简过后的最小化版本,commit id43b98fe
在调用patch方法时,会判断是否是VNodeisRealElement其实就是根据有没有nodeType来判断是不是真实DOMVNode是不存在这个字段的,如果不是真实DOM元素,并且这两个节点是相同的,那就就会进入这个if内部,调用patchVnodechildren进行diff以决定该如何更新。

// line 714
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {// patch existing root nodepatchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}else{// ...
}

接下来看一下sameVnode方法,判断如何算是相同节点。

// line 35
function sameVnode (a, b) {return (a.key === b.key && ((a.tag === b.tag &&a.isComment === b.isComment &&isDef(a.data) === isDef(b.data) &&sameInputType(a, b)) || (isTrue(a.isAsyncPlaceholder) &&a.asyncFactory === b.asyncFactory &&isUndef(b.asyncFactory.error))))
}

这里的判断条件其实主要是两个:

  • key必须相同,如果都是undefined则也是相同的。
  • DOM元素的标签必须相同。

如果满足以上条件,那么就认为是相同的VNode,因此就可以进行patchVnode操作,如果不是就认为是完全新的一个VNode,就会在上边的判断后执行下面的createElm
梳理一下逻辑,当进入patch之后有两种分支可以走,如果是第一次patch,即组件第一次挂载的时候,或者发现元素的标签不相同了,那么就认为是不同的元素,直接进行createElm 创建新的DOM元素进行替换,否则,就是对已存在的DOM元素进行更新,那么通过patchVnode进行diff,有条件的更新以提升性能,这样其实就实现了策略中原则的第一条,即两个不同类型的元素将产生不同的树,只要发现两个元素的类型不同,我们直接删除旧的并创建一个新的,而不是去递归比较。
在认为这是两个相同的VNode之后,就需要比较并更新当前元素的差异,以及递归比较children,在patchVnode方法中实现了这两部分。

// line 501
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {// ...if (isDef(data) && isPatchable(vnode)) {for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)}//...
}

cbs.update主要是用来更新attributes的,这里的cbs其实是从hooks中来的,hooks33行有如下定义,const hooks = ['create', 'activate', 'update', 'remove', 'destroy'],其是在VNode更新的各个阶段进行相应的操作,这里cbs.update包含如下几个回调:updateAttrsupdateClassupdateDOMListenersupdateDOMPropsupdateStyleupdateupdateDirectives,其主要都是更新当前结点的一些相关attributes
之后需要更新孩子节点,这时候分两种情况:

  • 如果孩子不是textNode,那么需要再分三种情况处理。
  • 如果当前孩子是textNode那么直接更新text即可。

对孩子是VNode的三种情况:

  • 有新孩子无旧孩子,直接创建新的。
  • 有旧孩子无新孩子,直接删除旧的。
  • 新旧孩子都有,那么调用updateChildren
if (isUndef(vnode.text)) {if (isDef(oldCh) && isDef(ch)) {if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)} else if (isDef(ch)) {if (process.env.NODE_ENV !== 'production') {checkDuplicateKeys(ch)}if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)} else if (isDef(oldCh)) {removeVnodes(oldCh, 0, oldCh.length - 1)} else if (isDef(oldVnode.text)) {nodeOps.setTextContent(elm, '')}
} else if (oldVnode.text !== vnode.text) {nodeOps.setTextContent(elm, vnode.text)
}

当新旧孩子都存在,那么便调用updateChildren方法,对于每一个孩子节点,我们依然有这么几种可能:

  • 更新了节点
  • 删除了节点
  • 增加了节点
  • 移动了节点

updateChildrendiff的核心算法,源代码实现如下。

// line 404
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {let oldStartIdx = 0let newStartIdx = 0let oldEndIdx = oldCh.length - 1let oldStartVnode = oldCh[0]let oldEndVnode = oldCh[oldEndIdx]let newEndIdx = newCh.length - 1let newStartVnode = newCh[0]let newEndVnode = newCh[newEndIdx]let oldKeyToIdx, idxInOld, vnodeToMove, refElm// removeOnly is a special flag used only by <transition-group>// to ensure removed elements stay in correct relative positions// during leaving transitionsconst canMove = !removeOnlyif (process.env.NODE_ENV !== 'production') {checkDuplicateKeys(newCh)}while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (isUndef(oldStartVnode)) {oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left} else if (isUndef(oldEndVnode)) {oldEndVnode = oldCh[--oldEndIdx]} else if (sameVnode(oldStartVnode, newStartVnode)) {patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)oldStartVnode = oldCh[++oldStartIdx]newStartVnode = newCh[++newStartIdx]} else if (sameVnode(oldEndVnode, newEndVnode)) {patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)oldEndVnode = oldCh[--oldEndIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved rightpatchVnode(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 leftpatchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)oldEndVnode = oldCh[--oldEndIdx]newStartVnode = newCh[++newStartIdx]} else {if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)idxInOld = isDef(newStartVnode.key)? oldKeyToIdx[newStartVnode.key]: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)if (isUndef(idxInOld)) { // New elementcreateElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)} else {vnodeToMove = oldCh[idxInOld]if (sameVnode(vnodeToMove, newStartVnode)) {patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)oldCh[idxInOld] = undefinedcanMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)} else {// same key but different element. treat as new elementcreateElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)}}newStartVnode = newCh[++newStartIdx]}}if (oldStartIdx > oldEndIdx) {refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elmaddVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)} else if (newStartIdx > newEndIdx) {removeVnodes(oldCh, oldStartIdx, oldEndIdx)}
}

其对新旧两个children数组分别在首位各用了一个指针,总共四个指针,由于指针仅仅对数组进行了一次遍历,因此时间复杂度是O(n),举个简单例子说明diff过程。

old VNode: a(oldStartIdx) b c d e f(oldEndIdx)
new VNode: b(newStartIdx) f g(newEndIdx)
DOM Node:  a b c d e f

首先指针相互比较,即四种对比,分别为oldStartIdxnewStartIdxoldStartIdxnewEndIdxoldEndIdxnewStartIdxoldEndIdxnewEndIdx,如果没有相等的则继续。此时分为两种情况,有key和无key,无key则直接创建新的DOM Node插入到a(oldStartIdx)之前,此处认为key存在,有key的话取newStartIdxkey值,到old VNode去找,记录此时的oldKeyToIdx,随即调整VNode,将b移动到a之前,然后找到old VNodeoldKeyToIdx对应的节点值设置为undefinednewStartIdx指针向中间靠拢,即++newStartIdx

old VNode: a(oldStartIdx) undefined c d e f(oldEndIdx)
new VNode: b f(newStartIdx) g(newEndIdx)
DOM Node:  b a c d e f

循环继续,此时对比oldStartIdxnewStartIdxoldStartIdxnewEndIdxoldEndIdxnewStartIdxoldEndIdxnewEndIdx,发现newStartIdxoldEndIdx相同,将DOM Node中的f进行移动调整到DOM Node中的a(oldStartIdx)之前,此时newStartIdxoldEndIdx指针向中间靠拢,即++newStartIdx--oldEndIdx

old VNode: a(oldStartIdx) undefined c d e(oldEndIdx) f
new VNode: b f g(newStartIdx)(newEndIdx)
DOM Node:  b f a c d e

循环继续,此时对比oldStartIdxnewStartIdxoldStartIdxnewEndIdxoldEndIdxnewStartIdxoldEndIdxnewEndIdx,并没有相同的情况,取newStartIdxkey值,到old VNode去找,没有发现相同的值,则直接创建一个节点插入到DOM Node中的a(oldStartIdx)之前,newStartIdx指针向中间靠拢,即++newStartIdx

old VNode: a(oldStartIdx) undefined c d e(oldEndIdx) f
new VNode: b f g(newEndIdx) (newStartIdx)
DOM Node:  b f g a c d e

此时循环结束,有两个选择:

  • 如果oldStartldx > oldEndldx,说明老节点遍历完成了,新的节点比较多,所以多出 来的这些新节点,需要创建出来并添加到真实DOM Node后面。
  • 如果newStartldx >newEndldx,说明新节点遍历完成了,老的节点比较多,所以多 出来的这些老节点,需要从真实DOM Node中删除这些节点。

此时我们符合场景二,所以需要从真实DOM Node中删除[oldStartldx,oldEndldx]区间 中的Node节点,根据上述内容,即需要删除a c d e四个节点,至此diff完成。

old VNode: a(oldStartIdx) undefined c d e(oldEndIdx) f
new VNode: b f g(newEndIdx) (newStartIdx)
DOM Node:  b f g

diff完成之后便是将new VNode作为old VNode以便下次diff时使用,此外关于组件的diff,组件级别的diff算法比较简单,节点不相同就进行创建和替换,节点相同的话就会对其子节点进行更新,最后关于调用createElm来根据VNode创建真实的DOM元素,如果是一个组件,那么 createComponent会返回true,因此不会进行接下来的操作,如果不是组件,会进行节点创建工作,并会递归对孩子创建节点。

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://github.com/aooy/blog/issues/2
https://www.zhihu.com/question/66851503
https://juejin.im/post/6844903607913938951
https://juejin.im/post/6844903592483094535
https://reactjs.org/docs/reconciliation.html
https://www.cnblogs.com/lilicat/p/13448827.html
https://www.cnblogs.com/lilicat/p/13448915.html
https://github.com/lihongxun945/myblog/issues/33
https://www.cnblogs.com/xujiazheng/p/12101764.html
https://blog.csdn.net/dongcehao/article/details/106987886
https://blog.csdn.net/qq2276031/article/details/106407647
https://github.com/Advanced-Frontend/Daily-Interview-Question/issues/151
https://leetcode-cn.com/problems/edit-distance/solution/bian-ji-ju-chi-by-leetcode-solution/

Vue中diff算法的理解相关推荐

  1. [Vue源码] Vue中diff算法原理

    一. Vue中diff算法原理 理解: 1.先同级比较,在比较子节点 2.先判断一方有儿子一方没儿子的情况 3.比较都有儿子的情况 4.递归比较子节点 图: 1.原节点:ABCD,新节点:ABCDE, ...

  2. React中diff算法的理解

    React中diff算法的理解 diff算法用来计算出Virtual DOM中改变的部分,然后针对该部分进行DOM操作,而不用重新渲染整个页面,渲染整个DOM结构的过程中开销是很大的,需要浏览器对DO ...

  3. [vue] 你了解vue的diff算法吗?

    [vue] 你了解vue的diff算法吗? 我的理解:计算出虚拟 DOM 中真正变化的部分,并且只针对该部分进行 DOM 更新,而非重新渲染整个页面 个人简介 我是歌谣,欢迎和大家一起交流前后端知识. ...

  4. Vue中虚拟DOM的理解

    Vue中虚拟DOM的理解 Virtual DOM是一棵以JavaScript对象作为基础的树,每一个节点称为VNode,用对象属性来描述节点,实际上它是一层对真实DOM的抽象,最终可以通过渲染操作使这 ...

  5. 2.vue的diff算法(2020.12.07)

    在之前的生命周期中有提到过虚拟dom的相关概念,这里来简单介绍一个vue的diff算法的原理 1.virtual dom 无论是vue还是react,在更新渲染的过程中,都是先根据真实dom生成一个虚 ...

  6. vue的diff算法原理

    1. 为什么要用Diff算法 由于在浏览器中操作DOM是很昂贵的,频繁的操作DOM,会产生一定的性能问题,这就是虚拟DOM的产生原因.虚拟DOM本质上是JavaScript对象,是对真实DOM的抽象状 ...

  7. vue中特殊符号的理解如$

    vue中特殊符号的理解如$ 1)$ mount:vue内部除了数据属性,Vue 实例还暴露了一些有用的实例属性与方法.它们都有前缀 $ ,以便与用户定义的属性区分开来:$ mount是 Vuex 源码 ...

  8. mixin机制 vue_谈谈vue中mixin的一点理解

    谈谈vue中mixin的一点理解 vue中提供了一种混合机制--mixins,用来更高效的实现组件内容的复用.最开始我一度认为这个和组件好像没啥区别..后来发现错了.下面我们来看看mixins和普通情 ...

  9. 详解vue的diff算法

    前言 目标是写一个非常详细的关于diff的干货,所以本文有点长.也会用到大量的图片以及代码举例,一起来get吧. 先来了解几个点... 1. 当数据发生变化时,vue是怎么更新节点的? 要知道渲染真实 ...

最新文章

  1. 实战项目五:抓取简书文章信息
  2. Altium designer中元器件重新编号,会残留之前的编号,浅色有括号——消除办法
  3. Maven常用命令和代码实操
  4. oracle无效的十六进制数字,值java.sql.SQLException:ORA-01465:无效的十六进制数
  5. centos6.5下的mysql5.6.30安装
  6. UGUI 图片灰显裁剪
  7. Codeforces 808G. Anthem of Berland
  8. 将web项目部署到阿里云服务器上
  9. delphi微信授权登陆
  10. 当面试官问你期望的薪资是多少的时候,他是这样回答的...
  11. 学习linux的第一天知识总结
  12. ipad照片文件删除了怎么恢复
  13. linux环境pwd下ls,Linux基础命令2:cd、pwd、ls、stat、touch、alias
  14. java request reponse 乱码的问题解决
  15. Dubbo/Dubbox的服务暴露(一)
  16. html标题如何设置行书,六个小招数,让你的行书不再俗气!
  17. mybatis collection 子查询,嵌套查询,解决分页问题
  18. Django cms 教程五:添加内容
  19. 猜数游戏 先由计算机,C++实现猜数游戏
  20. Element UI表格行拖拽功能

热门文章

  1. python设计模式1-单例模式
  2. 详解Redis的架构演化之路(附16张图解)
  3. RocketMq发送延迟消息
  4. php 类的属性与方法的注意事项
  5. linux中的inode节点
  6. Swift - 使用NSURLSession同步获取数据(通过添加信号量)
  7. 【回文字符串】 最长回文子串O(N) Manacher算法
  8. A damn at han’s Windows phone book 笔记(23:序列化,图片)
  9. UVa 495 Fibonacci Freeze
  10. insertAdjacentHTML方法:在指定的地方插入html标签语句