那么部分直接背八股文的同学可能就会回答,虚拟dom性能高,速度快,然后就没有下文了,这种情况面试官在往深度一问可能一些同学就当场宕机了,那么针对虚拟dom,就引出了一些不得不说的问题了

dom工作原理

开始之前,我们来了解一下dom的工作原理,不知道大家是否好奇,们写的是js代码,但是浏览器是用c++写的,那么浏览器是如何处理的呢?比如下面这个代码,我们都知道它实际上是创建了一个div的dom结点

 1const div=document.createElement("div")

那么浏览器拿到这段代码做了什么了呢?

介绍一个东西 web interface definition langure,WebIDL,翻译中文叫做web接口定义语言,这里就是定义浏览器和js之间如何进行通讯,换句话说就是浏览器所提供的一些功能能够被js调用

通过WebIDL,浏览器开发者可以描述那些方法和类能够被js访问,以及这些方法如何映射到js中的对象和方法

我们以创建dom元素的接口为例,假设存在如下的WebIDL定义,用于创建dom元素

 1
 2interface Document {
 3    Element createElement(DOMString localName);
 4}

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

 1
 2class Document {
 3public:
 4    
 5    Element* createElement(const std::string& tagName) {
 6        return new Element(tagName);
 7    }
 8};

接下来,需要生成绑定代码,绑定了 JS 如何调用这个 C++ 方法:

 1
 2// 这个绑定代码是由 WebIDL 编译器自动生成
 3// 这就是 JS 到 C++ 的绑定
 4// 换句话说,这段绑定代码决定了 JS 开发者可以调用哪些方法从而来调用上面的 C++ 方法
 5void Document_createElement(const v8::FunctionCallbackInfo<v8::Value>& args) {
 6    v8::Isolate* isolate = args.GetIsolate()
 7    v8::HandleScope handle_scope(isolate)
 8    Document* document = Unwrap<Document>(args.Holder())
 9
10    v8::String::Utf8Value utf8_value(isolate, args[0])
11    std::string localName(*utf8_value)
12
13    Element* element = document->createElement(localName)
14    v8::Local<v8::Value> result = WrapElement(isolate, element)
15    args.GetReturnValue().Set(result)
16}

有了绑定代码之后,接下来需要在 JS 引擎里面注册:

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

Web 开发者在进行开发的时候,可以在 JS 文件中书写如下的代码:

 1
 2const i = 1
 3document.createElement("div")

首先是 JS 引擎来执行 JS 代码,第一句是 JS 引擎完全能搞定的。第二句 JS 引擎发现你要创建 DOM 节点,会将其识别为一个 API 调用,然后向浏览器底层(渲染引擎)发出请求,由浏览器底层(渲染引擎)负责来创建这个 DOM 元素。浏览器底层创建完 DOM 元素之后,还需要给你最初的调用端返回一个结果,所谓最初的调用端,也就是 JS 代码中调用 DOM API 的地方。

如下图所示:

平时我们所指的真实 DOM,究竟是在指什么?

指的就是浏览器底层已经调用过 C++ 对应的 API 了

假设你在 JS 层面

 1
 2document.appendChild("div");

那么浏览器底层在调用对应的 C++ 代码的时候,还会涉及到浏览器重新渲染的相关内容

平时我们所指的真实DOM(Document Object Model),是指浏览器内部使用C++等底层语言实现的一套API和对象模型,它直接与浏览器的渲染引擎交互,用于表示和操作网页的结构、样式和内容。真实DOM是浏览器渲染页面和处理用户交互的基础。

当你在JavaScript层面执行如下代码:

javascript

 1document.appendChild("div");
 1const parentElement = document.body
 2const newDiv = document.createElement("div")
 3parentElement.appendChild(newDiv)

在这个例子中,document.createElement("div") 调用了浏览器底层对应的C++ API,创建了一个新的真实DOM元素。然后,appendChild 方法被调用来将这个新创建的 div 元素添加到父元素(例如 body)的子节点列表中。

以下是浏览器底层在调用对应的C++代码时会发生的过程:

  1. 创建DOM元素document.createElement("div") 会调用浏览器底层对应的C++ API来创建一个新的DOM元素。
  2. 修改DOM结构parentElement.appendChild(newDiv) 会修改DOM树的结构,将新创建的 div 元素添加到指定的父元素中。
  3. 浏览器重新渲染:当DOM结构发生变化时,浏览器会触发重新渲染的过程。这包括计算元素的位置、大小、样式等,并最终在屏幕上绘制出新的内容。
  4. 样式计算和布局:浏览器会重新计算受影响的元素的样式,并确定它们在页面上的布局。
  5. 绘制:浏览器将根据计算出的布局绘制元素,这可能包括绘制文本、边框、背景等。

因此,当你在JavaScript中修改DOM时,确实会涉及到浏览器重新渲染的相关内容。这个过程是由浏览器的渲染引擎管理的,它会确保页面的显示与DOM的状态保持一致。

虚拟dom本质

理论上来讲,无论你用什么样的结构,只要你将文档的结构能够展示出来,你的这种结构就是一种虚拟 DOM. 虽然理论是美好的,但实际上也只有 JS 对象适合干这个事情。

在 Vue 中,可以通过一个名叫 h 的函数,该函数的调用结果就是返回虚拟 DOM.

下面是一个简单的示例:

父组件 App.vue

 1<template>
 2  <div class="app-container">
 3    <h1>这是App组件</h1>
 4    <Child name="李四" email="123@qq.com" />
 5    <component :is="vnode" />
 6  </div>
 7</template>
 8
 9<script setup>
10import { h } from 'vue'
11import Child from '@/components/Child.vue'
12const vnode = h(Child, {
13  name: '李四',
14  email: '123@qq.com'
15})
16console.log('vnode:', vnode)
17

子组件 Child.vue

 1<template>
 2  <div class="child-container">
 3    <h3>这是子组件</h3>
 4    <p>姓名:{{ name }}</p>
 5    <p>email:{{ email }}</p>
 6  </div>
 7</template>
 8
 9<script setup>
10defineProps({
11  name: String,
12  email: String
13})
14</script>

我们不妨来看一下

我们不妨再次思考一下,虚拟dom究竟是什么。或者说虚拟dom是什么究竟重要吗?无非是用一些字符串或者对象来表示dom结构而已,通过这个结构再渲染出来真实的dom,相当于再渲染真实dom之前抽离出来了一层,方便后续的操作

通过上面的例子,我们可以得出一个结论:虚拟 DOM 的本质就是普通的 JS 对象。

为什么需要使用虚拟dom

那么为什么要使用虚拟dom呢?

早期的开发模式,在最早期的时候,前端是通过手动操作 DOM 节点来编写代码的。

创建节点:

 1
 2
 3var newDiv = document.createElement("div");
 4
 5var newContent = document.createTextNode("Hello, World!");
 6
 7newDiv.appendChild(newContent);
 8
 9document.body.appendChild(newDiv);

更新节点:

 1
 2// 假设我们有一个已存在的元素ID为'myElement'
 3var existingElement = document.getElementById("myElement")
 4// 更新文本内容
 5existingElement.textContent = "Updated content here!"
 6// 更新属性,例如改变样式
 7existingElement.style.color = "red"

删除节点:

 1
 2// 假设我们要删除ID为'myElement'的元素
 3var elementToRemove = document.getElementById("myElement")
 4// 获取父节点
 5var parent = elementToRemove.parentNode
 6// 从父节点中移除这个元素
 7parent.removeChild(elementToRemove)

插入节点:

 1
 2// 创建新节点
 3var newNode = document.createElement("div")
 4newNode.textContent = "这是新的文本内容"
 5// 假设我们想把这个新节点插入到id为'myElement'的元素前面
 6var referenceNode = document.getElementById("myElement")
 7referenceNode.parentNode.insertBefore(newNode, referenceNode)

上面的代码,如果从编程范式的角度来看,是属于 命令式编程,这种命令式编程的性能一定是最高的。

这意味着,假如你要创建一个 div 的 DOM 节点,没有什么比 document.createElement(“div”) 这句代码的性能还要高。既然这种命令式的方式性能更高,为什么还需要使用虚拟dom呢

虽然上面的方式是性能最高的,但是在实际开发中,开发者往往倾向于更加方便的方式。

 1
 2<div id="app">
 3  
 4</div>

如果是采用传统的操作 DOM 节点的方式:

 1
 2// 获取app节点
 3var app = document.getElementById("app")
 4
 5// 创建外层div
 6var messageDiv = document.createElement("div")
 7messageDiv.className = "message"
 8
 9// 创建info子div
10var infoDiv = document.createElement("div")
11infoDiv.className = "info"
12
13// 创建span元素并添加到infoDiv
14var nameSpan = document.createElement("span")
15nameSpan.textContent = "张三"
16infoDiv.appendChild(nameSpan)
17
18var dateSpan = document.createElement("span")
19dateSpan.textContent = "2024.5.6"
20infoDiv.appendChild(dateSpan)
21
22// 将infoDiv添加到messageDiv
23messageDiv.appendChild(infoDiv)
24
25// 创建并添加<p>
26var p = document.createElement("p")
27p.textContent = "这是一堂讲解虚拟DOM的课"
28messageDiv.appendChild(p)
29
30// 创建btn子div
31var btnDiv = document.createElement("div")
32btnDiv.className = "btn"
33
34// 创建a元素并添加到btnDiv
35var removeBtn = document.createElement("a")
36removeBtn.href = "#"
37removeBtn.className = "removeBtn"
38removeBtn.setAttribute("_id", "1")
39removeBtn.textContent = "删除"
40btnDiv.appendChild(removeBtn)
41
42// 将btnDiv添加到messageDiv
43messageDiv.appendChild(btnDiv)
44
45// 将构建的messageDiv添加到app中

如果使用 innerHTML 的方式:

 1
 2var app = document.getElementById("app");
 3
 4app.innerHTML += `
 5  <div class="message">
 6    <div class="info">
 7      <span>张三</span>
 8      <span>2024.5.6</span>
 9    </div>
10    <p>这是一堂讲解虚拟DOM的课</p>
11    <div class="btn">
12      <a href="#" class="removeBtn" _id="1">删除</a>
13    </div>
14  </div>`;

两种方式都可以完成任务,但是 虽然第一种方式性能最高,但是很显然写起来 Web开发者 的心智负担也很高。

因此 Web开发者往往选择第二种,虽然性能要差一些,但是心智负担也没有那么高,写起来轻松一些。

为什么第二种性能要差一些?差在哪里?

原因很简单,第二种方式涉及到了两个层面的计算:

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

而虚拟 DOM 也涉及到两个层面的计算:

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

这里我们不需要考虑同属于 JS 层面的计算,解析字符串和创建 JS 对象究竟谁快谁慢。只需要知道不同层面的计算,JS 层面的计算和 DOM 层面的计算,速度是完全不同的。

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

 1
 2console.time("time")
 3const arr = []
 4for(let i=0
 5  let div = {
 6    tag : "div"
 7  }
 8  arr.push(div)
 9}
10console.timeEnd("time")
11// 平均在几百毫秒左右

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

 1
 2console.time("time")
 3const arr = []
 4for(let i=0
 5  arr.push(document.createElement("div"))
 6}
 7console.timeEnd("time")
 8// 平均在几千毫秒

到目前为止,我们完全了解了 JS 层面的计算和 DOM 层面的计算,速度完全不一样。

接下来我们来看一下虚拟 DOM 真实的解决的问题。

实际上无论使用虚拟 DOM 还是 innerHTML,在初始化的时候性能是相差无几的。虚拟 DOM 发挥威力的时候,实际上是在更新的时候。

来看一个例子:

 1
 2<body>
 3  <button id="updateButton">更新内容</button>
 4  <div id="content"></div>
 5  <script src="script.js"></script>
 6</body>
 1
 2// 通过 innerHTML 来更新 content 里面的内容
 3document.addEventListener("DOMContentLoaded", function () {
 4  const contentDiv = document.getElementById("content")
 5  const updateButton = document.getElementById("updateButton")
 6
 7  updateButton.addEventListener("click", function () {
 8    const currentTime = new Date().toTimeString().split(" ")[0]
 9    contentDiv.innerHTML = `
10        <div class="message">
11            <div class="info">
12                <span>张三</span>
13                <span>${currentTime}</span>
14            </div>
15            <div class="btn">
16                <a href="#" class="removeBtn" _id="1">删除</a>
17            </div>
18        </div>`
19  })
20})

在上面的例子中,我们使用的是 innerHTML 来更新,这里涉及到的计算层面如下:

  1. 销毁所有旧的 DOM(DOM 层面)
  2. 解析新的字符串(JS 层面)
  3. 重新创建所有 DOM 节点(DOM 层面)

如果使用虚拟 DOM,那么只有两个层面的计算:

  1. 使用 diff算法 计算出更新的节点(JS 层面)
  2. 更新必要的 DOM 节点(DOM 层面)

因此,总结一下,平时所说的虚拟DOM“快”,是有前提的:

  • 首先看你和谁进行比较

    • 如果是和原生 JS 操作 DOM 进行对比,那么虚拟 DOM 性能肯定更低而非更高,因为你多了一层计算
  • 其次就算你和 innerHTML 进行比较

    • 初始化渲染的时候两者之间的差距并不大
    • 虚拟 DOM 是在更新的时候相比 innerHTML 性能更高

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

所以,使用虚拟dom并没有直接操作dom速度快,但是直接操作dom会给开发者带来不必要的心智负担,而是它提供了一种更高效的方式来管理和更新DOM,

  1. 批量更新:虚拟DOM可以收集多个更新操作,然后一次性将这些更改应用到真实DOM上。相比之下,直接操作真实DOM往往需要多次单独的更新,每次更新都可能引起浏览器的重排(reflow)和重绘(repaint),这是非常耗费性能的。
  2. 减少DOM操作:虚拟DOM通过diff算法比较新旧虚拟节点,计算出实际需要变更的最小部分,再去更新真实DOM。这样可以减少不必要的DOM操作,因为真实的DOM操作通常比JavaScript计算要慢。
  3. 跨平台:虚拟DOM不仅可以在浏览器中使用,还可以用于服务器渲染(如SSR)和原生应用开发(如React Native),这为开发人员提供了更高的灵活性和统一性。

那么回到最初的话题,虚拟dom是什么,为什么要用虚拟dom,虚拟dom的优势和劣势又是什么呢

最初虚拟 DOM 是由 React 团队提出的: 虚拟 DOM 是一种编程概念。在这个概念里, UI 以一种理想化的,或者说“虚拟的”表现形式被保存于内存中。 虚拟DOM是一个轻量级的JavaScript对象,它准确地反映了真实DOM的结构。它是真实DOM的抽象表示,当应用程序的状态发生变化时,虚拟DOM会先更新,然后通过一个高效的diff算法,计算出需要对真实DOM进行的最小变动

理论上来讲,无论你用什么样的结构,只要你将文档的结构能够展示出来,你的这种结构就是一种虚拟 DOM. 虽然理论是美好的,但实际上也只有 JS 对象适合干这个事情。

接下来,关于虚拟 DOM 咱们进行一个更深层次思考,虚拟 DOM 还有哪些好处?

  1. 跨平台性

虚拟 DOM 实际上是增加一层抽象层,相当于和原本的底层操作 DOM 进行解藕。这个其实就是设计原则里面的依赖倒置原则:

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

加一层的好处在于,底层模块是可以随时替换的。使用抽象层(虚拟DOM层)来描述 UI 的结构,回头可以通过不同的渲染引擎来进行渲染,而不是局限于浏览器平台。

  1. 框架更加灵活

Reactv15 升级到 Reactv16 后,架构层面有了非常大的变化,从 Stack 架构升级到了 Fiber 架构,React 内部实际上发生了翻天覆地的变化,但是对开发者的入侵是极小的,开发者基本上感受不到变化,仍然可以使用以前的开发方式进行开发。

因为 React 有虚拟 DOM 这个中间层,就将开发者的代码和框架内部的架构解藕了。架构的变化只是依赖于不同的虚拟 DOM 而已,回头开发者的代码会被编译为对应结构的虚拟 DOM.

目前有一些新的框架:Svelte、Solid.js 这一类框架提出了无虚拟 DOM 的概念。这一类框架直接将组件编译为命令式代码,而不是在运行时通过比较虚拟 DOM 来更新真实 DOM. 因此这一类框架在 性能 方面一定是优于虚拟 DOM 类的框架的。

包括 Vue 目前也在积极推出无虚拟 DOM 版本,简称“蒸汽模式”:github.com/vuejs/core-…

个人笔记记录 2021 ~ 2025