虚拟 DOM 有两个核心概念:
虚拟 DOM 是真实 DOM 的一种表示
当状态发生变更时,通过对新旧 DOM 树做 diff,只对真实 DOM 做必要的更改,以改善性能。
JSX 配置
因为会用到 JSX,因此会用到 Babel 作为 JS 的编译器,安装的包为:
@babel/cli
@babel/core
@babel/plugin-transform-react-jsx
编译的命令为 babel --plugins @babel/plugin-transform-react-jsx src/main.jsx -o build/release.js
DOM 树的表示
DOM 树的实际内容为:
<ul class=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
我们将其表示为:
{ type: 'ul', props: { 'class': 'list' }, children: [
{ type: 'li', props: {}, children: ['item 1'] },
{ type: 'li', props: {}, children: ['item 2'] }
] }
为了简化创建方式,我们使用遍历函数来创建 DOM 单元:
function h(type, props, …children) {
return { type, props: props || {}, children };
}
使用这种方式,保证 props 始终不为 None |
然后,上述的 DOM 树创建的方式为:
h('ul', { 'class': 'list' },
h('li', {}, 'item 1'),
h('li', {}, 'item 2'),
);
使用 JSX 的方式为:
/** @jsx h */
<ul className=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
默认情况下,JSX 被编译为 React.createElement,但是通过 @jsx h 注释,我们使用 h() 替换了 React.createElement() |
在使用 JSX 后,创建 DOM 的代码简化为:
const a = (
<ul>
<li>item 1</li>
<li>item 2</li>
</ul>
)
渲染虚拟 DOM
接下来的任务是如何将虚拟 DOM 渲染到真实的代码中。此后,将应用一下约定:
代表实际 DOM 节点的变量以 $ 开头
虚拟 DOM 节点以变量 node 表示
首先是创建节点:
function createElement(node) {
if (typeof node == "string") { (1)
return document.createTextNode(node)
}
const $el = document.createElement(node.type) (2)
node.children (3)
.map(createElement)
.forEach($el.appendChild.bind($el));
return $el;
}
1 | 如果是纯文本节点,则调用 createTextNode |
2 | 如果不是纯文本节点,就调用 createElement |
3 | 对节点的 children 递归调用 createElement |
这样,就完成了一个元素的创建
应用 DOM 更改
对比新旧 DOM 树,有三种情况:
旧 DOM 树不存在节点,需要创建
新 DOM 树不存在节点,需要删除
新旧节点不同,需要更新。
下面是三种情况的代码:
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) { // 旧节点不存在,需要创建
$parent.appendChild(
createElement(newNode)
)
} else if (!newNode) { // 新节点不存在,需要删除
$parent.removeChild(
$parent.childNodes[index]
)
} else if (changed(newNode, oldNode)) { // 新旧节点不同,需要更新
$parent.replaceChild(
createElement(newNode),
$parent.childNodes[index]
)
} else if (newNode.type) { // 对子节点进行递归 diff
const newLen = newNode.children.length;
const oldLen = oldNode.children.length;
for (let i = 0; i < newLen || i < oldLen; ++i) {
updateElement($parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
)
}
}
}
其中 changed
用来比对两个节点是否相同,其内容为:
function changed(node1, node2) {
return typeof node1 !== typeof node2 ||
typeof node1 === 'string' && node1 !== node2 ||
node1.type !== node2.type
}
首先是 html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtual DOM</title>
</head>
<style>
#root {
border: 1px solid black;
padding: 10px;
margin: 30px 0 0 0;
}
</style>
<body>
<button id="reload">Reload</button>
<div id="root"></div>
<script src="../build/release.js" type="text/javascript"></script>
</body>
</html>
然后是 jsx:
以上就是全部内容了。一个可用于测试的代码如下:
/** @jsx h */
function h(type, props, ...children) {
return { type, props, children };
}
function createElement(node) {
if (typeof node == "string") {
return document.createTextNode(node)
}
const $el = document.createElement(node.type)
node.children
.map(createElement)
.forEach($el.appendChild.bind($el));
return $el;
}
function changed(node1, node2) {
return typeof node1 !== typeof node2 ||
typeof node1 === 'string' && node1 !== node2 ||
node1.type !== node2.type
}
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
)
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
)
} else if (changed(newNode, oldNode)) {
$parent.replaceChild(
createElement(newNode),
$parent.childNodes[index]
)
} else if (newNode.type) {
const newLen = newNode.children.length;
const oldLen = oldNode.children.length;
for (let i = 0; i < newLen || i < oldLen; ++i) {
updateElement($parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
)
}
}
}
const a = (
<ul>
<li>item 1</li>
<li>item 2</li>
</ul>
)
const b = (
<ul>
<li>item 1</li>
<li>hello !</li>
</ul>
)
const $root = document.getElementById('root')
const $reload = document.getElementById('reload')
updateElement($root, a);
$reload.addEventListener('click', () => {
updateElement($root, b, a)
})
属性
在上面的代码中,DOM 的属性被储存到了 props 中,设置属性的方式是很简单的,只需要:
function setProp($target, name, value) {
$target.setAttribute(name, value);
}
对于多个属性而言,设置的方式为:
function setProps($target, props) {
Object.keys(props).forEach(name => {
setProp($target, name, props[name]);
});
}
然后就是在适当的位置添加设置属性的代码:
function createElement(node) {
// ...
const $el = document.createElement(node.type)
setProps($el, node.props) (1)
// ...
return $el;
}
1 | 创建元素后立即设置属性 |
除此之外,属性还有一些需要注意的问题:
class 是 HTML 中的一个关键字,因此需要使用 className 代替
布尔值设置后应当是布尔值,而不是字符串
为了可拓展性,应当允许设置自定义属性
修正后的代码如下:
function setBooleanProp($target, name, value) {
if (value) {
$target.setAttribute(name, value);
$target[name] = true;
} else {
$target[name] = false;
}
}
function isCustomProp(name) {
return false;
}
function setProp($target, name, value) {
if (isCustomProp(name)) {
return;
} else if (name == "className") {
$target.setAttribute('class', value);
} else if (typeof value === "boolean") {
setBooleanProp($target, name, value)
} else {
$target.setAttribute(name, value);
}
}
然后是在对 DOM 进行 diff 时,也需要对属性进行更改:
function removeBooleanProp($target, name) {
$target.removeAttribute(name);
$target[name] = false
}
function removeProp($target, name, value) {
if (isCustomProp(name)) {
return;
} else if (name === 'className') {
$target.removeAttribute('class');
} else if (typeof value === 'boolean') {
removeBooleanProp($target, name);
} else {
$target.removeAttribute(name);
}
}
function updateProp($target, name, newVal, oldVal) {
if (!newVal) {
removeProp($target, name, oldVal);
} else if (!oldVal || newVal != oldVal) {
setProp($target, name, newVal);
}
}
function updateProps($target, newProps, oldProps) {
const props = Object.assign({}, newProps, oldProps);
Object.keys(props).forEach(name => {
updateProp($target, name, newProps[name], oldProps[name]);
})
}
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) { (1)
// ...
} else if (!newNode) { (2)
// ...
} else if (changed(newNode, oldNode)) { (3)
// ...
} else if (newNode.type) { (4)
updateProps( (5)
$parent.childNodes[index],
newNode.props,
oldNode.props
)
// ...
}
}
1 | 代表需要创建节点 |
2 | 代表需要删除节点 |
3 | 代表需要更新节点 |
4 | 代表新旧节点外观看起来相同,需要检查子节点 |
5 | 更新属性 |
从上面的注释可以看出代码为什么放在那里
事件
TODO: 这部分有 bug,先不写了 |