数据状态更新时的差异 diff 及 patch 机制

数据更新视图

之前讲到,在对 model 进行操作对时候,会触发对应 Dep 中的 Watcher 对象。Watcher 对象会调用对应的 update 来修改视图。最终是将新产生的 VNode 节点与老 VNode 进行一个 patch 的过程,比对得出「差异」,最终将这些「差异」更新到视图上。

这一章就来介绍一下这个 patch 的过程,因为 patch 过程本身比较复杂,这一章的内容会比较多,但是不要害怕,我们逐块代码去看,一定可以理解。

跨平台

因为使用了 Virtual DOM 的原因,Vue.js具有了跨平台的能力,Virtual DOM 终归只是一些 JavaScript 对象罢了,那么最终是如何调用不同平台的 API 的呢?

这就需要依赖一层适配层了,将不同平台的 API 封装在内,以同样的接口对外提供。

const nodeOps = {setTextContent (text) {if (platform === 'weex') {node.parentNode.setAttr('value', text);} else if (platform === 'web') {node.textContent = text;}},parentNode () {//......},removeChild () {//......},nextSibling () {//......},insertBefore () {//......}
}

举个例子,现在我们有上述一个 nodeOps 对象做适配,根据 platform 区分不同平台来执行当前平台对应的API,而对外则是提供了一致的接口,供 Virtual DOM 来调用。

一些API

接下来我们来介绍其他的一些 API,这些API在下面 patch 的过程中会被用到,他们最终都会调用 nodeOps 中的相应函数来操作平台。

insert 用来在 parent 这个父节点下插入一个子节点,如果指定了 ref 则插入到 ref 这个子节点前面。

function insert (parent, elm, ref) {if (parent) {if (ref) {if (ref.parentNode === parent) {nodeOps.insertBefore(parent, elm, ref);}} else {nodeOps.appendChild(parent, elm)}}
}

createElm 用来新建一个节点, tag 存在创建一个标签节点,否则创建一个文本节点。


function createElm (vnode, parentElm, refElm) {if (vnode.tag) {insert(parentElm, nodeOps.createElement(vnode.tag), refElm);} else {insert(parentElm, nodeOps.createTextNode(vnode.text), refElm);}
}

addVnodes 用来批量调用 createElm 新建节点。

function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx) {for (; startIdx <= endIdx; ++startIdx) {createElm(vnodes[startIdx], parentElm, refElm);}
}

removeNode 用来移除一个节点。

function removeNode (el) {const parent = nodeOps.parentNode(el);if (parent) {nodeOps.removeChild(parent, el);}
}

removeVnodes 会批量调用 removeNode 移除节点。

function removeVnodes (parentElm, vnodes, startIdx, endIdx) {for (; startIdx <= endIdx; ++startIdx) {const ch = vnodes[startIdx]if (ch) {removeNode(ch.elm);}}
}

patch

首先说一下 patch 的核心 diff 算法,我们用 diff 算法可以比对出两颗树的「差异」,我们来看一下,假设我们现在有如下两颗树,它们分别是新老 VNode 节点,这时候到了 patch 的过程,我们需要将他们进行比对。

diff 算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有 O(n),是一种相当高效的算法,如下图。

这张图中的相同颜色的方块中的节点会进行比对,比对得到「差异」后将这些「差异」更新到视图上。因为只进行同层级的比对,所以十分高效。

patch 的过程相当复杂,我们先用简单的代码来看一下。

function patch (oldVnode, vnode, parentElm) {if (!oldVnode) {addVnodes(parentElm, null, vnode, 0, vnode.length - 1);} else if (!vnode) {removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);} else {if (sameVnode(oldVNode, vnode)) {patchVnode(oldVNode, vnode);} else {removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);addVnodes(parentElm, null, vnode, 0, vnode.length - 1);}}
}

因为 patch 的主要功能是比对两个 VNode 节点,将「差异」更新到视图上,所以入参有新老两个 VNode 以及父节点的 element 。我们来逐步捋一下逻辑, addVnodes 、 removeVnodes 等函数后面会讲。

首先在 oldVnode(老 VNode 节点)不存在的时候,相当于新的 VNode 替代原本没有的节点,所以直接用 addVnodes 将这些节点批量添加到 parentElm 上。

if (!oldVnode) {addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}

然后同理,在 vnode(新 VNode 节点)不存在的时候,相当于要把老的节点删除,所以直接使用 removeVnodes 进行批量的节点删除即可。

else if (!vnode) {removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
}

最后一种情况,当 oldVNode 与 vnode 都存在的时候,需要判断它们是否属于 sameVnode(相同的节点)。如果是则进行patchVnode(比对 VNode )操作,否则删除老节点,增加新节点。

if (sameVnode(oldVNode, vnode)) {patchVnode(oldVNode, vnode);
} else {removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}

sameVnode

上面这些比较好理解,下面我们来看看什么情况下两个 VNode 会属于 sameVnode (相同的节点)呢?

function sameVnode () {return (a.key === b.key &&a.tag === b.tag &&a.isComment === b.isComment &&(!!a.data) === (!!b.data) &&sameInputType(a, b))
}function sameInputType (a, b) {if (a.tag !== 'input') return truelet iconst typeA = (i = a.data) && (i = i.attrs) && i.typeconst typeB = (i = b.data) && (i = i.attrs) && i.typereturn typeA === typeB
}

sameVnode 其实很简单,只有当 key、 tag、 isComment(是否为注释节点)、 data同时定义(或不定义),同时满足当标签类型为 input 的时候 type 相同(某些浏览器不支持动态修改类型,所以他们被视为不同类型)即可。

patchVnode

之前patch的过程还剩下 patchVnode 这个函数没有讲,这也是最复杂的一个,我们现在来看一下。因为这个函数是在符合 sameVnode 的条件下触发的,所以会进行「比对」。

function patchVnode (oldVnode, vnode) {if (oldVnode === vnode) {return;}if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {vnode.elm = oldVnode.elm;vnode.componentInstance = oldVnode.componentInstance;return;}const elm = vnode.elm = oldVnode.elm;const oldCh = oldVnode.children;const ch = vnode.children;if (vnode.text) {nodeOps.setTextContent(elm, vnode.text);} else {if (oldCh && ch && (oldCh !== ch)) {updateChildren(elm, oldCh, ch);} else if (ch) {if (oldVnode.text) nodeOps.setTextContent(elm, '');addVnodes(elm, null, ch, 0, ch.length - 1);} else if (oldCh) {removeVnodes(elm, oldCh, 0, oldCh.length - 1)} else if (oldVnode.text) {nodeOps.setTextContent(elm, '')}}
}

首先在新老 VNode 节点相同的情况下,就不需要做任何改变了,直接 return 掉。

if (oldVnode === vnode) {return;
}

下面的这种情况也比较简单,在当新老 VNode 节点都是 isStatic(静态的),并且 key 相同时,只要将 componentInstance 与 elm 从老 VNode 节点“拿过来”即可。这里的 isStatic 也就是前面提到过的「编译」的时候会将静态节点标记出来,这样就可以跳过比对的过程。

if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {vnode.elm = oldVnode.elm;vnode.componentInstance = oldVnode.componentInstance;return;
}

接下来,当新 VNode 节点是文本节点的时候,直接用 setTextContent 来设置 text,这里的 nodeOps 是一个适配层,根据不同平台提供不同的操作平台 DOM 的方法,实现跨平台。

if (vnode.text) {nodeOps.setTextContent(elm, vnode.text);
}

当新 VNode 节点是非文本节点当时候,需要分几种情况。

  • oldCh 与 ch 都存在且不相同时,使用 updateChildren 函数来更新子节点,这个后面重点讲。
  • 如果只有 ch 存在的时候,如果老节点是文本节点则先将节点的文本清除,然后将 ch 批量插入插入到节点elm下。
  • 同理当只有 oldch 存在时,说明需要将老节点通过 removeVnodes 全部清除。
  • 最后一种情况是当只有老节点是文本节点的时候,清除其节点文本内容。
if (oldCh && ch && (oldCh !== ch)) {updateChildren(elm, oldCh, ch);
} else if (ch) {if (oldVnode.text) nodeOps.setTextContent(elm, '');addVnodes(elm, null, ch, 0, ch.length - 1);
} else if (oldCh) {removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (oldVnode.text) {nodeOps.setTextContent(elm, '')
}

updateChildren

接下来就要讲一下 updateChildren 函数了。

function updateChildren (parentElm, oldCh, newCh) {let oldStartIdx = 0;let 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, idxInOld, elmToMove, refElm;while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (!oldStartVnode) {oldStartVnode = oldCh[++oldStartIdx];} else if (!oldEndVnode) {oldEndVnode = oldCh[--oldEndIdx];} 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);nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));oldStartVnode = oldCh[++oldStartIdx];newEndVnode = newCh[--newEndIdx];} else if (sameVnode(oldEndVnode, newStartVnode)) {patchVnode(oldEndVnode, newStartVnode);nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);oldEndVnode = oldCh[--oldEndIdx];newStartVnode = newCh[++newStartIdx];} else {let elmToMove = oldCh[idxInOld];if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null;if (!idxInOld) {createElm(newStartVnode, parentElm);newStartVnode = newCh[++newStartIdx];} else {elmToMove = oldCh[idxInOld];if (sameVnode(elmToMove, newStartVnode)) {patchVnode(elmToMove, newStartVnode);oldCh[idxInOld] = undefined;nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);newStartVnode = newCh[++newStartIdx];} else {createElm(newStartVnode, parentElm);newStartVnode = newCh[++newStartIdx];}}}}if (oldStartIdx > oldEndIdx) {refElm = (newCh[newEndIdx + 1]) ? newCh[newEndIdx + 1].elm : null;addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);} else if (newStartIdx > newEndIdx) {removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);}
}

看到代码那么多先不要着急,我们还是一点一点地讲解。

首先我们定义 oldStartIdxnewStartIdxoldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引,同时 oldStartVnodenewStartVnodeoldEndVnode 以及 newEndVnode 分别指向这几个索引对应的 VNode 节点。

接下来是一个 while 循环,在这过程中,oldStartIdxnewStartIdxoldEndIdx 以及 newEndIdx 会逐渐向中间靠拢。

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) 

首先当 oldStartVnode 或者 oldEndVnode 不存在的时候,oldStartIdx 与 oldEndIdx 继续向中间靠拢,并更新对应的 oldStartVnode 与 oldEndVnode 的指向(注:下面讲到的 oldStartIdxnewStartIdxoldEndIdx 以及 newEndIdx 移动都会伴随着 oldStartVnodenewStartVnodeoldEndVnode 以及 newEndVnode 的指向的变化,之后的部分只会讲 Idx 的移动)。

if (!oldStartVnode) {oldStartVnode = oldCh[++oldStartIdx];
} else if (!oldEndVnode) {oldEndVnode = oldCh[--oldEndIdx];
}

接下来这一块,是将 oldStartIdxnewStartIdxoldEndIdx 以及 newEndIdx 两两比对的过程,一共会出现 2*2=4 种情况。

 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);nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));oldStartVnode = oldCh[++oldStartIdx];newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {patchVnode(oldEndVnode, newStartVnode);nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);oldEndVnode = oldCh[--oldEndIdx];newStartVnode = newCh[++newStartIdx];
} 

首先是 oldStartVnode 与 newStartVnode 符合 sameVnode 时,说明老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode,同时 oldStartIdx 与 newStartIdx 向后移动一位。

其次是 oldEndVnode 与 newEndVnode 符合 sameVnode,也就是两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode 操作并将 oldEndVnode 与 newEndVnode 向前移动一位。

接下来是两种交叉的情况。

先是 oldStartVnode 与 newEndVnode 符合 sameVnode 的时候,也就是老 VNode 节点的头部与新 VNode 节点的尾部是同一节点的时候,将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。

同理,oldEndVnode 与 newStartVnode 符合 sameVnode 时,也就是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时候,将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。同样的,oldEndIdx 向前移动一位,newStartIdx 向后移动一位。

最后是当以上情况都不符合的时候,这种情况怎么处理呢?

else {let elmToMove = oldCh[idxInOld];if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null;if (!idxInOld) {createElm(newStartVnode, parentElm);newStartVnode = newCh[++newStartIdx];} else {elmToMove = oldCh[idxInOld];if (sameVnode(elmToMove, newStartVnode)) {patchVnode(elmToMove, newStartVnode);oldCh[idxInOld] = undefined;nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);newStartVnode = newCh[++newStartIdx];} else {createElm(newStartVnode, parentElm);newStartVnode = newCh[++newStartIdx];}}
}function createKeyToOldIdx (children, beginIdx, endIdx) {let i, keyconst map = {}for (i = beginIdx; i <= endIdx; ++i) {key = children[i].keyif (isDef(key)) map[key] = i}return map
}

createKeyToOldIdx 的作用是产生 key 与 index 索引对应的一个 map 表。比如说:

[{xx: xx, key: 'key0'},{xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}
]

在经过 createKeyToOldIdx 转化以后会变成:

{key0: 0, key1: 1, key2: 2
}

我们可以根据某一个 key 的值,快速地从 oldKeyToIdxcreateKeyToOldIdx 的返回值)中获取相同 key 的节点的索引 idxInOld,然后找到相同的节点。

如果没有找到相同的节点,则通过 createElm 创建一个新节点,并将 newStartIdx 向后移动一位。

if (!idxInOld) {createElm(newStartVnode, parentElm);newStartVnode = newCh[++newStartIdx];
}

否则如果找到了节点,同时它符合 sameVnode,则将这两个节点进行 patchVnode,将该位置的老节点赋值 undefined(之后如果还有新节点与该节点key相同可以检测出来提示已有重复的 key ),同时将 newStartVnode.elm 插入到 oldStartVnode.elm 的前面。同理,newStartIdx 往后移动一位。

else {elmToMove = oldCh[idxInOld];if (sameVnode(elmToMove, newStartVnode)) {patchVnode(elmToMove, newStartVnode);oldCh[idxInOld] = undefined;nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);newStartVnode = newCh[++newStartIdx];}
}

如果不符合 sameVnode,只能创建一个新节点插入到 parentElm 的子节点中,newStartIdx 往后移动一位。

else {createElm(newStartVnode, parentElm);newStartVnode = newCh[++newStartIdx];
}

最后一步就很容易啦,当 while 循环结束以后,如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。

同理,如果满足 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 removeVnodes 批量删除即可。

if (oldStartIdx > oldEndIdx) {refElm = (newCh[newEndIdx + 1]) ? newCh[newEndIdx + 1].elm : null;addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}

到这里,比对的核心实现已经讲完了,这部分比较复杂,不过仔细地梳理一下比对的过程,相信一定能够理解得更加透彻的。

注:本节代码参考《数据状态更新时的差异 diff 及 patch 机制》。

上一篇:template 模板是怎样通过 Compile 编译的下一篇:批量异步更新策略及 nextTick 原理

数据状态更新时的差异 diff 及 patch 机制相关推荐

  1. React 记坑 ----- 关于 react-redux 中共享状态更新时自动重新 render 页面的问题

    自动重新 render 的触发条件 组件中的 render 方法内 return 部分使用了共享状态数据 通过 reducer 改变了共享状态数据的值 遇到的问题 import { ADD_PERSO ...

  2. taro更新页面数据_Taro 全局数据状态管理

    我们一说到全局数据状态管理时,对于 Vue 框架,或许你想到的是 Vuex:对于 React 框架,或者你想到的是 Redux:在微信小程序中,globalData 内定义全局使用变量:而在 Taro ...

  3. Mysql之乐观锁悲观锁:乐观锁检查数据状态 悲观锁更新时锁定数据

    1.问题来源 就是一数据表的数据  在两个人同时修改的时候  会出现混乱 例子:如一个字段记录status=1 表示可以下单  货品只有1个的时候    a下单的同时b也下单 : a有修改status ...

  4. Vue中的Diff算法 patch函数-简单Diff算法-双端Diff算法-快速Diff算法-当数据发生改变,视图如何更新?

    文章目录 Vue中的Diff算法 概述 前置知识 patch方法 简单Diff算法 总结 双端Diff算法 --vue2 快速Diff算法 --vue3 vue2和vue3 Diff算法的区别 当数据 ...

  5. SQL Server 统计信息更新时采样百分比对数据预估准确性的影响

    为什么要写统计信息 最近看到园子里有人写统计信息,楼主也来凑热闹. 话说经常做数据库的,尤其是做开发的或者优化的,统计信息造成的性能问题应该说是司空见惯. 当然解决办法也并非一成不变,"一招 ...

  6. 基于间隔推送全量更新数据状态的设计方法

    2019独角兽企业重金招聘Python工程师标准>>> 假如有个直播间,在数据有更新的时候,能及时反映在客户端上.通信方式来说,有两种: 1.拉取模式. 2.推送+拉取模式(或者纯推 ...

  7. 解决更新页面数据回显时时间不匹配问题:The specified value “Fri Nov 05 08:00:00 CST 2021“ does not conform to the requir

    解决更新页面数据回显时时间不匹配问题 我们输入时间数据回显,但是页面时间并没有显示 时间没有回显效果图 客户端控制台提示信息: The specified value "Fri Nov 05 ...

  8. SSM Mybatis将新增数据写入数据库时出现的500状态码:Error updating database.的可能...

    关于maven下ssm整合的项目推荐这篇博客:https://www.cnblogs.com/yiye/p/5969157.html 今日在ssm下的将新增数据写回数据库时遇到不少的问题,现作记录 如 ...

  9. 达梦数据charindex_更新日志 · dotnetcore/FreeSql Wiki · GitHub

    每三个月一次版本号:2.3/2.6/2.9,修复严重 bug 会发布 2.3.x v2.3-preview (myget.org) 增加 Column 特性 RewriteSql/RereadSql: ...

最新文章

  1. 练习2-14 求奇数分之一序列前N项和 (15 分)
  2. 猜一个介于1-10之间的数字,反复执行直到猜中为止
  3. 甘肃关于领取软考2021年上半年合格证书的通知
  4. 乌班图linux配置yum仓库,Linux系统的仓库配置(yum、epel)与软件安装(wireshark,wget,nginx)...
  5. GCPC2014 C Bounty Hunter
  6. Spring:设置日志依赖项
  7. 前端学习(3196):虚拟dom和真实dom
  8. 怎么锁定计算机密码忘了怎么办,如果忘记了手机数字锁的密码(仅需10秒钟即可解锁),该怎么办...
  9. Spring-beans-ListableBeanFactory/AutowireCapableBeanFactory/HierarchicalBeanFactory
  10. Linux系统如何测试无线网卡的信号强度,如何用wifi-linux检测AP信号强度
  11. [Shader2D]浮雕效果
  12. ORID焦点呈现法(Focused Conversation Method)
  13. cannot be resolved解决方法
  14. guava-Primitives
  15. Java基础篇之三----Java简介
  16. 浅谈客户端,服务器端,终端
  17. 矩阵连乘——动态规划算法
  18. MyBatis的参数传递
  19. postgres-----pgrouting
  20. 基于goEasy消息推送的扫码登录

热门文章

  1. IIS环境下ShopEx伪静态配置【图文教程+视频教程】
  2. 26个Jquery使用小技巧(jQuery tips, tricks amp; solutions)
  3. 让所有IE支持HTML5
  4. node.js——sequelize-auto模块生成数据库models及使用
  5. ssh不能连接 提示WARNING: POSSIBLE DNS SPOOFING DETECTED!处理方法
  6. Linux(debian 7)错误集锦(一)之开机显示 a start job is running for dev-disk 解决办法
  7. xshell终端中文乱码解决
  8. Pycharm设置utf-8自动显示
  9. 【今日CV 视觉论文速览】Fri, 15 Feb 2019
  10. 结构数据类型 struce c# 1613533319