React 18 中的新 Suspense SSR 架构的文章
1 views
这是 Dan 在社区介绍 React 18 中的新 Suspense SSR 架构的文章,
概述
React 18 将包括对 React 服务器端渲染 (SSR) 性能的架构改进。这些改进是实质性的,是多年工作的结晶。大多数改进都是幕后的,但您需要注意一些可选机制,特别是如果您不使用框架的话。
主要的新 API 是,您可以在 renderToPipeableStream 中阅读。我们计划更详细地介绍它,因为它不是最终版本,还有一些问题需要解决。
主要的现有 API 是 <Suspense> 。
本页是对新架构、其设计及其解决的问题的高层概述。
总结
服务器端渲染(本文中简称为“SSR”)可让您从服务器上的 React 组件生成 HTML ,并将该 HTML 发送给用户。 SSR 可让您的用户在 JavaScript 包加载和运行之前看到页面的内容。
React 中的 SSR 总是分几个步骤进行:
- 在服务器上,获取整个应用程序的数据。
- 然后,在服务器上,将整个应用程序呈现为
HTML并将其在响应中发送。 - 然后,在客户端上加载整个应用程序的
JavaScript代码。 - 然后,在客户端上,将
JavaScript逻辑连接到整个应用程序的服务器生成的 HTML(这就是“hydration”)。
关键部分是, 整个应用的每个步骤必须同时完成,然后才能开始下一步 。如果应用的某些部分比其他部分慢,那么这种方法就不高效,几乎所有非平凡应用都是这种情况。
React 18 可让您将应用 <Suspense> 分解为 更小的独立单元 ,这些单元将彼此独立地执行这些步骤,并且不会阻止应用的其余部分。因此,您的应用用户将更快地看到内容,并能够更快地开始与其交互。应用中最慢的部分不会拖累速度快的部分。这些改进是自动的,您无需编写任何特殊的协调代码即可使其发挥作用。
这也意味着 React.lazy 现在“可以与 SSR 配合使用”。这是一个
(如果您不使用框架,您将需要改变 HTML 生成的确切方式。)
什么是 SSR?
当用户加载你的应用时,你希望尽快显示一个完全可交互的页面:

此图使用绿色来传达页面的这些部分是可交互的。换句话说,它们的所有 JavaScript 事件处理程序都已附加,单击按钮可以更新状态,等等。
但是,在页面的 JavaScript 代码完全加载之前,页面无法交互。这包括 React 本身和您的应用程序代码。对于非平凡的应用程序,大部分加载时间将花在下载您的应用程序代码上。
如果不使用 SSR ,则 JavaScript 加载时用户唯一看到的就是空白页:

这不是很好,这就是我们建议使用 SSR 的原因。 SSR 允许您将服务器上的 React 组件渲染为 HTML 并将其发送给用户。HTML 的交互性不强(除了简单的内置 Web 交互,如链接和表单输入)。但是,它允许用户在 JavaScript 仍在加载时 看到一些内容 :

此处的灰色表示屏幕的这些部分尚未完全交互。应用的 JavaScript 代码尚未加载,因此单击按钮不会产生任何效果。但对于内容繁重的网站而言, SSR 非常有用,因为它可以让连接较差的用户在 JavaScript 加载时开始阅读或查看内容。
当 React 和应用程序代码都加载时,您希望使此 HTML 具有交互性。您告诉 React:“这是App在服务器上生成此 HTML 的组件。将事件处理程序附加到该 HTML!”React 将在内存中呈现您的组件树,但它不会为其生成 DOM 节点,而是将所有逻辑附加到现有 HTML。
渲染组件和附加事件处理程序的过程称为“水合” 。这就像用交互性和事件处理程序的“水”浇灌“干燥”的 HTML。(或者至少,这是我对自己解释这个术语的方式。)
经过 hydration 之后,它就可以“像往常一样做出反应”了:你的组件可以设置状态、响应点击等等:

您可以看到,SSR 就像是一种“魔术”。它不会让您的应用更快地实现完全交互。相反,它可以让您更快地显示应用的非交互版本,以便用户在等待 JS 加载时可以查看静态内容。但是,对于网络连接较差的用户来说,这个技巧会带来巨大的变化,并提高整体的感知性能。它还可以帮助您提高搜索引擎排名,这既因为索引更容易,也因为速度更快。
注意:不要将 SSR 与服务器组件混淆。服务器组件是一个更具实验性的功能,仍在研究中,可能不会成为初始 React 18 版本的一部分。您可以
在此处 了解服务器组件。服务器组件是 SSR 的补充,将成为推荐数据获取方法的一部分,但这篇文章不是关于它们的。
当今的 SSR 存在哪些问题?
上述方法是可行的,但在很多方面它并不是最佳的。
你必须先获取所有内容,然后才能显示任何内容
目前 SSR 的一个问题是它不允许组件“等待数据”。使用当前 API,在渲染 HTML 时,您必须已经在服务器上为组件准备好所有数据。这意味着您 必须先收集服务器上的所有数据,然后才能开始向客户端发送任何HTML 。这是非常低效的。
例如,假设您想要呈现带有评论的帖子。尽早显示评论很重要,因此您希望将它们包含在服务器 HTML 输出中。但您的数据库或 API 层很慢,这超出了您的控制范围。现在您必须做出一些艰难的选择。如果您将它们从服务器输出中排除,则用户在 JS 加载之前不会看到它们。但是如果您将它们包含在服务器输出中,您必须延迟发送其余的 HTML(例如,导航栏、侧边栏,甚至帖子内容),直到评论加载完毕,您就可以呈现完整的树。这不是很好。
顺便提一下,一些数据获取解决方案会反复尝试将树渲染为 HTML,并丢弃结果,直到数据解析完毕,因为 React 没有提供更符合人体工程学的选项。我们希望提供一种不需要如此极端妥协的解决方案。
你必须先装载所有东西,然后才能给任何东西补充水分
加载 JavaScript 代码后,您将告诉 React “hydrate” HTML 并使其具有交互性。 React 将在渲染组件时“遍历”服务器生成的 HTML,并将事件处理程序附加到该 HTML。 要使此功能正常工作,浏览器中组件生成的树必须与服务器生成的树匹配。 否则 React 无法“匹配它们!” 一个非常不幸的后果是, 您必须在客户端上加载所有组件的 JavaScript,然后才能开始hydrate任何组件 。
例如,假设评论小部件包含大量复杂的交互逻辑,并且需要一段时间才能为其加载 JavaScript 。现在您必须再次做出艰难的选择。将服务器上的评论渲染为 HTML 以便尽早将其显示给用户会很好。但是由于目前只能一次性完成 hydration ,因此在加载评论小部件的代码之前,您无法开始 hydration 导航栏、侧边栏和帖子内容!当然,您可以使用代码拆分并单独加载它,但您必须从服务器 HTML 中排除评论。否则,React 将不知道如何处理这段 HTML(它的代码在哪里?)并在 hydration 期间将其删除。
你必须先给所有东西补充水分,然后才能与任何东西互动
hydration 本身也存在类似的问题。目前,React 一次性完成组件树的 hydration 。这意味着一旦开始 hydration (本质上是调用组件函数),React 就不会停止,直到完成整个组件树的 hydration 。因此, 您必须等待所有组件都 hydration 完毕后才能与其中任何一个组件进行交互 。
例如,假设评论小部件具有昂贵的渲染逻辑。它可能在您的计算机上运行得很快,但在低端设备上运行所有这些逻辑并不便宜,甚至可能会锁定屏幕几秒钟。当然,理想情况下,我们根本不会在客户端上使用这样的逻辑(这是服务器组件可以提供帮助的)。但对于某些逻辑来说,这是不可避免的,因为它决定了附加的事件处理程序应该做什么,并且对于交互性至关重要。因此,一旦开始水合,用户就无法与导航栏、侧边栏或帖子内容进行交互,直到整个树水合完成。对于导航来说,这尤其不幸,因为用户可能想要完全离开这个页面——但由于我们正忙于水合,我们会让他们停留在他们不再关心的当前页面上。
我们如何解决这些问题?
这些问题有一个共同点。它们迫使你做出选择:是尽早做某件事(但会损害用户体验,因为它会阻碍所有其他工作),还是延迟做某件事(但会损害用户体验,因为你浪费了时间)。
这是因为存在“瀑布”: 获取数据(服务器)→ 渲染为 HTML(服务器)→ 加载代码(客户端)→ 水合(客户端) 。在应用程序的前一个阶段完成之前,这两个阶段都无法开始。这就是它效率低下的原因。我们的解决方案是将工作分开,以便 我们可以针对屏幕的一部分而不是整个应用程序执行每个阶段 。
这不是一个新奇的想法:例如, JavaScript Web 框架之一。挑战在于如何将这样的模式适应 React 编程模型。解决这个问题花了一段时间。我们 <Suspense> 在 2018 年为此目的引入了该组件。当我们引入它时,我们仅支持它在客户端上延迟加载代码。但我们的目标是将其与服务器渲染集成并解决这些问题。
让我们看看如何使用 <Suspense> React 18 来解决这些问题。
React 18:流式 HTML 和选择性水合
Suspense 解锁了 React 18 中的两个主要 SSR 功能:
- 在服务器上流式传输 HTML 。要选择使用此功能,您需要切换
renderToString到新renderToPipeableStream方法,如这里所述。 - 客户端上的选择性水合 。要选择此功能,您需要在客户端上切换到
hydrateRoot,然后开始使用包装应用程序的各个部分<Suspense>。
要了解这些功能的作用以及它们如何解决上述问题,让我们回到我们的例子。
在获取所有数据之前流式传输 HTML
在今天的 SSR 中,渲染 HTML 和 hydration 是“全有或全无”的。首先渲染所有 HTML:
<main> <nav> <!--NavBar --> <a href="/">Home</a> </nav> <aside> <!-- Sidebar --> <a href="/profile">Profile</a> </aside> <article> <!-- Post --> <p>Hello world</p> </article> <section> <!-- Comments --> <p>First comment</p> <p>Second comment</p> </section></main>客户端最终收到:

然后加载所有代码并补充整个应用程序:

但是 React 18 给了你一个新的可能性,你可以用 包裹页面的一部分 <Suspense> 。
例如,让我们包装注释块并告诉 React ,直到准备就绪,React 才应该显示该 <Spinner /> 组件:
<Layout> <NavBar /> <Sidebar /> <RightPane> <Post /> <Suspense fallback={<Spinner />}> <Comments /> </Suspense> </RightPane></Layout>通过包装 <Comments> 到中 <Suspense> ,我们告诉 React 它不需要等待评论开始为页面的其余部分流式传输 HTML 。相反,React 将发送占位符(微调器)而不是评论:

现在,初始 HTML 中已经找不到注释:
<main> <nav> <!--NavBar --> <a href="/">Home</a> </nav> <aside> <!-- Sidebar --> <a href="/profile">Profile</a> </aside> <article> <!-- Post --> <p>Hello world</p> </article> <section id="comments-spinner"> <!-- Spinner --> <img width=400 src="spinner.gif" alt="Loading..." /> </section></main>故事并没有就此结束。当评论的数据在服务器上准备好后, React会将其他 HTML 发送到同一流中,以及一个最小的内联 <script> 标记,以将该 HTML 放在“正确的位置” :
<div hidden id="comments"> <!-- Comments --> <p>First comment</p> <p>Second comment</p></div><script> // This implementation is slightly simplified document.getElementById('sections-spinner').replaceChildren( document.getElementById('comments') );</script>因此,甚至在 React 本身在客户端上加载之前 ,延迟的评论 HTML 就会“弹出”:
这解决了我们的第一个问题。现在您不必在显示任何内容之前获取所有数据。如果屏幕的某些部分延迟了初始 HTML,您不必在延迟所有HTML 或将其从 HTML 中排除之间做出选择。您可以让该部分稍后在 HTML 流中“弹出”。
与传统的 HTML 流式传输不同,它不必按自上而下的顺序进行 。例如,如果侧边栏需要一些数据,您可以将其包装在 Suspense 中,React 将发出一个占位符并继续呈现帖子。然后,当侧边栏 HTML 准备就绪时,React 会将其与 <script> 将其插入正确位置的标签一起流式传输 - 即使帖子的 HTML(在树中更远)已经发送!不要求数据以任何特定顺序加载。您指定微调器应出现的位置,React 会计算出其余部分。
注意:要实现此功能,您的数据获取解决方案需要与
Suspense集成。服务器组件将开箱即用地与Suspense集成,但我们还将提供一种方式让独立的 React 数据获取库与其集成。
在所有代码加载完成之前对页面进行补充
我们可以提前发送初始 HTML,但仍然有一个问题。在评论小部件的 JavaScript 代码加载之前,我们无法开始在客户端上为我们的应用添加内容。如果代码量很大,这可能需要一段时间。
为了避免捆绑包过大,您通常会使用“代码拆分”:您可以指定一段代码不需要同步加载,然后捆绑器会将其拆分为单独的 <script> 标签。
您可以使用代码拆分React.lazy将注释代码从主包中分离出来:
import { lazy } from 'react';
const Comments = lazy(() => import('./Comments.js'));
// ...
<Suspense fallback={<Spinner />}> <Comments /></Suspense>;以前,这不适用于服务器渲染。(据我们所知,即使是流行的解决方法也会迫使您选择是退出代码拆分组件的 SSR 还是在所有代码加载后对其进行补充,这在某种程度上违背了代码拆分的目的。)
但是在 React 18 中, <Suspense> 您可以 在评论小部件加载之前为应用程序补充水分。
从用户的角度来看,他们最初看到的是以 HTML 形式流入的非交互式内容:


然后告诉 React 进行 hydrate。 注释的代码还没有,但没关系:

这是选择性水合 的一个例子。通过包装 Comments , <Suspense> 您告诉 React 它们不应阻止页面的其余部分流式传输 - 事实证明,也不应该阻止水合!这意味着第二个问题得到了解决:您不再需要等待所有代码加载才能开始水合。React 可以在加载部分时对其进行水合。
在代码加载完成后,React 将开始补充评论部分的内容:

得益于 Selective Hydration ,大量的 JS 不会妨碍页面其余部分的交互。
在所有 HTML 流式传输之前对页面进行补充
React 会自动处理所有这些,因此你不必担心事情会以意想不到的顺序发生。例如,即使在流式传输 HTML 时,也可能需要一段时间才能加载:

如果 JavaScript 代码比所有 HTML 都早加载,React 就没有理由等待!它将补充页面的其余部分:

当评论的 HTML 加载时,它将显示为非交互式的,因为 JS 尚未存在:

最后,当评论小部件的 JavaScript 代码加载时,页面将变得完全可交互:

在所有组件完成之前与页面交互
当我们将注释包装在 中时,幕后还发生了另一项改进 <Suspense> 。现在它们的水合不再阻止浏览器执行其他工作。
例如,假设用户在评论被更新时点击了侧边栏:

在 React 18 中, Suspense 边界内的内容合并会以微小的间隙进行,浏览器可以在其中处理事件。得益于此, 点击会立即得到处理,并且在低端设备上长时间合并时浏览器不会出现卡顿 。例如,这可以让用户离开他们不再感兴趣的页面。
在我们的示例中,只有评论被包装在 Suspense 中,因此页面其余部分只需一次即可完成。但是,我们可以通过在更多地方使用 Suspense 来解决这个问题!例如,让我们也包装侧边栏:
<Layout> <NavBar /> <Suspense fallback={<Spinner />}> <Sidebar /> </Suspense> <RightPane> <Post /> <Suspense fallback={<Spinner />}> <Comments /> </Suspense> </RightPane></Layout>现在,在包含导航栏和帖子的初始 HTML 之后,它们都可以从服务器流式传输。但这也会对水合产生影响。假设它们两个的 HTML 都已加载,但它们的代码尚未加载:

然后,包含侧边栏和评论代码的包将被加载。React 将尝试对它们进行合并,从它在树中较早找到的 Suspense 边界开始(在此示例中为侧边栏):

但是假设用户开始与评论小部件进行交互,该小部件的代码也会被加载:

React 会在点击事件的捕获阶段同步补充评论:

因此,评论将及时补充,以处理点击并响应交互。然后,现在 React 没有紧急的事情要做,React 将补充侧边栏:

这解决了我们的第三个问题。得益于 Selective Hydration ,我们不必“为了与任何东西交互而对所有内容进行水合”。 React会尽早开始对所有内容进行水合,并根据用户交互优先考虑屏幕上最紧急的部分 。如果您考虑到在整个应用中采用 Suspense ,边界将变得更加细化,那么 Selective Hydration 的好处将变得更加明显:

在这个例子中,用户在补充开始时点击了第一条评论。React 将优先补充所有父 Suspense 边界的内容,但会跳过任何不相关的兄弟边界。 这造成了一种错觉,即补充是即时的,因为交互路径上的组件会首先补充 。React随后将补充应用程序的其余部分。
实际上,您可能会将 Suspense 添加到应用程序的根目录附近:
<Layout> <NavBar /> <Suspense fallback={<BigSpinner />}> <Suspense fallback={<SidebarGlimmer />}> <Sidebar /> </Suspense> <RightPane> <Post /> <Suspense fallback={<CommentsGlimmer />}> <Comments /> </Suspense> </RightPane> </Suspense></Layout>在这个例子中,初始 HTML 可以包含内容 <NavBar> ,但是其余部分将在相关代码加载后立即流入并分部分合并,并优先考虑与用户交互的部分。
注意:您可能想知道您的应用如何在这种未完全水化状态下工作。设计中有一些微妙的细节使其工作。例如,水化不是单独水化每个组件,而是针对整个
<Suspense>边界进行水化。由于<Suspense>已经用于不会立即出现的内容,因此您的代码可以承受其子项无法立即使用的情况。React 始终按照父级优先的顺序进行水化,因此组件始终设置了它们的props。React 会推迟分派事件,直到从事件点开始的整个父树都水化为止。最后,如果父级更新的方式导致尚未水化的 HTML 变得陈旧,React 将隐藏它并将其替换为fallback您指定的 HTML,直到代码加载完毕。这确保树对用户来说看起来一致。您不需要考虑它,但这就是它起作用的原因。
演示
我们准备了一个演示,您可以尝试看看新的 Suspense SSR 架构是如何工作的。它被人为地放慢了速度,因此您可以调整延迟 server/delays.js :
API_DELAY让您让评论在服务器上花更长时间才能获取,展示如何尽早发送其余的 HTML。JS_BUNDLE_DELAY让您延迟<script>标签的加载,展示评论小部件的 HTML 如何在 React 和应用程序包下载之前“弹出”。ABORT_DELAY如果服务器上的获取时间过长,您可以看到服务器“放弃”并将渲染移交给客户端。
综上所述
React 18 为 SSR 提供了两个主要功能:
- 流式 HTML 让您可以尽早开始发送 HTML,并将附加内容的 HTML 与
<script>放置在正确位置的标签一起流式传输。 - 选择性加载功能 可让您在其余 HTML 和
JavaScript代码完全下载之前尽早开始加载应用。它还会优先加载用户正在交互的部分,从而营造出即时加载的错觉。
这些功能解决了 React 中 SSR 的三个长期存在的问题:
- 您不再需要等待所有数据在服务器上加载后再发送 HTML 。相反,只要您有足够的数据来显示应用的外壳,您就可以开始发送 HTML,并在其余 HTML 准备就绪后传输它们。
- 您不再需要等待所有 JavaScript 加载完毕才能开始 hydrate 。相反,您可以将代码拆分与服务器渲染结合使用。服务器 HTML 将被保留,并且 React 将在相关代码加载时对其进行
hydrate。 - 您不再需要等待所有组件都完成水合才能开始与页面交互 。相反,您可以依靠选择性水合来优先考虑用户正在交互的组件,并尽早对其进行水合。
该 <Suspense> 组件是所有这些功能的可选组件。改进本身在 React 内部是自动的,我们希望它们能够与大多数现有 React 代码兼容。这展示了以声明方式表达加载状态的强大功能。从 到 看起来可能不是很大的变化 if (isLoading) , <Suspense> 但它却是解锁所有这些改进的关键。
问题:
在 React 18 中,Suspense 边界内的内容合并会以微小的间隙进行,浏览器可以在其中处理事件。得益于此,点击会立即得到处理,并且在低端设备上长时间合并时浏览器不会出现卡顿。例如,这让用户可以离开他们不再感兴趣的页面。