谈谈React中useLayoutEffect钩子函数与浏览器的故事让我们再谈谈 React 中的 DOM 访问。在上一
文章摘要: 如何根据DOM的一些是测量值来更改元素:
- useEffect 的问题是什么;
- useLayoutEffect 如何修复它;
- 什么是浏览器绘制;
- 以及 SSR 在这里如何发挥作用。
那么,它到底有什么问题,为什么“正常”策略不够好?让我们编写一些代码并弄清楚。在此过程中,我们将了解有关 useLayoutEffect 的所有信息,何时以及为什么要使用它而不是 useEffect,浏览器如何渲染我们的 React 代码,什么是重绘,为什么所有这些都很重要以及 SSR 如何在这里发挥作用。
useEffect 有什么问题?
我们来看一个示例:一个响应式导航组件。它可以渲染一行链接,并可以根据容器大小调整这些链接的数量。
如果某些链接不能在一行当中展示,则显示一个“更多”按钮,点击后将在下拉菜单中打开它们。
现在,组件本身。它将只是一个接受数据数组并呈现正确链接的组件:
const Component = ({ items }) => {
return (
<div className="navigation">
{items.map((item) => (
<a href={item.href}>{item.name}</a>
))}
</div>
);
};
现在,我们如何让它具有响应性?这里的问题是,我们需要计算可用空间中可以容纳多少个项目。为了做到这一点,我们需要知道渲染它们的容器的宽度以及每个项目的尺寸。我们不能提前假设任何事情,例如通过计算字符:文本在浏览器中的呈现方式将在很大程度上取决于所使用的字体、语言、浏览器,甚至表示一个周期变化的状态量。
在这里获取实际大小的唯一方法是让浏览器渲染这些项目,然后通过javascript API(如 getBoundingClientRect)提取大小。
我们必须分几步完成。首先,获取元素。我们可以创建一个 ref 并将其分配给包装这些项目的 div:
const Component = ({ items }) => {
const ref = useRef(null);
return (
<div className="navigation" ref={ref}>
// ...
</div>
}
其次,在 useEffect 中或取 div 元素并获取其大小。
const Component = ({ items }) => {
useEffect(() => {
const div = ref.current;
const { width } = div.getBoundingClientRect();
}, [ref]);
// return ...
}
第三,遍历 div 的子元素并将它们的宽度提取到一个数组中。
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 itemIndex = getLastVisibleItem(ref.current)
}, [ref]);
getLastVisibleItem 函数会进行所有计算并返回一个数字-最后一个可以放入可用空间的链接的索引,这里不会深入研究逻辑本身,尽管有无数种方法可以做到这一点,稍后将在最终的代码示例中展现。
这里重要的是我们得到了这个数字。从 React 的角度来看,我们接下来应该做什么?如果我们保持原样,所有链接和“更多”按钮都将可见。这里只有一个解决方案-我们需要触发组件的更新并使其删除所有不应该在那里的项目。
而且几乎只有一种方法可以做到这一点:我们需要在获取它时将该数字保存在状态中:
const Component = ({ items }) => {
// 将初始值设置为 -1,表示我们尚未运行计算
const [lastVisibleMenuItem, setLastVisibleMenuItem] = useState(-1);
useEffect(() => {
const itemIndex = getLastVisibleItem(ref.current);
// 使用实际数字更新状态
setLastVisibleMenuItem(itemIndex);
}, [ref]);
}
然后在呈现菜单时,我们需要考虑到这一点:
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>
)
}
就是这样!现在,在用实际数字更新状态后,它将触发导航的重新渲染,React 将重新渲染项目并删除不可见的项目。为了获得“正确”的响应体验,我们还需要监听 resize 事件并重新计算数字,但我将留给你来实现。
前往这里查看完整示例。
只是不要太兴奋:这里的用户体验有一个主要缺陷。
尝试刷新几次,尤其是在 CPU 速度变慢的情况下,不幸的是,导航内容闪烁得非常厉害。你应该能够清楚地看到初始渲染-当菜单中的所有项目和“更多”按钮都可见时,我们绝对需要在投入生产之前修复它。
使用 useLayoutEffect 修复此问题
闪烁的原因应该很明显:我们在移除不必要的项目之前,先渲染这些项目并使其可见。我们必须先渲染它们,否则,响应将无法工作。因此,一个可能的解决方法是仍然渲染第一遍,但不可见:将不透明度设置为 0,或者在可见区域之外的某个 div 中,并且只有在我们提取尺寸和魔法数字后,才使它们可见,这是我们过去处理此类情况的方法。
然而,在从React 版本 16.8(带有钩子)开始,我们需要做的就是用 useLayoutEffect 替换我们的 useEffect 钩子。
const Component = ({ items }) => {
// 所有内容完全相同,只有钩子名称不同
useLayoutEffect(() => {
// 代码仍然相同
}, []);
}
这是纯粹的魔法,并且不再有初始闪烁,前往这里查看示例看看吧。
但是这样做安全吗?为什么我们不经常使用它而选择使用 useEffect?文档明确指出 useLayoutEffect 会影响性能,应避免使用。为什么呢?它还说它是在“浏览器重新绘制屏幕之前”触发的,这意味着 useEffect 是在之后触发的。但从实际意义上讲,这到底意味着什么?现在编写简单的下拉菜单时,我是否需要考虑浏览器重绘之类的低级概念?
要回答这些问题,我们需要暂时放下 React,转而讨论浏览器和老旧的 Javascript。
修复为何有效:渲染、重绘和浏览器
这里我们需要的第一件事是“浏览器渲染”。在 React 世界中,它也被称为“重绘”,只是为了将其与 React 的渲染区分开来 - 它们是非常不同的!这里的想法相对简单。浏览器不会持续更新需要实时显示在屏幕上的所有内容。它不像在白板上画画,你画线、擦线、写一些文字或画一只猫头鹰。
相反,它更像是向人们展示幻灯片:你展示一张幻灯片,等待他们理解上面的天才想法,然后过渡到下一张幻灯片,依此类推。如果要求一个非常慢的浏览器给出如何画猫头鹰的说明,它可能就是臭名昭著的图片:
只有图片才能做到非常非常快,通常,现代浏览器会尝试保持 60 FPS 的速率,即每秒 60 帧,每 13 毫秒左右,一张幻灯片就会切换到下一张,这就是我们在 React 中所说的“重绘”。
更新这些幻灯片的信息被分成“任务”,任务被放入队列中,浏览器从队列中抓取任务并执行,如果还有更多时间,它会执行下一个任务,依此类推,直到在 ~13ms 的间隙中没有剩余时间,然后刷新屏幕。并继续不停地工作,不知疲倦地工作,以便我们能够做诸如在 Twitter 上滚动浏览等重要的事情,甚至不会注意到它所花费的努力。
什么是“任务”?对于普通的 Javascript,它是我们放入脚本标记并同步执行的所有内容。考虑以下代码:
const app = document.getElementById("app");
const child = document.createElement("div");
child.innerHTML = "<h1>Heyo!</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 附加到应用程序,并更改 div 的边框三次。整个过程将被视为浏览器的一项任务。因此它将执行每一行,然后才绘制最终结果:带有黑色边框的 div,你将无法在屏幕上看到这种红绿黑过渡。
如果“任务”花费的时间超过 13 ms会发生什么?不过很遗憾,浏览器无法停止或拆分它。它会继续执行直到完成,然后绘制最终结果,如果我在这些边框更新之间添加 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,但它当然不是作为一项单一任务执行的。将整个应用程序渲染成较小的任务等大型任务“分解”的方法是使用各种“异步”方法:回调、事件处理程序、期约(Promise)等。
如果我将这些样式调整包装在 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 依赖项与我们自己的编码相结合的庞大块分割成浏览器能够在 13 毫秒内(理想情况下)处理的最小块。
所有这些都是一个非常简短和简化的介绍,否则,这篇文章本身就会变成一本书。
关于浏览器事件循环和队列主题的非常好的综合指南在这里:浏览器事件循环:微任务和宏任务、调用堆栈、渲染队列:布局、绘制、复合。
回到 useEffect 与 useLayoutEffect
现在,终于回到 useEffect 与 useLayoutEffect 的比较以及如何回答我们一开始的问题。
useLayoutEffect 是 React 在组件更新期间同步运行的。在此代码中:
const Component = () => {
useLayoutEffect(() => {
// do something
})
return ...
}
无论我们在 Component 中渲染什么,都会使用 useLayoutEffect 作为同一个“任务”运行。React 保证了这一点。即使我们在 useLayoutEffect 中更新状态(我们通常认为这是一个异步任务),React 仍将确保整个流程同步运行。
如果我们回到一开始实现的“导航组件”示例,从浏览器的角度来看,它只是一个“任务”。
这种情况和我们看不到的红绿黑边框过渡一模一样!
另一方面,使用 useEffect 的流程将分为两个任务:
- 第一个渲染带有所有按钮的“初始”导航过程。
- 第二个删除了我们不需要的子项。
中间会重新绘制屏幕!与超时内的边框情况完全相同。
因此,回答我们一开始的问题,使用 useLayoutEffect 安全吗?是的!它会影响性能吗?绝对!我们最不希望看到的是整个 React 应用程序变成一个巨大的同步“任务”。
仅当你需要摆脱由于需要根据元素的实际大小调整 UI 而导致的视觉“故障”时才使用 useLayoutEffect,对于其他所有情况,useEffect 都是可行的方法,而且你甚至可能也不需要:你可能不需要effect - React。
关于 useEffect 的更多信息
虽然 useEffect 在 setTimeout 中运行的思维模型便于理解差异,但从技术上讲并不正确。首先,为了使实现细节清晰,React 改用 postMessage 结合 requestAnimationFrame 技巧。这里为那些喜欢细节的人描述了它:React:React 如何确保在浏览器有机会绘制后调用 useEffect?
其次,它实际上并不能保证异步运行,虽然 React 会尝试尽可能地优化它,但在某些情况下它可以在浏览器重绘之前运行,并因此阻止它。其中一种情况是当你已经在更新链中的某个地方使用了 useLayoutEffect 时。如果你需要了解其原因及其工作原理,可以前往这里查看:useEffect 有时会在重绘之前触发。
Next.js 和其他 SSR 框架中的 useLayoutEffect
低级 javascript 和浏览器的东西说得够多了,让我们回到我们的生产代码。因为在实际项目当中,我们不需要经常关心所有这些。在实际项目当中,我们只想编写漂亮的响应式导航,并在 Next.js(或任何其他框架)等花哨的框架中使用它构建一些不错的用户体验。
当我们尝试这样做时,我们首先会注意到它像往常一样根本不起作用,故障仍然存在,尝试打开此示例并刷新页面几次,或者,如果你有 Next.js 应用程序,请将之前修复的导航复制粘贴到其中。
发生了什么?
这是 SSR,服务器端渲染,一个很酷的功能,一些框架默认支持它,当涉及到这样的事情时,真的很痛苦。
你看,当我们启用 SSR 时,在代码到达浏览器之前,渲染 React 组件和调用所有生命周期事件的第一步是在服务器上完成的。如果你不熟悉 SSR 的工作原理,它只意味着在后端的某个地方,某个方法调用了类似 React.renderToString(<App />)
的东西。
然后,React 会遍历应用程序中的所有组件,“渲染”它们(即只调用它们的函数,毕竟它们只是函数),然后生成这些组件所代表的 HTML。
然后,此 HTML 被注入到将要发送到浏览器的页面中,然后它就开始运行了,就像在以前的互联网时代一样,一切都在服务器上生成,我们只使用 javascript 来打开菜单。之后,浏览器下载页面,向我们显示,下载所有脚本(包括 React),运行它们(再次包括 React),React 浏览预先生成的 HTML,在其上添加一些交互性,我们的页面现在又活跃起来了。
这里的问题是:当我们生成初始 HTML 时,还没有浏览器,因此,任何涉及计算元素实际大小的操作(就像我们在 useLayoutEffect 中所做的那样)都无法在服务器上工作:还没有具有尺寸的元素,只有字符串,由于 useLayoutEffect 的全部目的是访问元素的大小,因此在服务器上运行它没有多大意义,而 React 则没有。
因此,当浏览器向我们显示尚未交互的页面时,我们在第一次加载时看到的是我们在组件的“第一遍”阶段渲染的内容:所有按钮的行,包括“更多”按钮。在浏览器有机会执行所有操作并且 React 启动后,它终于可以运行 useLayoutEffect,按钮最终被隐藏,但视觉故障仍然存在。
如何修复它是一个用户体验问题,完全取决于你愿意“默认”向用户显示什么,我们可以向他们显示一些“加载”状态而不是菜单,或者显示一两个最重要的菜单项,或者甚至完全隐藏项目并仅在客户端上渲染它们,这取决于你。
一种方法是引入一些“shouldRender”状态变量并在 useEffect 中将其更改为“true”:
const Component = () => {
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
setShouldRender(true);
}, []);
if (!shouldRender) return <SomeNavigationSubstitude />;
return <Navigation />
}
useEffect 只会在客户端上运行,因此初始 SSR 传递将向我们显示替代组件,然后,客户端代码将启动,useEffect 将运行,状态将更改,React 将用正常的响应式导航替换它。
不要害怕在这里引入状态,也不要尝试像这样进行条件渲染:
const Component = () => {
// 通过检查窗口是否存在来检测是否是服务端环境
if (typeof window === undefined) return <SomeNavigationSubstitude />;
return <Navigation />
}
虽然从技术上讲 typeof window === undefined
表示 SSR 环境(服务器上没有窗口),但这不适用于我们的示例,React 需要来自 SSR 的 HTML 和客户端上第一次初始渲染的 HTML 才能完全匹配,否则,你的应用程序会表现得像醉了一样。如果在经历了所有这些之后,你仍然渴望了解更多细节,可以前往这里查看:the-perils-of-rehydration。
the-perils-of-rehydration个人理解应该是客户端重新激活。
哎呀,本来应该是一篇轻松愉快的“嘿,这是一个很酷的技巧”的文章,不知何故几乎变成了一场渲染深度探索,以下是一些参考文章链接。
- Dan Abramov 的React 作为 UI 运行时
- GitHub - acdlite/react-fiber-architecture:React 新核心算法的描述
- 浏览器事件循环:微任务和宏任务、调用堆栈、渲染队列:布局、绘制、复合
- 渲染性能
- the-perils-of-rehydration
- useEffect 有时会在绘制之前触发
- 你可能不需要effect - React
- 渲染和提交 - React
本文参考这篇文章,在原版上有做改动。
转载自:https://juejin.cn/post/7419907933243588619