next php

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} />;
}

代码逻辑清晰很多:

  1. 先用 Prisma 在服务端访问数据库查出数据。
  2. 如果卡片不存在,我们返回 notFound 错误提示页面。
  3. 最后我们在服务端渲染 CardText 组件并返回。

这里可以看出 Server Component 两个开发方面的优势:

  1. 可以执行服务端代码,例如查询数据库。更方便开发全栈应用。
  2. 代码逻辑按线性组织。加载数据导致的 errorisLoading 状态被专门的路由组件处理,简化主逻辑。了解 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
// 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 做到这一点。
它是一种能在客户端触发,服务端执行的函数,可以用它实现更新关键词的功能:

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); // 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 开发者感到亲切。

use php

结语

把数据操作从客户端代码移除,简化客户端,只保留渲染和交互逻辑,会更受有后端经验的开发者青睐。

目前 React Server Component 引入了不少复杂性(React Server Component Payload, Hydration 等),期待它后续能简化,不只在性能上,也在开发、维护成本上带来收益。