❝万丈高楼平地起,勿在浮沙筑高台 ❞
大家好,我是「柒八九」。一个「专注于前端开发技术/Rust
及AI
应用知识分享」的Coder
。
在React
中针对DOM
操作的最常见方法是使用refs
来访问DOM
节点,其实还有一种方法,就是使用useLayoutEffect
来访问DOM
节点,根据实际 DOM 测量
(例如元素的大小或位置)来更改元素。
今天,我们就来讲讲useLayoutEffect
如何处理DOM
,还有从底层是如何实现的?
好了,天不早了,干点正事哇。
❝
❞
❝「前置知识点」,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。「如果大家对这些概念熟悉,可以直接忽略」 同时,由于阅读我文章的群体有很多,所以有些知识点可能「我视之若珍宝,尔视只如草芥,弃之如敝履」。以下知识点,请「酌情使用」。 ❞
在EventLoop = TaskQueue + RenderQueue有介绍,然后我们在简单提一下。
强制布局(Forced Synchronous Layout
或 Forced Reflow
)是Web性能优化领域的一个术语,它指的是浏览器在能够继续「处理后续操作之前,必须完成当前的布局计算」。
❝当强制执行布局时,浏览器会
暂停JS主线程
,尽管调用栈不是空的。 ❞
有很多我们耳熟能详的操作,都会触发强制布局。
其中有我们很熟悉的getBoundingClientRect()
,下文中会有涉及。
想了解更多👉触发强制布局的操作[1]。
在浏览器中,阻塞渲染
是指当浏览器在加载网页时遇到阻塞资源(通常是外部资源如样式表、JavaScript文件或图像等),它会停止渲染页面的过程,直到这些资源被下载、解析和执行完毕。这种行为会导致页面加载速度变慢,用户可能会感觉到页面加载较慢或者出现空白的情况。
举例来说,如果一个网页中引用了外部的JavaScript
文件,并且这个文件比较大或者加载速度较慢,浏览器会等待这个JavaScript
文件下载完成后才继续渲染页面,导致页面在此过程中停滞或者出现明显的加载延迟。
下面是一个简单的示例,展示了一个会阻塞页面加载的情况:
<!DOCTYPE html>
<html>
<head>
<title>阻塞渲染示例</title>
<!-- 假设这是一个较大的外部 JavaScript 文件 -->
<script src="large_script.js"></script>
<style>
/* 一些样式 */
</style>
</head>
<body>
<h1>阻塞渲染示例</h1>
<!-- 页面其余内容 -->
</body>
</html>
在这个示例中,large_script.js
是一个较大的 JavaScript
文件,它会阻塞页面的加载和渲染。浏览器在遇到这个 <script>
标签时会暂停页面的渲染,直到large_script.js
文件完全下载、解析并执行完毕,然后才会继续渲染页面的其余内容。
为了减少阻塞渲染对页面加载速度的影响,可以采取一些优化策略,比如:
async
或 defer
属性加载 JavaScript 文件,让它们不会阻塞页面渲染。假设存在以下场景:有一个「响应式」导航组件,它会根据容器的大小来调整其子元素的数量。
如果,容器不能容纳这些组件,那么它会在容器的右侧显示一个“更多”按钮,点击后会显示一个下拉菜单,其中包含剩余未展示的子项目
让我们先从简单的逻辑入手,先创建一个简单的导航组件,它将呈现一个链接列表:(直接遍历items
来渲染对应的项目)
const Component = ({ items }) => {
return (
<div className="navigation">
{items.map((item) => (
<a href={item.href}>{item.name}</a>
))}
</div>
);
};
上面的代码,只负责对items
进行遍历和展示,没有任何响应式的处理。要想实现响应式,我们需要计算「可用空间」中可以容纳多少个项目。为此,我们需要知道容器的宽度
以及每个项目的尺寸
。并且,我们无法「未卜先知」其项目中文案信息,也就无法提前做任何工作,例如通过计算每个项目的文本长度来计算剩余空间。
既然,我们无法未雨绸缪
,那我们只能亡羊补牢
了,也就是我们只有在浏览器已经把这些项目都渲染出来后,然后通过原生 JavaScript API(例如getBoundingClientRect
)来获取这些项目的尺寸。
我们需要分几步来完成。
创建一个 Ref
并将其分配给包装这些项目的 div
const Component = ({ items }) => {
const ref = useRef(null);
return (
<div className="navigation" ref={ref}>
...
</div>
);
};
const Component = ({ items }) => {
useEffect(() => {
const div = ref.current;
const { width } = div.getBoundingClientRect();
}, [ref]);
return ...
}
const Component = ({ items }) => {
useEffect(() => {
// 与以前相同的代码
// 将div的子元素转换为数组
const children = [...div.childNodes];
// 所有子元素的宽度
const childrenWidths = children.map(child => child.getBoundingClientRect().width)
}, [ref]);
return ...
}
既然,父容器的宽度和所有子元素的宽度都已经计算出来了,我们现在可以开始计算可用空间。
现在,我们只需遍历该数组,计算子元素的宽度,将这些总和与父 div 比较,并找到「最后一个可见项目」。
当我们胸有成竹的把上述代码运行后,猛然发现,我们还缺失了一个重要的步骤:如何在浏览器中渲染更多
按钮。我们也需要考虑它的宽度。
同样,我们只能在浏览器中渲染它时才能获取其宽度。因此,我们必须在「首次渲染」期间明确添加按钮:
const Component = ({ items }) => {
return (
<div className="navigation">
{items.map((item) => (
<a href={item.href}>{item.name}</a>
))}
{/* 在链接后明确添加“更多”按钮 */}
<button id="more">...</button>
</div>
);
};
如果我们将计算宽度的所有逻辑抽象成一个函数,那么在我们的useEffect
中会有类似这样的东西:
useEffect(() => {
const { moreWidth, necessaryWidths, containerWidth } = getPrecalculatedWidths(
ref.current
);
const itemIndex = getLastVisibleItem({
containerWidth,
necessaryWidths,
moreWidth,
});
}, [ref]);
// 定义右侧间隙的常量
const rightGap = 10;
// 获取子元素的预先计算宽度信息
const getPrecalculatedWidths = (element: HTMLElement) => {
// 获取容器的宽度和左侧位置
const {
width: containerWidth,
left: containerLeft
} = element.getBoundingClientRect();
// 获取容器的所有子元素
const children = Array.from(element.childNodes) as HTMLElement[];
// 初始化“more”按钮宽度和子元素宽度数组
let moreWidth = 0;
const necessaryWidths = children.reduce<number[]>((result, node) => {
// 提取“more”按钮的宽度并跳过计算
if (node.getAttribute("id") === "more") {
moreWidth = node.getBoundingClientRect().width;
return result;
}
// 计算子元素的宽度,考虑了左侧位置和右侧间隙
const rect = node.getBoundingClientRect();
const width = rect.width + (rect.left - containerLeft) + rightGap;
return [...result, width];
}, []);
// 返回预先计算的宽度信息对象
return {
moreWidth,
necessaryWidths,
containerWidth
};
};
其中getLastVisibleItem
函数执行所有数学计算并返回一个数字——最后一个可以适应可用空间的链接的索引。
// 获取在给定容器宽度内可见的最后一个子元素的索引
const getLastVisibleItem = ({
necessaryWidths,
containerWidth,
moreWidth,
}: {
necessaryWidths: number[],
containerWidth: number,
moreWidth: number,
}) => {
// 如果没有子元素宽度信息,返回0
if (!necessaryWidths?.length) return 0;
// 如果最后一个子元素宽度小于容器宽度,说明所有元素都能完全显示
if (necessaryWidths[necessaryWidths.length - 1] < containerWidth) {
return necessaryWidths.length - 1;
}
// 过滤出所有宽度加上“more”按钮宽度小于容器宽度的子元素
const visibleItems = necessaryWidths.filter((width) => {
return width + moreWidth < containerWidth;
});
// 返回可见子元素的最后一个的索引,如果没有可见的元素,则返回0
return visibleItems.length ? visibleItems.length - 1 : 0;
};
从React
角度来看,我们既然得到了这个数字,我们就需要触发组件的更新,并让它删除不应该展示的组件。
我们需要在获取该数字时将其保存在状态中:
const Component = ({ items }) => {
// 将初始值设置为-1,以表示我们尚未运行计算
const [lastVisibleMenuItem, setLastVisibleMenuItem] = useState(-1);
useEffect(() => {
const itemIndex = getLastVisibleItem(ref.current);
// 使用实际数字更新状态
setLastVisibleMenuItem(itemIndex);
}, [ref]);
};
然后,在渲染菜单时,考虑根据lastVisibleMenuItem
来控制子元素的内容
const Component = ({ items }) => {
// 如果是第一次渲染且值仍然是默认值,则渲染所有内容
if (lastVisibleMenuItem === -1) {
// 在这里渲染所有项目,与以前相同
return ...
}
// 如果最后可见的项目不是数组中的最后一个,则显示“更多”按钮
const isMoreVisible = lastVisibleMenuItem < items.length - 1;
// 过滤掉那些索引大于最后可见的项目的项目
const filteredItems = items.filter((item, index) => index <= lastVisibleMenuItem);
return (
<div className="navigation">
{/* 仅呈现可见项目 */}
{filteredItems.map(item => <a href={item.href}>{item.name}</a>)}
{/* 有条件地呈现“更多” */}
{isMoreVisible && <button id="more">...</button>}
</div>
)
}
现在,在state
用实际数字更新后,它将触发导航的重新渲染,React
将重新渲染项目并删除那些不可见的项目。
为了实现真正的响应式,我们还需要监听resize
事件并重新计算数字。
// 用dimensions来存储 necessaryWidths和moreWidth
const [dimensions, setDimensions] = useState<{
necessaryWidths: number[];
moreWidth: number;
}>({
necessaryWidths: [],
moreWidth: 0
});
useEffect(() => {
const listener = () => {
if (!ref.current) return;
const newIndex = getLastVisibleItem({
containerWidth: ref.current.getBoundingClientRect().width,
necessaryWidths: dimensions.necessaryWidths,
moreWidth: dimensions.moreWidth,
});
if (newIndex !== lastVisibleMenuItem) {
setLastVisibleMenuItem(newIndex);
}
};
window.addEventListener("resize", listener);
return () => {
window.removeEventListener("resize", listener);
};
}, [lastVisibleMenuItem, dimensions, ref]);
上面的代码虽然不是全部的代码,但是主要的逻辑就是实现在响应式的组件,并且能够在屏幕大小发生变化时重新计算宽度。
但是呢,在在 CPU 计算能力下降时
,出产生内容闪动的情况。也就是,在某个时刻,我们先看到所有的项目和更多
按钮,随后,根据可用空间的多少,会隐藏掉部分项目。
上面出现闪烁的根本原因就是:我们先把所有元素都渲染出来了,然后依据计算后的剩余空间来控制哪些元素可见/隐藏。 也就是我们做的是一种「先渲染再删除」的操作。在useLayoutEffect
没出现之前,其实大家解决这类问题的方式都很奇葩。还是沿用第一次渲染全部元素,但是设置这些元素不可见(不透明度设置为 0/或者在可见区域之外的某个地方的某个 div 中呈现这些元素),然后在计算后再将那些满足条件的元素显示出来。
然而,在 React 16.8+
,我们可以用 useLayoutEffect
替换 useEffect
钩子。
const Component = ({ items }) => {
// 一切都完全相同,只是钩子的名称不同
useLayoutEffect(() => {
// 代码仍然一样
}, [ref]);
};
仅需要一行代码就可以解决上面的闪烁问题。神不神奇。
虽然,useLayoutEffect
能解决我们的问题,但是根据React 官方文档[2],它是有一定的缺陷的。
useLayoutEffect
可能会影响性能,应该避免使用。浏览器重新绘制屏幕
之前触发,这意味着 useEffect
在其后触发。虽然,useLayoutEffect
能解决我们的问题,但是也有一定的风险。所以,我们需要对其有一个更深的认知,这样才可以在遇到类似的问题,有的放矢。
然后,要想深入了解useLayoutEffect
,就需要从浏览器的角度来探查原因了。
so,让我们讲点浏览器方面的东西。
❝我们之前在EventLoop = TaskQueue + RenderQueue从
EventLoop
的角度分析了,浏览器渲染页面的流程。所以,我们就简单的回顾一下。 ❞
「浏览器不会实时连续地更新屏幕上需要显示的所有内容」,而是会将所有内容分成一系列帧,并逐帧地显示它们。在浏览器中,我们可以看到这些帧,它们被称为帧
,或者帧缓冲
,因为它们是浏览器用来显示内容的一系列帧。
❝浏览器显示页面的过程像你像领导展示
PPT
的过程。 ❞
你展示了一张PPT
,然后等待他们理解你天马行空的创意后,随后你才可以切换到一张PPT
。就这样周而复始的执行上面的操作。
如果一个非常慢的浏览器被要求制定如何画猫头鹰的指令,它可能实际上会是如下的步骤:
上述的过程非常快。通常,现代浏览器尝试保持 60 FPS
的速率,即每秒 60 帧
。每 16.6
毫秒左右切换一张PPT
。
❝更新这些
PPT
的信息被分成任务
。 ❞
任务
被放入队列中。浏览器从队列中抓取一个任务并执行它。如果有更多时间,它执行下一个任务,依此类推,直到在16.6ms
的间隙中没有更多时间为止,然后刷新屏幕
。然后继续不停地工作,以便我们能够进行一些重要的事情。
在正常的 Javascript
中,任务
是我们放在脚本中并「同步执行」的所有内容。
const app = document.getElementById("app");
const child = document.createElement("div");
child.innerHTML = "<h1>前端柒八九!</h1>";
app.appendChild(child);
child.style = "border: 10px solid red";
child.style = "border: 20px solid green";
child.style = "border: 30px solid black";
如上我们通过id
获取一个元素,将它放入 app
变量中,创建一个 div
,更新其 HTML
,将该 div
附加到 app
,然后三次更改 div
的边框。「对于浏览器来说,整个过程将被视为一个任务」。因此,它将执行每一行,然后绘制最终结果:带有黑色边框的 div
。
我们「无法在屏幕上看到这个红绿黑的过渡」。
如果任务
花费的时间超过 16.6ms
会发生什么呢?。浏览器不能停止它或拆分它。它「将继续进行,直到完成,然后绘制最终结果」。如果我在这些边框更新之间添加 1 秒的同步延迟
:
const waitSync = (ms) => {
let start = Date.now(),
now = start;
while (now - start < ms) {
now = Date.now();
}
};
child.style = "border: 10px solid red";
waitSync(1000);
child.style = "border: 20px solid green";
waitSync(1000);
child.style = "border: 30px solid black";
waitSync(1000);
我们仍然无法看到“中间”结果。我们只会盯着空白屏幕
直到浏览器解决它,并在最后看到黑色边框。这就是我们所说的阻塞渲染
代码。
尽管 React
也是 Javascript
,但是不是作为一个单一的任务执行的。我们可以通过各种异步方式(回调、事件处理程序、promises 等)「将整个应用程序渲染为更小的任务」
如果我只是用 setTimeout
包装那些样式调整,即使是 0 延迟:
setTimeout(() => {
child.style = "border: 10px solid red";
wait(1000);
setTimeout(() => {
child.style = "border: 20px solid green";
wait(1000);
setTimeout(() => {
child.style = "border: 30px solid black";
wait(1000);
}, 0);
}, 0);
}, 0);
这里处理方式和我们之前处理堆栈溢出的方式是一样的。
然后,每个定时器都将被视为一个新的任务
。因此,浏览器将能够在完成一个任务之后并在开始下一个任务之前重新绘制屏幕。我们将能够看到从红到绿再到黑的缓慢的过渡,而不是在白屏上停留三秒钟。
❝这就是
React
为我们所做的事情。实质上,它是一个非常复杂且高效的引擎,将由数百个 npm 依赖项与我们自己的代码组合而成的块分解成浏览器能够在16.6ms
内处理的最小块。 ❞
回到上面话题,为什么我们用了useLayoutEffect
就解决了页面闪烁的问题。
❝
useLayoutEffect
是React
在组件更新期间「同步运行的内容」。 ❞
const Component = () => {
useLayoutEffect(() => {
// 做一些事情
});
return ...;
};
我们在组件内部渲染的任何内容都将与 useLayoutEffect
被统筹为同一任务
。即使在 useLayoutEffect
内部更新state
(我们通常认为这是一个异步任务
),React
仍然会确保「整个流程以同步方式运行」。
如果我们回到一开始实现的导航
示例。从浏览器的角度来看,它只是一个任务
:
这种情况与我们无法看到的红绿黑边框过渡
的情况完全相同!
另一方面,使用 useEffect
的流程将分为两个任务:
第一个任务渲染了带有所有按钮的初始
导航。而第二个任务删除我们不需要的那些子元素。在「两者之间重新绘制屏幕」!与setTimeout
内的边框情况完全相同。
所以回答我们一开始的问题。使用 useLayoutEffect
它会影响性能!我们最不希望的是我们整个 React
应用程序变成一个巨大的同步任务
。
❝只有在需要根据元素的实际大小调整 UI 而导致的视觉
闪烁
时使用useLayoutEffect
。对于其他所有情况,useEffect
是更好的选择。 ❞
对于useEffect
有一点我们需要额外说明一下。
❝大家都认为
useEffect
在浏览器渲染后触发,其实不完全对。 ❞
useEffect
有时在渲染前执行在正常的流程中,React
更新过程如下:
effect
,更新真实DOMuseLayoutEffect
React
释放控制,浏览器绘制新的DOMuseEffect
React
文档并没有明确说明 useEffect
何时确切地执行,它发生在「布局和绘制之后,通过延迟事件进行」。
然而,在文档中有一个更有趣的段落:
❝尽管
useEffect
被延迟到浏览器绘制之后,但它保证在「任何新的渲染之前」执行。React
总是会在「开始新的更新之前刷新前一个渲染」的effect
。 ❞
如果 useLayoutEffect
触发state
更新时,那么effect
必须在那次更新之前被刷新,即在绘制之前。下面是一个时间轴:
React
更新 1:渲染虚拟DOM,安排effect
,更新DOMuseLayoutEffect
state
,安排重新渲染(re-render
)useEffect
React
更新 2useLayoutEffect
从更新 2React
释放控制,浏览器绘制新的DOMuseEffect
从更新 2在浏览者中就会出现如下的瀑布流。
上面的案例说明了,useLayoutEffect
可以在绘制之前强制提前刷新effect
。而像
<div ref={HERE}>
requestAnimationFrame
useLayoutEffect
调度的微任务也会触发相同的行为。
如果,我们不想在useLayoutEffect
强制刷新useEffect
。我们可以跳过状态更新。
使用ref
直接对DOM进行修改。这样,React
不会安排更新,也不需要急切地刷新effect
。
const clearRef = useRef();
const measure = () => {
// 不用担心 react,我会处理的:
clearRef.current.display = el.current.offsetWidth > 200 ? null : 'none';
};
useLayoutEffect(() => measure(), []);
useEffect(() => {
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
return (
<label>
<input {...props} ref={el} />
<button ref={clearRef} onClick={onClear}>clear</button>
</label>
);
当我们将使用useLayoutEffect
处理过的自适应导航组件写入到任何一个SSR
框架时,你会发现它还是会产生闪烁现象。
当我们启用了 SSR
时,意味着在后端的某个地方调用类似React.renderToString(<App />)
的东西。然后,React
遍历应用中的所有组件,“渲染”它们(即调用它们的函数,它们毕竟只是函数),然后生成这些组件表示的 HTML
。
然后,将此 HTML
注入要发送到浏览器的页面中,「一切都在服务器上生成」。之后,浏览器下载页面,向我们显示页面,下载所有脚本(包括 React
),随后运行它们,React
通过预生成的 HTML
,为其注入一些互动效果,我们的页面就会变的有交互性了。
问题在于:在我们生成初始 HTML
时,还没有浏览器。因此,任何涉及计算元素实际大小的操作(就像我们在 useLayoutEffect
中做的那样)在服务器上将不起作用:只有字符串,而没有具有尺寸的元素。而且由于 useLayoutEffect
的整个目的是获得对元素大小的访问权,因此在服务器上运行它没有太多意义。
因此,我们在浏览器显示我们的页面之前在“第一次通过”阶段渲染的内容就是在我们组件中渲染的内容:所有按钮的一行,包括“更多”按钮。在浏览器有机会执行所有内容并使 React
变得活跃之后,它最终可以运行 useLayoutEffect
,最终按钮才会隐藏。但视觉故障依然存在。
如何解决这个问题涉及用户体验问题,完全取决于我们想“默认”向用户展示什么。我们可以向他们显示一些“加载”状态而不是菜单。或者只显示一两个最重要的菜单项。或者甚至完全隐藏项目,并仅在客户端上渲染它们。这取决于你。
一种方法是引入一些shouldRender
状态变量,并在 useEffect
中将其变为true
:
const Component = () => {
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
setShouldRender(true);
}, []);
if (!shouldRender) return <SomeNavigationSubstitute />;
return <Navigation />;
};
useEffect
只会在客户端运行,因此初始 SSR
通过将向我们显示替代组件。然后,客户端代码将介入,useEffect
将运行,状态将更改,React
将其替换为正常的响应式导航。
「分享是一种态度」。
「全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。」
[1]
触发强制布局的操作: https://gist.github.com/paulirish/5d52fb081b3570c81e3a
[2]
React 官方文档: https://react.dev/reference/react/useLayoutEffect