数据发生变化时,vue是怎么更新节点的
我们先根据真实DOM生成一颗virtual DOM,当virtual DOM某个节点的数据改变后会生成一个新的Vnode,然后Vnode和oldVnode作对比,发现有不一样的地方就直接修改在真实的DOM上,然后使oldVnode的值为Vnode。
diff的过程就是调用名为patch的函数,比较新旧节点,一边比较一边给真实的DOM打补丁。
分析Vue中 diff算法与虚拟dom
vue中diff核心策略
平级比较,不考虑跨级比较节点的情况,内部采用深度优先递归 + 双指针(两端对比)策略进行比较
Vue2的核心Diff算法采用了双端比较的算法,同时从新旧children的两端开始进行比较,借助key值找到可复用的节点,再进行相关操作,
展开来说
(观察主流的虚拟 DOM 库(snabbdom、virtual-dom),通常都有一个 h 函数,也就是 React 中的 React.createElement,以及 Vue 中的 render 方法中的 createElement,另外 React 是通过 babel 将 jsx 转换为 h 函数渲染的形式,而 Vue 是使用 vue-loader 将模版转为 h 函数渲染的形式(也可以通过 babel-plugin-transform-vue-jsx 插件在 vue 中使用 jsx,本质还是转换为 h 函数渲染形式)。)
可以看到,最终 HTML 代码会被转译成 h 函数的渲染形式。h 函数接受是三个参数,分别代表是 DOM 元素的标签名、属性、子节点,最终返回一个虚拟 DOM 的对象。
1 | function h(tag, props, ...children) { |
vm.update
当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。
patch函数接收两个参数oldVnode和Vnode分别代表新的节点和之前的旧节点
- 判断两节点是否值得比较(基本属性是否相同,sameVnode),值得比较则执行patchVnode;
- 不值得比较则创建新节点Vnode替换oldVnode;
patchVnode:当我们确定两个节点值得比较之后我们会对两个节点指定patchVnode方法;
- 找到对应的真实dom,称为el;
- 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return;
- 如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点;
- 如果oldVnode有子节点而Vnode没有,则删除el的子节点;
- 如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el;
- 如果两者都有子节点且不相同,则执行updateChildren函数比较子节点,这一步很重要
updateChildren函数
现在分别对oldS、oldE、S、E两两做sameVnode比较,有四种比较方式,当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置
- 如果是oldS和E匹配上了,那么真实dom中的第一个节点会移到最后
- 如果是oldE和S匹配上了,那么真实dom中的最后一个节点会移到最前,匹配上的两个指针向中间移动
- 如果四种匹配没有一对是成功的,分为两种情况
- 如果新旧子节点都存在key,那么会根据oldChild的key生成一张hash表,用S的key与hash表做匹配,匹配成功就判断S和匹配节点是否为sameNode,如果是,就在真实dom中将成功的节点移到最前面,否则,将S生成对应的节点插入到dom中对应的oldS位置,S指针向中间移动,被匹配old中的节点置为null。
- 如果没有key,则直接将S生成新的节点插入真实DOM(ps:这下可以解释为什么v-for的时候需要设置key了,如果没有key那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了,没做到)
vue中 diff算法是如何比对新旧虚拟dom树节点的
- 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return
- 进行文本节点的判断,若 oldVnode.text !== vnode.text,那么就会直接进行文本节点的替换;
- 在vnode没有文本节点的情况下,进入子节点的 diff;
- 当 oldCh 和 ch 都存在且不相同的情况下,调用updateChildren对子节点进行 diff;
- 若 oldCh不存在,ch 存在,首先清空 oldVnode 的文本节点,同时调用 addVnodes 方法将 ch 添加到elm真实 dom 节点当中;
- 若 oldCh存在,ch不存在,则删除 elm 真实节点下的 oldCh 子节点;
- 若 oldVnode 有文本节点,而 vnode 没有,那么就清空这个文本节点。
Vue中的diff算法
看源码之前,先把俩个常用的工具函数贴一下
1 | function isUndef(v) { |
Vue.js 源码的 diff 调用逻辑
每个组件实例都会有相应的Watcher实,渲染组件的过程,会把属性记录为依赖,当我们操作数据的时候,依赖项的setter会被调用,从而通知Watcher重新计算,从而使得相关的组件得以更新。
完成视图的更新工作事实上就是调用了vm._update方法,这个方法接收的第一个参数是刚生成的Vnode,调用的vm._update方法定义在 src/core/instance/lifecycle.js
中。
1 | Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { |
在这个方法当中最为关键的就是 vm.patch 方法,这也是整个 virtual-dom 当中最为核心的方法,主要完成了prevVnode 和 vnode 的 diff 过程并根据需要操作的 vdom 节点打 patch,最后生成新的真实 dom 节点并完成视图的更新工作。
接下来,让我们看下 vm.patch 的逻辑过程, vm.patch 方法定义在 src/core/vdom/patch.js
中。
1 | function patch (oldVnode, vnode, hydrating, removeOnly) { |
在 patch 方法中,我们看到会分为两种情况,
一种是当 oldVnode 不存在时,会创建新的节点
另一种则是已经存在 oldVnode ,那么会对 oldVnode 和 vnode 进行 diff 及 patch 的过程
其中 patch 过程中会调用 sameVnode 方法来对对传入的2个 vnode 进行基本属性的比较,只有当基本属性相同的情况下才认为新旧俩个vnode 只是局部发生了更新,然后才会对这新旧俩个 vnode 进行 diff
如果俩个vnode 的基本属性存在不一致的情况,那么就会直接跳过 diff 的过程,进而依据 vnode 新建一个真实的 dom,同时删除老的 dom 节点。
patchVnode和patch区别
pacth调用patchVnode,如果存在老节点 才会执行该方法,颗粒度更细
1 | // 比较基本属性如tagName data属性等 |
diff 过程中主要是通过调用 patchVnode 方法进行的:
1 | function patchVnode ( |
以上代码得知
diff 过程中又分了好几种情况,oldCh 为 oldVnode的子节点,ch 为 Vnode 的子节点:
- 判断oldVnode和Vnode是否相同,相同直接退出
- 如果他们都有文本节点并且不相等,那么将更新为Vnode的文本节点
- 在 vnode 没有文本节点的情况下,进入子节点的 diff;
- 当 oldCh 和 ch 都存在且不相同的情况下,调用 updateChildren 对子节点进行 diff;
- 若 oldCh 不存在,ch 存在,首先清空 oldVnode 的文本节点,同时调用 addVnodes 方法将 ch 添加到elm真实dom节点当中;
- 若 oldCh 存在,ch 不存在,则删除 elm 真实节点下的 oldCh 子节点;
- 若 oldVnode 有文本节点,而 vnode 没有,那么就清空这个文本节点
子节点 diff 流程分析
这里着重分析下 updateChildren方法,它也是整个 diff 过程中最重要的环节,以下为 Vue.js 的源码过程,为了更形象理解 diff 过程,我们给出相关的示意图来讲解。
1 | function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { |
updateChildren函数主要做了一下工作
- 将Vnode的子节点Vch和oldVnode的子节点oldCh 开始和结束提取出来
1
2
3
4
5
6let 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] // 新的子节点 开始 - oldCh和vCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx > EndIdx 表明oldCh和vCh至少有一个已经遍历完了,就会结束比较。
patch过程中涉及的操作DOM api
diff 我们会看到 nodeOps 相关的方法对真实 DOM 结构进行操作,nodeOps 定义在 src/platforms/web/runtime/node-ops.js 中,其为基本 DOM 操作,有一个是关于setStyleScope设置属性scoped的
1 | export function createElement (tagName: string, vnode: VNode): Element { |
图解子节点diff
现在分别对oldS、oldE、S、E两两做sameVnode比较,有四种比较方式,当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置,这句话有点绕,打个比方
- 如果是oldS和E匹配上了,那么真实dom中的第一个节点会移到最后
- 如果是oldE和S匹配上了,那么真实dom中的最后一个节点会移到最前,匹配上的两个指针向中间移动
- 如果四种匹配没有一对是成功的,分为两种情况
- 如果新旧子节点都存在key,那么会根据oldChild的key生成一张hash表,用S的key与hash表做匹配,匹配成功就判断S和匹配节点是否为sameNode,如果是,就在真实dom中将成功的节点移到最前面,否则,将S生成对应的节点插入到dom中对应的oldS位置,S指针向中间移动,被匹配old中的节点置为null。
- 如果没有key,则直接将S生成新的节点插入真实DOM(ps:这下可以解释为什么v-for的时候需要设置key了,如果没有key那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了,没做到)
(假设old和new中的子节点都有key)
第一步
oldS: a, oldE: d
S: a, E: b
oldS和S匹配,则将dom中的a节点放到第一个,已经是第一个了就不管了,此时dom的位置为:a b d第二步
oldS:b oldE:d
S:c E:b
oldS和E匹配上,将真实dom节点b移到最后,因为E中的b在最后位置,印证这句话:一旦能匹配上,则真实dom对应节点将会按照newE中的位置来,此时真实dom位置是 a d b第三步
oldS: d oldE: d
S: c E: d
oldS和E匹配上,位置不变 此时还是 a d b第四步
oldS++;
oldE–;
oldS > oldE;
遍历结束,说明oldCh先遍历完。就将剩余的vCh节点根据自己的的index插入到真实dom中去,此时dom位置为:a c d b
这个匹配过程的结束有两个条件:
- oldS > oldE表示oldCh先遍历完,新节点多余老节点,那么就将多余的vCh根据index添加到dom中去(如上图)
- S > E表示vCh先遍历完,新节点少于老节点,那么就在真实dom中将区间为[oldS, oldE]的多余节点删掉
第二个例子模拟下diff过程
第一步
oldS: b, oldE: e
S: a, E: e
oldE和E匹配,则将dom中的e节点放到最后一个,此时真实dom位置是 b a d f e第二步
oldS:b oldE:f
S:a E:b
oldS和E匹配上,将真实dom节点b移到倒数第二位,此时真实dom位置是 a d f b e第三步
oldS: a oldE: f
S: a E: a
oldS和S匹配上,位置不变 此时还是 a d f b e第四步
S ++;
E –;
S > E;
遍历结束,说明Ch先遍历完。S > E表示vCh先遍历完,新节点少于老节点,那么就在真实dom中将区间为[oldS, oldE]的多余节点删掉,此时真实dom位置就是 a b e
当这些节点sameVnode成功后就会紧接着执行patchVnode(又回到前面的patchVnode方法了,递归咯)了,可以看一下上面的代码
就这样层层递归下去,直到将oldVnode和Vnode中的所有子节点比对完。也将dom的所有补丁都打好啦.DomDiff的过程更像是俩棵树的比较,每找到相同的节点,都会层层往下比较子节点,这才是真正的深度递归遍历比较的过程