译: Hyration如何削弱懒加载的效果
本文翻译至:Hydration, the Saboteur of Lazy Loading
延迟加载和执行更少的代码是优化应用程序的标准建议。这个建议是合理的,但Hyration会使其变得性能地下。
目录
The Counter
让我们从构建我们能想到的最简单的应用程序开始,一个不起眼的计数器。
计数器组件由count
状态、setCount
突变和count
状态绑定组成。当然,这是一个简单的应用程序,但它代表了一个现实世界的应用程序,因为现实世界的应用程序也有状态、状态突变和状态绑定,只是形式更复杂。
这个类比不正确的地方是,现实世界的应用程序由许多组件组成,因此让我们通过将其从单个组件分解为三个组件来修改我们的计数器。每个组件都包含应用程序的一个单独部分:状态、状态突变和状态绑定。
现实世界的应用程序并不那么简单;他们将行为和表现结合在一起。让我们通过引入包装组件来使我们的计数器更加真实。引入的组件除了添加视觉样式之外没有任何行为功能。
它们是静态和惰性的纯函数组件(我们用虚线边框表示静态组件)。但是因为它们包含了非静态组件,所以它们负责将信息传递给它们的子组件(prop-drilling)。
让我们添加一些没有props的实际静态组件:向计数器组件添加叶子图标。
您应该考虑应用程序的行为需要哪些组件。
请注意,应用程序中唯一的数据流是从 Action
到 Display
,经过定义状态的 Counter
;所有其他组件不会给应用程序的行为带来任何作用,仅供演示使用。
虽然这里的计数器示例很琐碎,但让我们假设它很大且启动缓慢。
我们能做些什么来提高启动性能呢?最常见的建议是延迟加载和延迟执行代码。因此,让我们看看我们的示例,并考虑哪些组件需要第一时间加载,哪些可以延迟加载,以及哪些不应加载,因为它们是静态的。
让我们来谈谈一个理想的情况,在这个情况中,服务器将预渲染的 HTML 发送给客户端,客户端仅下载并执行了最少量的代码:
延迟加载和延迟执行
1. 永远不应该发送给客户端的代码:
- 所有带有虚线边框的组件(
AppRoot
、ActionWrapper
、ActionIcon
、DisplayWrapper
和DisplayIcon
),它们不需要在客户端上重新渲染,也不拥有任何状态和时间处理器,因此是静态的。在我们的应用程序运行期间不需要加载它们。
2. 可能会发送给客户端但应该是惰性的代码:
Counter
组件不需要在客户端重新渲染,但它负责创建应用程序的状态。理想情况下,理想情况下,只有当用户与+1
按钮交互时才应执行代码。Display
组件需要在客户端上重新渲染,前提是应用程序的状态发生变化。它应该在状态突变时延迟加载。
3. 需要在客户端立即执行的代码:
Action
组件永远不需要在客户端上重新渲染,但它具有React所需的事件侦听器。理想情况下,我们不需要加载Action
,但我们确实需要以某种方式告诉React有侦听器的信息,因此需要在应用程序可以交互之前执行此代码。
Hydration(水合)
好的,既然Demo已经搭建完毕,我们来谈谈 Hydration 如何将静态 HTML 变成能交互的<Counter>
组件。为了使应用程序具有交互性,框架需要了解三件事:
- 应用程序的状态(当前计数)
- 组件边界和其中的绑定(更改时重新绘制的内容)
- 事件侦听器的位置和闭包(要监听什么以及要执行什么操作)
大多数框架通过在启动期间执行应用组件代码来获取此信息,我们称之为Hydration(水合作用)。框架首先执行根组件,该组件告诉框架其状态、绑定的事件处理器和子组件。
然后,框架递归到新发现的组件,直到处理应用程序当前渲染树中的所有组件。
执行 <AppRoot>
并了解 <Counter>
。执行 <Counter>
了解 <ActionWrapper>
和 <DisplayWrapper>
。执行 <ActionWrapper>
来了解 <Action>
组件。执行 <Action>
组件并了解点击监听器,等等...
想想上面的水合过程是如何影响延迟加载和延迟执行的:
<AppRoot>
组件必须立即下载并执行,因为它是应用程序的入口点。必须执行
<Counter>
组件才能了解应用程序的状态以及<ActionWrapper>
和<DisplayWrapper>
。即使
<ActionWrapper>
是静态的,框架也必须执行它,以便它可以知道<Action>
组件。框架必须执行
<Action>
才能了解<click>
侦听器。执行该组件允许框架获取事件处理程序闭包,闭包使得可以获取到计数器的状态。如果没有一些附加数据(稍后讨论),框架必须执行
<ActionIcon>
组件,以防万一有事件侦听器(或其他子组件)。需要执行
<DisplayWrapper>
将状态从<Counter>
传递到<Display>
。必须执行
<Display>
以确保没有侦听器(并了解状态绑定)。<DisplayIcon>
还必须立即执行,以确保没有额外的组件或侦听器。
也就是说,必须下载并执行渲染树中的每个组件才能使应用程序具有交互性。
并且在与应用程序交互之前,必须尽快完成loading。尝试对组件进行任何延迟加载都无济于事,因为水合作用会第一时间加载它们。
水合作用需要:
从根组件开始;这意味着水合作用必须从根到叶子节点处理组件。它不能从渲染树的中间开始。
执行这些组件以了解状态、侦听器和子组件。
Hydration没有提示哪些组件是静态的,因此它必须递归每个组件,这限制了任何延迟加载或延迟执行。让我们计算一下必须下载和执行多少个组件才能使应用程序具有交互性。
必须立即下载并执行所有八个组件(对于事件处理器,闭包必须立即实例化并附加到DOM上,即使时间处理函数闭包不会立即运行)。
Hydration的派生解决方案
水合作用性能缓慢且开销昂贵,因为它必须访问渲染树中的每个组件。我们可以做些什么来增加性能?
必须运行组件的大部分原因都是为了查看每个组件中是否还有子组件(及其状态,事件监听等)。如果我们可以为水合算法提供更多信息来避免许多低效率的查找会怎样?
那么,让我们看看人们尝试过的一些改善方法。
渐进式水合
渐进式水合与常规水合一样(从访问所有组件的意义上来说),但它可以根据用户交互情况优先处理树中的不同分支。
当水合处理 <Counter>
组件时,它会知道下面还有 <ActionWrapper>
和 <DisplayWrapper>
。应首先处理哪个分支?如果水合可以观察到用户单击了 <ActionWrapper>
子树内的 <button>
,则渐进式水合可以优先处理 <ActionWrapper>
而不是 <DisplayWrapper>
(它可以暂停当前树中的分支并首先处理更高优先级的分支)。
从需要下载和执行代码量的角度来看,渐进式水合与经典水合相同。尽管如此,渐进式水合还是有用户能感知到的交互时间上的提升。
部分水合(也可以叫岛屿式水合)
将网页想象成大部分是静态 HTML,不需要在浏览器中重新渲染或水合。在其中,有少数用户可以与其进行交互的地方,我们可以称之为“岛屿”。这种方法通常被称为部分水合,因为我们只需要给这些岛屿加水。
部分水合是指,识别到并非树中的所有的组件都需要水合,因为有些组件是静态的。
在我们的例子中,<AppRoot>
可以被跳过,因为它是静态的并且没有state(或providers)或事件处理器。
但部分水合通常不能跳过中间的静态组件。例如,<ActionWrapper>
和 <DisplayWrapper>
,即使是静态的,也必须执行,因为数据流可以从 <Counter>
到 <Action>
和 <Counter>
到 <Display>
。尽管它们是静态组件,但它们在传递数据给子组件中起到承上启下的作用。
部分水合也对bundler有帮助,因为它指定了应用程序的不同入口点。这允许bundler分割 <AppRoot>
并且不将其发送给客户端。
当然,优化效果很大程度上取决于有多少组件可以标记为静态,因此它可能会因您正在构建的应用程序类型而有很大差异。
如果"岛屿"足够小,那么水合作用的开销可以作为初始交互的一部分被吸收;在这种情况下,可以将开销全部转移到惰性加载的页面元素列。也可以将 <Action>
和 <Display>
分解为单独的岛屿,但现在您有一个位于岛屿之外并跨越岛屿的状态(使问题进一步复杂化)。
分支修剪水合
<ActionIcon>
和 <DisplayIcon>
是静态组件(静态叶节点)。如果可以告诉 hydration 这些分支是静态的,它可能会短路访问这些组件,因为它们没有需要添加交互的需要。
困难的是,在运行时,框架只有一种方法可以知道组件是否是静态的,那就是执行它。因此,必须在构建时或预渲染时收集组件的静态特性,并以声明方式提供给水合算法。
这些信息也会影响bundlers,因为我们不想将不会执行的组件打进包里。典型的tree shaking在这里无济于事,因为父组件直接引用分支组件(<Display>
包含<DisplayIcon>
)。所以bundler要将它打进包内。
问题是,我们想要的是不仅仅是不执行这个组件,而且一开始就不包含它。
服务端组件的水合
服务端组件要求开发人员通过在代码中标记哪些组件在客户端是静态的。静态组件不能有状态或事件处理程序。
因此,可以在 SSR/SSG 期间对它们进行序列化。这对框架有利,因为框架拥有有关组件的所有信息,而无需实际执行组件。
通过巧妙地使用投影(children),可以使静态和动态组件。因此即使 <DisplayWrapper>
夹在 <Counter>
和 <Display>
之间,水合也可以跳过它。相同的投影技巧可以应用于 <DisplayIcon>
,但组件距离根越远,投影就越困难。
但有一些约束,组件只能有一个 children
属性,因此额外的投影需要其他属性,这是可能的,但变得不够方便,因此开发人员往往只对最大/最昂贵的组件使用它。
在这里,<ActionIcon>
和 <DispalyIcon>
被标记为一个浅色对勾,并且给出了0.5的成本,以表明它们可以避免,但可能不会。
组件裁剪
某些框架可以将 <ActionIcon>
或 <DisplayIcon>
作为编译的一部分,并认识到它们是永远不会在客户端上重新渲染的叶节点,并自动从构建中忽略它。渲染树被修剪。但这仅适用于静态节点。
此技巧不能用于修剪 <DisplayWrapper>
,因为它包含 <Display>
,它将在客户端上渲染。
当然,这些策略可以结合起来进一步改善结果。
水合顺序
需要理解的一个重要概念是水合作用是连续的。水合作用必须从入口(通常是根)开始,然后作用到叶节点。水合可以优先考虑不同的分支,但不能跳过中间组件直接进入叶节点。
这意味着树中的子组件需要先对所有父组件进行水合,然后才能对子组件进行水合。部分原因是组件从父组件获取 props
,因此要创建组件,父组件必须执行并生成 props
,这是递归到根的。
延迟加载组件
function DisplayWrapper({count}) {
return (
<div className="my-app-styles">
<Display count={count} />;
</div>
);
}
function Display({count}) {
return <div><DisplayIcon/>{count}</div>;
}
function DisplayIcon() {
return <svg>...</svg>
}
我们可以延迟加载 <Display>
,因为在用户与计数器交互之前它不是必要的。
将
<Display>
(和<DisplayIcon>
)移至单独的文件中。使用动态
import()
来获取<Display>
。将动态加载的
<Display>
包裹在lazy()
中。最后,在
<Suspense>
中使用懒加载的<Display>
。
export function Display({count}) {
return <div><DisplayIcon/>{count}</div>;
}
function DisplayIcon() {
return <svg>...</svg>
}
import { lazy, Suspense } from 'react';
const LazyDisplay = lazy(() => (await import('./lazy')).Display);
function DisplayWrapper({count}) {
return (
<div className="my-app-styles">
<Suspense fallback={<span>loading...</span>}>
<LazyDisplay count={count} />
</Suspense>
</div>
);
}
有几点需要注意:
这不是一个小的更改。将组件移动到不同的文件并添加必要的包裹标签(
<lazy>
和<Suspense>
)并非易事。在我们的示例中,
<DisplayIcon>
组件依赖于<Display>
,也必须移动它才能获得全部优势。这是一个基本案例,但在实际开发中,仅确定哪些组件需要移动到单独的文件可能是一项复杂的任务。
但最大的问题是,水合会迫使 <Display>
组件急切地加载,尽管我们做了很多工作来延迟加载(可以认为,水合过程中延迟加载组件会对启动时间产生负面影响)。
延迟加载事件处理程序
我们可以使用延迟加载的另一个地方是事件处理程序。
export function Action({ setCount }) {
return (
<button onClick={() => setCount((v) => v + 1)}>
<ActionIcon />
</button>
);
}
就像以前一样,创建一个包含延迟加载处理程序的新文件。
// Action_click.tsx
export function ActionClickHandler(setCount) {
setCount((v) => v + 1);
}
export function Action({ setCount }) {
return (
<button
onClick={async () => {
const module = await import('./Action_click.tsx'));
module.Action_click(setCount);
}}
>
<ActionIcon />
</button>
);
}
有几点需要注意:
虽然不像将多个组件移动到新文件那样复杂,但移动事件处理函数仍然需要一些工作量。
这次重构最困难的部分是,通过移动这些码,我们将代码从一个闭包变成了函数。因为将代码从闭包转换为函数会无法封闭组件的状态(比如
setCount
)。而函数位于顶层,因此无法封闭任何组件的状态。因此,在重构过程中必须明确所有已封闭的引用,并将它们传递给延迟加载的函数。