Server Component
2020 年末 React Blog 介绍了 Server Component,它是一种可以在服务端渲染的 React 组件。 它几乎跟普通组件一样,只是没有交互功能,所以你可以先在服务端渲染这些组件,然后在客户端继续渲染剩下的部分。
React Server Component 的目标是提高 Web 应用的性能,但这篇文章更想讨论它架构可能带来的开发便利。
Reactive vs Linear
作为一个新手,React 让我头疼的一点是它的逻辑分支很容易变多。
假设我们在写一个记忆卡片(Flash Card)应用。 卡片内容主要是一段话,里面有些被标注的关键词。 我们为卡片写一个简单的 React Component:
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 的实现作为对比:
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 实现:
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。
// page.tsx
export async function Page({ params }: { params: { id: string } }) {
const card = await prisma.card.findUnique({
where: { id: params.id },
}); // only fetch card
if (!card) {
return notFound();
}
return <KeysPage2 card={card} />;
}
// KeysPage2.tsx
"use client";
export function KeysPage2({ card }) {
const {
data: keys,
error,
isLoading,
} = useSWR(`/api/cards/${cardId}/keys`, keysFetcher); // fetch keys on client
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 做到这一点。 它是一种能在客户端触发,服务端执行的函数,可以用它实现更新关键词的功能:
export function KeyForm({ keyword }) {
async function serverAction(_prev: Response, formData: FormData) {
"use server";
const parsed = schema.safeParse(formData); // validate request
if (!parsed.success) {
return { error: MakeValidateError(parsed.error) };
}
const { id, meaning } = parsed.data;
await prisma.keyword.update({
where: { id },
data: { meaning },
});
revalidatePath(`/cards/${cardId}`); // Invalidate Router Cache
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 等),期待它后续能简化,不只在性能上,也在开发、维护成本上带来收益。