虚拟 DOM 如何工作?(自行构建)
插件:我帮助开发
million
:<1kb 虚拟 DOM - 它很快!
介绍
虚拟 DOM 是一个虚拟节点树,它代表了 DOM 的外观。虚拟节点是轻量级的、无状态的,并且是仅包含必要字段的 JavaScript 对象。虚拟节点可以组装成树,并通过“差异化”对 DOM 进行精确更改。
背后的原因是,修改和访问 DOM 节点的计算成本很高。虚拟 DOM 的前提是,在虚拟节点之间进行 diff 操作,仅在修改时才访问 DOM。它尽可能地避免使用 DOM,转而使用普通的 JavaScript 对象,从而使读写操作更加便宜。
它是如何工作的?
百万虚拟 DOM 包含三个主要函数:m
、createElement
、patch
。为了完全理解虚拟 DOM 的工作原理,让我们尝试基于这些函数创建我们自己的基本虚拟 DOM(阅读时间约 7 分钟)。
在开始之前,我们需要定义一下什么是虚拟节点。虚拟节点可以是 JavaScript 对象(虚拟元素),也可以是字符串(文本)。
该m
函数是一个创建虚拟元素的辅助函数。虚拟元素包含三个属性:
tag
:将元素的标签名称存储为字符串。props
:将元素的属性/特性存储为对象。children
:将元素的虚拟节点子节点存储为数组。
辅助函数的示例实现m
如下:
const m = (tag, props, children) => ({
tag,
props,
children,
});
这样,我们就可以轻松构建虚拟节点:
m('div', { id: 'app' }, ['Hello World']);
// Is the same as:
{
tag: 'div',
props: { id: 'app' },
children: ['Hello World']
}
该createElement
函数将虚拟节点转换为真实的 DOM 元素。这很重要,因为我们将在patch
函数中使用它,并且用户也可能使用它来初始化他们的应用程序。
我们需要以编程方式创建一个新的分离 DOM 元素,然后迭代虚拟元素的 props 并将它们添加到 DOM 元素中,最后迭代子元素并为其添加首字母。createElement
辅助函数的示例实现如下:
const createElement = vnode => {
if (typeof vnode === 'string') {
return document.createTextNode(vnode); // Catch if vnode is just text
}
const el = document.createElement(vnode.tag);
if (vnode.props) {
Object.entries(vnode.props).forEach(([name, value]) => {
el[name] = value;
});
}
if (vnode.children) {
vnode.children.forEach(child => {
el.appendChild(createElement(child));
});
}
return el;
};
这样,我们可以轻松地将虚拟节点转换为 DOM 元素:
createElement(m('div', { id: 'app' }, ['Hello World']));
// Is the same as: <div id="app">Hello World</div>
该patch
函数接受一个现有的 DOM 元素、一个旧的虚拟节点和一个新的虚拟节点。这不一定是性能最佳的实现,仅供演示之用。
我们需要比较两个虚拟节点的差异,然后在需要时替换元素。首先,我们确定其中一个虚拟节点是文本还是字符串,如果新旧虚拟节点不相等,则替换它。否则,我们可以安全地假设两者都是虚拟元素。之后,我们比较标签和属性,如果标签已更改,则替换该元素。然后,我们遍历子元素,并递归修补子元素是否是虚拟元素。辅助patch
函数的示例实现如下:
const patch = (el, oldVNode, newVNode) => {
const replace = () => el.replaceWith(createElement(newVNode));
if (!newVNode) return el.remove();
if (!oldVNode) return el.appendChild(createElement(newVNode));
// Handle text case
if (typeof oldVNode === 'string' || typeof newVNode === 'string') {
if (oldVNode !== newVNode) return replace();
} else {
// Diff tag
if (oldVNode.tag !== newVNode.tag) return replace();
// Diff props
if (!oldVNode.props?.some((prop) => oldVNode.props?[prop] === newVNode.props?[prop])) return replace();
// Diff children
[...el.childNodes].forEach((child, i) => {
patch(child, oldVNode.children?[i], newVNode.children?[i]);
});
}
}
这样,我们可以轻松地基于虚拟节点修补 DOM 元素:
const oldVNode = m('div', { id: 'app' }, ['Hello World']);
const newVNode = m('div', { id: 'app' }, ['Goodbye World']);
const el = createElement(oldVNode);
patch(el, oldVNode, newVNode);
// el will become: <div id="app">Goodbye World</div>
笔记:
- 旧的虚拟节点必须始终对 DOM 元素进行建模,直到修补之后。
- 一般来说,应用程序不是直接用这些方法编写的,而是应该将它们抽象为组件和 JSX,以简单起见。
- 这与 Million 的实现不同,而是一个演示,以便让您更好地了解虚拟 DOM 的工作原理。
那么...Million 有何独特之处?
Million 提供了五项主要改进:粒度修补、更少的迭代过程、快速文本插值、键控虚拟节点、编译器标志。
- 细粒度修补:当 props 或子项出现差异时,不是仅仅替换整个元素,而是只更改必要的 props。
- 更少的迭代次数:百万次尝试减少差异分析期间的次数,从而实现更好的时间和空间复杂度。
- 快速文本插值: Million 不使用 DOM 方法替换文本节点,而是使用编译器标志来设置
textContent
元素以提高性能。 - 键控虚拟元素:如果新的虚拟元素键与旧的虚拟元素键相同,则修补算法可以跳过节点,从而最大限度地减少不必要的工作量。
- 编译器标志:这允许修补算法跳过条件分支,这意味着完成的工作更少。