虚拟 DOM 如何工作?(自行构建)

2025-05-24

虚拟 DOM 如何工作?(自行构建)

插件:我帮助开发million:<1kb 虚拟 DOM - 它很快!

介绍

虚拟 DOM 是一个虚拟节点树,它代表了 DOM 的外观。虚拟节点是轻量级的、无状态的,并且是仅包含必要字段的 JavaScript 对象。虚拟节点可以组装成树,并通过“差异化”对 DOM 进行精确更改。

背后的原因是,修改和访问 DOM 节点的计算成本很高。虚拟 DOM 的前提是,在虚拟节点之间进行 diff 操作,仅在修改时才访问 DOM。它尽可能地避免使用 DOM,转而使用普通的 JavaScript 对象,从而使读写操作更加便宜。

它是如何工作的?

百万虚拟 DOM 包含三个主要函数:mcreateElementpatch。为了完全理解虚拟 DOM 的工作原理,让我们尝试基于这些函数创建我们自己的基本虚拟 DOM(阅读时间约 7 分钟)。

在开始之前,我们需要定义一下什么是虚拟节点。虚拟节点可以是 JavaScript 对象(虚拟元素),也可以是字符串(文本)。


m函数是一个创建虚拟元素的辅助函数。虚拟元素包含三个属性:

  • tag:将元素的标签名称存储为字符串。
  • props:将元素的属性/特性存储为对象。
  • children:将元素的虚拟节点子节点存储为数组。

辅助函数的示例实现m如下:

const m = (tag, props, children) => ({
  tag,
  props,
  children,
});
Enter fullscreen mode Exit fullscreen mode

这样,我们就可以轻松构建虚拟节点:

m('div', { id: 'app' }, ['Hello World']);
// Is the same as:
{
  tag: 'div',
  props: { id: 'app' },
  children: ['Hello World']
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

这样,我们可以轻松地将虚拟节点转换为 DOM 元素:

createElement(m('div', { id: 'app' }, ['Hello World']));
// Is the same as: <div id="app">Hello World</div>
Enter fullscreen mode Exit fullscreen mode

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]);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

这样,我们可以轻松地基于虚拟节点修补 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>
Enter fullscreen mode Exit fullscreen mode

笔记:

  • 旧的虚拟节点必须始终对 DOM 元素进行建模,直到修补之后。
  • 一般来说,应用程序不是直接用这些方法编写的,而是应该将它们抽象为组件和 JSX,以简单起见。
  • 这与 Million 的实现不同,而是一个演示,以便让您更好地了解虚拟 DOM 的工作原理。

那么...Million 有何独特之处?

Million 提供了五项主要改进:粒度修补、更少的迭代过程、快速文本插值、键控虚拟节点、编译器标志。

  • 细粒度修补:当 props 或子项出现差异时,不是仅仅替换整个元素,而是只更改必要的 props。
  • 更少的迭代次数:百万次尝试减少差异分析期间的次数,从而实现更好的时间和空间复杂度。
  • 快速文本插值: Million 不使用 DOM 方法替换文本节点,而是使用编译器标志来设置textContent元素以提高性能。
  • 键控虚拟元素:如果新的虚拟元素键与旧的虚拟元素键相同,则修补算法可以跳过节点,从而最大限度地减少不必要的工作量。
  • 编译器标志:这允许修补算法跳过条件分支,这意味着完成的工作更少。
文章来源:https://dev.to/aidenybai/how-does-virtual-dom-work-b74
PREV
Angular 几乎总是比 React 更好
NEXT
CSS 网格初学者指南