最近笔者腾出了大把的时间,学习了一下React18。
什么是服务器组件
React18带来了一个实验性的特性:React Server Components,简称RSC,即服务器组件。
服务器组件可能会在React19版本作为稳定特性推出。如果目前就用于生产的话,可能需要注意API在未来的变化情况。
什么是服务器组件?简单来说就是渲染过程运行在服务端的组件,它通过将组件在服务端执行,并输出序列化的数据到客户端以构建UI。在推出RSC之前,React组件并没有特定的名称,但是现在可以称之为客户端组件(RCC)。它们最大的区别就是运行环境,RCC需要在客户端执行渲染。
那么问题就来了,为什么要出现RSC?RCC存在什么问题吗?
阅读本文,必须要知道React18的SSR新特性的知识,可以看看我之前写的文章谈一谈React18的服务端渲染。此外官方RFC也可以读一读。
客户端组件的缺点
客户端组件在用户浏览器上运行渲染。这就表示,如果React要在页面上渲染某RCC,那么必不可少的需要经过 加载依赖的js bundle -> 按顺序解析和执行js 这个过程。如果这些js很大怎么办?那就会阻塞渲染以及交互。
比如我们最常见的SPA方案,想要完整的渲染出页面,就要等待包括React在内的库加载和执行完成,导致FCP比较高、白屏时间长。所以才出现了代码分包、懒加载、SSR等技术,来降低页面的FCP,可以说解决了一大痛点。但对于单个组件来说,仍然存在问题。
1. TTI(Time to Interactive)比较长
一个很可能会遇见的情况就是,项目中有一个组件依赖了另一个js库,而这个js库即使打包压缩了仍然很大。比如以下代码:
import React from 'react';
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)
function Markdown({ content }) {
return <div>{sanitizeHtml(marked(content))}</div>;
}
这个<Markdown />
组件接收一个字符串的markdown文本作为参数,并在组件中使用依赖的第三方库marked
和sanitize-html
来解析文本,并转换为相应的html内容。这两个三方库都比较大。React想要成功渲染该组件,那么必不可少的需要先下载这些js。无论是提前加载还是懒加载,下载的耗时不会消失。所以在成功执行前,组件无法交互。
2. js依赖包过重
由于是在客户端渲染,那么依赖的js库都需要被打包,上面的例子只是一个组件。如果项目中用到了成百个组件,不敢想象打包后的产物得有多大。
3. 访问服务端资源不便
在浏览器上,无法直接获取服务器上的资源。所以我们需要通过RESTful或其它API来获取。这样很合理,但是确实不太便利。有的时候我们甚至需要在一个组件中发起很多次请求,并且请求数据之间有依赖(请求B的参数依赖请求A的结果)。这些请求直接影响页面的展示。会产生客户端-服务端请求瀑布。
服务器组件如何解决问题
上述问题都是源于在客户端环境中渲染组件这个条件下。那如果把组件的渲染移到服务端,这些问题是否能解决?答案是完全可以。
1. 降低TTI
RSC在服务端执行渲染,发送到客户端的只是一段序列化的数据。叫RSC Payload
。它的数据大概长这样:
3:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
5:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
1:D{"name":"","env":"Server"}
2:D{"name":"PostItem","env":"Server"}
4:["id","1","d"]
6:D{"name":"rQ","env":"Server"}
6:null
7:D{"name":"","env":"Server"}
0:["development",[["children","posts","children",["id","1","d"],[["id","1","d"],{"children":["__PAGE__",{}]}],[["id","1","d"],{"children":["__PAGE__",{},[["$L1","$L2"],null],null]},["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","posts","children","$4","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}],null],[null,["$6","$L7"]]]]]
什么是
RSC Payload
?
它是表示RSC树渲染结果的一段压缩文本内容。用于客户端React更新dom。它包含:
- 服务端组件的渲染结果及描述如何更新dom。
- 包含客户端组件的占位,以及客户端组件对应的js文件地址。
- 包含传递给客户端组件的props。
采用这种自定义格式的好处是它比较适合流式传输,每读一行便可以操作一次。
在之前的例子中,如果使用RSC的话,那客户端并不需要加载这些js。服务端渲染完后,发送RSC Payload
到客户端,客户端只需解析并更新dom即可。最后得到如下html:
<div>
<!-- 在服务端渲染出来的markdown html -->
</div>
2. js依赖大幅度减少
RSC组件的依赖并不需要在客户端执行,所以也不需要打包并发送到客户端。
3. 可以直接访问服务端
由于是在node server中运行,那么可以在RSC组件中直接访问服务器资源如文件、数据库…比如下面代码:
import db from 'db';
async function Note({id}) {
const note = await db.notes.get(id);
return <NoteWithMarkdown note={note} />;
}
同时,对于一个组件中可能会访问许多资源的情况。由于都是运行在服务端,所以不会产生客户端-服务端请求瀑布。只需要发起一次请求。
4. 自动的代码分割
这是RSC特性的另一个优势。客户端组件想要实现代码分割,需要借助于React.lazy
。比如下面代码:
// *客户端组件*
import { lazy } from 'react';
const OldPhotoRenderer = lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = lazy(() => import('./NewPhotoRenderer.js'));
function Photo(props) {
// 根据flag判断使用哪个Renderer组件
if (FeatureFlags.useNewPhotoRenderer) {
return <NewPhotoRenderer {...props} />;
} else {
return <OldPhotoRenderer {...props} />;
}
}
例子展示了,在运行时,根据当前flag来使用新的或者老的Renderer组件,懒加载组件代码并执行渲染。
在RSC中,对于导入的客户端组件,React已经实现了自动的代码分割。只有浏览器需要执行这些组件时,对应的组件js才会被加载。
// *服务端组件*
import OldPhotoRenderer from './OldPhotoRenderer.js'; // RCC
import NewPhotoRenderer from './NewPhotoRenderer.js'; // RCC
function Photo(props) {
// 可以稍后切换flag,比如用户登录、状态变更。
if (FeatureFlags.useNewPhotoRenderer) {
return <NewPhotoRenderer {...props} />;
} else {
return <OldPhotoRenderer {...props} />;
}
}
可以看到写出的代码更加自然。每个renderer组件都是客户端组件,在RSC中被导入,它们被自动代码分割了。只有flag对应的renderer组件被使用到时,浏览器才会去加载该组件的代码块。
使用RSC
使用Async Components
RSC使用async/await
来编写组件代码。如果在组件内部使用await
,React会中断渲染(Suspend。如果配合<Suspense />
组件可实现流式传输),并等待Promise被解决后继续向下执行。
import db from 'db';
async function Note({id}) {
// 这一句会中断渲染并等待,resolve后继续执行
const note = await db.notes.get(id);
// 这一句不会中断,不等待
const commentsPromise = db.comments.get(note.id);
return <NoteWithMarkdown note={note} />;
}
进阶例子
下面这个例子是一个展示笔记详情的页面。看看它们分别用RCC组件和RSC组件是怎么写的。
// *客户端组件*
function Note({id}) {
const [note, setNote] = useState(null);
// 获取note数据
useEffect(() => {
fetch(`/api/notes/${id}`).then(data => {
setNote(data.note);
});
}, [id]);
return (
<div>
{/* note数据获取后才执行Author组件 */}
{note && <Author id={note.authorId} />}
<p>{note.content || '空'}</p>
</div>
);
}
function Author({id}) {
const [author, setAuthor] = useState('');
// 再次请求author数据
useEffect(() => {
fetch(`/api/authors/${id}`).then(data => {
setAuthor(data.author);
});
}, [id]);
return <span>作者: {author.name}</span>;
}
上面的是使用客户端组件的前端代码写法。当然还需要后端提供两个接口:“/api/notes/:id"和”/api/authors/:id"。
// *后端api*
import db from './database';
app.get(`/api/notes/:id`, async (req, res) => {
const note = await db.notes.get(id);
res.send({note});
});
app.get(`/api/authors/:id`, async (req, res) => {
const author = await db.authors.get(id);
res.send({author});
});
如果我们使用服务端组件,那代码可以大幅简化:
// *服务端组件*
import db from './database';
async function Note({id}) {
// 立刻获取note数据
const note = await db.notes.get(id);
return (
<div>
<Author id={note.authorId} />
<p>{note.content}</p>
</div>
);
}
async function Author({id}) {
// 立刻获取author数据
const author = await db.authors.get(id);
return <span>作者: {author.name}</span>;
}
添加交互
由于服务端组件不会被发送到浏览器上,所以它不能访问浏览器API,也不能使用交互相关的React APIs。比如useState
、useEffect
等。那如果要在RSC中为组件添加交互怎么办呢?那就是组合使用RSC和RCC。此时需要使用"use client"
指令来显式的声明某个组件为RCC。并在RSC中导入RCC组件。
// *服务端组件*
import Expandable from './Expandable';
export default async function Notes() {
const notes = await db.notes.getAll();
return (
<div key="Note">
{notes.map(note => (
<Expandable key={note.id}>
<p>{note.title}</p>
</Expandable>
))}
</div>
)
}
// *客户端组件*
"use client"
import { useState } from "react";
export default function Expandable({children}) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<button onClick={() => setExpanded(!expanded)}>
Toggle
</button>
{expanded && children}
</div>
)
}
该示例展示了一个RSC组件<Notes />
,在服务端获取一组笔记数据notes,然后在组件内部使用了RCC组件<Expandable />
。<Expandable />
组件是可交互的,它的功能是点击后展开/收缩笔记数据。最后在客户端渲染出来的组件+dom结构如下:
<div key="Note">
<Expandable key="1">
<button />
<p>...</p>
</Expandable>
<Expandable key="2">
<button />
<p>...</p>
</Expandable>
<!-- ... -->
</div>
由于RSC并不会被发送到客户端,所以客户端的组件树(可以用React Developer Tools查看)里看不到<Notes />
组件,但是<Expandable />
组件是可以看到的。<Expandable />
可以交互。
RCC下嵌入子RSC
到目前为止,客户端组件还不能导入服务端组件。一旦定义了某组件为RCC。那么从这个入口开始,其下的组件树都将被视为客户端组件。比如如下的代码:
// share.client.jsx
// *客户端组件*
"use client";
import { Suspense, use, useState } from "react";
import Loading from "./loading";
import ShareSocial from "./sharesocial.server"; // 导入RSC
// 这个组件是交互的,所以定义为RCC
export default function ShareView() {
const [showShare, setShowShare] = useState(false);
const clickShare = () => {
setShowShare(true);
};
return (
<>
<button onClick={clickShare}>Share</button>
{showShare && (
{/* 分享弹窗 */}
<div className="fixed inset-0 bg-black bg-opacity-50">
<Suspense fallback={<Loading />}>
{/* 分享的社交平台 */}
<ShareSocial shareText={shareText} />
</Suspense>
<button onClick={() => setShowShare(false)}>
Close
</button>
</div>
)}
</>
);
}
这个组件展示了一个分享功能。点击分享按钮打开分享弹窗。因为是可交互的,所以定义为客户端组件。此外,分享平台需要从server获取,所以定义为RSC:
// sharesocial.server.jsx
// *服务端组件*
import db from 'db';
export default async function ShareSocial() {
const list = await db.socials.getAll();
return (
<>
<h2>Share to Friends</h2>
<p>You can share to friends at:</p>
<ul>
{list.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</>
);
}
分享平台定义为RSC,并从db获取平台列表。看起来设计很合理,RCC和RSC分工很明确!实际运行看看!
控制台直接报错:*async/await is not yet supported in Client Components, only Server Components.*这是因为<ShareSocial />
组件被<ShareView />
组件导入,而后者是RCC,前者故而也被当作RCC,代码最终被发送到浏览器上执行。而RCC是不支持async/await
的。
我懵了,那应该怎么处理这种情况呢?
两种方法:一是还原以前的方式,不使用RSC,改为RCC并在useEffect
中调用接口获取数据。二是将RSC作为props.children
传递给RCC:
// postitem.server.jsx
// *服务端组件*
import db from 'db';
import ShareSocial from "./sharesocial.server"; //
// PostItem组件是ShareView组件的父级
export default async function PostItem({ params }: { params: { id: string } }) {
const post = await db.posts.get(id)
return (
<div>
{/* ... */}
<ShareView>
{/* 作为children */}
<ShareSocial />
</ShareView>
{/* ... */}
</div>
);
}
改为在<ShareView />
的父级组件<PostItem />
(RSC)中,导入<ShareSocial />
,并作为children传递到<ShareView />
中。
// share.client.jsx
// *客户端组件*
"use client";
// ...
// 增加children属性
export default function ShareView({ children }) {
// ...
return (
<>
{/* ... */}
{showShare && (
<div className="fixed inset-0 bg-black bg-opacity-50">
{/* 用children代替组件 */}
{children}
{/* ... */}
</div>
)}
</>
);
}
<ShareView />
稍加改造即可。RSC支持将组件作为props传递给RCC,也支持其它可以序列化的属性。至于不可序列化的属性比如function则不行。
RSC的缺点
介绍了RSC的优势,也介绍了它如何使用。现在来说说它的缺点。我们可以看出来,在RSC+RCC这种组件架构下。我们书写组件的方式跟原来有所不同。原来我们更多的是考虑复用和业务逻辑分离,来设计组件结构。而现在,我们在考虑这两点的基础上,还需要额外考虑组件是否需要获取数据,是否需要交互。这会带来几个很麻烦的缺点:
-
组件抽取的粒度变得很细。笔者自己体验后,感受就是,使用RSC后需要抽取的组件数量可能比原来多50%。
-
组件设计的心智负担提高。那就是如果组件设计不合理,很容易出bug。比如在RSC里使用了
useState
、比如RCC导入RSC。 -
需要显式声明
"use client"
客户端组件,很容易疏漏。旧项目就不用提了,那么多组件根本改不完… -
三方组件库需要适配。三方组件如果没适配添加"use client"指令。RCC中导入不会有什么问题。但RSC导入就会出问题:因为React也不知道组件是客户端专用的。
到目前为止的React版本,RSC和RCC的使用限制在编译阶段是没有任何错误提示的。导致很多问题只能在运行时发现。可以使用
server-only
和client-only
来标识组件和js是客户端专用还是服务端专用,并且会在编译时给出提示。npm i server-only
总结一:RSC和RCC
前面介绍了RSC出现的背景和使用方法,现在来做一个总结:
-
服务端组件的出现使任务分工更明确。客户端组件(RCC)专注于状态和交互,服务端组件(RSC)专注于数据获取。
-
RCC运行在客户端,拥有完整的交互性。所以可以使用状态管理
useState
、副作用useEffect
、事件监听器和生命周期钩子等等。 -
RSC仅在服务端运行,它的代码和依赖都不会下载到客户端。它无法使用和客户端交互相关的React API。
-
由于RSC运行在服务器上,它可以直接访问服务器资源。比如读取数据库和文件系统。
-
RSC需要和RCC结合使用。RSC负责在服务器获取数据,并传递给RCC。RCC负责为UI添加交互性。
-
RSC中导入的RCC默认是自动Code-Spliting的,所以浏览器只会加载真正需要的RCC组件的代码。
-
RSC组件中的
await
是会中断渲染的。如果不结合Suspense,那服务器响应会延迟,等待Promise解决。如果结合Suspense,那么响应不会等待,会先返回fallback内容。等到Promise解决后再流式传输到客户端。
RSC对比SSR
我们再来讨论另一个问题,这可能也是很多同学会疑惑的:那就是React已经提供了SSR能力,可以在服务器上渲染组件了,为什么又推出了RSC?这两者之间又有什么异同呢?
回顾一下,SSR是一种服务端渲染技术,服务器收到某个请求后,使用React在服务端渲染页面并输出html内容发送到客户端,客户端需要执行水合后接管。SSR的目标是SEO优化和加速首屏渲染。
比较一下异同:
-
相同点就是,它们都运行在服务端。
-
RSC在服务端渲染组件;SSR在服务端渲染整个路由页面。
-
RSC被渲染成特殊的序列化数据,发送到客户端来构建UI;SSR渲染生成静态HTML标记,发送到客户端展示。
-
RSC不需要水合,因为它不运行在客户端;SSR必须要水合,否则组件无法交互。
这个比较主要是为了让我们清晰这两者的功能边界。实际上,RSC和SSR不是互斥的技术,并不是用了SSR就不能用RSC。它们并不对立,反而是互补的。
当两者结合使用时,SSR仍然是负责渲染整个路由的组件树为html,当然组件树包含了RSC和RCC,它们都可以在SSR过程被渲染成html。而RSC除了在SSR阶段起作用,在客户端接管后仍然起作用。
Next.js下RSC的工作流程
下面所讲的内容基于Next.js
之类的全栈框架。这是因为想要不借助框架,直接使用React实现RSC+SSR还是很困难的。现在分析下Next.js
下的工作流程见识一下难度系数。
RSC在SSR流程下,以及在客户端接管后,工作流程是不同的,分情况来分析:
SSR流程下
客户端发起一个页面请求。比如在浏览器里,输入一个页面地址。
在服务端上:
-
当请求到达服务端,匹配到了路由,将从路由页面的根组件开始。(我们只考虑根组件为RSC的情况)。路由参数通过props传递给根组件。
-
React从根RSC开始向下深度遍历,并渲染树上所有RSC,生成
RSC Payload
数据。这个渲染过程仅限于RSC。遇到RCC组件,不会向下遍历:Host组件(如<div>)会被包含在RSC Payload
中;RCC组件则会生成chunk,并存储props和chunk引用也放到RSC Payload
中。 -
Next.js开始SSR渲染。根据
RSC Payload
和RCC代码渲染出来初始HTML。和原来一样,该初始HTML不包含交互,并支持Suspense
和流式渲染。不同的是,生成的HTML里被注入了RSC Payload
,在<script>标签内。如图:
在客户端上:
-
浏览器上接收到初始HTML并立即显示。此时还不可交互。
-
客户端React开始运行,从window上读取
RSC Payload
。有了这些数据,React就可以进行重建、协调和水合。 -
浏览器加载RCC对应的js chunks。并开始重建客户端组件树并开始水合,使整个页面变得可交互。
当然,这整个过程中流式HTML + 选择性水合还是生效的。
客户端接管后
当客户端React接管了交互后。路由跳转也是在客户端上处理。不会发起全新的请求。
在客户端上:
- 点击一个Link,跳转到一个新路由。Next.js发起一个rsc数据请求*(这个请求以
?__rsc=xxx
结尾)*。
在服务端上:
-
Next.js接收rsc请求,并匹配对应的RSC组件。
-
React从该RSC开始,渲染RSC树,并生成
RSC Payload
。 -
Next.js流式输出
RSC Payload
到rsc请求的reponse里。
在客户端上:
-
Next.js接收到流式response后,触发一次路由的重新渲染。
-
React根据最新的
RSC Payload
。加载RCC对应的js chunks。并与已存在的组件树进行协调。重新渲染完成后页面更新即完成。
从这里也可以看出来,React选用RSC Payload
,而不是直接使用HTML标记,来描述UI的另一个好处就是,它可以跟旧组件树很方便的协调。
总结二:RSC和SSR
前面介绍了SSR和RSC的功能重心,以及两者结合下的工作流程。也来做一个总结:
-
SSR是对整个路由页面的渲染,它生成初始html以在客户端快速展示。它的目标是降低首屏渲染。一旦页面呈现,它的职责就结束了。
-
RSC使组件可以在服务端渲染,它的粒度更细。它可以运用在SSR流程中,也可以在SSR流程结束后,客户端接管时起作用。生命周期长。
-
这两种情况下RSC的工作流程不太一样。
-
RSC被React渲染成一种叫
RSC Payload
的数据格式。这个数据格式旨在帮助客户端React重建、协调和水合组件树。
其它
到目前为止,想要在React项目中自己实现RSC+SSR还是很难的。从Next13开始的App Router已经默认支持RSC。感兴趣的小伙伴可以使用App Router
开始新项目,来体验RSC带来的全栈体验吧。
后续可能补充的知识点:RSC Payload数据详解、结合Server Actions、Next.js中的组件缓存、等等。