为什么要理解虚拟 DOM

Q: 为什么要深入理解虚拟 DOM?它不就是 Vue/React 的一个内部实现细节吗?

A: 虚拟 DOM 是理解 Vue 和 React 核心原理的关键钥匙。理解了它,在进行技术选型时更有底气;遇到渲染相关的 bug 能精准定位;学习编译原理、自定义渲染器等进阶主题时也有了坚实的基础。

学习之前,建立正确认知:理解程度取决于你基础知识的扎实程度;要以空杯心态重新审视那些「看起来懂了」的概念。

DOM 的工作原理

Q: 我们写的是 JavaScript 代码,但浏览器引擎是用 C++ 写的。当执行 document.createElement('div') 时,浏览器内部到底发生了什么?

A: 这里涉及一个关键的技术标准——WebIDL(Web Interface Definition Language,Web 接口定义语言)。它定义了浏览器(C++ 实现)和 JavaScript 之间如何进行通信,决定了浏览器提供的本地功能如何被 JS 调用。

整个过程分为几个阶段:

1. 定义接口(WebIDL 层)

浏览器开发者首先用 WebIDL 描述哪些类和方法能被 JS 访问:

interface Document {
    Element createElement(DOMString localName);
};

这里定义了一个 Document 接口,内部有一个 createElement 方法,用于创建 DOM 元素。

2. C++ 实现

浏览器开发者使用 C++ 来实现这个接口:

class Document {
public:
    Element* createElement(const std::string& tagName) {
        return new Element(tagName);
    }
};

3. 生成绑定代码(绑定层)

WebIDL 编译器自动生成绑定代码,决定 JS 如何调用 C++ 方法:

void Document_createElement(const v8::FunctionCallbackInfo<v8::Value>& args) {
    v8::Isolate* isolate = args.GetIsolate();
    v8::HandleScope handle_scope(isolate);
    Document* document = Unwrap<Document>(args.Holder());

    v8::String::Utf8Value utf8_value(isolate, args[0]);
    std::string localName(*utf8_value);

    Element* element = document->createElement(localName);
    v8::Local<v8::Value> result = WrapElement(isolate, element);
    args.GetReturnValue().Set(result);
}

4. 注册到 JS 引擎

void RegisterDocument(v8::Local<v8::Object> global, v8::Isolate* isolate) {
    v8::Local<v8::FunctionTemplate> tmpl = v8::FunctionTemplate::New(isolate);
    tmpl->InstanceTemplate()->Set(isolate, "createElement", Document_createElement);
    global->Set(v8::String::NewFromUtf8(isolate, "Document"), tmpl->GetFunction());
}

5. 开发者调用

const div = document.createElement('div');

当 JS 引擎执行到这句代码时,流程如下:

  1. JS 引擎将其识别为一个 API 调用
  2. 向浏览器底层(渲染引擎)发出请求。
  3. 浏览器底层(C++)负责创建这个 DOM 元素。
  4. 创建完成后,将结果返回给 JS 调用端。
DOM 工作原理示意图

关键结论:平时所说的「真实 DOM」,指的就是浏览器底层已经调用过 C++ 对应的 API,涉及到了渲染引擎的实际操作。这个调用链经过 JS 引擎 -> 绑定层 -> C++ DOM 实现,每一步都存在性能开销。

虚拟 DOM 的本质

Q: 既然「虚拟 DOM」听起来这么玄乎,它到底是什么?

A: 虚拟 DOM 最初由 React 团队提出,核心定义是:

虚拟 DOM 是一种编程概念。在这个概念里,UI 以一种理想化的,或者说「虚拟的」表现形式被保存于内存中。

理论上,任何能描述文档结构的数据结构都可以算作「虚拟 DOM」。但在实践中,最合适的就是 JavaScript 对象

使用 h 函数创建虚拟 DOM

Vue 提供了一个 h 函数,调用后会返回虚拟 DOM(VNode)。下面通过一个示例来感受:

父组件 App.vue:

<template>
  <div class="app-container">
    <h1>这是App组件</h1>
    <Child name="李四" email="123@qq.com" />
    <component :is="vnode" />
  </div>
</template>

<script setup>
import { h } from 'vue';
import Child from '@/components/Child.vue';

const vnode = h(Child, {
  name: '李四',
  email: '123@qq.com',
});
console.log('vnode:', vnode);
</script>

子组件 Child.vue:

<template>
  <div class="child-container">
    <h3>这是子组件</h3>
    <p>姓名:{{ name }}</p>
    <p>email:{{ email }}</p>
  </div>
</template>

<script setup>
defineProps({
  name: String,
  email: String,
});
</script>

打开控制台,你会看到 vnode 的输出——它就是一个普通的 JS 对象,包含 typepropschildren 等属性,完整地描述了组件实例和它的属性。

结论:虚拟 DOM 的本质就是普通的 JavaScript 对象。它不是什么黑魔法,只是用 JS 数据结构来描述 UI 结构的一种方式。

为什么需要虚拟 DOM

Q: 既然虚拟 DOM 的本质只是 JS 对象,那为什么要多此一举?直接操作真实 DOM 不是更直接吗?

A: 这个问题需要从开发模式的演变来回答。

方式一:原生 DOM 操作(命令式)

早期前端开发就是手动操作 DOM:

创建节点:

var newDiv = document.createElement('div');
var newContent = document.createTextNode('Hello, World!');
newDiv.appendChild(newContent);
document.body.appendChild(newDiv);

更新节点:

var existingElement = document.getElementById('myElement');
existingElement.textContent = 'Updated content here!';
existingElement.style.color = 'red';

删除节点:

var elementToRemove = document.getElementById('myElement');
var parent = elementToRemove.parentNode;
parent.removeChild(elementToRemove);

从编程范式的角度看,这是命令式编程。它的性能一定是最高的——没有什么比 document.createElement('div') 创建 div 节点还要快的方式了。

但问题也很明显:写起来心智负担极高,尤其是构建复杂的嵌套结构时。

方式二:innerHTML(声明式)

var app = document.getElementById('app');
app.innerHTML += `
  <div class="message">
    <div class="info">
      <span>张三</span>
      <span>2024.5.6</span>
    </div>
    <p>这是一堂讲解虚拟DOM的课</p>
    <div class="btn">
      <a href="#" class="removeBtn" _id="1">删除</a>
    </div>
  </div>`;

innerHTML 写法简洁很多,心智负担低。但它涉及两个层面的计算

  1. JS 层面:解析字符串
  2. DOM 层面:创建对应的 DOM 节点

方式三:虚拟 DOM

使用虚拟 DOM 同样涉及两个层面:

  1. JS 层面:创建 JS 对象(虚拟 DOM)
  2. DOM 层面:根据 JS 对象创建对应的 DOM 节点

这里有一个关键认知:JS 层面的计算和 DOM 层面的计算,速度完全不在一个量级

做一个对比实验:

JS 层面创建 1 千万个对象:

console.time('time');
const arr = [];
for (let i = 0; i < 10000000; i++) {
  let div = { tag: 'div' };
  arr.push(div);
}
console.timeEnd('time');
// 平均在几百毫秒左右

DOM 层面创建 1 千万个对象:

console.time('time');
const arr = [];
for (let i = 0; i < 10000000; i++) {
  arr.push(document.createElement('div'));
}
console.timeEnd('time');
// 平均在几千毫秒,差距约10倍

虚拟 DOM 真正的威力:更新阶段

实际上,无论是虚拟 DOM 还是 innerHTML,初始化渲染时性能相差无几。虚拟 DOM 真正发挥威力的是更新阶段

对比 innerHTML 的更新过程:

使用 innerHTML 更新某个 div 内容时:

updateButton.addEventListener('click', function () {
  const currentTime = new Date().toTimeString().split(' ')[0];
  contentDiv.innerHTML = `
    <div class="message">
      <div class="info">
        <span>张三</span>
        <span>${currentTime}</span>
      </div>
      <p>这是一堂讲解虚拟DOM的课</p>
      <div class="btn">
        <a href="#" class="removeBtn" _id="1">删除</a>
      </div>
    </div>`;
});

这里涉及的计算:

步骤 层面 操作
1 DOM 层面 销毁所有旧的 DOM 节点
2 JS 层面 解析新的 HTML 字符串
3 DOM 层面 重新创建所有 DOM 节点

即使是只改了一个时间字符串,也需要全量销毁再全量重建

而虚拟 DOM 的更新过程:

步骤 层面 操作
1 JS 层面 使用 diff 算法计算出需要更新的节点
2 DOM 层面 只更新必要的 DOM 节点

只需要更新那个真正变化了的时间节点,其余结构完全复用。

「虚拟 DOM 更快」的真相

平时所说的虚拟 DOM「快」,是有严格前提的:

  • 和原生 JS 操作 DOM 对比:虚拟 DOM 更慢,因为多了一层 JS 计算。
  • 和 innerHTML 对比
    • 初始化渲染时:差距不大。
    • 更新时:虚拟 DOM 通过 diff + 精确 DOM 更新,性能更高

一句话总结:使用虚拟 DOM 是为了防止组件在重渲染时导致的性能恶化。

虚拟 DOM 的更深层次价值

Q: 除了更新性能,虚拟 DOM 还有什么好处?

A: 虚拟 DOM 作为一层抽象,带来了两个更深层次的价值。

1. 跨平台性

虚拟 DOM 增加了一层抽象层,与底层的 DOM 操作解耦。这体现了设计原则中的依赖倒置原则

高层模块不应依赖于低层模块(实际的底层操作 DOM)的实现细节,两者都应依赖于抽象(虚拟 DOM 层)。

加一层抽象的好处是:底层模块可以随时替换。通过不同的渲染引擎,虚拟 DOM 可以渲染到浏览器 DOM、移动端原生组件(如 React Native)、Canvas、甚至命令行界面(如 Ink)。框架不再局限于浏览器平台。

2. 框架架构灵活性

React 从 v15 升级到 v16 时,架构从 Stack 架构变为 Fiber 架构,内部发生了翻天覆地的变化。但因为有虚拟 DOM 这个中间层,对开发者的入侵极小——开发者几乎感受不到变化,依然用同样的方式写代码。

虚拟 DOM 将开发者的代码和框架内部的架构解耦。架构的变化只是依赖于不同的虚拟 DOM 处理方式,开发者的代码依然被编译为对应结构的虚拟 DOM。

无虚拟 DOM 的探索

目前一些新框架(如 SvelteSolid.js)提出了无虚拟 DOM的概念。这类框架直接将组件编译为命令式代码,在编译阶段就知道哪些节点需要更新,运行时不需要通过比较虚拟 DOM 来更新真实 DOM。因此在性能方面一定优于虚拟 DOM 类框架。

Vue 也在积极探索无虚拟 DOM 的方向,推出了代号为「蒸汽模式」的实验性项目:vuejs/core-vapor。这代表了前端框架的未来方向——编译时优化替代运行时计算

总结

本文从三个层面深入剖析了虚拟 DOM:

层面 核心内容 关键结论
DOM 工作原理 WebIDL 标准、JS-C++ 绑定层 真实 DOM 操作跨越 JS 引擎和渲染引擎,开销巨大
虚拟 DOM 本质 h 函数、VNode 对象 虚拟 DOM 只是普通的 JS 对象
设计动机 innerHTML vs 虚拟 DOM、diff 算法 虚拟 DOM 的价值在更新阶段——避免全量销毁重建

关键认知总结

  1. 虚拟 DOM 并不比原生 DOM 操作快——它更快的前提是与 innerHTML 的「全量替换」策略对比。
  2. 虚拟 DOM 的真正价值是「防止性能恶化」——通过 diff 算法实现精确的增量更新,让开发者既能享受声明式编程的便利,又不至于在复杂场景下出现性能雪崩。
  3. 抽象层才是灵魂——虚拟 DOM 作为中间抽象层,带来了跨平台能力和架构灵活性,这比单纯的性能优化更有意义。
  4. 未来趋势——无虚拟 DOM 的编译时优化方案代表了前端框架的进化方向,Vue 也在积极探索中。