为什么要理解虚拟 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 对象,包含 type、props、children 等属性,完整地描述了组件实例和它的属性。

结论:虚拟 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 的探索

目前一些新框架(如 Svelte、Solid.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 也在积极探索中。