之前写过一篇《一篇掌握React Server Components》(不熟悉的可以回顾起来),今天带来它的续集——一些使用上的体会。本身这个功能是用来增强服务端能力,进一步优化用户体验,但能力尚未成熟。
不一定要立即用起来,可以通过别人的使用感受学习它实现的优缺点,进行更多地概念知识的积累。
目前个人还不敢直接来用,确实能感觉到其中的一些写法习惯问题和潜在风险的问题,先观望一阵子,也许一直都不会用😄。
React Server Components为React带来了服务端专属能力。我已经在Next.js13和14中使用了这个特性,接下来是我对于这个特性最忠实的分析和评价。
考虑到过往React针对批判的处理方式,我是不想发布这篇文章的。但是最近看到很多已经存在的评判:像是文档不规范,使用不熟悉的问题等,我还是决定分享我的一些想法。
我写这篇文章是源于那些十分关注用户体验的人,我当然也很关注开发体验,但是用户总是最重要的。
简单回顾一下
我想在分享观点前跟大家对齐一下概念上的认知,因为有很多关于React Server Components和React自身错误的观念。
直到最近,React的定义都仍是:作为一个UI渲染框架,你可以通过JS函数写出可复用、组合式的组件。
- 这些函数返回的是一些模版,同时可运行在客户端和服务端
- 在客户端(浏览器),这些函数能够“水合”服务端发送的HTML。这个过程是React在模版上添加事件处理器并执行初始化逻辑,可以让你进入任意的js代码里面进行交互
React通常与服务端框架(Next.js, Remix, Express or Fastify)结合使用,这些框架控制着HTTP的请求/响应生命周期。这些框架都提供了三个最重要的功能:
- 路由:定义了哪个模板与哪个URL路径关联
- 数据请求:任何运行在渲染之前的逻辑,包括了从数据库读取数据,调用API,用户授权等
- 转化:在初始加载后处理用户启动的操作。这包括处理表单提交、暴露API端点等
到今天,React已经能够针对这三个部分进行更多地控制了,不仅仅只是个UI渲染框架。同时也是服务端框架应该如何暴露这些重要的服务端特性的蓝图。
这些新特性第一次被引用是在三年前,并最终在React的'canary'版本发布,这意味着主要在Next.js App Router中可稳定使用。
Next.js,作为一个完整的元框架,同样包括了很多其他功能:打包、中间件、静态生成等等。将来,会有更多的元框架与React的新特性结合。但这将需要一些时间,因为它需要在bundler级别进行紧密集成。
React的旧特性被重命名为了Client Components
,通过在服务端添加"use client"
指令,这些旧特性可以和新的服务端特性一起使用。命名上确实有一些困扰,这些客户端组件可以添加客户端交互能力,同时又可以再服务端预渲染(跟之前一样)。
那么基于上述的回顾,我想我们我可以进入主题了。
优点
服务端的数据请求和UI渲染在一个地方,很棒!
首先,这很COOL:
export default async function Page() {
const stuff = await fetch(/* … */);
return <div>{stuff}</div>;
}
服务端的数据请求和UI渲染在一个地方,这确实很好!👍🏻
但是这并不是必要的新功能,2022年的Preact(via Fresh)中就已经有这种写法了。
甚至在旧版的React中,始终可以在服务器上获取数据并使用该数据呈现一些UI,所有这些都是同一请求的一部分。下面这段代码就十分简洁,通常你会使用框架设计好的数据请求方式,像是Remix loaders或者Astro frontmatter。
const stuff = await fetch(/* … */);
ReactDOM.renderToString(<div>{stuff}</div>);
特别是在Next.js中,过去只能在路由级别实现,这很好,在大多数情况下甚至更可取。时至今日,React组件可以独立地请求他们自己的数据,这个新的组件级别的数据请求能力确实实现了额外的组合性能力,但是我不在乎这个(访问页面的用户也不在乎😅)
你仔细想想,“仅服务端组件”的目的应该是:只在服务端渲染HTML,并且不需要在客户端进行水合作用。这也是岛屿架构的框架(像是Astro和Fresh)背后的实现的前提:一切默认均为服务端组件,只有交互的部分需要水合。
React Server Components更大的不同是其底层的实现:服务端组件被转化为中间态序列化的数据格式,这种格式可以预渲染成HTML(跟之前一样)同时会被发送到客户端进行渲染(这是新特性)。
但是,HTML不是可序列化的吗,为什么不直接发送HTML呢?是的,这就是我们一直在做的事情,这个额外的步骤带来了一些有趣的可能性:
- 服务端组件可以被作为客户端组件的props
- React可以在不丢失客户端状态的情况下重新验证服务器HTML
在某种程度上,这与岛屿体系结构相反,在岛屿体系结构中,“静态”HTML部分可以被视为大多数交互式组件海洋中的服务器孤岛。
一个稍显做作的例子:你需要展示一个通过一个fancy library格式化后的时间戳,结合服务端组件,你可以这样做:
- 在服务端格式化这个时间戳,进而在客户端的bundle中不再需要打包这个fancy library
- 在服务端重新验证这个时间戳,然后在客户端重新渲染这个并展示这个字符串
之前的方式中,你需要通过innerHTML
来展示从服务端生成的字符串,这种方式并不灵活且不可取。所以上述的方式确实得到了不小的改善。
相较于把服务端仅仅作为一个获取数据的地方,现在你可以从服务端获取一个整个组件树(初始加载和未来更新)。这确实对于用户和开发者来说都是十分高效且体验良好的。
还有一些好
渐进增加了
form
,进而可以做到无JS运行
基于server actions,React提供了一个官方类RPC方式执行服务端代码来响应用户的交互。它渐进式增强了内置HTMLform
元素,进而可以达到无JS运行。🆒
<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
属性,并且将默认的get方法改成post方法。我不喜欢,但是没办法。
我们同样也掩盖了一个命名奇怪的指令"use server"
,即使在服务端组件中定义了action,这个指令还是需要显式地声明。更倾向于将名称更改为"use endpoint"
,因为本质上它是API endpoint的语法糖。但是,基于我个人我并不在乎它叫什么,叫"use potato"
也行。
上述的案例也算是近乎完美的,所有东西都位于同一位置,更优雅,无需JavaScript即可工作。纵使大多数业务逻辑散落在单独的地方,因为表单数据对象依赖于表单字段的名称,整个逻辑管理的表现还是挺好的。
最为重要的是,它规避了一些手动维护的代码片段(向服务端发送器并处理请求的返回内容)和三方依赖库。
在前面的内容里,我都把他们归类到好
的那一方面,因为相较于传统方式它确实有了更合理的改善。然而,当你想要尝试一些更高级的cases的时候,就会体会到其中的坏
。
坏的方面
"use server"
指令的设计,会让开发不友好
假设你想要渐进增强你的form,进而当服务正在响应处理中的时候,你通过禁用按钮来阻止一些偶发的重新提交。
因为按钮使用useFormStatus
(客户端hook)的缘故,你需要将Button移动到另一个文件里面。这个问题还好,至少form的剩下的部分扔不会变化。
"use client";
export default function SubmitButton({ children }) {
const { pending } = useFormStatus();
return <button disabled={pending}>{children}</button>;
}
现在你又需要添加一些错误处理能力,大部分的form都需要一些基本的错误处理能力,在这个例子中,你可能想要展示一个error当email没有验证通过或丢弃了等等。
为了使用这个服务端返回的错误值,你需要引用useFormState
(另一个客户端hook),这就意味着form需要移动到客户端组件,同时事件则需要放在另一个文件里面。
"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>
确实有一些困扰,纵使form已经在客户端组件里面了,仍然没有JS的相关作用。👍🏻
然而:
- 🙅🏻♀️紧密关联的代码并没有放在一起,服务的action需要使用
"use server"
指令,为什么不允许将其定义在客户端组件里面? - action的参数发生了变化,为什么不保留form data作为第一个参数?
- 我花费了一些精力在没有js的情况最终得以运行,因为官方文档给了我一些误导。这里的一个关键点是直接将服务端action传递到
useFormState
,同时直接将返回的action传递到form的action
属性中。如果在任何时候创建任何包装器函数,那么在没有JavaScript的情况下,它将不再工作。此时就需要一个lint rule
来规避这个错误。
"use client"
指令会在应用逐渐复杂的时候开始变得笨重。可能出现客户端和服务端交叉的现象,它需要将服务端组件作为props传递给客户端,而不是通过import的方式。在form层级比较浅的时候,这种方式还是可以管理的,但在实际情况中,在应用树更深的地方你更需要的是客户端的组件,这是写代码更自然也更方便的方式。
让我们在回头看看之前说到的时间戳的这个例子,如果你想要在一个table里面展示时间戳,这个table是一个客户端组件,同时多层内嵌在其他的客户端组件里面。你会怎么做?通过多层传递参数或直接将服务端组件存储在最近的服务-客户端边界的全局状态里面。实际想想,不,你不会这么干,你会直接保留使用客户端组件,哪怕这会带来发送data-fns
到客户端的代价。
在组件树有一定深度之后不再使用异步组件可能并不是一件坏事,你仍旧可以合理地构建你的应用,因为数据请求的部分大概率都发生在路由层或接近路由层的地方。岛框架也有同样的限制,在岛框架中在一定深度之后不允许引用静态/服务端组件。不过这个限制仍然是令人失望的,因为React花了3年多的时间,提出了最复杂的解决方案,同时承诺服务器和客户端组件将无缝互操作。
这种限制同样存在着一些不明显的严重隐患。在客户端组件内部,它所有的依赖都是属于客户端的一部分。有大量的组件并不使用专门的客户端/服务端的特性,这些特性都应该在服务端。但是最终这些都会打包到客户端,因为他们都被其他客户端组件引用。如果这些组件没有使用"use client"
这个指令,你可能都不会意识到这些方面。为了能让客户端的组件代码最小,你需要有意识地且更警觉地,因为做错是很容易的。就像从失败的深渊中爬出来。
不优雅的部分
fetch的继承带来难以控制的成本
因为可能被遗弃的原因,Next.js决定在服务端组件里面继承内置的fetch
API。他们本可以暴露一个包装函数的,我想那会更有意义。
继承——这里并不仅仅是添加几个额外的选项而已,他们完全地改变了fetch
的用法。所有的请求都默认强制缓存,除了你访问cookies,那才不会被缓存。这种默认方案会变得混乱、随意且毫无意义。甚至在发布到线上你都不知道哪些被缓存,哪些没有,因为本地开发服务表现的并不一致。
为了表现的更为糟糕,Next.js不允许让你访问request object,面对这一行为,我只能表示无语。
在中间件之外,你也不能设置headers,cookies, code status,重定向等等。
- 这是因为app router是通过流构建的,一旦流开始了就很难修改返回内容。但是,为什么不能在流开始的地方提供一些能力呢?
- 中间件只能运行在Edge,这在很多场景中都有很多的限制,为什么不能让中间件在流开始前的Node运行时执行呢?
在旧版本的Next.js的页面路由中,并没有上述中的这些问题(除了中间件运行时的限制)。路由的行为均可预测,在静态和动态数据之间有着清晰的界限。你可以访问请求信息并修改返回内容。你有很多控制的能力。这并不是说页面路由器没有自己的怪异之处,但它运行得很好。
Note: 我选择忽略目前Next.js应用程序路由中存在的几个bug(“稳定”并不意味着“无bug”)。我也不涉及任何尚未发布的实验性API,因为,嗯……它们是实验性的。结合任何错误修复和新的(更新的?)API的效果,很可能在六个月内体验不那么令人沮丧。如果发生这种情况,我将更新此部分。
难以接受的部分
实际情况下,client bundle 体积并没有得到优化,甚至会增加
上述提到的问题在不同程度上来说都是可以忍受的,如果能让最终的bundle体检变得更小了。
在实际情况里面,bundle的体积确是变得更大了。
两年前,Next.js12(页面路由)保持着一个基准的bundle体积——压缩后约70kb。而如今Next.js14(app路由)则保持着85-90kb的基准体积。解压以后,大约有300kb的js需要浏览器解析和执行,仅仅只是为了渲染一个hello world
页面。
重申一下,不管你的应用的体积多少,这都是你的用户需要消费的最小代价。并发特征和选择性的合成能帮忙优化用户的事件行为和用户体验,但是对于基准的最小代价没有任何帮助。他们甚至可能也在为此付出代价,仅仅是因为现有的。缓存可以减少重复下载的问题,但是浏览器仍然需要解析和执行所有的代码。
如果这听起来也没什么大不了,那么考虑一下JS其实有很多方式执行失败。在现实环境中,很多愿意访问你app的用户的设备并没有那么强大。
当初,减少bundle体积是React Server Components最主要的动机。当然,服务端组件不会将更多的js打包到客户端中,但是基本的bundle仍然是那些。现在基本的bundle还需要包括处理服务端组件适配在客户端组件的代码。
同时,也会有数据重复的问题。记住,服务端组件不会直接渲染成HTML,他们首先会被转化成一个中间态来表示HTML(RSC Payload),所以尽管中间态会在服务端进行预渲染并且作为HTML发送,但是中间态还是会需要一并发送。
实际项目中,这意味着完整的HTML会在页面底部的script标签中重复,项目越大,script标准中的内容也就越多。如果你用了tailwind css?这都会重复。服务端组件不会在客户端bundle中添加更多代码,但是会在这个payload中持续添加内容。用户在设备上需要下载更大的文档同时也会消费更多的内存。
当然,这个payload会加速客户端导航,但我不认为这是消费payload很好的理由,很多框架(Fresh Partials)已经通过HTML就实现了这个功能。更重要的是,我不同意客户端导航这个前提。绝大多数导航都应该使用常规的链接来完成,这些链接工作更可靠,不会丢弃浏览器优化(BFCache),不会导致可访问性问题,并且可以执行得同样好(预请求)。使用客户端导航是一个应该在每个链接的基础上深思熟虑的决定。围绕客户端导航构建一个完整的范式感觉是错误的。
最后的思考
相较于引入React server的能力,一些现有的问题更加需要关注并解决
React正在向React世界引入一些急需的服务器语言。其中许多功能并不一定是新功能,但现在有了一种共享语言和一种惯用的服务器操作方式,这是一个积极的方面。我对新的API持谨慎乐观的态度。我很高兴看到React采用服务器优先的心态。
与此同时,React没有做任何事情(除了2019年放弃的一项实验)来改善他们可怜的客户端问题。它是一个老框架,旨在利用Facebook规模的资源解决Facebook规模的问题,因此不适合大多数用例。进入2024年,以下是React尚未解决的许多问题:
- 客户端bundle包充斥着不必要的“功能”,比如合成事件系统
- 内置的状态管理对于层级很深的组件树很不友好,进而导致很多应用不得不适配一些三方状态管理能力
- 广泛的浏览器APIs支持,像是自定义元素和模版,这些要么没有完全支持要么就是不生效
- 新的HTML APIs(像是
inert
和popover
属性)不能开箱即用 - 在组件内写css不友好,新的样式APIs(像是显式的声明@scope)并没有按照预期工作
- 很多不必要和可以规避的模版(像是
forwardRef
)需要经常来写,尤其是当在构建库的时候 - 复杂的组件需要小心维护来避免一些性能问题
- 不支持ESM build,class组件也不支持tree-shaking
这些都不是“未解决”的问题;这些都是发明的问题,是React设计方式的直接结果。在一个充满现代框架(Svelte、Solid、Preact、Qwik、Vue、Marko)的世界里,React实际上是技术债务。
我认为,向React添加服务器功能远不如解决其许多现有问题重要。有很多方法可以在没有React服务器组件的情况下编写服务器端逻辑,但如果不完全替换React,就无法避免React在客户端上造成的糟糕局面。
也许你不关心我所说明的任何问题,或者你称之为沉没成本,继续你的一天。希望您至少能认识到React和Next.js还有很长的路要走。
我确实理解开源项目没有义务解决任何人的问题,但React和Next.js都是由大公司构建的(它们都在营销中使用),所以我认为所有的批评都是有道理的。
最后,我想强调的是目前很难在React和Next.js之间划清界限。在一个更加尊重标准的框架内(àla Remix),这些新API中的一些(或许多)看起来和感觉可能会有所不同。当这种情况发生时,我一定更新出来。