浏览器如何绘制页面以及 React 的作用
了解关键渲染路径、协调机制、Fiber以及调度器是如何协同作用,将 React 的更新转化为屏幕上的像素的。
首先,先忘掉 React:浏览器究竟是如何渲染页面的?
暂且把 React 放在一边。即便只是一个简单的 HTML 文档加上少量 CSS,也要经过一系列固定的步骤才能显示出一个像素。无论页面是由何种工具生成的,所有浏览器都会遵循这一流程:
HTML → DOM tree
CSS → CSSOM tree
DOM + CSSOM → Render Tree
Render Tree → Layout → Paint → Composite → Screen
每个阶段都有特定的功能,但仅从名称上很难看出来:
- DOM 树 —— 浏览器会解析你的 HTML 并将其转换为节点树。这纯粹是结构上的表示,即哪些元素位于哪个节点内。
- CSSOM 树 —— 风格规则也是如此。你编写的每一条 CSS 规则都会被转换成浏览器可以查询的树结构。
display: none属性的元素仍存在于DOM中,但会被排除在渲染树之外)。这些步骤共同构成了所谓的关键渲染路径,即CRP。
无论你使用的是React、Vue、纯jQuery还是完全不使用任何框架,这一流程都是完全相同的。绘制像素是浏览器的职责,而非任何UI框架所能替代的。
那么React在这一切中扮演什么角色呢?
这一点可能需要稍作解释。React并不会取代关键渲染路径,而是在其上游发挥作用。
没有React时,要更新UI就需要手动找到对应的DOM节点并自行进行修改:
const counterEl = document.getElementById('counter');
counterEl.textContent = newCount;
对于单个计数器来说这还算可行。但想象一下一个有四十个独立数值的仪表板——你得手动跟踪并更新每一个数值。而React存在的意义正是为了解决这类问题。
使用 React 时,同样的更新操作看起来是这样的:
function Counter({ count }) {
return <p>{count}</p>;
}
你只需描述在当前数据下界面应呈现的样子,而无需直接操作 DOM。因此必须有其他机制来完成这项工作。这正是 React 的职责,而且它会在浏览器的关键渲染路径开始之前就执行:
State changes → React figures out what changed → applies a small patch to the real DOM
↓
browser does its normal thing: Layout → Paint → Composite → Screen
React 的核心优势在于尽可能快速、精确地完成第一步——即判断哪些内容发生了变化,这样浏览器只需重新处理页面中真正需要调整的那一小部分内容的布局和绘制操作,而无需重新处理整个页面。
命令式与声明式:思维方式的转变
这种对比解释了为何 React 会被设计成现在的样子。
命令式代码会明确列出每一个步骤:
list.innerHTML = '';
for (const item of items) {
const li = document.createElement('li');
li.textContent = item;
list.appendChild(li);
}
由你来决定:删除这个,创建那个元素,然后将其放在这里。
声明式代码则描述你希望看到的结果:
<ul>
{items.map(item => <li key={item}>{item}</li>)}
</ul>
与其指示浏览器“创建一个
“协调”到底意味着什么
一旦直接查看,这部分内容其实并没有听起来那么复杂。
React 会为你的用户界面保存一个称为虚拟 DOM的快照。每当有变化发生时,React 会构建该结构的最新版本,并与之前的版本进行比较以确定差异所在。这个比较过程就是人们所说的协调。
若要检查两个结构之间的所有可能差异,计算成本会非常高,因此 React 采用了两条规则来确保在实际使用中的高效性:
- 元素类型发生了变化(例如 `
` 变成了 ``)——React 甚至不会查看其子节点,而是直接丢弃旧节点并构建新的节点。
- 元素类型保持不变(`
变为
)——React 会保留现有的真实 DOM 节点,仅修复其中的差异部分。
几乎所有人都会遇到的一个陷阱与列表有关。默认情况下,React会通过索引来匹配列表项——将第0项与第0项比较,第1项与第1项比较,以此类推。如果你在未设置键的列表的开头插入新项,React会认为其下方的所有项也都发生了变化。
这就是为什么你应该始终为列表项分配一个稳定的key的原因:
{items.map(item => <li key={item.id}>{item.name}</li>)}
一旦列表项有了键,React就能识别“这个特定项的位置刚刚发生了变化”,而不会认为整个列表都被重新构建了。
经过这些比较之后,React最终会得到一组简短且针对性的指令——比如“更新这个文本节点”或“在这里插入一个节点”——这些指令才会真正应用到实际的DOM中。
这不就是Fiber所做的吗?
这是个合理的问题,值得仔细思考。
调和是核心理念,而 Fiber只是实现这一理念的工具。
在 React 16 之前,这个工具被称为Stack Reconciler。它会递归且同步地遍历整个组件树,这意味着一旦开始运行就必须执行完毕才能停止。对于大规模的更新来说,这可能会占用主线程过长时间,导致应用运行迟缓——帧率会下降,输入操作也会显得无响应。
Fiber在React 16中引入,取代了原有的机制。虽然同步的概念并未改变,但现在任务被分解为多个小块,这些小块可以暂停、丢弃或稍后继续处理。如果有更紧急的任务出现——比如用户输入操作——React可以中断正在处理的低优先级任务,先处理紧急更新,然后再回到之前的工作状态。
因此,说Fiber取代了同步机制并不准确。更准确的表述是,原本负责同步处理的旧引擎被功能更强的新引擎所替代。
那么调度器的作用是什么呢?
Fiber使得暂停和恢复任务成为可能,但还需要有其他机制来决定何时暂停以及哪个任务应享有优先权。这就是调度器的职责。
它的职责包括:
- 根据紧急程度更新排序——例如,将输入框中的按键操作视为紧急任务,而远端的背景列表刷新则不属于紧急任务。
- 填补浏览器框架之间的空隙,以便在优先级较低的任务上逐步取得进展,并在下一个框架需要渲染之前暂停当前操作。
- 启用 React 18 的功能,如
startTransition,通过将某项更新标记为非紧急任务,实际上是在告知调度器可以将其放在优先级列表的较低位置。
一个简单的思维模型可以将这三个概念联系起来:
Reconciliation → the algorithm (what changed?)
Fiber → the engine that makes that algorithm interruptible
Scheduler → the traffic controller deciding when to pause/resume Fiber's work
渲染阶段与提交阶段
还有另一个值得理解的区分:Fiber 将其工作分为两个遵循截然不同规则的阶段。
渲染阶段是实际进行差异对比的环节。React会调用组件的函数,构建新的组件树,并将其与之前的版本进行比较。这些操作都尚未触及真实的页面,正因如此,这一阶段可以安全地暂停、跳过或重新开始。
提交阶段是React最终将更改写入真实DOM并应用相应补丁的环节。这一阶段不可中断——它必须一次性完整执行,因为若只部分应用UI更新,会导致页面出现视觉异常。在DOM更新之后、浏览器绘制屏幕之前,useLayoutEffect会同步触发。而相比之下,useEffect则会在浏览器完成绘制之后才执行。
真的需要React吗?
老实说,并非总是如此。很多生产环境中的网站仅使用HTML、CSS和纯JavaScript即可运行,而且表现良好。
当需求变得更加复杂时,React的优势才会显现:
- 对于小型项目,手动处理DOM更新尚可管理,但一旦需要同时处理数十个相互关联的UI组件,就会变得难以掌控。
- 现实中大部分UI故障都是因为状态与显示的UI不同步造成的。React采用将UI视为状态的函数,并由框架负责差异检测的设计方式,从本质上降低了这类风险。
- 在有多名开发者共同开发同一代码库的情况下,能够构建可复用的组件,并借助路由工具、开发工具以及统一的规范体系,会带来巨大价值。
不过,对于简单的着陆页或基本静态的网站而言,纯 JavaScript 是更好的选择。引入 Fiber、调度器以及完整的同步流程意味着要为根本不存在的问题支付额外开销。
React 并不比 JavaScript 更优越。它只是一个为解决特定问题而设计的工具包:即在团队协作、大规模应用场景下,让 UI 能够与不断变化的状态保持同步。在较小的规模下,纯 HTML、CSS 和 JS 就已能完美完成这项工作。
相关阅读
- 利用组合与插槽解决 React 属性过载问题 — 了解为何配置复杂的 React 属性会带来维护负担,以及如何通过控制反转、组合和插槽打造真正可复用的组件。