L
O
A
D
I
N
G

Vue2源码学习笔记(六)—— 异步更新原理和diff算法原理

前言

要来力! diff算法,以前只是知其然而不知其所以然,接下来将完成diff算法原理的学习,至于为什么把他和异步更新原理一起学习,是我认为它们都是性能优化方面的,异步更新是对视图更新的性能优化,而diff是对渲染更新的性能优化。同样还是跟着鲨鱼大佬的源码学习,后面有大佬文章链接。

异步更新原理

异步更新原理主要是对视图更新的性能优化,总结:

在Vue 2中,异步更新是通过事件循环机制实现的。当Vue组件的响应式状态发生变化时,Vue会将更新操作推入一个队列中,而不是立即执行更新。然后,在下一个事件循环周期中,Vue会遍历队列并执行更新操作。

这种异步更新的机制有以下几个好处:

  1. 提高性能:将多个状态变化合并为一个更新操作,避免了频繁的DOM操作,从而提高了性能。
  2. 避免重复更新:如果在同一个事件循环周期内多次修改了同一个状态,Vue只会执行一次更新操作,避免了重复更新。
  3. 避免阻塞UI线程:由于更新操作是在下一个事件循环周期中执行的,所以不会阻塞UI线程,保持了页面的流畅性。

1.watcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/observer/watcher.js
import { queueWatcher } from "./scheduler";
export default class Watcher {
update() {
// 每次watcher进行更新的时候 是否可以让他们先缓存起来 之后再一起调用
// 异步队列机制
queueWatcher(this);
}
run() {
// 真正的触发更新
this.get();
}
}

2.queueWatcher 实现队列机制

queueWatcher函数是用于实现异步更新队列机制的关键函数之一。它的作用是将Watcher对象推入更新队列中,以便在下一个事件循环周期中执行更新操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/observer/scheduler.js

import { nextTick } from "../util/next-tick";
// 定义一个全局的更新队列数组
const queue = [];
// 定义一个标志位,用于表示是否正在执行更新操作
let isUpdating = false;
// 定义一个函数,用于执行更新操作
function flushSchedulerQueue() {
// 标记为正在执行更新操作
isUpdating = true;

// 遍历更新队列中的Watcher对象,并依次执行其更新操作
for (let i = 0; i < queue.length; i++) {
const watcher = queue[i];
watcher.run();
}

// 清空更新队列
queue.length = 0;

// 标记更新操作已完成
isUpdating = false;
}

// 定义一个函数,用于将Watcher对象推入更新队列中
function queueWatcher(watcher) {
// 如果Watcher对象尚未在更新队列中,则将其推入队列中
if (!queue.includes(watcher)) {
queue.push(watcher);
}

// 如果更新队列为空且当前不处于更新操作中,则执行更新操作
if (queue.length === 1 && !isUpdating) {
nextTick(flushSchedulerQueue);
}
}

3.nextTick的实现原理
可以看到在执行异步渲染时,我们使用nexTick,nexTick是在下次Dom更新执行延迟回调,那它的具体实现方式是什么呢?

简单版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 定义一个回调函数数组
const callbacks = [];

// 定义一个标志位,用于表示是否正在执行回调函数
let pending = false;

// 定义一个函数,用于执行回调函数
function flushCallbacks() {
// 标记为正在执行回调函数
pending = true;

// 遍历回调函数数组,并依次执行回调函数
for (let i = 0; i < callbacks.length; i++) {
callbacks[i]();
}

// 清空回调函数数组
callbacks.length = 0;

// 标记回调函数已执行完成
pending = false;
}

// 定义一个函数,用于将回调函数推入回调函数数组中
function nextTick(callback) {
// 将回调函数推入回调函数数组中
callbacks.push(callback);

// 如果当前不处于执行回调函数的状态,则执行回调函数
if (!pending) {
setTimeout(flushCallbacks, 0);
}
}

采用优雅降级后的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// src/util/next-tick.js

let callbacks = [];
let pending = false;
function flushCallbacks() {
pending = false; //把标志还原为false
// 依次执行回调
for (let i = 0; i < callbacks.length; i++) {
callbacks[i]();
}
}
let timerFunc; //定义异步方法 采用优雅降级
if (typeof Promise !== "undefined") {
// 如果支持promise
const p = Promise.resolve();
timerFunc = () => {
p.then(flushCallbacks);
};
} else if (typeof MutationObserver !== "undefined") {
// MutationObserver 主要是监听dom变化 也是一个异步方法
let counter = 1;
const observer = new MutationObserver(flushCallbacks);
const textNode = document.createTextNode(String(counter));
observer.observe(textNode, {
characterData: true,
});
timerFunc = () => {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
} else if (typeof setImmediate !== "undefined") {
// 如果前面都不支持 判断setImmediate
timerFunc = () => {
setImmediate(flushCallbacks);
};
} else {
// 最后降级采用setTimeout
timerFunc = () => {
setTimeout(flushCallbacks, 0);
};
}

export function nextTick(cb) {
// 除了渲染watcher 还有用户自己手动调用的nextTick 一起被收集到数组
callbacks.push(cb);
if (!pending) {
// 如果多次调用nextTick 只会执行一次异步 等异步队列清空之后再把标志变为false
pending = true;
timerFunc();
}
}

3.nextTick原理总结

因此nextTcik说白了就是通过事件循环的机制(每次当一次事件循环结束后,即一个宏任务执行完成后以及微任务队列被清空后,浏览器就会进行一次页面更新渲染。),利用微任务和宏任务结合优雅降级的方式来执行回调函数,从而实现在下次Dom更新完后执行的回调函数。当某个由watcter监听观察的值发生,并通知相应的DOM更新,这时dom更新同步执行发现nextTcik是个微任务或宏任务,放在队列中,同步任务执行完了,浏览器就会进行一次页面更新渲染,再取出nextTick的队列,执行了nextTick的回调函数。

最后将其挂载到vue的原型方法上,就是我们经常使用的$nextTick()方法了

diff算法原理

前提纪要:Vue2源码学习笔记(三)——初次渲染原理 - 掘金 (juejin.cn)

Vue在初始化页面后,会将当前的真实DOM转换为虚拟DOM(Virtual DOM),并将其保存起来,这里称为oldVnode。然后当某个数据发变化后,Vue会先生成一个新的虚拟DOM——vnode,然后将vnodeoldVnode进行比较,找出需要更新的地方,然后直接在对应的真实DOM上进行修改。当修改结束后,就将vnode赋值给oldVnode存起来,作为下次更新比较的参照物。其中新旧vnode的比较,也就是我们常说的Diff算法

  • 为什么需要 diff ?

复用 DOM 比直接替换(移除旧 DOM,创建新 DOM )性能好的多。

  • diff 原则?

原地复用 > 移动后复用 >> 暴力替换

1.patch 核心渲染方法

patch()方法,该方法接收新旧虚拟Dom,即oldVnodevnode.

1.首先,检查oldVnode是否是一个真实的DOM元素,如果是,则表示这是初次渲染,不需要进行Diff算法的比较。

2.如果oldVnode是一个虚拟DOM节点,那么就需要使用Diff算法进行更新过程。首先,代码检查新旧虚拟DOM节点的标签是否一致,如果不一致,则直接用新的虚拟DOM节点替换旧的真实DOM节点。

3.接下来,代码检查旧虚拟DOM节点是否是一个文本节点,如果是,则比较新旧文本内容是否一致,如果不一致,则更新旧文本节点的textContent

4.如果既不是标签不一致的情况,也不是文本节点的情况,那么说明标签一致且不是文本节点。为了节点复用,代码将旧虚拟DOM节点对应的真实DOM节点赋值给新虚拟DOM节点的el属性

5.然后,代码调用updateProperties函数来更新新虚拟DOM节点的属性。

6.接下来,代码获取旧虚拟DOM节点的子节点和新虚拟DOM节点的子节点,并进行比较和更新。

  • 如果旧节点和新节点都有子节点,代码调用updateChildren函数来比较和更新子节点。
  • 如果只有旧节点有子节点,代码将旧节点的innerHTML清空,相当于移除所有旧子节点。
  • 如果只有新节点有子节点,代码遍历新子节点,并将它们创建为真实DOM节点,并添加到旧节点的el中。

这样,根据新旧虚拟DOM节点的差异,代码完成了相应的更新操作。以下是简单的实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// src/vdom/patch.js

export function patch(oldVnode, vnode) {
const isRealElement = oldVnode.nodeType;
if (isRealElement) {
// oldVnode是真实dom元素 就代表初次渲染
} else {
// oldVnode是虚拟dom 就是更新过程 使用diff算法
if (oldVnode.tag !== vnode.tag) {
// 如果新旧标签不一致 用新的替换旧的 oldVnode.el代表的是真实dom节点--同级比较
oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
}
// 如果旧节点是一个文本节点
if (!oldVnode.tag) {
if (oldVnode.text !== vnode.text) {
oldVnode.el.textContent = vnode.text;
}
}
// 不符合上面两种 代表标签一致 并且不是文本节点
// 为了节点复用 所以直接把旧的虚拟dom对应的真实dom赋值给新的虚拟dom的el属性
const el = (vnode.el = oldVnode.el);
updateProperties(vnode, oldVnode.data); // 更新属性
const oldCh = oldVnode.children || []; // 老的儿子
const newCh = vnode.children || []; // 新的儿子
if (oldCh.length > 0 && newCh.length > 0) {
// 新老都存在子节点
updateChildren(el, oldCh, newCh);
} else if (oldCh.length) {
// 老的有儿子新的没有
el.innerHTML = "";
} else if (newCh.length) {
// 新的有儿子
for (let i = 0; i < newCh.length; i++) {
const child = newCh[i];
el.appendChild(createElm(child));
}
}
}
}

2.调用updateProperties函数来更新新虚拟DOM节点的属性

updateProperties函数的主要作用就是将新虚拟DOM节点的属性映射到对应的真实DOM节点上,是根据新旧虚拟DOM节点的属性差异,代码完成了对真实DOM节点属性的更新操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//  src/vdom/patch.js

// 解析vnode的data属性 映射到真实dom上
function updateProperties(vnode, oldProps = {}) {
const newProps = vnode.data || {}; //新的vnode的属性
const el = vnode.el; // 真实节点
// 如果新的节点没有 需要把老的节点属性移除
for (const k in oldProps) {
if (!newProps[k]) {
el.removeAttribute(k);
}
}
// 对style样式做特殊处理 如果新的没有 需要把老的style值置为空
const newStyle = newProps.style || {};
const oldStyle = oldProps.style || {};
for (const key in oldStyle) {
if (!newStyle[key]) {
el.style[key] = "";
}
}
// 遍历新的属性 进行增加操作
for (const key in newProps) {
if (key === "style") {
for (const styleName in newProps.style) {
el.style[styleName] = newProps.style[styleName];
}
} else if (key === "class") {
el.className = newProps.class;
} else {
// 给这个元素添加属性 值就是对应的值
el.setAttribute(key, newProps[key]);
}
}
}

3.updateChildren 更新子节点-diff 核心方法

updateChildren函数通过双指针的方式,新旧头尾指针进行比较,循环向中间靠拢,对比新旧子节点数组的差异,并根据差异进行相应的更新操作,实现了虚拟DOM的高效更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// src/vdom/patch.js

// 判断两个vnode的标签和key是否相同 如果相同 就可以认为是同一节点就地复用
function isSameVnode(oldVnode, newVnode) {
return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key;
}
// diff算法核心 采用双指针的方式 对比新老vnode的儿子节点
function updateChildren(parent, oldCh, newCh) {
let oldStartIndex = 0; //老儿子的起始下标
let oldStartVnode = oldCh[0]; //老儿子的第一个节点
let oldEndIndex = oldCh.length - 1; //老儿子的结束下标
let oldEndVnode = oldCh[oldEndIndex]; //老儿子的起结束节点

let newStartIndex = 0; //同上 新儿子的
let newStartVnode = newCh[0];
let newEndIndex = newCh.length - 1;
let newEndVnode = newCh[newEndIndex];

// 根据key来创建老的儿子的index映射表 类似 {'a':0,'b':1} 代表key为'a'的节点在第一个位置 key为'b'的节点在第二个位置
function makeIndexByKey(children) {
let map = {};
children.forEach((item, index) => {
map[item.key] = index;
});
return map;
}
// 生成的映射表
let map = makeIndexByKey(oldCh);

// 只有当新老儿子的双指标的起始位置不大于结束位置的时候 才能循环 一方停止了就需要结束循环
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
// 因为暴力对比过程把移动的vnode置为 undefined 如果不存在vnode节点 直接跳过
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIndex];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIndex];
} else if (isSameVnode(oldStartVnode, newStartVnode)) {
// 头和头对比 依次向后追加
patch(oldStartVnode, newStartVnode); //递归比较儿子以及他们的子节点
oldStartVnode = oldCh[++oldStartIndex];
newStartVnode = newCh[++newStartIndex];
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
//尾和尾对比 依次向前追加
patch(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIndex];
newEndVnode = newCh[--newEndIndex];
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// 老的头和新的尾相同 把老的头部移动到尾部
patch(oldStartVnode, newEndVnode);
parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); //insertBefore可以移动或者插入真实dom
oldStartVnode = oldCh[++oldStartIndex];
newEndVnode = newCh[--newEndIndex];
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// 老的尾和新的头相同 把老的尾部移动到头部
patch(oldEndVnode, newStartVnode);
parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
oldEndVnode = oldCh[--oldEndIndex];
newStartVnode = newCh[++newStartIndex];
} else {
// 上述四种情况都不满足 那么需要暴力对比
// 根据老的子节点的key和index的映射表 从新的开始子节点进行查找 如果可以找到就进行移动操作 如果找不到则直接进行插入
let moveIndex = map[newStartVnode.key];
if (!moveIndex) {
// 老的节点找不到 直接插入
parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
} else {
let moveVnode = oldCh[moveIndex]; //找得到就拿到老的节点
oldCh[moveIndex] = undefined; //这个是占位操作 避免数组塌陷 防止老节点移动走了之后破坏了初始的映射表位置
parent.insertBefore(moveVnode.el, oldStartVnode.el); //把找到的节点移动到最前面
patch(moveVnode, newStartVnode);
}
}
}
// 如果老节点循环完毕了 但是新节点还有 证明 新节点需要被添加到头部或者尾部
if (newStartIndex <= newEndIndex) {
for (let i = newStartIndex; i <= newEndIndex; i++) {
// 这是一个优化写法 insertBefore的第一个参数是null等同于appendChild作用
const ele =
newCh[newEndIndex + 1] == null ? null : newCh[newEndIndex + 1].el;
parent.insertBefore(createElm(newCh[i]), ele);
}
}
// 如果新节点循环完毕 老节点还有 证明老的节点需要直接被删除
if (oldStartIndex <= oldEndIndex) {
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
let child = oldCh[i];
if (child != undefined) {
parent.removeChild(child.el);
}
}
}
}

4.改造原型渲染更新方法_update

当数据发生变化时订阅者watcher就会调用_update

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/lifecycle.js

export function lifecycleMixin(Vue) {
// 把_update挂载在Vue的原型
Vue.prototype._update = function (vnode) {
const vm = this;
const prevVnode = vm._vnode; // 保留上一次的vnode
vm._vnode = vnode;
if (!prevVnode) {
// patch是渲染vnode为真实dom核心
vm.$el = patch(vm.$el, vnode); // 初次渲染 vm._vnode肯定不存在 要通过虚拟节点 渲染出真实的dom 赋值给$el属性
} else {
vm.$el = patch(prevVnode, vnode); // 更新时把上次的vnode和这次更新的vnode穿进去 进行diff算法
}
};
}

5.diff算法总结

  • 当数据发生改变时,订阅者watcher就会调用原型方法_update中的patch给真实的DOM打补丁

  • 判断是否是初始化渲染,是则不需要diff算法,否则就走diff算法

    • 找到对应的真实dom,称为el
    • 如果都有都有文本节点且不相等,将el文本节点设置为Vnode的文本节点
    • 如果oldVnode有子节点而VNode没有,则删除el子节点
    • 如果oldVnode没有子节点而VNode有,则将VNode的子节点真实化后添加到el
    • 如果两者都有子节点,则执行updateChildren函数比较子节点
  • updateChildren主要做了以下操作:
    • 设置新旧VNode的头尾指针
    • 新旧头尾指针进行比较,循环向中间靠拢,根据情况patch重复流程或调用createElem创建一个新节点
    • 从哈希表寻找 key一致的VNode 节点再分情况操作,如果标签,key值,属性都相同就可以就地复用

个人博客

耀耀切克闹 (yaoyaoqiekenao.com)

参考文章

手写Vue2.0源码(六)-diff算法原理 - 掘金 (juejin.cn)

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

我是穷比,在线乞讨!

支付宝
微信