2020 年末 React Blog 介绍了 Server Component,它是一种可以在服务端渲染的 React 组件。
它几乎跟普通组件一样,只是没有交互功能,所以你可以先在服务端渲染这些组件,然后在客户端继续渲染剩下的部分。
React Server Component 的目标是提高 Web 应用的性能,但这篇文章更想讨论它架构可能带来的开发便利。
Reactive vs Linear
作为一个新手,React 让我头疼的一点是它的逻辑分支很容易变多。
假设我们在写一个记忆卡片(Flash Card)应用。
卡片内容主要是一段话,里面有些被标注的关键词。
我们为卡片写一个简单的 React Component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| export function Card({ id }) { const { data: card, error, isLoading, } = useSWR(`/api/cards/${id}`, cardFetcher); const errorMsg = error ? getErrorMsg(error) : undefined; return ( <> {isLoading ? ( <Skeleton variant="rectangular" /> ) : ( <CardText text={card.text} keys={card.keys} /> )} {errorMsg && <Alert severity="error">{errorMsg}</Alert>} </> ); }
|
这里用了SWR库来调用后端 API。
它提供 React Hooks 的方式来管理远程数据。
每个查询被划分为三个状态:data
, error
, loading
。
状态是让 React 组件逻辑分支变多的主要原因。
这里可以把流程判断简化成是否有加载数据,以及是否有错误这两个独立的分支。
现在写一个 Next.js Server Component 的实现作为对比:
1 2 3 4 5 6 7 8 9 10
| export async function Page({ params }: { params: { id: string } }) { const card = await prisma.card.findUnique({ where: { id: params.id }, include: { keywords: true }, }); if (!card) { return notFound(); } return <CardText text={card.text} keys={card.keys} />; }
|
代码逻辑清晰很多:
- 先用 Prisma 在服务端访问数据库查出数据。
- 如果卡片不存在,我们返回
notFound
错误提示页面。
- 最后我们在服务端渲染
CardText
组件并返回。
这里可以看出 Server Component 两个开发方面的优势:
- 可以执行服务端代码,例如查询数据库。更方便开发全栈应用。
- 代码逻辑按线性组织。加载数据导致的
error
和 isLoading
状态被专门的路由组件处理,简化主逻辑。了解 Web 后端开发的人会联想到各种框架里的中间件/拦截器机制。
混合使用 Component
现在只有查看功能,我们再加点编辑功能:用户可以在旁边修改关键词的释义。
先用普通 React 实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| export function KeysPage({ card }) { const [selectedKeyId, setSelectKeyId] = useState<string | null>(null); const keys = card.keys; const keyword = keys.find((key) => key.id === selectedKeyId); return ( <> <CardText text={card.text} keys={card.keys} setSelectKeyId={setSelectKeyId} /> {keyword && <KeyForm cardId={card.id} keyword={keyword} />} </> ); }
export function KeyForm({ cardId, keyword }) { const { trigger, isMutating } = useSWRMutation( `/api/cards/${cardId}`, updateKeyFetcher ); const [meaning, setMeaning] = useState(keyword.meaning); return ( <div> <textarea rows="4" value={meaning} onChange={(e) => setMeaning(e.target.value)} /> <button onClick={() => trigger({ id: keyword.id, meaning })} disabled={isMutating} /> Update </div> ); }
|
这里用 useSWRMutation
Hook 配合 useSWR
管理数据。
调用 trigger
更新关键词后,会自动失效参数 key
指定的 /api/cards/${cardId}
的 SWR 缓存。
回到 Server Component 的实现,我们要把卡片数据和关键词数据分开。
因为这个页面不会更新卡片,卡片可以在服务端渲染,而关键词可能会被更新,需要在客户端渲染。
需要在客户端渲染的组件称为 Client Component,也就是以前的 React Component。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| export async function Page({ params }: { params: { id: string } }) { const card = await prisma.card.findUnique({ where: { id: params.id }, }); if (!card) { return notFound(); } return <KeysPage2 card={card} />; }
("use client"); export function KeysPage2({ card }) { const { data: keys, error, isLoading, } = useSWR(`/api/cards/${cardId}/keys`, keysFetcher); const [selectedKeyId, setSelectKeyId] = useState<string | null>(null); const keyword = !isLoading ? keys.find((key) => key.id === selectedKeyId) : undefined; const errorMsg = error ? getErrorMsg(error) : undefined; return ( <> <CardText text={card.text} keys={card.keys} setSelectKeyId={setSelectKeyId} /> {keyword && <KeyForm2 keyword={keyword} />} {errorMsg && <Alert severity="error">{errorMsg}</Alert>} </> ); }
|
好消息是,Server Component 和 Client Component 可以无缝混合使用。
坏消息是,现在要区分哪些数据是在服务端获取,哪些在客户端获取。在提供了性能优化机会的同时,也增加了复杂度。
主动刷新客户端
能否在服务端获取所有数据,直接渲染,在客户端更新数据后,重新渲染呢?
可以用 Next.js Server Action 做到这一点。
它是一种能在客户端触发,服务端执行的函数,可以用它实现更新关键词的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| export function KeyForm({ keyword }) { async function serverAction(_prev: Response, formData: FormData) { "use server"; const parsed = schema.safeParse(formData); if (!parsed.success) { return { error: MakeValidateError(parsed.error) }; } const { id, meaning } = parsed.data; await prisma.keyword.update({ where: { id }, data: { meaning }, }); revalidatePath(`/cards/${cardId}`); return DefaultResponse(); } const [state, action] = useFormState(serverAction, DefaultResponse()); const { pending } = useFormStatus(); const errorMsg = GetApiError(state)?.message; return ( <form action={action}> <input type="hidden" name="id" value={keyword.id} /> <textarea name="meaning" rows="4" defaultValue={keyword.meaning} /> {errorMsg ? <Alert severity="error">{errorMsg}</Alert> : null} </form> ); }
|
关键代码是用 revalidatePath
触发客户端重新渲染。
为了紧凑,这里把 Server Action 定义在 Client Component 里。
在描述界面的文件里写数据库操作,这也许会让 PHP 开发者感到亲切。
结语
把数据操作从客户端代码移除,简化客户端,只保留渲染和交互逻辑,会更受有后端经验的开发者青睐。
目前 React Server Component 引入了不少复杂性(React Server Component Payload, Hydration 等),期待它后续能简化,不只在性能上,也在开发、维护成本上带来收益。