React 服务组件:优点、缺点与不足之处 [译]

作者:

Mayank

React 服务组件 为 React 引入了专属于服务端的强大功能。我在 Next.js 13 和 14 的应用中实践了这一新范式,接下来是我对其的真实评价 1

之前我一直在犹豫是否要发表这篇文章,因为 React 社区过去对待批评的态度让我有所顾虑。但最近,我认为分享我的看法变得尤为重要,特别是在我发现大多数现有批评要么记录不充分,要么基于对此技术的不熟悉。

写下这篇文章,我是站在一个非常重视用户体验的角度。虽然我也关注开发者的体验,但我始终认为用户体验是最重要的。

快速回顾

我可以直接切入主题,但首先我想确认我们对 React 服务器组件和 React 本身的理解是一致的,因为围绕这些存在许多误解。

直到不久前,React 被视为一个UI 渲染框架,它使你能够用 JavaScript 函数来编写可复用、可组合的组件。

  • 这些函数简单地返回某些标记(markup),能够在服务器和客户端上运行。
  • 在客户端(即浏览器),这些函数可以“激活”从服务器接收的 HTML。在这个过程中,React 会在现有的标记上添加事件处理器并执行初始化逻辑,使你能够“钩入”任何自定义的 JavaScript 代码来实现交互。

React 通常与一个服务器端框架2(例如 Next.jsRemixExpressFastify)结合使用,该框架控制着 HTTP 请求和响应的生命周期。这个框架方便地处理三个关键环节:

  1. 路由:决定哪些标记与哪些 URL 路径对应。
  2. 数据获取:在“渲染”开始之前执行的所有逻辑,包括数据库读取、API 调用、用户认证等。
  3. 变更处理:处理初始加载后用户发起的操作,如处理表单提交、设置 API 端点等。

时至今日,React 已经能够更多地控制这些部分。它不再仅是一个 UI 渲染框架,而是成为了一种蓝图,指导服务器端框架如何提供这些关键的服务器端特性。

这些新特性最初是三年多前首次提出的,现在终于在 React 的一个名为“金丝雀”的版本中正式发布。这个版本被认为足够“稳定”,主要应用于Next.js App Router中。

作为一个全面的元框架(即集成了多种技术的高级框架),Next.js 还包含了如打包、中间件、静态生成等附加功能。未来,更多类似的元框架也会整合 React 的这些新特性,但这需要一段时间,因为它需要在打包工具层面进行紧密的结合。

React 之前的功能现在被称为客户端组件,它们可以通过在服务器与客户端的交界处添加"use client"指令,与新的服务器端特性一起使用。确实,这个名称有些让人迷惑,因为这些客户端组件不仅可以增强客户端的交互性,还可以像以前一样在服务器上进行预渲染

都跟上了吗?接下来,让我们一起深入了解这些内容!

优点

首先,这确实很酷:

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

将服务器端的数据获取与 UI 渲染集中在同一处,实在是太方便了!

但这其实并不是一个全新的概念。自 2022 年起,通过 Fresh,Preact 中就已经可以运行这样的代码了。

甚至在传统的 React 中,也一直可以在服务器上获取数据并利用这些数据来渲染 UI,所有这些都是作为同一个请求的一部分完成的。下方的代码为了简洁已被简化;你通常会希望使用你的框架指定的数据获取方法,例如 Remix loadersAstro frontmatter

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

在 Next.js 中,这种做法过去只能在路由级别实现,这本身就很好,甚至在大多数情况下更受欢迎。而如今,React 组件可以独立地获取它们自己的数据。这种新的组件级数据获取能力虽然增强了组合性,但我并不特别看重它(对于访问你页面的终端用户来说也是如此)。

如果你仔细思考,“仅限服务器的组件”这个概念其实非常简单:只在服务器上渲染 HTML,并且从不在客户端进行混合(hydrate)。这正是像 Astro 和 Fresh 这样的岛屿架构框架的核心理念,其中默认所有内容都是服务器组件,只有需要交互的部分才会进行混合。

React 服务器组件的一个主要区别在于底层的实现。服务器组件被转换成一种中间可序列化格式,这种格式不仅可以预先渲染成 HTML(和以前一样),而且 还可以通过网络传输到客户端进行渲染(这是新的特点!)。

但等一下……既然 HTML(超文本标记语言)是可以序列化的,为何不直接通过网络传输呢?确实,这正是我们长期以来所做的。然而,这个额外步骤为我们打开了一些新奇的可能性:

  • 我们可以将服务器组件(server components)作为属性(props)传递给客户端组件(client components)。
  • React(一个用户界面构建库)能够重新校验服务器上的 HTML,同时不丢失客户端的状态。

从某种角度来看,这与岛屿架构(islands architecture)恰恰相反,其中“静态”的 HTML 部分可以被看作是浮现在大量互动组件海洋中的服务器岛屿

举一个略显牵强的例子:假设你想展示一个使用 fancy library(一个高级库) 格式化的时间戳。通过使用服务器组件,你可以:

  1. 在服务器上格式化这个时间戳,而无需在客户端包中加入这个复杂的库。
  2. (过了一段时间之后)在服务器上重新校验这个时间戳,并让 React 完全在客户端重渲染这个显示的字符串。

在以前,为了达到类似的效果,你可能需要使用 innerHTML 方法来插入一个服务器生成的字符串,这种方式并不总是可行或明智的。因此,这无疑是一个进步。

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

几乎完美

随着 服务器动作 的引入,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" 指令。即便 action 已经在服务器组件中定义,这个指令仍是必需的。如果命名为类似于 "use endpoint" 可能会更合适,因为它实际上是对 API 端点的一种语法简化。但不管怎样,我个人不太在意它到底被称作什么,哪怕是 "use potato"。🤷

上述示例几乎达到了完美。所有元素都紧密结合在一起,既优雅又实用,而且即使没有 JavaScript 也能运行。尽管大部分的业务逻辑可能位于别处,但这种紧密结合的方式特别好,因为 表单数据对象 依赖于表单字段的 name

最重要的是,它避免了手动组合这些部分的需求(这可能涉及到一些复杂的 fetch 请求代码,用于向端点发送请求并处理响应),也不需要依赖任何第三方库。

在之前的草稿中,我本打算把所有这些内容都归入“优点”一节,因为它确实比传统方法有了很大的提升。然而,当你开始处理一些更复杂的情况时,这些改进很快就会显得有些烦人。

缺点

设想一下,你想让你的表单逐步提升性能,例如在服务器处理动作期间,为防止用户不小心重复提交,你决定让提交按钮暂时失效。

为此,你需要把按钮放到另一个文件中,因为它用到了 useFormStatus(一个客户端的 hook)。这稍微有点麻烦,但好在表单的其他部分还是保持原样。

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

再比如,你希望表单能够处理错误。大多数表单都需要基本的错误处理机制。在这个场景中,如果用户输入的电子邮件地址无效或被封禁,你可能希望展示一个错误提示。

为了处理服务器操作返回的错误信息,你得引入 useFormState(另一个客户端 hook),这就意味着你需要把表单移到一个客户端组件里,同时把相关操作挪到另一个文件中。

"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" 指令,为什么就不能允许在客户端组件的同一文件中定义它呢?
  • 👎 操作的签名突然发生了变化。为什么不继续将表单数据对象作为第一个参数呢?
  • 👎 我花了些时间才让它在没有 JavaScript 的情况下运行,因为官方文档(最近变动不小)提供的是一个有问题的示例。关键在于直接将服务器操作传给 useFormState,再将其返回的操作直接用于表单的 action 属性。如果你创建了任何封装函数,表单就无法在无 JavaScript 环境中运行。一个合理的 lint 规则应该能帮助避免这种错误。

随着你的应用变得更加复杂,使用 `"

丑陋的一面

出于某种难以理解的原因,Next.js 决定对服务器组件 (server components) 中的内置 fetch API 进行“扩展”。他们原本可以提供一个包装函数,但看起来他们选择了另一种方式。

这里所说的“扩展”,并不仅仅是增加了额外的选项。他们彻底改变了 fetch 的工作机制!默认情况下,所有的请求都会被强制缓存。但如果涉及到 cookies 的访问,情况可能就不同了。这导致了一个令人迷惑且混乱的局面,难以理解其逻辑。而且,由于本地开发服务器的行为与生产环境不同,你可能直到部署时才会发现哪些内容被缓存了。

更加令人头痛的是,Next.js 竟然不允许开发者访问 请求对象。这种做法让人难以接受,难以用言语表达它的荒谬。

中间件 外部,你也不能设置头信息、cookies、状态码、重定向等操作。

  • 这是因为 App 路由器是基于流媒体构建的,一旦开始流媒体传输,就无法修改响应。但这也引发了一个问题:为什么不在流媒体开始之前提供更多的控制权限呢?
  • 中间件只能在 边缘上运行,这在许多情况下显得过于限制。为何不允许中间件在 Node 运行时,即流媒体开始之前运行?

在旧版的 Next.js 页面路由器 中,这些问题都不存在(除了中间件的运行时限制)。路由的行为是可预测的,而且“静态”和“动态”数据之间有明确的界限。你能够访问请求信息并据此修改响应。你拥有更多的控制权!虽然页面路由器也有它的怪异之处,但总体来说,它运行得相当不错。

注意:我决定不考虑目前 Next.js 应用路由中存在的几个 bug(“稳定”并不等同于“完全没有 bug”)。我同样不打算讨论那些还未发布的实验性 API,因为它们目前仍属于实验阶段。考虑到未来可能出现的 bug 修复和新的(或许是更新的?)API,六个月后的使用体验可能会变得不再那么让人失望。如果真的有所改进,我会对这部分内容进行更新。

棘手的部分

我之前提到的所有问题,如果打包文件的体积能减小,我们或许还能在一定程度上接受。

但现实是,打包文件的体积正在增加。

两年前,Next.js 12(集成了 Pages 路由器)的基本打包体积是大约 70KB 的压缩文件。而今天,Next.js 14(搭载了 App 路由器)的起步体积已经达到了 85-90KB3。解压后,这几乎等同于 300KB 的 JavaScript,浏览器需要解析和执行这些代码,仅仅为了加载一个“hello world”页面。

再次强调,不管你的网站有多大,这是用户必须承担的最小成本。虽然并发特性选择性渲染可以优先处理用户的互动事件,但它们并不能减轻这一基础成本。事实上,它们可能因为自身的存在而增加了这部分成本。缓存在某些情况下可以避免重复下载的成本4,但浏览器仍然需要解析和运行所有这些代码。

如果你认为这不是什么大问题,请考虑 JavaScript 可能出现的众多问题。并且请记住,现实世界不仅仅局限于你手头的高配 MacBook Pro 和千兆级的网络;大多数用户可能是在性能较差的设备上访问你的网站。

那么,为什么这对本文来说很重要?因为减少打包体积被认为是 React 服务器组件的主要推动因素之一

当然,服务器组件本身不会为客户端打包增加“更多”的 JavaScript,但基础打包仍然存在。并且,现在基础打包还需要包括处理服务器组件与客户端组件融合的代码。

还有一个关于数据重复的问题 5。需要注意的是,服务端组件(Server Components)并不是直接渲染为 HTML;它们首先被转换成 HTML 的中间表现形式,即所谓的 “RSC 载荷”。这意味着,尽管它们会在服务器上预先渲染成 HTML 并发送出去,这个中间载荷还是会同时被发送。

在实际应用中,这意味着你整个 HTML 会在页面底部的 script 标签中被复制一遍。页面越大,这些 script 标签也就越庞大。所有的 Tailwind CSS 类?是的,它们都被复制了。服务端组件可能不会增加客户端代码包的体积,但它们会不断增加这个载荷。这并非无代价。用户的设备需要下载一个更大的文件(尽管压缩和流媒体技术可以在一定程度上减轻这个问题),同时也会消耗更多内存。

据说这个载荷能够加速客户端的页面跳转,但我对此表示怀疑。许多其他框架已经实现了仅使用 HTML 的类似功能(例如 Fresh Partials)。更重要的是,我并不认同依赖客户端导航的理念。在网页中,绝大多数的页面跳转应该通过传统的链接完成,这种方式更为可靠,不会放弃浏览器的优化功能(如 BFCache),也不会造成可访问性问题,而且在使用预加载技术(如 预取链接)时,表现同样出色。客户端导航的使用应该是基于每个链接的具体情况谨慎决定的。将整个系统建立在客户端导航之上,似乎是一个错误的选择。

结束语

React 正在向其生态系统中引入一些急需的服务器端基础功能。虽然其中许多功能并非首创,但如今 React 提供了一套共识的语言和一种标准化的方法来处理服务器端问题,这无疑是一大进步。尽管这些新的 APIs 存在一些不足之处,但我对它们抱有谨慎的乐观态度。看到 React 开始优先考虑服务器端,我感到十分欣慰。

然而,自 2019 年一个已放弃的实验之后,React 在改善其客户端表现方面几乎没有任何进展。它是一个为了解决 Facebook 级别的问题而诞生的传统框架,对于大多数情况来说并不太合适。展望 2024 年,React 还有很多问题需要解决:

这些问题并不是“无解”的;它们是由 React 设计理念直接造成的人为问题。在当今这个充斥着许多现代框架(如 Svelte、Solid、Preact6、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(isomorphic JavaScript)的一个自然结果,这种 JavaScript 先在服务器上运行以进行预渲染,然后再发送至客户端进行动态加载。Ryan Carniato 写了一篇关于高效动态加载的挑战的精彩文章,我极力推荐阅读。

  6. 我之所以反复提及 Preact,是因为它实在太令人印象深刻了。它生动证明了,保持 React 的模型不变,同时避免了任何额外的复杂性,是完全可行的。他们甚至成功实现了对类组件的摇树优化!最近,他们甚至开始摆脱 React,以一种非常优雅的方式规避 React 状态管理的问题。Preact 目前唯一缺少的是 React 所具有的流式处理功能,但他们也在积极开发中

  7. 在旧版 Next.js 中,替换 React 曾经是实际可行的,这多亏了preact/compat的帮助。但这是在 React 和 Next.js 加入更多并发特性等复杂功能之前的情况。曾经有一段时间,有人试图让 Preact 在 Remix 中运行,但这一目标现在已不再追求