前言

mini-Vue 是精简版本的 Vue3,包含了 vue3 源码中的核心内容,附加上 demo 的具体实现。
本篇是patch 篇,是关于 Vue3 中 patch 的基本理解和实践。

patch的介绍

patch 是对比新旧节点的算法,当新节点不存在的时候,执行卸载操作,当新节点存在的时候,进行对比

卸载操作需要判断对应节点的类型,如果是组件执行组件的卸载,如果是 Fragment 执行 Fragment 的卸载,最后到 TextElement 执行 removeChild

patch 操作需要判断新旧节点的类型是否相同,不同的话就要卸载旧的节点将原有的节点树完全卸载掉。

然后再判断新节点
新节点是否是组件,如果是则进行 processComponent

新节点如果是 Text 类型,执行 processText。之后再来判断旧节点是否存在,如果存在说明之前已经创建过旧的文本内容了,直接复用这个文本节点,更新他的 textContent,如果不存在旧节点,直接使用 mountTextNode 挂载文本节点

新节点如果是 Fragment 类型,就执行 processFragment,如果此时旧节点不存在,直接使用 mountChildren,如果旧节点存在就要进行 diff

新节点最后就判断为 Element 类型,执行 processElement。之后来判断旧节点是否存在,如果不存在,直接使用 mountElement 对新节点进行挂载。如果存在,则要对他进行 diff 操作了。

因为此时新旧节点的 type 一样,就直接复用 type,只要对 propschildren 进行 diff

分析完了之后就开始准备写新的 render 函数了

render

我们之前的 render 是挂载了一个节点,怎么才能产生新旧节点呢?

实际上我们只要将 vnode 挂载在 container 上面,下次进入的时候获取 container 上面的 vnode,此时这个 vnode 就是旧节点

1
2
3
4
export function render(vnode, container) {
const prevNode = container._vnode;
container._vnode = vnode;
}

接下来进行判断了,我们首先判断新节点存不存在。

1
2
3
4
5
6
7
8
9
10
11
export function render(vnode, container) {
const prevNode = container._vnode;
if (!vnode) {
if (prevNode) {
unmount(prevNode);
}
} else {
patch(prevNode, vnode, container);
}
container._vnode = vnode;
}

接下来就要编写 unmountpatch

unmount

前面的情况就是利用 shapeflag 判断组件的卸载或 fragment 的卸载。

1
2
3
4
5
6
7
8
9
10
function unmount(vnode){
const {shapeFlag,el} = vnode;
if(shapeFlag&ShapeFlags.COMPONENT){
unmountComponent(vnode)
}else if(shapeFlag&ShapeFlags.FRAGMENT){
unmountFragment(vnode)
}else{
...?
}
}

但我们发现最后一种情况:对 TextElement 进行 removeChild 的时候,没有获取到具体的 el,所以我们要从 vnode 里面拿 el,也就是要在 vnode 的返回值里面添加一个 el

1
2
3
4
5
6
7
return {
type,
props,
children,
shapeFlag,
el: null,
};

且在挂载 elementtextnode 之后需要将 el 挂载到 vnode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function mountElement(vnode, container) {
// 取出元素 挂载元素 挂载props children
const { type, props } = vnode;
const el = document.createElement(type);
// 将props挂载到el上
mountProps(props, el);
// 把节点挂载到el上
mountChildren(vnode, el);
container.appendChild(el);
// 保存el
vnode.el = el;
}

function mountTextNode(vnode, container) {
const textNode = document.createTextNode(vnode.children);
container.appendChild(textNode);
vnode.el = el;
}

所以现在最后一种情况应该这样写了

1
2
3
4
5
6
7
8
9
10
11
12
function unmount(vnode) {
const { shapeFlag, el } = vnode;
if (shapeFlag & ShapeFlags.COMPONENT) {
unmountComponent(vnode);
} else if (shapeFlag & ShapeFlags.FRAGMENT) {
unmountFragment(vnode);
} else {
// 其次是Text或者Element 但要拿到child节点
// 所以要在vnode里面初始化一个el
el.parentNode.removeChild(el);
}
}

patch

接下来是实现 patch 的部分,要通过他判断新旧节点的类型是否相同,设置一个函数。然后分情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function patch(prevVNode, vnode, container) {
if (prevVNode && !isSameVNode(prevVNode, vnode)) {
unmount(prevVNode);
// 注意卸载之后要将节点设置为null
prevVNode = null;
}
const { shapeFlag } = vnode;
if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(prevVNode, vnode, container);
} else if (shapeFlag & ShapeFlags.TEXT) {
processText(prevVNode, vnode, container);
} else if (shapeFlag & ShapeFlags.FRAGMENT) {
processFragment(prevVNode, vnode, container);
} else {
processElement(prevVNode, vnode, container);
}
}
function isSameVNode(prevVNode, vnode) {
return prevVNode.type === vnode.type;
}

之后要处理剩下的四个函数 processComponent,processText,processFragment,processElement

processText

存在旧节点的时候,复用旧节点的 textContent。否则执行 mountTextNode,这个前面也写过了。直接 cv 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
function processText(prevVNode, vnode, container) {
if (prevVNode) {
vnode.el = prevVNode.el;
prevVNode.el.textContent = vnode.children;
} else {
mountTextNode(vnode, container);
}
}
function mountTextNode(vnode, container) {
const textNode = document.createTextNode(vnode.children);
container.appendChild(textNode);
vnode.el = el;
}

processElement

对于存在旧节点的情况下调用 patchElement 进行 diff 比较,不存在的情况则调用原先写过的 mountElement 进行挂载

1
2
3
4
5
6
7
function processElement(prevVNode, vnode, container) {
if (prevVNode) {
patchElement(prevVNode, vnode, container);
} else {
mountElement(vnode, container);
}
}

这里需要注意的一点是 mountChildren 中我们之前是没有实现 mount 的,其实在这里用 patch 实现即可。

1
2
3
4
5
6
7
8
9
10
11
function mountChildren(vnode, container) {
const { shapeFlag, children } = vnode;
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
mountTextNode(vnode, container);
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 递归调用挂载
children.forEach((child) => {
patch(null, child, container);
});
}
}

除此之外就是 patchElement,因为此时新旧节点的 type 都是一样的,所以将旧节点的 el 赋值给新节点的 el,然后对比它们之间的 propschildren 的异同。

patchProps

其中包含了两个函数一个是 patchProps 一个是 patchChildren

对于 patchProps,回顾一下 props 的内容,他是一个对象里面有很多属性。

1
2
3
4
5
6
7
8
9
10
{
class: 'a b',
style: {
color: 'red',
fontSize: '14px',
},
onClick: () => console.log('click'),
checked: '',
custom: false
}

所以我们对于新旧节点的 props,需要取出对应的 value 值,对比是否相同,如果相同我们才去进行重新赋值。并且我们还要遍历旧属性,移除旧属性中有的,新属性中没有的。遍历新属性,添加旧属性没有的新属性有的。

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
function patchProps(oldProps, newProps, el) {
if (oldProps === newProps) {
return;
}
// 防止传过来的props是空 注意这里没有用到es6赋默认值的写法是因为他有可能传递的是null,es6默认值只能处理undefined的情况
oldProps = oldProps || {};
newProps = newProps || {};

// 移除旧属性有的,新属性没有的
for (const key in oldProps) {
// 当前属性是 'key' 则跳过
if (key === "key") {
continue;
}

if (newProps[key] == null) {
patchDomProp(oldProps[key], null, key, el);
}
}

// 添加旧属性没有的,新属性有的
for (const key in newProps) {
if (key === "key") {
continue;
}

if (oldProps[key] !== newProps[key]) {
patchDomProp(oldProps[key], newProps[key], key, el);
}
}
}
patchDomProp

patchDomProp 这一步操作和我们之前写的 mountProps 有点类似。不过有几个细节要注意:

如果 nextfalse 或者是 null 的话,他就会变成字符串,而不是去掉,我们希望的是去掉。所以要写el.className = next || ''

如果新旧属性中的 style 有不一致的,我们添加新的 styleName,移除不需要的 styleName

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// next为空直接移除
if (next == null) {
el.removeAttribute("style");
} else {
// 我们添加新的 styleName
for (const styleName in next) {
el.style[styleName] = next[styleName];
}
// 移除不需要的 `styleName`。
if (prev) {
for (const styleName in prev) {
if (next[styleName] == null) {
el.style[styleName] = "";
}
}
}
}

如果事件中存在旧事件,移除旧事件,如果存在新事件,添加新事件。

1
2
3
4
5
6
7
8
9
10
11
if (/^on[^a-z]/.test(key)) {
const eventName = key.slice(2).toLowerCase();
// 移除旧事件
if (prev) {
el.removeEventListener(eventName, prev);
}
// 添加新事件
if (next) {
el.addEventListener(eventName, next);
}
}

完整代码

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
switch (key) {
// 如果是class 直接赋className
case "class":
el.className = next;
break;
// 如果是style 遍历赋值value值
case "style":
for (const styleName in next) {
el.style[styleName] = next[styleName];
}
if (prev) {
for (const styleName in prev) {
if (next[styleName] == null) {
el.style[styleName] = "";
}
}
}
break;
// 如果是事件,正则匹配on开头,并将后面的转为小写单词 然后添加事件
// 如果是别的属性,分类判断,不能统一设置attribute
default:
if (/^on[^a-z]/.test(key)) {
const eventName = key.slice(2).toLowerCase();
if (prev) {
el.removeEventListener(eventName, prev);
}
if (next) {
el.addEventListener(eventName, next);
}
} else if (domPropsRE.test(key)) {
if (next === "" || isBoolean(el[key])) {
next = true;
}
el[key] = next;
} else {
if (next == null || next === false) {
el.removeAttribute(key);
} else {
el.setAttribute(key, next);
}
}
break;
}

注意此时我们已经可以利用 patchProps 取代 mountProps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function mountElement(vnode, container) {
// 取出元素 挂载元素 挂载props children
const { type, props } = vnode;
const el = document.createElement(type);
// 将props挂载到el上
// mountProps(props, el);
if (props) {
patchProps(null, props, el);
}
// 把节点挂载到el上
mountChildren(vnode, el);
container.appendChild(el);
// 保存el
vnode.el = el;
}

patchChildren

我们重新修改一下 mountChildren,让他的职责更加单一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function mountElement(vnode, container) {
// 取出元素 挂载元素 挂载props children
const { type, props, shapeFlag, children } = vnode;
const el = document.createElement(type);
// 将props挂载到el上
// mountProps(props, el);
patchProps(null, props, el);
// 把节点挂载到el上
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
mountTextNode(vnode, el);
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(children, el);
}
container.appendChild(el);
// 保存el
vnode.el = el;
}

function mountChildren(children, container) {
// 递归调用挂载
children.forEach((child) => {
patch(null, child, container);
});
}

然后开始写 patchChildren,这一部分中,我们需要对九种情况进行判断

简单写一下模板大致如下:

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
function patchChildren(prevVNode, vnode, container) {
const { shapeFlag: prevShapeFlag, children: c1 } = prevVNode;
const { shapeFlag, children: c2 } = vnode;
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
container.textContent = c2;
} else if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(c1);
container.textContent = c2;
} else {
container.textContent = c2;
}
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
container.textContent = "";
mountChildren(c2, container);
} else if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
patchArrayChildren(c1, c2, container);
} else {
mountChildren(c2, container);
}
} else {
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
container.textContent = "";
} else if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(c1);
}
}
}

此时剩下的 unmountChildrenpatchArrayChildren 两个函数暂未实现,但是我们发现可以对这个模板做一个合并

1
2
3
4
5
6
7
8
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(c1);
}
if (c1 !== c2) {
container.textContent = c2;
}
}

下面一段是 vue 源码的合并结构,虽然简介但是还是分情况比较好理解

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
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(c1);
}
if (c2 !== c1) {
container.textContent = c2;
}
} else {
// c2 is array or null

if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// c1 was array

if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// c2 is array
// patchArrayChildren()
} else {
// c2 is null
unmountChildren(c1);
}
} else {
// c1 was text or null

if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
container.textContent = "";
}
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(c2, container, anchor);
}
}
}

之后我们编写 unmountChildren,其实他也就是遍历 children 然后卸载

1
2
3
4
5
function unmountChildren(children) {
children.forEach((child) => {
unmount(child);
});
}

然后就到了 patchArrayChildren,这个部分

patchArrayChildren

举个例子,我们看他如何实现

1
2
3
4
5
6
7
8
9
10
11
const n1 = h("ul", null, [
h("li", null, "a"),
h("li", null, "b"),
h("li", null, "c"),
]);

const n2 = h("ul", null, [
h("li", null, "d"),
h("li", null, "e"),
h("li", null, "f"),
]);

c1: a b c
c2: d e f

c1: a b c
c2: d e f g h

c1: a b c g h
c2: d e f

对比新旧孩子,如果长度相同则执行 patch 对比,如果新孩子比旧孩子长则挂载新孩子多出来的部分,如果旧孩子比新孩子长则删除旧孩子多出来的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
function patchArrayChildren(c1, c2, container) {
const oldLength = c1.length;
const newLength = c2.length;
const commonLength = Math.min(oldLength, newLength);
for (let i = 0; i < commonLength; i++) {
patch(c1[i], c2[i], container);
}
if (oldLength > newLength) {
unmountChildren(c1.slice(commonLength));
} else if (oldLength < newLength) {
mountChildren(c2.slice(commonLength), container);
}
}

接下来就是处理 processFragment

processFragment

旧节点存在的时候对比新旧的孩子,不存在时候直接挂载新节点的孩子。

1
2
3
4
5
6
7
function processFragment(prevVNode, vnode, container) {
if (prevVNode) {
patchChildren(prevVNode, vnode, container);
} else {
mountChildren(vnode.children, container);
}
}

先来个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { render, Fragment, h } from "./runtime";

render(
h("ul", null, [
h("li", null, "first"),
h(Fragment, null, []),
h("li", null, "last"),
]),
document.body
);
setTimeout(() => {
render(
h("ul", null, [
h("li", null, "first"),
h(Fragment, null, [h("li", null, "middle")]),
h("li", null, "last"),
]),
document.body
);
}, 2000);

我们跑这个例子之前,知道这个 Fragment 节点应该是 2s 加入到 first 和 last 中间的,而实际的结果却是他到了最后面。

我们先分析一下原因:首先 patch 新旧节点,它们类型是相同的,是 Fragment,则进入 processFragment。此时旧节点不存在,执行 mountChildren,然后执行 patch,传的值是(null, child, container),到 patch,此时孩子是一个元素节点,所以执行 processElement。之后 mountElement

这一步就是问题所在了,mountElement 中,我们的 el 是直接挂载到 container 中的,也就是container.appendChild(el);代码。所以他被加到了最后面

为了解决这个问题我们要使用 anchor 属性。

Anchor

anchor 属性和 el 类似,因为 Fragment 节点之前挂载到的是容器,所以我们不知道应该在何处插入或者删除我们的 Fragment 节点,就要使用 el 和 anchor 生成节点然后就可以在这两个节点中间插入 Fragment

所以我们要在 vnode 返回值里面添加 anchor

1
2
3
4
5
6
7
8
return {
type,
props,
children,
shapeFlag,
el: null,
anchor: null,
};

然后重新写一下 processFragment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function processFragment(prevVNode, vnode, container) {
// 如果旧节点的el存在就复用,anchor也是一样
const fragmentStartAnchor = (vnode.el = prevVNode
? prevVNode.el
: document.createTextNode(""));
const fragmentEndAnchor = (vnode.anchor = prevVNode
? prevVNode.anchor
: document.createTextNode(""));

if (prevVNode) {
patchChildren(prevVNode, vnode, container);
} else {
container.appendChild(fragmentStartAnchor);
container.appendChild(fragmentEndAnchor);
// 传递anchor
mountChildren(vnode.children, container, fragmentEndAnchor);
}
}

这样的话,就需要在后续的地方增加 anchor 属性了

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
function mountChildren(children, container,anchor) {
// 递归调用挂载
children.forEach((child) => {
patch(null, child, container,anchor);
});
}
function patch(prevVNode, vnode, container,anchor) {
if (prevVNode && !isSameVNode(prevVNode,vnode)) {
unmount(prevVNode);
prevVNode = null;
}
const {
shapeFlag
} = vnode;
if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(prevVNode, vnode, container,anchor);
} else if (shapeFlag & ShapeFlags.TEXT) {
processText(prevVNode, vnode, container,anchor);
} else if (shapeFlag & ShapeFlags.FRAGMENT) {
processFragment(prevVNode, vnode, container,anchor);
} else {
processElement(prevVNode, vnode, container,anchor);
}
}
function processElement(prevVNode, vnode, container,anchor) {
if (prevVNode) {
patchElement(prevVNode, vnode);
} else {
mountElement(vnode, container,anchor);
}
}
function mountElement(vnode, container,anchor) {
// 取出元素 挂载元素 挂载props children
const {
type,
props,
shapeFlag,
children
} = vnode;
const el = document.createElement(type);
// 将props挂载到el上
// mountProps(props, el);
patchProps(null, props, el);
// 把节点挂载到el上
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
mountTextNode(vnode, el);
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(children, el);
}
// container.appendChild(el);
container.insertBefore(el,anchor);
// 保存el
vnode.el = el;
}
function processText(prevVNode, vnode, container,anchor) {
if (prevVNode) {
vnode.el = prevVNode.el;
prevVNode.el.textContent = vnode.children;
} else {
mountTextNode(vnode, container,anchor);
}
}
function mountTextNode(vnode, container,anchor) {
const textNode = document.createTextNode(vnode.children);
// container.appendChild(textNode);
container.insertBefore(textNode,anchor);
vnode.el = textNode;
}
function processFragment(prevVNode, vnode, container,anchor) {
const fragmentStartAnchor = vnode.el = prevVNode ? prevVNode.el : document.createTextNode('');
const fragmentEndAnchor = vnode.anchor = prevVNode ? prevVNode.anchor : document.createTextNode('');

if (prevVNode) {
// 用fragmentEndAnchor作为他的anchor
patchChildren(prevVNode, vnode, container,fragmentEndAnchor);
} else {
// 此处也要insertuu
container.insertBefore(fragmentStartAnchor,anchor)
container.insertBefore(fragmentEndAnchor,anchor)
mountChildren(vnode.children, container,fragmentEndAnchor);
}
}
...

总之在需要 anchor 的节点都需要添加 anchor 属性,并且最后的时候需要替换 appendChildinsertBefore

之后我们编写遗留的 unmountFragment,这个函数本身我们可以用 unmountChildren 的形式来写,但是现在由于添加了 el 和 anchor 两个文本节点,所以我们要换种方式了。

思路大概就是将他们循环删除

1
2
3
4
5
6
7
8
9
10
function unmountFragment(vnode) {
let { el: cur, anchor: end } = vnode;
const { parentNode } = cur;
while (cur != end) {
let next = cur.nextSibling;
parentNode.removeChild(cur);
cur = next;
}
parentNode.removeChild(end);
}

此时回去测试我们的用例,发现可以成功 patch 了。

最后还有一个小问题,就是关于 patchArrayChildren 里面的。我们看这么个例子

h1, h1, h1
h1, h2, h1

例如,对上面这个例子进行 patchChildren 第一次 patch 时,n2.el = n1.el,没有创建元素,anchor 没有用。 第二次 patch 时,先删除了 n1,对 n2 进行创建,执行 insertBeforeanchor 就需要设置为 n1 的下一个兄弟节点。

1
2
3
4
5
6
7
8
function patch(prevVNode, vnode, container,anchor) {
if (prevVNode && !isSameVNode(prevVNode,vnode)) {
anchor = prevVNode.el.nextSibling;
unmount(prevVNode);
prevVNode = null;
}
...
}

而如果是

n1, “” n1 “”,
n1 n1, n2, n1

如果 n1Fragment,那么 anchor 应该设置为 n1anchor 的下一个兄弟节点。

所以不妨将两个情况合并为以下的代码

1
anchor = (prevVNode.anchor || prevVNode.el).nextSibling;

总结

在本节里,我们学习了patch算法简单的对新旧节点进行比对,然后根据对应的情况去挂载新节点或删除旧节点,后面又重新编写了render方法来对我们之前的操作做一些补充和修改。下节将进行核心diff算法的学习。