Screenshot of two apps, one with loading spinners
2021 年 12 月 7 日

React 服务端组件和 Remix

Ryan Florence
联合创始人

Remix v1 发布后,一个重要的问题不断出现

React 服务端组件怎么样?

好问题!像你们中的许多人一样,自从 2018 年首次宣布 React Suspense 以来,我们就一直在尝试它。事实上,Remix 的早期版本就使用了它。意识到它可能在我们准备好之前不会发布,我们将 Remix 的异步部分构建到了框架中,并且对结果非常满意。

现在,React 服务端组件(RSC)似乎越来越受到关注,我们再次进行了研究和测试,试图找出在 Remix 中利用它们的最佳方法。我们很清楚 RSC 仍处于实验阶段,因此我们不打算将此研究作为我们关于 RSC 和 Remix 的最终结论。我们只是认为,为了所有使用 Remix(和 React Router)并对使用 RSC 也感到好奇的人,分享我们的观点会很有用。

但首先,简单介绍一下背景:什么是 React 服务端组件?

痴迷于用户体验

在 Remix,我们绝对痴迷于用户体验(UX)。我们密切关注的一个主要方面是浏览器中的网络选项卡。如果网络选项卡一团糟,用户体验可能也会一团糟:弹跳的微调器、加载时间缓慢等等。如果网络选项卡干净,您的用户体验很可能快速且响应迅速。您的应用程序加载数据的方式会影响网络选项卡的形状。

在当今的 React 生态系统中,有三种方法可以将数据加载到您的应用程序中

  1. 渲染-获取瀑布(又名“边渲染边获取”):这指的是在组件内部,从浏览器中,在加载和渲染 JavaScript 包之后获取数据。我们称之为“瀑布”,因为在加载一个包、渲染并启动数据获取后,它会渲染执行相同操作的子组件。加载模块 → 渲染(微调器)→ 获取 → 渲染子组件(更多微调器)→ 在子组件中获取 → 等等。每次渲染和显示微调器都是瀑布中的另一步。

    如果您还没有这样做,请向下滚动我们的主页,看看这种加载数据的方法如何影响 UI。它通过将这些资源与 UI 层次结构耦合来创建人为的数据和模块层次结构。在渲染之前,您不知道要获取什么,而且在获取父级数据之前,您无法渲染!这往往会在 UI 中产生“卡顿”,并导致 累积布局偏移 (CLS),因为子视图在父视图已经渲染后会弹出到页面中。

  2. 获取,然后渲染:在渲染页面之前,获取所有数据,然后一次渲染整个页面。这是 Remix 中的默认行为。这也是大多数网站几十年来的工作方式。由于嵌套路由,Remix 仅从 URL 就知道页面的所有依赖项(JS 模块、数据,甚至 CSS),因此它可以并行运行所有查询并加载资源。当您认为用户将要访问页面时,它甚至可以预取这些资源。您将在本文中看到,这对初始页面加载和后续导航有积极影响。

  3. 边获取边渲染:与获取,然后渲染类似,您可以并行启动所有加载,但您不会等待所有资源。相反,您会在准备好时渲染准备好的任何部分。除非您已经能够获取,然后渲染,否则这是不可能的。这是一个优化,可以尽快向用户提供有用的东西(而不是一个空的 div!)。

当今 React 生态系统中的几乎每个应用程序都使用渲染-获取瀑布。这是在 useEffect() 钩子内运行的任何数据获取的默认行为,包括 react-queryuseSWR、Apollo Client 和许多其他库。

开箱即用,React 服务端组件是一个渲染-获取瀑布。由于获取是在组件内部完成的,因此您的应用程序在组件渲染之前不知道要获取什么。

问题是,在这三者中,渲染-获取瀑布提供最差的用户体验。让我们运行一些测试,看看为什么。

React 团队的演示

我从 Facebook 的核心 React 团队中获取了 React 服务端组件演示,将代码重新排列到 Remix 的路由约定中,然后将两个版本都部署到澳大利亚的服务器,以便我们可以在美国真正感受到它(我现在非常想吃叻沙王。如果你知道,你就懂了)。

Remix 版本没有使用 React 服务端组件,它只是 React 17 上的普通 Remix。我的目标是看看我从每个版本中获得的开箱即用的性能,以及 Remix 从 RSC 中可以获益的地方。

虽然 RSC 仍然只是一个实验,这只是一个玩具应用程序,但我真的惊讶地发现Remix 在初始页面加载时速度是 RSC 的两倍多。(我上次在悉尼时应该吃两倍的叻沙王。)

如果您查看网络选项卡,您可以看到 Remix 如何并行加载资源,而 RSC 会导致级联的请求瀑布。获取代码,渲染,获取服务端组件,渲染。这个 UI 也没有任何嵌套,这就是为什么我如此惊讶地看到 Remix 的性能如此优于 RSC。加载嵌套的 UI 是 Remix 真正优于其他替代方案的地方。

但是,我也认识到 React 团队的演示并没有真正利用 React 服务端组件的杀手级功能,即在初始服务器渲染期间流式传输响应的能力。如果没有流式渲染,RSC 实际上只是另一种在组件内部获取数据的方式。

SSR 流式传输,Next.js 演示

我决定针对也包含流式服务器渲染的 React 服务端组件演示进行衡量。我对这个功能(我想)已经兴奋了好几年了。我抓住了 Next.js Hacker News 克隆版,并将代码重新排列到 Remix 的数据加载约定中,以查看两者并排的感觉。然后我将两个应用程序都部署到 Vercel,以便它们在同一服务器上运行。

这次我完全预期 Remix 会输。

再次,即使没有 HTTP 缓存(真正的 HN 中包含用户数据,因此行不通),Remix 的速度也是 RSC 和 Next.js 的两倍多。此外,Remix 版本没有显示任何微调器,也没有任何内容布局偏移。

而且,这里也没有嵌套的 UI,但 Remix 的加载速度仍然比 Next.js + RSC + SSR 流式传输快 2 倍(使用陈旧时重新验证缓存时快 5 倍)。

Remix 可以充分利用 RSC

RSC 的构建策略是边获取边渲染。RSC 本身不足以边获取边渲染。它需要一个位于其上方的框架才能在渲染之前启动并行加载资源。这两个演示都没有任何嵌套,这在嵌套中变得至关重要。

Facebook 有 Relay,一个奇特的编译器、后端基础设施和多个工程师团队,他们的收入远高于您和我,他们知道在渲染之前要获取什么。

但您有 Remix 🤗

在当今的环境下,Remix 拥有独特的优势,可以充分利用 Suspense、RSC 和 SSR 流式传输:它仅从 URL 就已经了解页面的所有信息,这正是 React 边获取边渲染所需要的。

此外,Remix 已经具有协同定位您的服务器和客户端代码的好处,包括 {name}.client.js{name}.server.js 文件约定(通常不需要,但它会提示编译器哪些文件应该仅在一个地方运行)。

对于开发人员体验,Remix 路由模块已经是“服务端组件”。使用 RSC 只是 Remix 本身的一个实现细节。

当 RSC 准备好在 Remix 中采用时,迁移可能就像重命名您的路由文件一样简单

git mv routes/posts.tsx routes/posts.server.tsx

但是,我们将等待 RSC 稳定下来,并且没有我们在这里看到的性能和用户体验问题,然后再将其集成到 Remix 中。

真正的测试将是 Remix + RSC vs 仅 Remix。如果 Remix + RSC 提供更好的用户体验,我们将全力以赴。但是,当我们已经以 2 倍或更高的速度击败当前的演示时,很难证明投入这种努力是合理的。

再次,这些都是虚假应用程序的演示,因此我们不会太认真对待它们。但是,我们确实对 RSC 在网络选项卡上进行的权衡存在一个相当大的担忧。

零捆绑包,还是无限捆绑包?

似乎当今 Web 开发的时代精神是对初始页面加载和首字节时间(TTFB)的痴迷。但是,这里有一个同样重要的问题:在用户进入页面后会发生什么?

只痴迷于 TTFB 就像试图通过锻炼来增强体魄,但忽略了您的饮食(哎呀,这就是我做的!...但是,叻沙王听起来真不错)。

关上您的 M1X MacBook Pro,从学校拿起我孩子的 Chromebook,拔掉 CAT-6 电缆,然后跳上我岳父母的 WiFi。您会看到您的网站展现出完全不同的一面。

在低功耗设备上通过不稳定的网络花费一个小时阅读状态更新、更新记录、创建帖子和发送消息的用户体验,与 Remix 上任何网络上的任何设备上的初始页面加载一样重要。那么这与 RSC 有什么关系呢?

我“快速浏览”了 React 团队的演示,以查看应用程序显示每个页面需要多长时间。这是一个愚蠢的指标,但是当我使用一个缓慢的网站时,那种迟缓的感觉会随着时间的推移而加剧,并给我一种“这个网站不是那么好”的总体感觉。我认为这捕捉到了这种感觉。

正如您在此演示中看到的,RSC 版本通过网络加载的 JavaScript 比 Remix 版本多 34 倍(!)。

不,使用预先加载的浏览器缓存时,它是 16 倍

哦,对了。16 倍的 JavaScript?这到底是怎么回事?

除了流式渲染外,React 服务端组件的另一个重要功能是“零捆绑”。其想法是在初始页面加载时发送更少的数据以加快速度(我们之前看到在此演示中没有)。其想法是

  1. 浏览器永远不需要加载包含渲染服务端组件的模板的 JavaScript 包
  2. 它还消除了对典型的 React SSR 内联水合 <script> 的需要,该 <script> 中充满了已经在标记中重复的 JSON(在此站点上打开开发者工具,您会注意到此帖子在标记和底部的内联脚本标记中重复 ... 此外,所有这些错误都是 youtube 的,而不是我们的 😰)

这表面上听起来很棒,但现在每次用户与网站互动时,模板都会在服务器组件的有效负载中重复。换句话说,每次您从服务器获取数据时,您都会获得完全渲染的标记,而不仅仅是数据。

在服务器组件演示中单击单个项目会导致为此(请注意:一个“服务器组件”)进行提取。

M1:{"id":22,"chunks":[2],"name":""}
M2:{"id":20,"chunks":[0],"name":""}
S3:"react.suspense"
J0:["$","div",null,{"className":"main","children":[["$","section",null,{"className":"col sidebar","children":[["$","section",null,{"className":"sidebar-header","children":[["$","img",null,{"className":"logo","src":"logo.svg","width":"22px","height":"20px","alt":"","role":"presentation"}],["$","strong",null,{"children":"React Notes"}]]}],["$","section",null,{"className":"sidebar-menu","role":"menubar","children":[["$","@1",null,{}],["$","@2",null,{"noteId":null,"children":"New"}]]}],["$","nav",null,{"children":["$","$3",null,{"fallback":["$","div",null,{"children":["$","ul",null,{"className":"notes-list skeleton-container","children":[["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}]]}]}],"children":"@4"}]}]]}],["$","section","3",{"className":"col note-viewer","children":["$","$3",null,{"fallback":["$","div",null,{"className":"note skeleton-container","role":"progressbar","aria-busy":"true","children":[["$","div",null,{"className":"note-header","children":[["$","div",null,{"className":"note-title skeleton","style":{"height":"3rem","width":"65%","marginInline":"12px 1em"}}],["$","div",null,{"className":"skeleton skeleton--button","style":{"width":"8em","height":"2.5em"}}]]}],["$","div",null,{"className":"note-preview","children":[["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}]]}]]}],"children":"@5"}]}]]}]
M6:{"id":21,"chunks":[3],"name":""}
J4:["$","ul",null,{"className":"notes-list","children":[["$","li","1",{"children":["$","@6",null,{"id":1,"title":"Meeting Notes","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"This is an example note. It contains Markdown!"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Meeting Notes"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","2",{"children":["$","@6",null,{"id":2,"title":"A note with a very long title because sometimes you need more words","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"You can write all kinds of amazing notes in this app! These note live on the server in the notes..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"A note with a very long title because sometimes you need more words"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","3",{"children":["$","@6",null,{"id":3,"title":"I wrote this note toda","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It was an excellent note."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"I wrote this note toda"}],["$","small",null,{"children":"5:59 PM"}]]}]}]}]]}]
J5:["$","div",null,{"className":"note","children":[["$","div",null,{"className":"note-header","children":[["$","h1",null,{"className":"note-title","children":"I wrote this note toda"}],["$","div",null,{"className":"note-menu","role":"menubar","children":[["$","small",null,{"className":"note-updated-at","role":"status","children":["Last updated on ","3 Dec 2021 at 5:59 PM"]}],["$","@2",null,{"noteId":3,"children":"Edit"}]]}]]}],["$","div",null,{"className":"note-preview","children":["$","div",null,{"className":"text-with-markdown","dangerouslySetInnerHTML":{"__html":"<p>It was an excellent note.</p>\n"}}]}]]}]

那超过 4 kB。如果您单击所有三个项目,您会得到每个项目类似的响应。

让我们与 Remix 进行对比。第一次单击链接时,您必须下载项目视图的代码拆分 JavaScript 模板

import{a as i,c as a,d as p}from"/build/_shared/chunk-CDZR6LSD.js";import{a as m}from"/build/_shared/chunk-DQ7ZO7ZN.js";import"/build/_shared/chunk-XXRJHXMM.js";import{i as d}from"/build/_shared/chunk-2FSL4QX2.js";import{b as l,e as t,f as e}from"/build/_shared/chunk-AKSB5QXU.js";e();e();e();var f=l(p());function r(){let{id:n,title:s,body:u,updatedAt:o}=d();return o=new Date(o),t.createElement("div",{className:"note"},t.createElement("div",{className:"note-header"},t.createElement("h1",{className:"note-title"},s),t.createElement("div",{className:"note-menu",role:"menubar"},t.createElement("small",{className:"note-updated-at",role:"status"},"Last updated on ",i(o,"d MMM yyyy 'at' h:mm bb")),t.createElement(a,{noteId:n},"Edit"))),t.createElement(m,{body:u}))}export{r as default};

import{a as d}from"/build/_shared/chunk-XXRJHXMM.js";import{b as i,e as t,f as e}from"/build/_shared/chunk-AKSB5QXU.js";e();e();var n=i(d());function o({text:r}){return t.createElement("div",{className:"text-with-markdown",dangerouslySetInnerHTML:{__html:(0,n.default)(r)}})}function a({body:r}){return t.createElement("div",{className:"note-preview"},t.createElement(o,{text:r}))}export{a};

但从现在开始,每次点击项目只会传输这个小家伙

{"id": 1, "createdAt": "2020-12-30T10:13:29.023Z", "updatedAt":
"2020-12-30T10:13:29.023Z", "title": "Meeting Notes", "body": "This is an
example note. It contains **Markdown**!"}

因为服务器组件将您的数据与模板耦合在一起,您的用户每次与该组件相关的交互都必须下载模板。虽然对于您的 JavaScript 来说是“零捆绑”,但对于后续导航来说却是“无限捆绑” 😟。

当然,这是一个小玩具演示应用程序,在现实世界中情况总是会有所不同,但从逻辑上讲,这些模板总是比数据大。以我的经验,每一盎司数据都有一磅标记(天哪,我现在真想来一磅叻沙)。

我们的看法

我花了好几天摆弄这两个演示:在高速公路上用手机加载它们(当然不是在开车,但希望我能开车去叻沙王),在手机信号不好的小山上等着我的孩子放学,甚至在一个几乎没有手机信号的教堂里。

在所有情况下,无一例外,Remix 都比 React 服务器组件更快。而且快得多。

我不清楚 RSC 是为哪种网络、设备和服务器条件而构建的。从 Remix 发送整个文档总是比从 RSC 发送的第一个块快:无论网络速度快还是慢。

这并不是说 RSC “不好”。它仍然处于实验阶段!我只是说它们目前对 Remix 没有吸引力。当 RSC 稳定后,我们将应用我们在此处展示的相同严格测试,如果它们能提供更好的用户体验,我们将向 Remix 用户推荐它们。

我的直觉是,当用户的网络速度快但服务器的数据加载速度慢时,RSC 有机会提供更好的用户体验。这将是我的下一个研究方向(这些演示都具有快速的服务器数据加载速度)。我期望第一个块比您的慢速服务器通过 Remix 将完整文档发送给用户更快地对用户有用。

但如果问题出在您的慢速服务器上,您可以解决这个问题。您可以让您的服务器速度加快,但您无法对用户的网络做任何事情。而这正是 Remix 的优势,充分利用现代基础设施,减少网络传输的数据量。

后端基础设施正在变得非常好。Remix 可以在边缘(靠近您的用户)运行您的整个应用程序,例如在 Cloudflare Workers(请参阅我们的演示)和(即将推出)Deno Deploy 等平台上。您不仅可以在边缘运行您的应用程序服务器,还可以通过诸如 Fly.io Postgres 读取副本Cloudflare KVDurable ObjectsFaunaDB 等技术将您的数据也推送到边缘。这些技术使您可以在短短几毫秒内渲染完整页面——即使包含用户数据!

如果您可以使用 Remix 在 500 毫秒甚至 50 毫秒内渲染包含用户数据的完整文档,您可能会问自己,为什么要使用四处弹跳的加载指示器来流式传输该文档(即使它今天快了两倍而不是慢了两倍)。

我们很想听听 React 团队本周在 React Conf 上关于 React 服务器组件和流式渲染的看法。目前,我们很高兴能够向核心团队提供我们的反馈,并希望我们的研究能够推动这项技术向前发展。请继续关注我们在这个领域的更多消息!

实时演示和源代码

Hacker News 演示

Hacker News 源代码

Notes App 演示

Notes App 源代码


获取最新的 Remix 新闻

率先了解新的 Remix 功能、社区活动和教程。