本文翻译自 「Layout Thrashing and Forced Reflows」,点击底部可查看原文
布局是浏览器计算各元素几何信息的过程,即元素的大小以及在网页中的位置。根据所使用的 CSS、元素内容或父元素,每个元素都将具有显式或隐式大小信息。此过程在 Chrome(以及 Edge 等派生浏览器)和 Safari 中称之为布局。在 Firefox 中称为回流 (Reflow),但过程实际上是相同的。
当页面更改样式时,浏览器会检查这些变更是否需要计算布局,以及是否需要更新渲染树。为了在屏幕上渲染出一帧的内容,浏览器会首先运行 JavaScript,然后计算样式,最后运行布局。我们把这种布局方式称之为异步布局(Asynchronous Layout),也被称作异步回流(Asynchronous Reflow)。
还有另外一种布局方式,当执行某些 JavaScript 代码时,会强制浏览器提前执行布局,以便获取元素的样式信息和精确坐标。比如,Element.getBoundingClientRect() 同步返回 bounding box 信息和坐标信息:
const element = document.getElementById("item1");
const rect = element.getBoundingClientRect();
console.log(rect);
如果请求的元素的坐标或样式尚未计算出来,浏览器必须立即通过执行回流来计算它们。这种立即调用样式和布局引擎来解析未确定坐标的情况被称为强制同步布局(或强制同步回流)。
在 Chrome 浏览器中,使用开发者工具来分析性能,在火焰图中可以看到强制同步回流:
将鼠标移到这些任务上,还会给出下面的提示信息:Forced reflow is a likely performance bottleneck.
当网页开始加载时,DOM 被解析,这个阶段元素还没有视觉样式或位置信息。浏览器的布局引擎必须在把这些元素绘制到屏幕上之前,为每个可见元素计算样式和几何信息。
浏览器会尽可能地降低重排的成本。在布局完成之后,它通过将每个可见元素的样式和位置存储和缓存到浏览器内部数据结构中(称为渲染树,Render Tree)。
以一个简单的静态 HTML 网页为例,来讨论这个缓存的机制。
HTML 被解析为 DOM。所有元素都是没有定位和样式。
浏览器执行异步回流,为每个可视元素分配样式和坐标。
样式和坐标被组合成渲染树,样式信息被缓存以供后续访问使用。
一旦样式和坐标被缓存,后续对样式和几何信息的访问速度非常快。在我们的示例中,一旦异步回流完成,如果我们有一个脚本调用了某个元素的 getBoundingClientRect()方法,结果将从缓存中获取:
在实际应用中,大部分 Web 应用程序不是静态 HTML 网页。相反,它们使用客户端 JavaScript(如 React)来创建和更新可见元素。
当客户端 JavaScript 代码改变 DOM 时,它可以添加、移除或更新元素,这经常直接影响渲染树,从而导致先前缓存的样式和坐标失效。
当 JavaScript 向 DOM 中添加元素时,它们是没有样式和定位信息的。这是因为回流通常是异步执行的,会在绘制帧之前某个时刻在主线程上运行,此时 JavaScript 任务已经完成。
例如,考虑向 DOM 中 添加一个模态框。当模态框的 HTML 元素被插入 DOM 时,它们最初是未定位的:
const modalRoot = document.createElement("div");
modalRoot.classList.add("modal--root");
const subDiv = document.createElement("div");
const paragraph = document.createElement("div");
// Add other DOM nodes and styles as needed...
document.body.firstChild.appendChild(modalRoot);
// DOM nodes are added!
这种变化使得渲染树的部分或全部缓存失效。随后,当异步回流发生时,每个 DOM 节点会被赋予样式和位置,然后显示在屏幕上:
当这个过程完成时,将更新并缓存渲染树:
通常浏览器不会完全重新计算整个树结构 —— 浏览器会尝试只重新计算受到影响的最小子集。因此,回流是增量的。
DOM 变动的大小和类型(例如添加单个元素、更新类、移除多个 DOM 节点等)不同,应用于渲染树的失效范围也会有所不同。
我们已经讨论了 JavaScript 代码路径如何使渲染树失效,以及 JavaScript API 如何查询底层的布局引擎原语(例如 getBoundingClientRect())。
结合这两个概念,让我们来展示一个使渲染树失效并强制进行回流的 JavaScript 代码片段:
const element = document.getElementById("modal-container");
// 1. invalidate Layout Tree
element.classList.add("width-adjust");
// 2. force a synchronous reflow. This can be SLOW!
element.getBoundingClientRect();
在这里,我们首先执行更新一个 DOM 元素的类。这个操作使得布局树的一个子集失效,并标记该节点为脏节点。它的定位和样式信息不再准确,需要重新计算。
问题在于接下来的操作,即在失效的 DOM 节点上调用 getBoundingClientRect()。这将触发同步回流,因为我们要求浏览器获取一个脏的/未定位的元素的位置。浏览器必须立即对其进行定位,以满足请求,否则它没有可用的准确信息。
从线程的角度来看,这将延长 JavaScript 任务的持续执行时间,可能导致一个长任务。
一旦同步回流完成,浏览器将在渲染树中缓存这些信息,以供后续访问使用。假设在渲染树没有其他更新的情况下,浏览器可能没有脏元素需要重新定位,因此异步回流可能会减少(甚至完全跳过!)。
强制回流并不总是表现为性能问题。在某些情况下,它只是将回流成本转移到 JavaScript 任务运行期间,而不是异步回流阶段(因为异步回流可以利用同步回流的缓存输出)。
不过,总的来说,如果可能的话,应该尽量避免无意中的同步回流。接下来我们将讨论为什么。
如果在单个任务/帧内多次触发同步回流,所观察到的现象称为布局抖动(Layout Thrashing)。
比如下面的代码:
const elements = [...document.querySelectorAll(".some-class")];
// In a loop, force a reflow for each element :(
for (const element of elements) {
element.classList.add("width-adjust"); // 1. invalidate Layout Tree
element.getBoundingClientRect(); // 2. force a synchronous reflow. This can be SLOW!
}
我们可以看到在同一个任务中多次使渲染树失效,然后强制进行回流。
根据失效的范围大小(例如,如果失效了渲染树的大部分),这可能会导致严重的性能问题!
在这种情况下,我们强制浏览器在单个帧中多次执行昂贵的同步回流操作:
不同于常规的同步回流,布局抖动不仅改变了回流的时机,还增加了回流操作的次数,这可能导致长任务(Long Tasks)的产生并降低帧率。
在任何情况下,我们都要避免布局抖动。有一些通用策略可以预防它!
批量读取和写入
在完全缓存、已样式化和已定位的渲染树上,读取位置和样式信息可能非常快速。
写入位置信息会标记节点为脏节点,但不会立即强制浏览器执行回流。
如果我们可以利用这两个事实,我们可以将上面引起布局抖动的代码改写为:
const elements = [...document.querySelectorAll(".some-class")];
// Do all reads
const rects = elements.map((element) => element.getBoundingClientRect());
// Do all writes
elements.forEach((element) => element.classList.add("width-adjust"));
// Done! Asynchronous reflow will compute positions later.
这样做的好处是,昂贵的回流成本只会在异步回流期间发生一次。
在开发 Web 应用程序时,您应该注意可能会强制触发回流的各种浏览器 API。虽然我们不需要不惜一切代价避免它们,但应在调用它们的时机上保持警惕,并进行适当的性能分析,以确保不会引发意外的同步回流。
使用 React 并不意味着您免于布局抖动的影响。所有的 Web 应用程序都可能滥用浏览器的布局引擎,即使是使用现代 Web 框架编写的应用程序也是如此。
React 的声明式语法为实际 DOM 变动(和布局失效)的发生增加了一层间接性。这使得在代码中发现布局抖动变得更加困难。
根据我的经验,在 React 中,布局抖动通常是由于在 useEffect 中尝试测量 DOM 节点或其他 React 组件。常见的这类测量场景包括 Tooltip 固定位置提示或恢复滚动位置。
function MyComponent() {
const elementRef = React.useRef();
// Be careful with Layout APIs in `useEffect`!
React.useEffect(() => {
// When does this run? Before or After DOM updates?
const rect = elementRef.getBoundingClientRect();
// do something with `rect`
}, []);
return <div ref={elementRef}>{/* more DOM nodes... */}</div>;
}
如果您不确定您的 React useEffect 回调是否会强制同步回流,您可以收集跟踪数据,并在火焰图中搜索强制回流的调用堆栈。
React 团队提供了一种专门的钩子,称为 useLayoutEffect,可以在 React 将其变更刷新到 DOM 后,读取位置信息:
function MyComponent() {
const elementRef = React.useRef();
// Use the `useLayoutEffect` hook instead if you are forcing reflow.
React.useLayoutEffect(() => {
// This will run after React has flushed DOM updates.
const rect = elementRef.getBoundingClientRect();
// Use the values read from `rect`
// But writing to the DOM here will likely cause more reflow!
}, []);
return <div ref={elementRef}>{/* more DOM nodes... */}</div>;
}
我们讨论了浏览器中回流的复杂生命周期,包括异步回流、同步回流和布局抖动。
作为 Web 开发者,我们必须注意浏览器何时以及如何使用其底层的样式和布局引擎,以确保 Web 应用程序充分利用浏览器提供的高度优化的增量设计,而不是逆其道而行。
这将确保我们提供一致且最佳的帧率和用户体验。