大家好,这里是大家的林语冰。"前端猫猫教"每日 9 点半更新,坚持阅读,自律打卡,每天一次,进步一点。
免责声明
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 www.mayank.co/blog/react-...。
本期共享的是 ------ RSC(React 服务器组件)为 React 带来了服务器独有的功能。本人一直在 Next 13 和 14 中使用这种新型范式。本文从一个关心用户体验的角度撰写,我也关心开发者体验,但用户永远是上帝。
快速回顾
社区关于 React 本身和 RSC 存在一大坨错误解读,直到最近,React 还可能被解读为一个 UI 渲染框架,这让我们可以将可重用、可组合的组件编写为 JS 函数。
- 这些函数只是返回某些标记,并且可以在服务器和客户端上运行。
- 在客户端(浏览器)上,这些函数可以"水合(hydrate)"从服务器接收到的 HTML。在此过程中,React 将事件处理程序附加到现有标记上,并运行初始化逻辑,让我们"hook"任意 JS 代码,从而实现交互。
React 通常与控制 HTTP 请求/响应生命周期的服务器框架(比如 Next/Remix/Express)梦幻联动。React 提供了一个方便的地方,管理三件重要的事情:
- 路由:定义哪个标记与哪个 URL 路径关联。
- 数据请求:在"渲染"开始之前运行的任何逻辑。这包括但不限于读取数据库、进行 API 调用、用户身份验证等。
- 变更:在初始加载后,处理用户发起的操作。这包括但不限于处理表单提交,暴露 API 端点等。
时至今日,React 现在也能尽责尽职。React 不再只是一个 UI 渲染框架,它也是服务器框架应如何暴露上述这些重要服务器功能的蓝图。
这些新功能在三年多前首次推出,最终在 React 的"canary"版本中发布,该版本主要在 Next App Router 中稳定使用。
Next 是一个完整的元框架,还包括打包、中间件、静态生成等附加功能。未来,更多的元框架会融入 React 的新功能,但这需要一些时日,因为它需要在打包器维度紧密集成。
React 的旧功能已重命名为客户端组件 ,通过在服务器-客户端边界添加 "use client"
指令,它们可以与新型服务器功能梦幻联动。是的没错,"客户端组件"这个名称有点猪头,因为这些组件可以添加客户端交互性,也可以和以前一样在服务器上预渲染。
RSC 的优势
首先,这简直酷毙了:
jsx
export default async function Page() {
const stuff = await fetch(/* ... */)
return <div>{stuff}</div>
}
服务器端数据请求和 UI 渲染在同一个地方真的棒棒哒!
但这不一定是新鲜玩意。自 2022 以来,完全相同的代码一直通过 Fresh 在 Preact 中运行。
即使在老派的 React 中,一直也可以在服务器上获取数据,并使用该数据渲染一些 UI,所有这些都是同一请求的一部分。为了简洁起见,对下述代码进行了简化;我们通常需要使用框架指定的数据请求方法,比如 Remix loader 或 Astro frontmatter。
jsx
const stuff = await fetch(/* ... */)
ReactDOM.renderToString(<div>{stuff}</div>)
具体而言,在 Next 中,这过去只能在路由级别实现,这很好,在大多数情况下甚至利大于弊。而现在,React 组件可以独立请求自己的数据。这种新型组件级数据请求功能确实赋能了额外的可组合性,但我并不关心它,最终用户访问我们的页面时也不关心。
如果我们认真思考一下,"仅服务器组件"的想法本身就很容易实现:只在服务器上渲染 HTML,而永远不会在客户端上对其进行水合。这就是 Astro 和 Fresh 等 Island 架构框架幕后的全部前提,默认情况下,所有内容都是服务器组件,只有交互部分会被水合。
RSC 的最大区别在于底层发生的工作机制。服务器组件被转换为中间可序列化格式,该格式可以和以前一样预渲染为 HTML,也可以通过线路发送,从而在客户端上渲染,这才是新功能!。
但是请等一下...... HTML 不是可序列化的吗,为什么不直接通过网络发送呢?是的没错,这当然就是我们一直在做的事情。但这个额外的步骤带来了某些有趣的可能性:
- 服务器组件可以作为
props
传递给客户端组件。 - React 可以在不丢失客户端状态的情况下,重新验证服务器 HTML。
在某种程度上,这就像 Island 架构的反面,其中"静态"HTML 部分可以视为是大多数交互式组件海洋中的服务器 Island。
简单举个栗子:我们想要显示使用精美库格式化的时间戳。使用服务器组件,我们可以:
- 在服务器上格式化此时间戳,而不用花哨的库使我们的客户端包膨胀。
- (一段时间后)在服务器上重新验证此时间戳,并让 React 完全在客户端上重新渲染显示的字符串。
以前,要获得"图灵等价"的结果,我们必须 innerHTML
服务器生成的字符串,这并不总是可行,甚至不可取。所以 RSC 无疑是一个进步。
我们现在可以从服务器检索整个组件树,用于初始加载和将来的更新,而不是仅将服务器视为从中检索数据的地方。这更加高效,并且可以为开发者和用户带来更好的体验。
RSC 差强人意的地方
通过服务器操作,React 现在拥有一种类似 RPC 的官方方式,执行服务器端代码,响应用户交互,这是一种"变更"。它还逐步增强了内置的 HTML <form>
元素,使其无需 JS 即可工作。简直酷毙了!
jsx
<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"
。
上面的例子还是近乎完美的。一切都位于同一位置,感觉很优雅,并且无需 JS 即可工作。即使大部分业务逻辑位于单独的位置,共置也特别好,因为表单数据对象依赖于表单字段的 name
。
最重要的是,它避免了手动连接这些部分的需要,这将涉及一些粗略的意大利面条代码,用于向端点发出 fetch
请求,并处理其响应,或依赖第三方库。
所有这些勉为其难算作"RSC 的优点",因为这确实是对传统方法的重大改进。虽然但是,当我们想要处理高级用例时,这很快就会变得头大。
RSC 的短板
假设我们希望逐步增强表单,以便在处理服务器操作时,通过禁用按钮,防止意外重新提交。
我们需要将按钮移动到不同的文件中,因为它使用 useFormStatus
客户端钩子。这有点猪头,但至少表单的其余部分仍然没有改变。
jsx
'use client'
export default function SubmitButton({ children }) {
const { pending } = useFormStatus()
return <button disabled={pending}>{children}</button>
}
现在假设我们还想处理错误。大多数表单至少需要一些基本的错误处理。在此示例中,如果电子邮件无效或被禁止,或发生其他情况,我们可能希望显示错误。
jsx
'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>
令人困惑的是,即使现在它位于客户端组件中,该表单仍然可以在没有 JS 的情况下工作!
虽然但是:
- 密切相关的代码不再位于同一位置。无论如何,该操作都需要一个
"use server"
指令,那么为什么不允许在与客户端组件相同的文件中定义它呢? action
签名突然改变了。为什么不将表单数据对象保留为第一个参数?- 我花了一点时间才在没有 JS 的情况下完成这项工作,因为官方文档显示了一个有问题的示例。这里的关键是将服务器操作直接传递到
useFormState
中,并将其返回的操作直接传递到表单的action
属性中。如果我们在任何时候创建任何包装函数,那么如果没有 JS,它将不再奏效。良好的 lint 规则可能有助于避免此错误。
随着 App 越来越复杂,"use client"
也开始变得难以处理。可以交错服务器和客户端组件,但它要求我们将服务器组件作为 props
传递,而不是从客户端组件导入它们。对于从顶部开始的前几个级别,这可能是可以管理的,但实际上,在树的更深处时,我们将主要依赖于客户端组件。这就是编写代码的水到渠成的方式。
让我们回顾一下上面的时间戳示例。如果我们想要显示表中的时间戳,而该表恰好是嵌套在多个级别的其他客户端组件中的客户端组件,那该怎么办?我们可以尝试进行一些 props dirlling
,或将服务器组件存储在最近的服务器-客户端边界的全局 store
或 context
中。但实际上,我们可能只是继续使用客户端组件,并产生将 date-fns
发送到浏览器的成本。
在达到一定深度后被禁止使用异步组件可能并不是一件坏事。我们仍然可以合理地构建 App,因为数据请求可能只发生在路由级别或附近。Island 框架中也存在类似的限制,因为它们不允许在 Island 导入静态/服务器组件。但它仍然令人失望,因为 React 花了 3 年多的时间才提出了最复杂的解决方案,同时承诺服务器和客户端组件将无缝互操。
但这一限制可能会产生一些严重的影响,这一点可能并不明显。在客户端组件内部,其所有依赖及其依赖的依赖等也是客户端的一部分。大量组件不使用服务器或客户端专有的功能,它们可能应该保留在服务器上。但它们最终会出现在客户端打包中,因为它们被导入到其他客户端组件中。如果这些组件本身不使用 "use client"
指令,我们甚至可能没有意识到这一点。为了保持客户端代码较小,我们必须有意识且格外警惕,因为做"错误"的事情更容易。
缺陷
出于某种不可预见的原因,Next 决定在服务器组件中"扩展"内置 fetch
API 是个好主意。它们本可以暴露一个包装函数。
我所说的"扩展"并不仅仅意味着添加额外的选项。它们确实改变了 fetch
的工作方式!默认情况下,所有请求都会被积极缓存。除非我们正在访问 cookie,否则它可能不会被缓存。在部署到生产环境之前,我们甚至可能不会意识到缓存的内容和未缓存的内容,因为本地开发服务器的行为不同。
更糟糕的是,Next 不允许我们访问请求对象。我们也无法在中间件之外设置 header、cookie、状态代码、重定向等。
- 这是因为 App Router 是围绕流式传输构建的,在流式传输开始后修改响应就为时已晚。但是,为什么不允许对流媒体何时开始进行更多控制呢?
- 中间件只能在边缘运行,这对于许多场景而言限制太大。为什么不允许中间件在流开始之前在 Node 运行时中运行?
在旧版 Next Pages Router 中,这些问题都不存在,除了中间件运行时限制。路由的行为是可预测的,并且"静态"和"动态"数据之间存在明显区别。我们有权访问请求信息,并且可以修改响应。我们有更多的控制权!这并不是说 Pages Router 没有它自己的怪异之处,但它运行得很好。
粉丝请注意:我选择忽略目前 Next App Router 中存在的若干错误,"稳定"并不意味着"无错误"。我也不涉及任何尚未发布的实验性 API,因为它们是实验性的。结合所有错误修复和新 API 的效果,六个月后体验很可能会感觉不那么令人沮丧。
更变态的地方
到目前为止,如果打包体积变小的话,我提到的所有内容在不同程度上都是可以容忍的。
事实上,打包体积越来越大。
两年前,Next 12 带有 Pages Router 的基准包压缩大小约为 70KB。今天,Next.js 14 带有 App Router 的起始基线为 85-90KB。解压缩后,浏览器需要解析和执行近 300KB 的 JS,只是为了生成一个"hello world"页面。
重申一下,无论我们的网站有多大,这是用户需要支付的最低成本。并发功能和选择性水合作用可以辅助确定用户事件的优先级,但无助于降低基准成本。仅仅凭借现有的优势,它们甚至可能也承担了这一成本。缓存可以帮助避免在某些情况下重新下载的成本,但浏览器仍然需要解析,并执行所有代码。
减少打包体积被认为是 RSC 的主要设计动机之一。
当然,服务器组件本身不会向客户端打包添加"更多" JS,但基本打包仍然存在。基础包现在还需要包含处理服务器组件如何适应客户端组件的代码。
还有数据重复问题。粉丝请记住,服务器组件不会直接渲染为 HTML;它们首先被转换为 HTML 的中间表示,这称为"RSC 有效负载"。因此,即使它们将在服务器上预渲染,并以 HTML 形式发送,中间有效负载仍将一起发送。
实际上,这意味着整个 HTML 将被复制到页面末尾的脚本标记内。页面越大,这些脚本标签就越大。服务器组件可能不会向客户端捆绑包添加更多代码,但它们将继续添加到此有效负载。用户的设备将需要下载更大的文档,这对于压缩和流媒体来说问题不大,但会消耗更多的内存。
显然,这个有效负载有助于加速客户端导航,但我不相信这是一个足够有力的理由。许多其他框架只使用 HTML 实现了同样的功能。更重要的是,我不同意客户端导航的前提。网络上的绝大多数导航应该使用常规链接来完成,这种链接工作更可靠,不会放弃浏览器优化,不会导致可访问性问题,并且可以通过 prefrtching
很好地执行。使用客户端导航是一个应该在每个链接的基础上深思熟虑做出的决定。围绕客户端导航构建一个完整的范式感觉是错误的。
总结
React 正在向 React 世界引入一些急需的服务器原语。其中许多功能不一定是新的,但现在有一种共享语言和一种处理服务器事务的惯用方式,这是一个积极的因素。我对新的 API 持谨慎乐观的态度,不管它有什么缺点。我很高兴看到 React 拥抱服务器优先的心态。
与此同时,React 没有采取任何措施,来改善它们可怜的客户端故事。它是一个遗留框架,旨在使用 Facebook 规模的资源解决 Facebook 规模的问题,因此不适合大多数用例。进入 2024,React 还需要解决以下一些问题:
- 客户端包因不必要的"功能"而变得臃肿,比如合成事件系统。
- 内置状态管理对于深层树来说效率非常低,导致大多数 App 采用第三方状态管理器。
- 广泛可用的浏览器 API,比如例如自定义元素和模板,要么不完全受支持,要么根本不起作用。
- 如果没有解决方法,较新的 HTML API,比如
inert
和popover
属性,无法开箱即用。 - 没有惯用的方式在组件中编写 CSS,并且像隐式
@scope
这样的新样式 API 也不会如期工作。 - 需要经常编写大量不必要且可避免的样板文件,尤其是在构建库时。
- 昂贵的组件需要谨慎记忆化,避免性能问题。
- 没有可用的 ESM 构建,也无法对未使用的功能(比如类组件)进行 tree-shake 优化。
这些不是"未解决"的问题;而是悬而未决的问题。这些都是发明的问题,是 React 设计方式的直接后果。在一个充满 Vue/Preact/Svelte 等现代框架的世界中,这些框架大多不存在这些问题,React 实际上是技术债务。
我认为,向 React 添加服务器功能远不如解决其许多现有问题重要。有很多方法可以在不使用 React 服务器组件的情况下编写服务器端逻辑,但是如果不完全替换 React,就不可能避免 React 在客户端上造成的严重混乱。
也许大家可能无视我所说明的任何问题,或者大家可能将其称为沉没成本,并埋头继续美好的一天。希望大家至少能够认识到 React 和 Next 其实道阻且长。
本期话题是 ------ 你是如何看待 RSC 这一新范式的?
欢迎在本文下方群聊自由言论,文明共享。谢谢大家的点赞,掰掰~
"前端猫猫教"每日 9 点半更新,坚持阅读,自律打卡,每天一次,进步一点。