译:React 服务器组件:好的、坏的和丑的

原文:https://www.mayank.co/blog/react-server-components/
作者:Mayank
译者:ChatGPT 4 Turbo

编者注:一些收获,1)好的,包括服务端数据逻辑可以和 UI 渲染写在一起,特定场景下由于不需要在客户端下载 js 从而减少产物尺寸,Server Action 带来的类 RPC 调用体验,2)坏的,包括由于 server 组件和 client 组件拆分导致本应在一起的代码需要被分开,以及由于 client 组件的传递性导致大量 client 组件的存在从而让产物尺寸减少的效果大打折扣,3)丑的,Next.js 「扩展」了 fetch 接口并不让访问和设置 request、headers 等,4)更丑的,包尺寸变大,RSC 最大的卖点是减少包大小,但如果这个效果大打折扣,那么应用 RSC 的意义在哪?以及数据重复问题,HTML 和 RSC Payload 是重复的,4)我的想法是,RSC 是把双刃剑,解了一些问题,但由于其非凡的学习、部署、可能挖坑等成本,应用到业务中得小心其带来的技术债。

React Server Components 为 React 带来了服务器独有的功能。我一直在 Next.js 13 和 14 中使用这一新范式,以下是我对它的诚实评价 1

我曾经考虑过不发表这篇文章,因为 React 社区在历史上处理批评的方式。直到最近,我才决定分享我的想法是很重要的,特别是在看到现有的批评要么没有得到很好的记录,要么源于不熟悉之后。

我之所以这样写,是因为我非常关心用户体验。我也在乎开发者体验,但用户始终是第一位的。

快速复习

我本可以直接开始,但我想先确保我们所有人对此有共识,因为关于 React 服务器组件和 React 本身有很多误解。

直到最近,React 可以被描述为一个 UI 渲染框架,它让你以 JavaScript 函数的形式编写可复用、可组合的组件。

  • 这些函数只是返回一些标记,并且可以在服务器和客户端上运行。
  • 在客户端(浏览器)上,这些函数可以“hydrate”服务器接收到的 HTML。这个过程是 React 在现有标记上附加事件处理程序并运行初始化逻辑的地方,让你可以为交互性“hook”进任意的 JavaScript 代码。

React 经常与服务器框架2(如 Next.jsRemixExpressFastify)一起使用,该框架控制 HTTP 请求/响应生命周期。这个框架为管理三个重要事项提供了一个便利的地方:

  1. 路由: 定义哪些标记与哪些 URL 路径关联。
  2. 数据获取: 在“渲染”开始之前运行的任何逻辑。这包括从数据库读取、进行 API 调用、用户认证等。
  3. 突变: 在初始加载后处理用户发起的操作。这包括处理表单提交,暴露 API 端点等。

快进到今天,React 现在能够对这些部分中的每一个进行更多控制。它不再仅仅是一个 UI 渲染框架。它也在某种程度上成为了服务器框架应该如何暴露这些重要的服务器端特性的蓝图。

这些新功能首次推出是在三年多前,现在终于在 React 的“金丝雀”版本中发布,这个版本被认为是主要用于 Next.js 应用路由器的“稳定”版本。

Next.js 作为一个完整的元框架,还包括了打包、中间件、静态生成等附加功能。未来,更多的元框架将会整合 React 的新特性,但这需要一些时间,因为它需要在打包器级别进行紧密集成。

React 的旧特性已被重命名为客户端组件,通过在服务器与客户端的边界处添加 "use client" 指令,它们可以与新的服务器特性一起使用。是的,这个名称有点令人困惑,因为这些客户端组件可以添加客户端交互性,并且也可以在服务器上预渲染(与之前相同)。

都跟上了吗?我们开始吧!

好的

首先,这很酷:

export default async function Page() {
  const stuff = await fetch(/* … */);
  return <div>{stuff}</div>;
}

服务器端数据获取和 UI 渲染在同一个地方真是太棒了!

但这并不一定是新事物。自 2022 年以来,同样的代码在 Preact(通过 Fresh)中就已经可以工作了。

即使在传统的 React 中,也一直可以在服务器上获取数据并使用这些数据渲染一些 UI,所有这些都作为同一个请求的一部分。下面的代码为了简洁而简化;你通常会想要使用你的框架指定的数据获取方法,比如 Remix loaderAstro frontmatter

const stuff = await fetch(/* … */);
ReactDOM.renderToString(<div>{stuff}</div>);

在 Next.js 中,这种操作过去只能在路由级别进行,这在大多数情况下是可以的,甚至是更好的。而现在,React 组件可以独立地获取它们自己的数据。这种新的组件级数据获取能力确实提供了额外的组合性,但我并不喜欢它(访问你页面的终端用户也不会在意)。

如果你仔细想想,“server-only 组件”的概念本身实现起来相当直接:只在服务器上渲染 HTML,而不在客户端进行 hydrate。这就是像 Astro 和 Fresh 这样的岛屿架构框架背后的全部前提,其中默认一切都是服务器组件,只有交互部分才会进行 hydrate。

与 React 服务器组件的较大不同在于其底层发生的事情。服务器组件会被转换成一种中间可序列化的格式,这种格式既可以预渲染成 HTML(与之前相同),也可以通过网络发送到客户端进行渲染(这是新的!)。

但是等等……HTML 不是可以序列化的吗,为什么不直接通过网络发送呢?是的,当然,这正是我们一直在做的。但是这个额外的步骤为我们打开了一些有趣的可能性:

  • 服务器组件可以作为属性传递给客户端组件。
  • React 可以在不丢失客户端状态的情况下重新验证服务器 HTML。

在某种程度上,这就像是岛屿架构的反面,其中“静态”的 HTML 部分可以被视为大部分是交互式组件的海洋中的服务器岛屿

略显牵强的例子:你想要显示一个使用华丽的库来格式化时间戳。使用服务器组件,你可以:

  1. 在服务器上格式化这个时间戳,而不用在客户端包中添加繁琐的库。
  2. (一段时间后)在服务器上重新验证这个时间戳,并让 React 在客户端完全重新渲染显示的字符串。

之前,要获得类似的结果,你可能需要 innerHTML 一个服务器生成的字符串,这并不总是可行的,甚至不总是明智的。所以这无疑是一个进步。

你现在可以从服务器检索整个组件树(包括初始加载和未来更新),而不仅仅是将服务器视为一个简单的数据检索点。这种方式更高效,并且为开发者和用户带来了更好的体验。

接近好的

通过 Server Action,React 现在有了一种官方的类 RPC 方式来响应用户交互(“变更”)执行服务器端代码。并且它逐步增强了内置的 HTML <form> 元素,使其即使在没有 JavaScript 的情况下也能工作。酷!👍

<form
  action={async (formData) => {
    "use server";
    const email = formData.get("email");
    await db.emails.insert({ email });
  }}
>
  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" />
  <button>Send me spam</button>
</form>

我们将忽略 React 重载了内置的 action 属性并将默认的 method 从 “GET” 改为 “POST” 的事实。我不喜欢这样,但无所谓了。

我们还将略过奇怪命名的 "use server" 指令,即使该操作已在服务器组件中定义,也需要此指令。将其命名为 "use endpoint" 或许更合适,因为它基本上是 API 端点的语法糖。但不管怎样,我个人真的不在乎它甚至被称为 "use potato" 。🤷

上面的例子几乎已经完美了。所有内容都集中在一起,感觉优雅,并且在没有 JavaScript 的情况下也能工作。即使大部分业务逻辑存在于一个独立的地方,集中放置特别好,因为表单数据对象依赖于表单字段的 name

最重要的是,它避免了手动连接这些部分的需要(这将涉及一些用于发起 fetch 请求到一个端点并处理其响应的糟糕的意大利面条代码),或依赖于第三方库。

在之前的草稿中,我把所有这些内容都写在“优点”部分,因为它确实比传统方法有了很大的改进。然而,当你想要处理一些高级情况时,这很快就开始变得烦人了。

坏的

假设你想要逐步增强你的表单,以便在服务器操作处理时,通过禁用按钮来防止意外的重复提交。

你需要将按钮移动到另一个文件中,因为它使用了 useFormStatus (一个客户端钩子)。有点烦人,但至少表单的其他部分仍然保持不变。

"use client";
export default function SubmitButton({ children }) {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{children}</button>;
}

现在假设你还想处理错误。大多数表单至少需要一些基本的错误处理。在这个例子中,如果电子邮件无效或被禁止之类的情况,你可能想显示一个错误。

要使用服务器操作返回的错误值,你需要引入 useFormState (另一个客户端钩子),这意味着表单需要移动到客户端组件中,且操作需要移动到一个单独的文件中。

"use server";
export default async function saveEmailAction(_, formData) {
  const email = formData.get("email");
  if (!isEmailValid(email)) return { error: "Bad email" };
  await db.emails.insert({ email });
}
"use client";
const [formState, formAction] = useFormState(saveEmailAction);
<form action={formAction}>
  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" aria-describedby="error" />
  <SubmitButton>Send me spam</SubmitButton>
  <p id="error">{formState?.error}</p>
</form>

令人困惑的是,尽管这现在是在客户端组件中,表单即使没有 JavaScript 也依然可以工作!👍

然而:

  • 👎 紧密相关的代码不再位于同一位置。无论如何,操作需要一个 "use server" 指令,那为什么不允许在客户端组件的同一个文件中定义它呢?
  • 👎 action 的签名突然改变了。为什么不保持表单数据对象作为第一个参数呢?
  • 👎 让这个功能在没有 JavaScript 的情况下工作,我稍微摆弄了一下,因为官方文档(最近几次有所更改)展示了一个错误的例子。这里的关键洞见是将服务器动作直接传递给 useFormState ,并将其返回的动作直接传递给表单的 action 属性。如果你在任何时候创建了任何包装函数,那么它将不再能够在没有 JavaScript 的情况下工作。一个好的 lint 规则可能有助于避免这个错误。

随着应用程序变得更加复杂, "use client" 事情也开始变得难以处理。可以交错服务器和客户端组件,但这需要你将服务器组件作为 props 传递,而不是从客户端组件中导入它们。这在最顶层的前几个级别可能还是可以管理的,但实际上,当在树的更深层次时,你主要还是依赖客户端组件。这只是编写代码的自然和方便的方式。

让我们重新审视上面的时间戳示例。如果你想在一个表格中显示时间戳,而这个表格恰好是嵌套在多个层级的其他客户端组件中的客户端组件呢?你可以尝试进行一些严肃的属性传递,或者在最近的服务器-客户端边界处将服务器组件存储在全局存储(或上下文)中。但现实情况是,你可能会继续使用客户端组件,并承担将 date-fns 发送到浏览器的成本。

在一定深度后无法使用异步组件可能并不是一件坏事。你仍然可以合理地构建你的应用程序,因为数据获取应该只在路由级别或其附近发生。在岛屿框架中也存在类似的限制,即它们不允许在岛屿内导入静态/服务器组件。尽管如此,这仍然令人失望,因为 React 花了 3 年多的时间提出了最复杂的解决方案,一直承诺服务器和客户端组件将无缝互操作。

这个限制可能不明显的是,它有一些严重的含义。在客户端组件内部,它的所有依赖(以及依赖的依赖等等)也都是客户端的一部分。这种情况很快就会层层传递下去。大量组件并不使用专属于服务器或客户端的功能,它们应该留在服务器上。但是,因为它们被导入到其他客户端组件中,它们最终会出现在客户端包中。如果这些组件自己没有使用 "use client" 指令,你甚至可能没有意识到这一点。为了保持客户端代码的精简,你必须有意识并且格外警觉,因为做“错误”的事情更容易。这就像是从失败的深渊中爬出来。

丑的

出于某种被遗弃的神秘原因,Next.js 决定在服务器组件中“扩展”内置的 fetch API。他们本可以暴露一个包装函数,但我猜那样似乎太合理了。

而且我说的“扩展”不仅仅是指给它增加额外的选项。他们真的改变了 fetch 的工作方式!所有请求默认都会被积极缓存。除非你在访问 cookies,那样可能就不会被缓存。这是一团混乱、偶然的糟糕局面,几乎毫无意义。而且你可能直到部署到生产环境之前都没有意识到哪些被缓存了,哪些没有,因为本地开发服务器的行为不同。

让情况更糟的是,Next.js 不允许你访问request 对象。我甚至无法用言语来表达他们将这个隐藏起来有多荒谬。

你也不能在中间件之外设置头部、cookies、状态码、重定向等。

  • 这是因为 App Router 是围绕流处理构建的,一旦流处理开始后就太晚了,无法修改响应。但是,为什么不允许更多地控制流处理的开始时间呢?
  • 中间件只能在 edge 运行,这对许多场景来说限制太大。为什么不允许在开始流式传输之前,让中间件在 Node 运行时运行呢?

在旧的 Next.js Pages 路由器中,这些问题都不存在(中间件运行时限制除外)。路由的行为是可预测的,并且“静态”和“动态”数据之间有一个清晰的区分。你可以访问请求信息,并且可以修改响应。你拥有更多的控制权!这并不是说 Pages 路由器没有它自己的怪异之处,但它工作得很好。

注意: 我选择忽略现今 Next.js App 路由器中存在的几个错误(“稳定”并不意味着“无错误”)。我也不会涉及任何尚未发布的实验性 API,因为它们毕竟是实验性的。结合任何错误修复和新的(更新的?)API 的影响,六个月后的体验可能会感觉不那么令人沮丧。如果发生这种情况,我将更新这一部分。

更丑的

到目前为止我提到的一切在不同程度上都是可以忍受的……如果捆绑包的大小能变小的话。

实际上,包正在变得更大。

两年前,Next.js 12(带有 Pages 路由器)的基线捆绑包大小为压缩后约 70KB。今天,Next.js 14(带有 App 路由器)的起始基线为 85-90KB3。解压后,浏览器需要解析和执行近 300KB 的 JavaScript,仅仅是为了渲染一个“hello world”页面。

要重申,这是你的用户不管网站大小都需要支付的最低成本。并发特性选择性融合可以帮助优先处理用户事件,但对于这个基线成本没有帮助。它们可能甚至还因为存在而增加了这个成本。缓存在某些情况下可以避免重新下载的成本4,但浏览器仍然需要解析和执行所有这些代码。

如果这听起来不是什么大问题,请考虑 JavaScript 可能(并且确实)会有很多种失败的方式。还要记住,真实世界存在于你那高级的 MacBook Pro 和千兆互联网之外;你的大多数用户可能是在一个远不如此强大的设备上访问你的网站。

为什么这些对这篇文章很重要?因为减少包大小被吹捧为 React 服务器组件的主要动机之一。

当然,服务器组件本身不会向客户端包添加任何“更多”的 JavaScript,但基础包仍然存在。现在基础包还需要包含代码来处理服务器组件如何与客户端组件配合。

那么还有数据重复问题5。记住,服务器组件不会直接渲染成 HTML;它们首先被转换成 HTML 的中间表示(称为“RSC Payload”)。所以,尽管它们将在服务器上预渲染并作为 HTML 发送,中间 payload 仍然也会一同发送。

在实践中,这意味着你的整个 HTML 将在页面末尾的 script 标签内被复制一遍。页面越大,这些 script 标签就越大。你所有的 tailwind 类?哦,它们全都被复制了。服务器组件可能不会向客户端包添加更多代码,但它们会继续增加这个负载。这并不是没有代价的。用户的设备将需要下载更大的文档(尽管有压缩和流式传输,问题会小一些,但仍然存在)并且还会消耗更多内存。

显然,这个 payload 有助于加速客户端跳转,但我不相信这是一个足够强的理由。许多其他框架已经仅使用 HTML 实现了同样的事情(参见 Fresh Partials)。更重要的是,我不同意客户端跳转的基本前提。网络上的绝大多数导航应该使用普通链接来完成,这些链接工作更可靠,不会丢弃浏览器优化(BFCache),不会引起可访问性问题,并且可以表现得同样好(通过预取)。使用客户端导航是一个应该在每个链接的基础上经过深思熟虑的决定。围绕客户端导航构建整个范式感觉是错误的。

结束语

React 正在向 React 世界引入一些非常需要的服务器原语。这些能力中的许多并不一定是新的,但现在有了一种共享的语言和一种惯用的服务器操作方式,这是一个净正面的发展。我对新的 API 持谨慎乐观的态度,尽管它们有缺点。我很高兴看到 React 拥抱服务器优先的心态。

与此同时,React 除了在 2019 年放弃了一个实验外,没有做任何事情来改善它们可悲的客户端情况。它是一个为解决 Facebook 级别的问题而创建的遗留框架,因此对于大多数用例来说都不合适。进入 2024 年,以下是 React 尚未解决的许多问题:

  • 客户端包因包含了不必要的“功能”,如合成事件系统,而变得臃肿。
  • 内置状态管理对于深层次的树结构来说效率极低,导致大多数应用程序采用第三方状态管理器
  • 广泛可用的浏览器 API,如自定义元素和模板,要么不被完全支持,要么根本不工作
  • 较新的 HTML API(例如 inertpopover 属性)没有变通方法就无法开箱即用
  • 组件内编写 CSS 没有惯用方法,而像隐式 @scope 这样的新式样式 API 并不像你期望的那样工作。
  • 需要频繁编写大量不必要且可避免的样板代码(例如,forwardRef)。
  • 没有可用的 ESM 构建,也无法摇树优化未使用的功能(如类组件)。
  • useEffect 。我们不会谈论这个。

这些不是“未解决”的问题;这些是由 React 设计方式直接导致的虚构问题。在一个充满现代框架(Svelte、Solid、Preact、Qwik、Vue、Marko)的世界里,这些框架大多数没有这些问题,React 实际上成了技术债务

我认为,给 React 添加服务器功能远没有解决其现有的诸多问题重要。没有 React 服务器组件,也有很多编写服务器端逻辑的方法,但如果不完全替换 React,就无法避免它在客户端造成的可怕混乱7

也许你对我所举的任何问题都不感到担心,或者你认为那是沉没成本,继续你的日常。希望你至少能认识到,React 和 Next.js 还有很的路要走。

我确实理解开源项目没有义务解决其他人的问题,但 React 和 Next.js 都是由/为大公司构建的(这是它们在市场营销中都会使用的点),所以我认为所有的批评都是有根据的。

作为最后的说明,我只想强调,目前很难在 React 和 Next.js 之间划清界限。在一个更尊重标准的框架(如 Remix)中,一些(或许多)新的 API 可能看起来和感觉都不同。一旦发生这种情况,我会确保发布更新。


1: 我今天只涉及纯技术性的内容。一个真正诚实的全面评估还应该包括道德、文化和政治方面的考量。不过,让我们把这些留到另一天再讨论吧;这篇博客文章已经够长了。 ↩

2: 我打算假装忘记那段开发者们对单页应用程序进行客户端渲染的时期。当 React 已经支持服务器端渲染整整十年时,这样做是非常荒谬的。当然,这在很大程度上是 React 自己的过错,因为它们在文档中长时间推广了庞大的 Create-React-App 抽象层。 ↩

3: 相比之下,Remix 的基线大约为 ~70KB,Nuxt 为 ~60KB,SvelteKit 为 ~30KB,而 Fresh 为 ~10KB。当然,包的大小并非全部,有些框架每个组件的成本较高,可能会在足够大的页面上达到一个“拐点”。↩

4: 为了使缓存有效,框架的基础包需要被拆分成一个独立的块,这样它就可以独立于应用程序代码(更频繁地变化)进行指纹识别。这种技术还假设框架代码将保持稳定,但目前并非如此。React 和 Next.js 都在积极开发中,你可能会想要定期升级它们,以便利用一些修复和改进。还有一个事实是 Next.js 抽象了打包器,所以你对它的手动控制较少。 ↩

5: 数据重复并不是一个新问题。它是编写同构 JavaScript 组件的自然结果,这些组件在服务器上进行预渲染,然后也被发送到客户端进行注水。Ryan Carniato 有一篇关于高效注水挑战的优秀文章,我强烈推荐阅读。↩

6: 我一再提到 Preact,因为它确实非常令人印象深刻。它活生生地证明了你可以保持 React 模型的完整性,而不会被任何额外的琐碎事物所拖累。他们甚至设法对类组件进行了摇树优化!最近,他们也开始从 React 分化,以避免 React 状态的纸上谈兵,并且方式相当优美。Preact 目前缺少的一个大功能(在 React 中存在的)是流处理能力,但他们也在努力解决这个问题! ↩

7: 在早期版本的 Next.js 中,由于 preact/compat 的原因,实际上有可能替换 React。但这是在 React 和 Next.js 因并发特性等变得更加复杂之前的事情。曾经也有尝试让 Preact 在 Remix 中工作的努力,但这个目标现在已经不再追求了。↩