把 React 当成 PHP 来写

next php

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

代码逻辑清晰很多:

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

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

  1. 可以执行服务端代码,例如查询数据库。更方便开发全栈应用。
  2. 代码逻辑按线性组织。加载数据导致的 errorisLoading 状态被专门的路由组件处理,简化主逻辑。了解 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 开发者感到亲切。

use php

结语

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

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