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

React 服务器组件和 Remix

Ryan Florence
联合创始人

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

React 服务器组件怎么样?

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

现在 React 服务器组件 (RSC) 似乎越来越受关注,我们再次进行了一些测试,试图找出在 Remix 中利用它们的最佳方法。我们非常清楚 RSC 仍处于实验阶段,因此我们不打算将此研究作为我们关于 RSC 和 Remix 的最后一句话。我们只是认为分享我们的观点对所有使用 Remix(和 React Router)并对使用 RSC 感兴趣的人来说很有用。

但首先,一些背景信息:什么是 React 服务器组件?

痴迷于 UX

在 Remix 中,我们绝对痴迷于用户体验 (UX)。我们密切关注的一件事是浏览器中的网络选项卡。如果网络选项卡很乱,那么 UX 可能也很乱:跳动的加载器、加载时间过长等等。如果网络选项卡很干净,那么您的 UX 可能很敏捷且响应迅速。您的应用程序加载数据的方式会影响网络选项卡的形状。

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

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

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

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

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

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

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

问题是,在三种方法中,渲染-获取瀑布提供了最糟糕的 UX。让我们运行一些测试,看看原因。

React 团队的演示

我从 Facebook 的核心 React 团队那里获得了React 服务器组件演示,将代码整理成 Remix 的路由约定,然后将这两个版本都部署到澳大利亚的服务器上,这样我们就可以在美国真正感受到(我现在真的很想吃 Laksa King。如果你知道,你就知道)。

Remix 版本没有使用 React 服务器组件,它只是在 React 17 上的 Remix。我的目标是看看每个版本的开箱即用性能,以及 Remix 在哪里可以从 RSC 中获益。

虽然 RSC 仍然只是一个实验,而这只是一个玩具应用程序,但我真的感到惊讶的是,Remix 在初始页面加载方面比 RSC 快两倍以上。(我上次在悉尼应该吃两倍的 Laksa King。)

如果您查看网络选项卡,您会看到 Remix 如何并行加载资源,而 RSC 导致请求级联的瀑布。获取代码、渲染、获取服务器组件、渲染。这个 UI 也没有任何嵌套,这就是为什么我如此惊讶地看到 Remix 在性能方面远远超过 RSC。加载嵌套 UI 是 Remix 比其他选择真正出色的地方。

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

SSR 流式传输,Next.js 演示

我决定针对一个也包含流式传输服务器渲染的 React 服务器组件演示进行测量。我认为我已经对此功能兴奋了(我认为)几年了。我抓取了Next.js Hacker News 克隆,并将代码整理成 Remix 的数据加载约定,看看这两个版本并排运行时的感觉。然后,我将这两个应用程序都部署到 Vercel,以便它们在同一个服务器上运行。

这次我完全预期 Remix 会输。

再一次,Remix 比 RSC 和 Next.js 快两倍以上,即使没有 HTTP 缓存(真正的 HN 包含用户数据,因此这行不通)。此外,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

但是,在我们将其集成到 Remix 之前,我们将等到 RSC 稳定并且没有我们在此处看到的性能和 UX 问题。

真正的测试将是 Remix + RSC 与 Remix 本身。如果 Remix + RSC 提供更好的用户体验,我们就会使用它。但是,当我们已经比当前的演示快两倍或更多倍时,很难证明投入这种努力是合理的。

再说一次,这些都是假应用程序的演示,因此我们没有太认真对待它们。但是,我们确实对 RSC 在网络选项卡上做出的权衡感到非常担忧。

零捆绑,还是无限捆绑?

似乎当今的 Web 开发潮流是痴迷于初始页面加载和首字节时间 (TTFB)。但这里还有一个同样重要的问题:您将用户带到页面后会发生什么?

只关注 TTFB 就好像试图通过锻炼来增强体格,但却忽略了饮食(啊,糟糕,这就是我做的事情!……Laksa King 现在听起来真是太好吃了)。

合上您的 M1X MacBook Pro,从学校里拿过孩子的 Chromebook,拔掉 CAT-6 电缆,连接到岳父母家的 WiFi。你会看到你的网站会展现出完全不同的个性。

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

我对 React 团队的演示进行了“快速浏览”,看看应用程序显示每个页面需要多长时间。这是一个愚蠢的指标,但是当我使用一个缓慢的网站时,这种缓慢的感觉会随着时间的推移而加剧,让我有一种普遍的感觉,即“这个网站不是很好”。我认为这抓住了这种感觉。

如您在演示中看到的那样,RSC 版本比 Remix 版本通过网络传输的 JavaScript 多 34 倍(!)。

不是,它是 16 倍,并且浏览器缓存已启动

哦,对了。JavaScript 多了 16 倍?!这究竟是怎么回事?

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

  1. 浏览器永远不需要加载包含渲染服务器组件的模板的 JavaScript 包
  2. 它还消除了对典型的 React SSR 内联水合 <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 Server Components 更快。快很多。

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

这并不意味着 RSC “不好”。它仍然处于实验阶段!我只是说它们目前对 Remix 来说没有吸引力。当 RSC 稳定后,我们将应用相同的严格测试(如本文所示),并在它们为 Remix 用户提供更好的用户体验时向他们推荐。

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

但是,如果您的缓慢服务器是问题所在,您可以解决它。您可以让您的服务器更快,但您无法对用户的网络做任何事。这就是 Remix 的主旨:利用现代基础设施,减少网络传输的数据量。

后端基础设施正在变得**非常**好。**Remix 可以将您的整个应用程序运行在边缘**(靠近您的用户)上,例如 Cloudflare Workers(请参见我们的演示)以及即将推出的Deno Deploy。您不仅可以在边缘运行应用程序服务器,还可以使用Fly.io Postgres Read ReplicasCloudflare KVDurable ObjectsFaunaDB 等工具将数据也带到边缘。这些技术使您能够在几毫秒内呈现完整的页面,即使包含用户数据!

如果您能够使用 Remix 在 500 毫秒甚至 50 毫秒内呈现包含用户数据的完整文档,您可能会问自己为什么要使用加载动画(即使它今天是两倍快而不是两倍慢)。

我们迫切想知道 React 团队在本周的 React Conf 上会对 React Server Components 和流式渲染有何评论。目前,我们很高兴能够向核心团队提供我们的反馈,我们希望我们的研究有助于推动技术发展。请继续关注我们在这方面的更多新闻!

实时演示和源代码

Hacker News 演示

Hacker News 源代码

笔记应用程序演示

笔记应用程序源代码


获取 Remix 最新消息的更新

成为第一个了解 Remix 新功能、社区活动和教程的人。