虚拟 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,先不写了
Last moify: 2025-01-17 02:01:39
Build time:2025-07-18 09:41:42
Powered By asphinx