【react】虚拟dom

142 阅读12分钟

虚拟dom是什么,为啥要用虚拟dom

虚拟dom是用js对象来描述真实dom(也就是 通过虚拟dom可以生成真实dom)(修改虚拟dom也能影响真实dom)

虚拟dom 就像一个中间层,用户不是直接修改真实dom(因为直接修改真实dom,可能会引发重绘重排)

提问 我先操作虚拟dom,虚拟dom再映射成真实dom,不会还是会引发重绘重排么?

是这样,但是因为有了虚拟dom作为中间层,虚拟dom作为缓冲。我们多次操作,都是在操作虚拟dom,多次操作可以被批处理,并且react会通过新旧dom树对比,自动进行最小化更新。(减少人工操作的心智负担)

 虚拟DOM (Virtual DOM)

这是React性能的基石,也是最核心的概念。

  • 是什么?  虚拟DOM本质上是一个普通的JavaScript对象,它是对真实DOM结构的一个轻量级描述。你可以把它想象成一张建筑蓝图。

  • 为什么需要它?  直接操作真实DOM的开销非常大。每次你修改DOM(比如添加、删除、修改一个元素),浏览器都需要进行重排(Reflow)重绘(Repaint) ,这是一个非常耗费性能的过程。如果频繁操作,页面就会变得卡顿。

  • 如何工作?

    1. 状态变更:当组件的状态(state)发生变化时,React不会直接去操作真实DOM。
    2. 创建新树:React会根据新的状态,在内存中重新构建一棵新的虚拟DOM树(新的蓝图)。
    3. Diffing(差异对比) :React会拿出新的虚拟DOM树旧的虚拟DOM树进行对比(这个过程就是著名的Reconciliation)。这个对比算法经过高度优化,能非常快速地找出两棵树之间的最小差异。
    4. 批量更新:React会将这些差异(比如“把这个p标签的文本改掉”、“给这个div增加一个class”)收集起来,然后一次性地、批量地应用到真实的DOM上。

核心优势:通过在内存中进行计算和对比,React最大限度地减少了对真实DOM的操作次数,将多次修改合并为一次,从而极大地提升了性能。它用“计算开销”(JS运算很快)替代了“DOM操作开销”(很慢)。

第一部分:什么时候会出现“多次修改”?

在典型的Web应用中,“多次修改”指的是在一个单一、连贯的用户操作或数据流中,需要更新UI上的多个不同部分。

想象一下,你没有使用React,而是用原生JavaScript或jQuery来写代码。

场景:用户点击“添加到购物车”按钮

这个简单的点击操作,可能会触发UI上的一系列连锁反应:

  1. 按钮本身的变化:按钮文本从“添加到购物车”变为“添加中...”,并变为禁用状态,防止重复点击。
  2. 购物车图标的变化:右上角的购物车图标上的数字需要 +1。
  3. 消息提示:页面顶部弹出一个“商品已成功添加”的提示框。
  4. 商品列表的变化:如果商品有库存显示,库存数量需要 -1。

在原生JS中,你可能会这样写(伪代码):

codeJavaScript

function handleAddToCart() {
  // 修改 1: 更新按钮
  const button = document.getElementById('add-to-cart-btn');
  button.innerText = '添加中...';
  button.disabled = true;

  // 修改 2: 更新购物车图标
  const cartIcon = document.getElementById('cart-icon-count');
  let currentCount = parseInt(cartIcon.innerText, 10);
  cartIcon.innerText = currentCount + 1;

  // 修改 3: 显示提示框
  const notification = document.getElementById('notification');
  notification.innerText = '商品已成功添加';
  notification.style.display = 'block';

  // 修改 4: 更新库存
  const stock = document.getElementById('stock-count');
  let currentStock = parseInt(stock.innerText, 10);
  stock.innerText = currentStock - 1;
}

看到了吗?一个 handleAddToCart 函数,执行了至少4次独立的DOM写操作。每一次写操作,都可能引起浏览器的重排(Reflow)和重绘(Repaint),这是非常昂贵的。这就是典型的“多次修改”。


第二部分:React如何将“多次修改”合并为“一次更新”?

React通过两大核心机制来解决这个问题:状态更新批处理 (State Update Batching)  和 差异对比算法 (Diffing Algorithm)

机制一:状态更新批处理 (Batching)

这是React做的第一层优化。

在React中,上面那个场景的代码会是这样的:

codeJsx

function ProductPage() {
  const [isAdding, setIsAdding] = useState(false);
  const [cartCount, setCartCount] = useState(0);
  const [notification, setNotification] = useState('');
  const [stock, setStock] = useState(10);

  function handleAddToCart() {
    // --- 开始批处理 ---
    setIsAdding(true);          // 告诉React:isAdding要变
    setCartCount(c => c + 1);   // 告诉React:cartCount要变
    setNotification('商品已成功添加'); // 告诉React:notification要变
    setStock(s => s - 1);       // 告诉React:stock要变
    // --- 批处理结束 ---
  }

  // ... JSX to render the UI based on these states
}

发生了什么?

当 handleAddToCart 被调用时,React并不会在执行 setIsAdding(true) 后立刻去更新UI,然后再执行 setCartCount 再更新一次UI……它不会这么傻。

相反,React会说:“好的,我收到了4个状态更新的请求。我先把它们都记下来,等这个 handleAddToCart 函数同步代码执行完了,我再把这4个更新合并在一起,只触发一次组件的重新渲染 (re-render) 。”

这个过程就像去超市购物:

  • 没有批处理:想到要买牛奶,跑一趟超市;回到家又想到要买面包,再跑一趟;接着又想到要买鸡蛋,又跑一趟。效率极低。
  • 有批处理 (React的方式) :把牛奶、面包、鸡蛋都写在购物清单上,然后只去一趟超市,一次性把所有东西都买回来。效率极高。

所以,批处理(Batching)的作用是:将一个事件循环内的多次 setState 合并成一次 re-render。  这就从源头上减少了不必要的重复渲染计算。

注意:在React 18之前,批处理主要发生在React事件处理函数中(如onClick)。在React 18之后,通过自动批处理(Automatic Batching),在setTimeout、Promise等异步回调中的多次setState也会被自动批处理,进一步提升了性能。

机制二:差异对比算法 (Diffing)

批处理解决了“多次渲染计算”的问题,但我们最终还是要操作真实DOM。Diffing算法就是解决“如何以最小代价操作DOM”的问题。

经过批处理后,React决定进行一次更新。它会:

  1. 根据新的状态(isAdding: true, cartCount: 1, notification: '...', stock: 9)在内存中生成一棵新的虚拟DOM树
  2. 将这棵新树与更新前的旧树进行对比(Diffing)。

我们来模拟一下这个对比过程:

  • 对比按钮

    • 旧树:添加到购物车
    • 新树:添加中...
    • Diff结果:发现disabled属性和文本内容变了。
    • 待办操作:button.disabled = true; button.innerText = '添加中...';
  • 对比购物车图标

    • 旧树:0
    • 新树:1
    • Diff结果:发现span标签内的文本变了。
    • 待办操作:cartIcon.innerText = 1;
  • 对比提示框

    • 旧树:<div style={{display: 'none'}}>
    • 新树:<div style={{display: 'block'}}>商品已成功添加
    • Diff结果:发现style属性和文本内容变了。
    • 待办操作:notification.style.display = 'block'; notification.innerText = '...';
  • 对比其他没变的DOM节点

    • 比如页面标题 

      商品详情

    • Diff结果:新旧树中完全一样。
    • 待办操作:无。

最关键的一步来了:

React将所有这些“待办操作”收集起来,然后一次性地、批量地应用到真实DOM上。这个过程被称为“提交(Commit)”。

浏览器接收到的是一个批量的更新指令,而不是零散的四次。这使得浏览器可以更高效地计算布局和绘制,大大减少了性能损耗。

总结:两层“合并”

React通过两层强大的“合并”机制,实现了“最大限度地减少对真实DOM的操作次数”:

  1. 第一层合并(在“因”上合并)- 批处理

    • 做什么:合并状态更新(setState)。
    • 目的:将多次状态变更的“意图”合并,只触发一次渲染计算,避免了因为中间状态而产生的无效渲染。
  2. 第二层合并(在“果”上合并)- Diffing与批量更新

    • 做什么:合并DOM操作。
    • 目的:在一次渲染中,找出所有需要改变的DOM节点,生成一个最小化的“补丁集”,然后一次性应用到真实DOM上。

最终效果:对于开发者来说,你只需要声明式地告诉React“我想要UI变成什么样”(通过调用setState),而React在底层帮你处理了所有复杂的、命令式的、批量的优化操作。你写的是看似会触发“多次修改”的代码,但React确保它最终只执行最高效的“一次更新”。这就是React性能和开发体验双赢的秘诀。

批处理的好处

简单来说,批量渲染的核心思想是:将多次分散的、小规模的“修改指令”合并成一次大规模的、集中的“执行任务”。  这极大地减少了浏览器最耗时的工作——重排(Reflow)重绘(Repaint)


首先,理解“敌人”:浏览器是如何工作的

当你用JavaScript修改DOM时,浏览器并不会瞬间更新页面。它会遵循一个流程:

  1. 接收指令:比如 element.style.width = '100px';。
  2. 计算布局(重排/Reflow/Layout) :这是最昂贵的一步。当DOM的几何属性(如宽度、高度、位置、边距)发生变化时,浏览器需要重新计算页面上所有受影响元素的几何位置和大小。这就像移动了一件家具,整个房间的布局可能都要重新规划。
  3. 绘制像素(重绘/Repaint/Paint) :计算完布局后,浏览器需要将元素的像素重新绘制到屏幕上。如果只是改变颜色、背景等不影响布局的属性,浏览器可能只会执行重绘,跳过重排,这会快一些。

关键点在于:重排和重绘,尤其是重排,是极其耗费CPU资源的操作。


场景对比:没有批量处理 vs React的批量处理

我们还是用之前的“添加到购物车”的例子,假设有3个状态更新,分别会修改3个不同的DOM元素。

场景一:没有批量处理(“想到哪做到哪”的模式)

如果React不进行批量处理,每次setState都立刻去更新DOM,流程会是这样的:

  1. setIsAdding(true) 被调用。

    • React立刻找到按钮,执行 button.disabled = true;
    • 浏览器可能触发一次重排/重绘,来更新按钮的状态。
  2. setCartCount(c => c + 1) 被调用。

    • React立刻找到购物车图标,执行 cartIcon.innerText = 1;
    • 浏览器可能再次触发一次重排/重绘,来更新那个小小的数字。
  3. setNotification('...') 被调用。

    • React立刻找到提示框,执行 notification.style.display = 'block';
    • 浏览器又双叒叕触发一次重排/重绘,来显示提示框。

结果:  3次状态更新 -> 3次独立的DOM操作 -> 可能导致3次代价高昂的重排/重绘周期

这就像一个没经验的装修工人:

“老板,我先来给你刷一面墙。”(刷完,收拾工具,走人)
“老板,我又来了,给你安个灯。”(安完,收拾工具,走人)
“老板,我又双叒叕来了,给你铺块地砖。”(铺完,走人)

每次来回的路费和准备时间(浏览器计算开销)都远超实际干活的时间。

场景二:React的批量处理(“计划周全,一次搞定”的模式)
  1. setIsAdding(true) 被调用。

    • React:“收到!isAdding要变成true。记在我的小本本上。”  (不操作DOM)
  2. setCartCount(c => c + 1) 被调用。

    • React:“收到!cartCount要加1。也记下来。”  (不操作DOM)
  3. setNotification('...') 被调用。

    • React:“收到!notification要更新。记下来。”  (不操作DOM)
  4. 同步代码执行完毕,React开始工作。

    • 第一步(内存中) :React在JS世界里,根据最终状态(isAdding: true, cartCount: 1, ...)快速计算出新的虚拟DOM。
    • 第二步(内存中) :通过Diff算法,找出所有需要做的DOM修改(按钮、图标、提示框)。
    • 第三步(一次性提交) :React一次性地告诉浏览器:“嘿,听好了,把按钮的disabled设为true,把图标的文字改成1,把提示框的display设为block。干活吧!”
    • 浏览器收到一揽子指令,只进行一次重排/重绘,一次性将所有变更应用到屏幕上。

结果:  3次状态更新 -> 1次集中的DOM操作 -> 最多只有1次重排/重绘周期

这就像一个专业的装修队长:

“老板,把你的需求清单(多个setState)给我。好的,我规划一下,先刷墙,再铺地板,最后安灯。所有工具材料一次性带齐,一天之内全部搞定!”

准备和收尾工作只做了一次,效率极高。


总结:批量渲染到底节省了哪些时间?

  1. 节省了多次“重排”和“重绘”的时间(这是最大的节省!)

    • 将多次可能触发浏览器重新计算整个页面或部分页面布局的昂贵操作,合并为一次。一次大的重排,其开销远小于多次小的重排开销的总和。这是性能提升最核心的来源。
  2. 节省了React自身的计算时间

    • 如果没有批量处理,每次setState都会触发一次组件的重新渲染(执行组件函数、生成虚拟DOM、Diff)。批量处理确保了在同一次事件循环中,无论你调用多少次setState,组件函数最多只执行一次,只进行一次Diff计算。
  3. 避免了“布局抖动”(Layout Thrashing)

    • 这是一个更深层次的性能问题。当你连续地“写DOM”然后又“读DOM”(比如获取元素宽度element.offsetWidth),浏览器为了给你一个准确的值,不得不立即、同步地执行重排,这会强制打断浏览器的优化流程,导致性能急剧下降。
    • React的渲染机制(渲染阶段只计算,提交阶段只写入)天然地分离了读和写,从机制上就避免了这种最糟糕的性能杀手。
  4. 提供了更流畅的用户体验

    • 因为所有的UI变化是在一帧(通常是16.7毫秒)内完成的,用户看到的是一个瞬时、完整的状态切换,而不是一个元素变完、另一个元素再变的“撕裂感”或“卡顿感”。