首页 / 文章 / React 实际在主线程中渲染的内容成本是多少

React 实际在主线程中渲染的内容成本是多少

React中的渲染与提交:为何隐形的渲染操作仍会在主线程上与用户输入竞争,以及记忆化真正能节省什么。

1665 词

React 的“渲染”操作本身并不会改变浏览器中显示的内容。一个只运行一次的组件与运行上百次的同一个组件,在使用应用的人看来可能完全一样。它们看起来相同是因为浏览器只有在提交阶段向 DOM 写入数据时才会更新,而单纯的渲染操作并不能保证会有这样的写入。那么为什么那么多 React 性能优化建议都集中在避免不必要的渲染上——比如使用 useCallback(在多次渲染中保持函数身份不变)、useMemo(在多次渲染中保持计算结果不变)、React.memo(当属性未变化时跳过子组件的渲染),以及减少状态更新呢?

这种矛盾确实存在:优化的事情用户可能根本注意不到。

如果渲染操作从未触及浏览器,那它实际上在消耗什么资源呢?

一次渲染中包含的两个阶段

人们通常用“渲染”来指代整个更新周期。React则将这个周期划分为两个具有不同任务的阶段。一个阶段负责构建下一个用户界面的描述,另一个阶段则决定该描述是否应转化为实际的DOM变化。

相同的四种触发条件会启动这两个阶段:状态更新、属性变化、父组件重新渲染,或是上下文值变化(即未通过属性传递而直接读取的Context API值)。两个阶段之后的具体操作以及各自的开销则是它们之间的差异所在。

当React安排更新时,总是会运行的第一个阶段内部会发生什么?

解析渲染阶段

当 React 判断需要更新时,它会从顶层再次调用组件函数。该函数体内的每一条语句都会被执行:包括计算、循环以及内联编写的对象创建代码。

随后 React 会评估 return 之后的 JSX 代码。JSX(使用 <div>...</div> 语法)并非 HTML,也无法单独传输到浏览器。在构建阶段,Babel 或 TypeScript 编译器会将其转换为函数调用——早期是 React.createElement,现在更多使用的是现代的 jsx() 工具函数。正是这些调用构建出了对应的 UI 描述。

其结果通常被称为虚拟 DOM;React 内部则称其为元素树。它只是一个普通的 JavaScript 对象,用于描述期望的界面——既不是真实的 HTML,也不是实时的 DOM 节点。

来看一个简单的组件:

function Greeting({ name }) {
  const message = `Hello, ${name}`;
  return (
    <div>
      <h3>{message}</h3>
    </div>
  );
}

当name发生变化时,React会再次调用Greeting函数。message对应的模板字符串也会被重新执行。经过编译后的JSX最终会生成类似如下的对象树:

{
  "type": "div",
  "props": {
    "children": {
      "type": "h3",
      "props": { "children": "Hello, Akshat" }
    }
  }
}

该对象就是Greeting函数生成的更新后的虚拟DOM。其结构仍保留在内存中——没有发生DOM修改、重绘或布局调整。那么接下来是谁来使用这个对象树呢?

解析提交阶段的过程

React会将新的元素树与之前的元素树进行比较——即协调过程,通过差异对比找出实际发生的变化。只有经过比较发现的差异才会被写入真实的DOM中。如果没有任何差异,就不会有任何内容被写入。

回到Greeting示例,假设name的值从一个字符串变为另一个字符串。之前的结构中旧问候语文本位于h3标签内,而新结构则包含更新后的文本。整体结构保持不变——依然是用div包裹h3——因此差异记录仅显示那个文本节点。提交阶段只会更新该文本,树结构的其余部分则保持不变。

这是唯一涉及真实浏览器的阶段,也正是因此它才是唯一能够强制重新计算布局(重新生成几何信息)或重新绘制的阶段。如果内容更改较多,浏览器就必须重新执行这些操作;若没有更改,则无需如此。

以下是支撑后续论点的关键案例。如果重新触发时name并未改变,那么新的节点结构与旧的完全一致,差异检测会找不到任何不同,因此提交操作实际上毫无作用。没有DOM写入,没有布局调整,也没有绘制操作——用户根本察觉不到任何变化。然而就在片刻之前,渲染阶段——函数调用、重新计算的message值以及新分配的节点结构——依然已经完整执行完毕。

如果渲染可以完成却不会留下任何可见痕迹,那这段操作究竟付出了什么代价?

渲染阶段可以执行却依然不会改变任何内容

正是在这里,讨论才变得有逻辑可循。即便生成的结果完全相同,渲染过程依然会消耗真实资源:会有实际的函数调用,树结构中每个节点都需要实际的内存分配,还会在确认没有变化之前对整个树结构进行逐一检查。这些过程都不会显示在屏幕上,但它们确实发生了。

正是这种——实际存在却看不见的工作——导致了“渲染无关紧要”与“渲染至关重要”这两种观点在不同层面上的合理性。渲染确实无法决定用户最终看到的内容,这一职责在于提交操作。不过,对于那些与像素无关的事物而言,渲染却非常重要。

那究竟是什么事物呢?

主线程并不关心工作是否可见

那个“东西”就是 JavaScript 主线程:一个共享队列,一次只处理一个任务。同一条线程负责执行你的 JS 代码、计算布局以及处理各种输入事件——点击、滚动、按键等。

浏览器大约每 16.7 毫秒创建一个新帧,从而实现每秒 60 帧的显示效果。每个帧的所有操作——渲染阶段的逻辑、数据同步、DOM 写入的确认、布局计算、绘制——都必须在这个时间限制内完成,而且即便渲染阶段的输出可能会被丢弃,它也不会获得特殊的处理优先权。它与用户通过交互所能感受到的队列是完全相同的。

需要准确理解:并非框架的每个浏览器操作都在同一线程中执行。光栅化(将绘图指令转换为像素)和合成(将各层组合成最终画面)通常在其他线程中运行,这就是为什么在主线程繁忙时,预光栅化的图层仍能保持滚动。渲染阶段本身以及触发它的事件依然在主线程上处理。独立的合成线程虽然能在负载较大时提升流畅度,但并不能消除渲染阶段的开销。

一次不必要的渲染可能只耗费几毫秒的一小部分时间。但何时这种微小的延迟会让用户有所感知呢?

为何渲染阶段的开销依然会累积

因为它很少只执行一次。当父组件重新渲染时,React默认会为每个子组件执行渲染阶段,即便该子组件的属性并未发生变化,除非该子组件被包裹在React.memo中。

function Dashboard() {
  const [searchTerm, setSearchTerm] = useState('');
  const products = useProducts(); // 200 items
  return (
      <div>
        <input
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
        />
        <ProductList products={products} />
      </div>
    );
  }

function ProductList({ products }) {
  return (
    <div>
      {products.map((product) => (
        <ProductRow key={product.id} product={product} />
      ))}
    </div>
  );
}

在搜索框中输入一个字符,searchTerm就会随之更新。控制面板会重新运行,因为其输出已发生变化。ProductList以及其下的200个ProductRow组件也会重新运行,这并非因为它们的属性发生了变化(按键操作与产品数据无关),而是因为当父组件重新渲染时,子组件也会随之重新渲染,除非有机制阻止这种级联反应。

这200次调用每一次都是真正的函数执行,都会生成独立的行级元素树,并进行实际比对,最终确定没有哪一行需要写入DOM。 一次按键操作的成本很低。以正常的打字速度,实时搜索框每秒会在同一线程上触发数次这样的操作,而该线程还必须处理接下来的按键。

这就是useMemo、useCallback和React.memo所针对的场景——那么它们究竟在保护什么?

useMemo、useCallback和React.memo实际在保护什么

React.memo会包裹一个组件,当新属性与之前的属性浅比较相等(每个属性使用===判断)时,就会跳过该组件的渲染阶段。将ProductRow包裹起来后,每次在控制台输入内容就不再会触发200次渲染调用;React会为每一行属性比较一次,一旦product引用没有变化就会停止比较。

useMemo会在渲染之间缓存计算结果,这样组件内部的昂贵操作只有在所依赖的项发生变化时才会重新执行。useCallback则用于确保函数的标识一致性。它存在的意义更多是为了保护被缓存的子组件:每次渲染生成新的函数意味着产生新的引用,而新的引用会打破React.memo对接收该属性的元素的浅层检查。

这三种工具都不会改变提交阶段的行为。提交操作早已在 reconciliation 过程中发现实际差异后才会触发。如果无论如何都不会有内容显示在屏幕上,这些工具也不会改变是否进行 DOM 写入。它们所节省的是渲染阶段的计算开销——即即使输出相同也会消耗的主线程时间。

它们也并非免费。React.memo的比较操作以及useMemo的缓存查找都会在每次调用时产生开销,因此为那些成本低且更新频率低的组件添加包装反而可能带来净损失:为本就不需要额外处理的工作支付了不必要的开销。

对于使用该产品的人来说,这些到底有什么意义呢?

渲染成本决定了用户实际感受到的性能

在渲染阶段,没有任何东西能够单独改变一个像素——这一点从一开始就成立。无论一个组件运行一次还是上百次,其显示效果都可能相同,因为最终呈现在屏幕上的内容是由“提交”操作而非“渲染”操作决定的。

由于渲染过程是不可见的,它仍然不是免费的。实际上,它与布局处理、DOM写入操作以及每一次点击、滚动和按键输入都在同一个单线程队列中执行。避免将不必要的渲染阶段任务加入该队列——尤其是在父元素更新会级联影响到深层子节点列表时,或是输入和滚动操作频繁触发更新时——才是节省交互所需毫秒时间的关键。

人们很少提及其中不为人知的部分。减少渲染次数从来都不是最终目标,真正的目标是为那些用户能够感知到的DOM写入操作和输入处理留出足够的共享时间预算,大约16.7毫秒。