VDom 的含义和实现

原文地址

原文是一篇讲得很明白的文章,本文基本上是照着写了一遍
不但学到了 VDOM 相关的一些东西,原生 JS 的方法也熟悉了一遍,收获颇丰

一文说清「VirtualDOM」的含义与实现

如何理解 VDom

前端常做的事情就是根据数据状态的更新,来更新页面视图。然而频繁的更新 DOM 会造成回流或者重绘,引发性能下降,页面卡顿
因此我们需要方法避免频繁更新 DOM 树
思路就是对比 DOM 差距,只更新需要更新的节点,而不是整棵树
实现这个算法的基础,需要遍历 DOM 树的结点,来进行比较更新
为了更快地处理,不使用 DOM 对象,而改用 JS 对象
他就像是 JS 与 DOM 之间的一层缓存

如何表示 VDom

借助 ES6 的 class,表示 VDom 的语义化更强。一个基础的 VDom 需要有标签名,标签属性以及子节点

1
2
3
4
5
6
7
class Element {
constructor(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
}
}

为了更方便调用(不用每次都 new),将其封装返回实例的函数

1
2
3
function el(tagName, props, children) {
return new Element(tagName, props, children);
}

用上面的方法表达 DOM 结构:

1
2
3
<div class="test">
<span>span1</span>
</div>

用 VDom 表示 ↓

1
2
const span = el("span", {}, ["span1"]);
const div = el("div", { class: "test" }, [span]);

之后再对比和更新两棵 vdom 树的时候,将会涉及到将 VDom 渲染成真正的 Dom 节点。因此给class Element 增加render方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Element {
constructor(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
}
render() {
const dom = document.createElement(this.tagName);
// 设置标签属性值
Reflect.ownKeys(this.props).forEach((name) =>
dom.setAttribute(name, this.props[name])
);
// 递归更新子节点
this.children.forEach((child) => {
const childDom =
child instanceof Element
? child.render()
: document.createTextNode(child);
dom.appendChild(childDom);
});
return dom;
}
}

如何比较 dom 树,并且进行高效更新

前面已经说明了 VDom 的用法与含义, 多个 VDom 就会组成一棵虚拟的 VDom 树
剩下要做的就是:根据不同情况,来进行树上结点的增删改操作
这个过程分为diffpath

  • diff: 递归对比两棵 dom 树对应位置的差异
  • patch: 根据差异,进行节点的更新

现在有两种思路,一种是先 diff 一遍,记录所有差异,再统一进行 patch
另一种是 diff 同时进行 patch
相比较,第二种方法少了一次递归查询,以及不需要构造过多对象,下面采用第二种思路

变量的含义

将 diff 和 patch 的过程放入updateEl方法中

1
2
3
4
5
6
7
8
/**
*
* @param {HTMLElement} $parent
* @param {Element} newNode
* @param {Element} oldNode
* @param {Number} index
*/
function updateEl($parent, newNode, oldNode, index = 0) {}

所有以$开头的变量,代表真实DOM
参数index表示oldNode再$parent 的所有子节点构成的数组的下标

1.新增节点

如果 oldNode 为 undefined,说明 newNode 是一个新增的 DOM 节点。将其直接追加到 DOM 中即可

1
2
3
4
5
function updateEl($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(newNode.render());
}
}

2.删除节点

如果 newNode 为 undefined,说明新的 VDom 中,当前位置没有节点,因此需要将其从实际的 DOM 中删除
删除就调用$parent.removeChild(), 通过 index 参数,可以拿到被删除元素的引用

1
2
3
4
5
6
7
function updateEl($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(newNode.render());
} else if (!newNode) {
$parent.removeChild($parent.childNodes[index]);
}
}

3.变化节点

对比 oldNode 和 newNode,有三种情况,均可视为改变

  • 节点类型发生变化,文本变成 vdom,vdom 变成文本
  • 新旧节点都是文本,内容改变
  • 节点属性发生变化

首先,借助 Symbol 更好地语义化声明三种变化

1
2
3
const CHANGE_TYPE_TEXT = Symbol("text");
const CHANGE_TYPE_PROP = Symbol("props");
const CHANGE_TYPE_REPLACE = Symbol("replace");

针对节点属性发生改变,没有现成 API 供我们批量更新,所以封装replaceAttribute方法,将新的 vdom 属性值直接映射到 dom 结构上

1
2
3
4
5
6
7
8
9
function replaceAttribute($node, removedAttrs, newAttrs) {
if (!$node) {
return;
}
Reflect.ownKeys(removedAttrs).forEach((attr) => $node.removeAttribute(attr));
Reflect.ownKeys(newAttrs).forEach((attr) =>
$node.setAttribute(attr, newAttrs[attr])
);
}

编写 checkChangeType 函数判断变化的类型,如果没有变化,则返回空

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function checkChangeType(newNode, oldNode) {
if (
typeof newNode !== typeof oldNode ||
newNode.tagName !== oldNode.tagName
) {
return CHANGE_TYPE_REPLACE;
}
if (typeof newNode === "string") {
if (newNode !== oldNode) {
return CHANGE_TYPE_TEXT;
}
return;
}
const propsChanged = Reflect.ownKeys(newNode.props).reduce(
(prev, name) => prev || oldNode.props[name] !== newNode.props[name],
false
);
if (propsChanged) {
return CHANGE_TYPE_PROP;
}
return;
}

在 updateEl 中,根据 checkChangeType 返回的变化类型,做出对应处理
如果类型为空,则不进行处理

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
function updateEl($parent, newNode, oldNode, index = 0) {
let changeType = null;
if (!oldNode) {
// 新增节点
$parent.appendChild(newNode.render());
} else if (!newNode) {
// 删除节点
$parent.removeChild($parent.childNodes[index]);
} else if ((changeType = checkChangeType(newNode, oldNode))) {
// 检查节点的变更内容
if (changeType === CHANGE_TYPE_TEXT) {
// 文字内容变化
$parent.replaceChild(
document.createTextNode(newNode),
$parent.childNodes[index]
);
} else if (changeType === CHANGE_TYPE_REPLACE) {
// 元素变更
$parent.replaceChild(newNode.render(), $parent.childNodes[index]);
} else if (changeType === CHANGE_TYPE_PROP) {
// 元素上的属性变更
replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
}
}
}

4.递归对子节点进行 diff

如果情况 1,2,3 都没有命重,那说明当前的新旧节点自身并没有变化
需要遍历他们的 children 数组,递归进行处理

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 updateEL($parent, newNode, oldNode, index = 0) {
let changeType = null;
if (!oldNode) {
$parent.appendChild(newNode.render());
} else if (!newNode) {
$parent.removeChild($parent.childNodes[index]);
} else if ((changeType = checkChangeType(newNode, oldNode))) {
if (changeType === CHANGE_TYPE_TEXT) {
$parent.replaceChild(
document.createTextNode(newNode),
$parent.childNodes[index]
);
} else if (changeType === CHANGE_TYPE_REPLACE) {
$parent.replaceChild(newNode.render(), $parent.childNodes[index]);
} else if (changeType === CHANGE_TYPE_PROP) {
replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
}
} else if (newNode.tagName) {
const newLength = newNode.children.length;
const oldLength = oldNode.children.length;
for (let i = 0; i < newLength || i < oldLength; ++i) {
updateEl(
$parent.childNodes[index],
newNode.children[i],
oldNode.children[i]
);
}
}
}