为什么修复生效:渲染、绘图与浏览器
我们这里首先要了解的是 “浏览器渲染”。在 React 的语境中,为了将其与 React 的渲染区分开来,它也被称作 “绘制” —— 这两者可是大不相同!这里的概念相对简单。浏览器并不会实时持续更新屏幕上需要显示的所有内容。这不像在白板上作画,你可以随意画线条、擦除线条、书写文字或者勾勒出一只猫头鹰的图案。
相反,它更像是给人们播放幻灯片:你展示一张幻灯片,等他们理解上面的精妙想法后,再切换到下一张幻灯片,以此类推。
只不过浏览器切换的速度极快。通常情况下,现代浏览器会尽力维持 60 帧每秒(FPS)的速率。大约每 13 毫秒就会从一张 “幻灯片” 切换到下一张。这就是我们在 React 里所说的 “绘制”。
要更新这些幻灯片的信息被切分成若干“子任务”。这些任务被放进了一个队列。浏览器会拿到队列里的任务并执行它。如果浏览器还有时间,它会执行队列里的下一个任务,直到队列被清空。
那么,什么是一个任务?我们以一个在script标签中同步执行的JS代码为例:
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,再把它放在app下边,然后又三次改动了边框。这所有的步骤,在浏览器看来只是一个任务。浏览器执行完这段代码后,会渲染出一个有黑色边框的div。
你将看不到屏幕上div的颜色变化。
如果我们把一个任务的时间拉长到13ms,会怎么样?很不幸。浏览器并不会停下来,切分变化颜色的任务。
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);
我们仍然无法看到 “过渡阶段” 的结果。我们只能盯着空白屏幕,直到浏览器处理好一切,最后才能看到最终呈现的黑色边框。这就是我们所说的 “阻塞渲染” 或 “阻塞绘制” 代码。
代码示例: advanced-react.com/examples/12…
虽然React本质上也是JavaScript,但它并不是只在一个任务里执行。不然的话,开发出来的页面是不可用的。React会通过异步的方法把渲染app的大任务切分成小任务。这些一步方法有:回调函数,事件回调,期约,等等
如果我吧刚刚的代码用setTimeout调整,会是这样:
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);
如此一来,每一个定时器内的代码会被认定为一个新的任务。这样,浏览器会在完成上一个任务后,重绘下一个任务。
代码示例: advanced-react.com/examples/12…
React正是通过拆分异步任务,实现这个效果的。React通过极其复杂和高效的算法,把我们的代码拆分成每一个执行时间小于13ms的代码片段。
再比对 useEffect 和 useLayoutEffect
现在,我们再次回到useEffect 和 useLayoutEffect。
useLayoutEffect的内容,会被React给同步执行了:
const Component = () => {
useLayoutEffect(() => {
// do something
})
return ...
}
这个组件内的渲染任务,会和useLayoutEffect内的任务,执行在同一个任务里。这是React来实现的。甚至我们在useLayoutEffect里改变状态,React也会确保这一行为被同步执行。
如果我们回到导航栏的例子,在浏览器的视角,useLayoutEffect内的都是一个任务。
这个效果,好比我们刚刚展示的无过度边框效果的div一样。
而如果使用useEffect,则会被拆分成两个任务:
第一个任务会渲染所有的内容。而第二个任务会移除掉不需要的部分。这在屏幕上看,就是重绘的效果。就好比刚刚边框有渐变的div。
那么,回到最初的问题。使用useLayoutEffect安全吗?安全。使用它会损耗性能吗?绝对会!
只有当你需要消除因根据元素的实际尺寸调整用户界面(UI)而导致的视觉 “故障” 时,才使用 useLayoutEffect。对于其他情况,使用 useEffect 即可。而且,你可能连 useEffect 都不需要呢。
关于useEffect的一些点
虽然为了方便,我们用setTimeout来模拟useEffect的异步执行,但是它在React的技术上不是这样实现的。首先,React是用postMessage混合着requestAnimationFrme来实现这个异步的。
第二,useEffect并不是百分之一百异步执行的。尽管React在尽量优化这个流程,还是存在useEffect在浏览器绘图前执行,并造成了一定的阻塞。其中一种情况是,在更新链的某个地方已经使用了useLayoutEffect。
useEffect同步执行的情况是这样的:React在“快照”中执行重新渲染,或者循环式执行。每一个重新渲染循环,或按照这个顺序执行:状态更新被触发 -> useLayoutEffect被触发 -> useEffect被触发。如果这其中有任何一个状态更新,会再次触发重新渲染循环。但是在重新触发前,React需要完成启动状态更新的循环。所以,useEffect得在新的循环启动前被同步执行。React别无选择,只能同步执行useEffect。
useLayoutEffect在Next.j和其他SSR框架中
讲完了底层的JavaScript原理和浏览器原理,让我们回到生产代码吧。因为在真实生活中,我们不需要关心这些。在真实的生活中,我们只是希望我们的代码可以很好地实现响应式导航栏,并在Next.js实现用它实现更好的用户体验。
然而,当我们试图这么做时,我们首先会注意到的是,这根本行不通,完全不行。画面闪烁的问题依旧存在,神奇的效果不复见了。要复现这个问题,如果你有自己的 Next.js 应用,只需把我们之前修复好的导航代码复制粘贴进去即可。
这背后发生了什么?
这是SSR,服务端渲染。有一些框架是默认支持服务端渲染的。当然,服务端渲染也会给开发者带来很多问题。
你看,当我们使用了SSR,第一次调用React组件和生命周期事件,是发生在服务端把代码发送给浏览器之前。如果你对SSR不熟悉,你应该知道:在后端,有一个类似React.renderToString(<App />方法被调用。之后,React会遍历app上所有的组件,“渲染”它们,并生成它们所对应的HTML。
之后,这个HTML会注入到要返回给浏览器的页面里。这就像在以前,一切内容都由浏览器返回,我们仅仅使用JavaScript来打开菜单。后来,浏览器来下载页面,展示页面给我们,下载所有的脚本(包括React),执行它们(也包括React),React 会遍历预先生成的 HTML,为其增添一些交互性,这样我们的页面就又变得生动起来了。
而问题也在这里:我们生成最初的HTML时,并没有浏览器环境。所以,任何关于计算元素尺寸的代码(就像我们在useLayoutEffect里的代码)在服务端将无法生效。React也无法运行。
结果,我们初次打开浏览器的页面将没有交互性,会渲染组件的所有链接。之后,当浏览器可以执行代码了,React也开始运行了,就可以运useLayoutEffect里的代码了,“更多”按钮也被隐藏了。但在视觉上,还是存在一个闪烁。
至于怎么解决这个bug,取决于你想向用户展示多少默认的内容。我们可以向用户展示一个loading图标,而非菜单。或者,仅仅展示菜单里几个比较重要的内容。甚至,你可以隐藏它,直到页面被渲染在浏览器后再渲染菜单。这些都是由你来决定的。
其中一个解法,是引入“shouldeRender”状态,并在useEffect中把它设置为“true”:
const Component = () => {
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
setShouldRender(true);
}, []);
if (!shouldRender) return <SomeNavigationSubstitude />;
return <Navigation />;
};
useEffect只会在客户端运行,所以初始的服务端渲染页面会渲染SomeNavigationSubstitude组件。之后,当页面进入到浏览器环境后,useEffect会运行,而shouldRender会为true,SomeNavigagtionSubtitude也会被替换为Navigation。
不要害怕在此引入状态,更不要像下面这样条件渲染:
const Component = () => {
// Detectign SSR by checking whether window is there
if (typeof window === undefined)
return <SomeNavigationSubstitude />;
return <Navigation />;
};
从技术层面讲,typeof window === undefined 可以表明当前处于服务器端渲染(SSR)环境(因为服务器上没有 window 对象),但这在我们的实际应用场景中并不适用。React 要求服务器端渲染生成的 HTML 与客户端首次初始渲染生成的 HTML 必须完全匹配。否则,你的应用会表现得像喝醉了一样:样式会错乱,区块会定位错误,内容会出现在奇怪的位置。
知识概要
这就是处理闪烁问题的所有内容了。在下一章,我们会讨论如何通过Protals来处理界面UI。在此之前,需要记住:
- 我们可以在
useEffect钩子内计算元素的尺寸,或者改变它们的尺寸。改尺寸时,也许会看到闪烁问题。 - 这是因为,
useEffect通常是异步运行的。在浏览器看来,一个异步任务是一个单独的任务。所以,这个任务可能在绘图前或者绘图后执行,从而造成页面卡顿。 - 为了避免这一现象,我们可以使用
useLayoutEffect。它是同步运行的。从浏览器的角度而言,它会打来一个巨大而不可分割的任务。所以浏览器会等待,不重绘,直到这个钩子内的任务被完成。 - 在SSR环境,
useLayoutEffect不会生效,因为React无法在SSR模式下运行useLayoutEffect,所以我们又会看到这个闪烁问题。 - 针对这个特定功能,我们可以选择不使用服务器端渲染(SSR)来解决这个问题。