React 从 RSC 到 v19 的主要新范式介绍及应用场景分析

从 React v16.8 发布 hooks 以来,已经过去足足 5 年时间(2024),我们似乎已经很久没有学习 React 新的范式并应用到开发中了。这其中主要原因可能有两点:
- Hooks (也被称为 signals (tc39 proposal)) 范式已经是响应式编程的终点,在每种框架甚至原生 JS 都将要实现它后,剩下的只有打补丁工作
- React 团队的重心迁移到 SSR 上,新的范式全是从 SSR 的场景中衍生的,在商业利益上也和 Vercel (NextJS) 更加绑定。越来越脱离群众,以致社区也出现了分歧。
本文将从需求场景的演进历史出发介绍 React 19 主要新范式的源流,相信看完后会让你对 hooks 版本之后的 React 演进路线有更全面的认知,提升姿势水平,对社区的分歧也会有自己的判断。
本文全部代码都有 codesandbox 分享,推荐优先打开并 fork 代码体验。
示例一 NextJS SSR + postgreSQL +Shadcn
nextjs: react server component (RSC) + suspense
RSC ( react server component) 是 React 的新SSR 解决方案,也是 React 团队目前主要的业务发展方向,尽管社区不太买账。
React Dan Abramov 的论点是将 ui=f(state) 这种范式拿到 server 端是有用的,所以我们需要 RSC 。
社区普遍的态度是不希望被强加 SSR,比如各种 Web App 场景。对 SSR 的强推说明 React 和 Vercel 的利益绑定太深。
所以社区出现了分歧, 许多 KOL 不再 follow React 团队的方向,比如 tanstack 出了 react-router ,对 Nextjs 取其精华去其糟粕(狗头) tanstack.com/router/late…
抛开这些不谈, 在必须 SSR 的场景下, RSC 的确带来了比较好的开发体验 ,让 SSR 组件的开发体验和 CSR 几乎没有差别,语法上没有任何区别。暴论:React 团队永远最在乎开发体验,其实不在乎性能和渲染损耗(都是假装的)。
async function getData() {
const res = await fetch('https://api.example.com/...')
if (!res.ok) {
throw new Error('Failed to fetch data')
}
return res.json()
}
// 将 Function Compoent 语义化的当做普通函数对待
export default async function Page() {
// 内联 await
// Promise pending 时打到组件树上层的 loading <Suspense>
// throw Error 时打到 Error boundary <Suspense>
const data = await getData()
// 限制也很明显 ,RSC 内不能有状态 ( 如 useState ) 否则 State 变化 await 也会重执行
// 表现为state变一下 loading 一下,所以 RSC 预期只初次加载时执行一次(如刷新页面时)。
return <main></main>
}
async FC 内联 await ,配合 suspense 边界,在 Nextjs 示例里这一套能很优雅的实现 SSR 的流式 UI。
顺便了解一下社区火热但一直没用过的 headless ui shadcn/ui,在 next 里天然适配。
npx shadcn-ui@latest add input
简述一下就是不用npm装,组件代码直接拷贝到项目约定文件夹里,但需要项目先配好 tailwind ,组件所有样式全用 tailwind 实现,所以组件库不带样式文件即 headless。同时也是 React 库,基于 Radix UI , 用户体验也是开箱即用。
如果你没用过新版本的 NextJS 和 RSC ,推荐新建项目并走到 Loading and Streaming 这一步,预计十分钟左右。
同构范式:<form>
+ action
站在 SSR 的角度,必须要区分哪些逻辑在服务端执行, 哪些逻辑在客户端执行,RSC 发明的范式如下:
- 对于component (Function Component), 默认为 server component 服务端渲染即 RSC,不能引入 useState 这种,即不能有状态,如果有状态则 state 变一下 FC 会重新 Render 一下不符合预期;如需使用 client component 则 tsx 文件头用 "use client"标识
- 对于普通函数ts 文件,默认客户端执行,如需要服务端执行(如数据库操作),ts 文件头用 "use server" 标识
于是出现问题: 典型场景-表单提交,表单有状态,需要"use client" ,而提交动作在同构上下文中一般直接ORM 调库,则这块逻辑必须放进另一个文件,标识"use server"
codesandbox 示例,页面url是 /dashboard : codesandbox.io/p/devbox/po…
use client Form 组件
"use client";
....
import { submitName } from "./handleSubmit";
export function ProfileForm() {
function onSubmit(values: z.infer<typeof formSchema>) {
// server action here
submitName(values.username);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} >
<FormField
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
</FormItem>
)}
/>
<Button type="submit">Add</Button>
</form>
</Form>
);
}
use server ts 文件
"use server";
import { user } from "@/db/schema";
import { db } from "@/db";
import { revalidatePath } from "next/cache";
export const submitName = async (name: string) => {
if (name === "del") {
await db.delete(user);
} else {
await db.insert(user).values({ name }).execute();
}
revalidatePath("/dashboard");
};
是否有办法将一个表单页面作为 Server component ,此时 server action 和表单 FC 在一个文件里?
React 给出的解决方案是 react-dom 新增范式: <form>
dom 元素的action :
react.dev/reference/r…
它不仅是只用于 form , 设计上来看它希望这种范式作为全部状态提交的最佳实践,不再需要一个 Input 一个受控的模式,而是至少套一个 form 这样使用。我们可以从 shadcn 文档看出端倪 ui.shadcn.com/docs/compon… input 组件不再给出单独受控示例,只有结合 form 的使用示例。
简要来说就是 react-dom 给原生 <form>
元素封装了受控逻辑,通过 action 回调拿到值,所以即使 form 自身有状态和变化,但不会引起使用 form 的父组件的重执行。
如下组件没有任何状态,只会执行一次,form 内状态变化不会影响外层:
export default function Search() {
function search(formData) {
const query = formData.get("query");
alert(`You searched for '${query}'`);
}
return (
<>
<h3>formAction</h3>
<form action={search}>
<input name="query" />
<button type="submit">Search</button>
</form>
</>
);
}
这种模式非常适合 RSC ,可以在同一个文件里既让 Form 组件作为 Server 组件,同时还能直接调 Server action
示例 nextjs + postgreSQL 同构调库,页面 url 是 /form-actions:
import { user } from "@/db/schema";
import { db } from "@/db";
export default async function Home() {
return (
<main>
<div>
<form
//** form 既是 Server 组件, 又能调 Server action**
action={async (formData: FormData) => {
"use server";
const name = formData.get("name") as string;
if (name === "del") {
await db.delete(user);
} else {
await db.insert(user).values({ name }).execute();
}
revalidatePath("/form-actions");
}}
>
</form>
</div>
</main>
);
}
你可能会质疑在前端代码里调用数据存储是否有点草台,可以看下面章节。
边缘计算: Serverless 执行 SSR 首屏渲染并缓存,分布式应用
SSR 的首屏逻辑可以不部署在单点服务器,而部署到 serverless 节点运行,使用 serverless 提供的 KV (key value)存储 。nextjs 和 vercel 的利益绑定体现在此。
如何缓存POST, 对body摘要
developers.cloudflare.com/workers/exa…
你可能会问这个 serverless 函数实例里的 KV 存储是单点的还是分布式的, 是分布式的,比较涨姿势。
虽然 Serverless 架构的价值存在争议,比如有人质疑科技公司就是不时的创造新概念让企业来买单,但 Vercel 这一套就是当前最先进的 Web 无服务架构,并且做到了最佳易用性。
它也契合当前的互联网安全要求,很自然的做到应用层和存储层都只存在于各国的数据中心。
这一套也被称为 Jamstack (JavaScript、APIs、Markup) 架构,只适合一些内容更新不太频繁的网站(比如新闻、电商、文档)。它不适合 Feeds 流、聊天室、论坛、个性化推荐这样高度动态化的网站,以及邮箱、编辑器这样偏重型的 Web App。
很多人会问它和传统的 PHP, JSP , WordPress 比的优越性在哪。传统的一体化架构还是单点部署,而 Serverless 架构的整个应用层是纯分布式部署运行的。即使国内流量入口都是 APP,但 APP 内的页面也能接入这一套,实现最理想的云服务架构。
zhuanlan.zhihu.com/p/281085404
当然你可能又会问,如果回归农业文明,纯静态资源分布式部署到 CDN 是不是也算?比如 Dapp (en.wikipedia.org/wiki/Decent…) , 或者 SPA 也是纯静态资源,直接前端发 SQL连库,是不是也算应用层分布式了?好像也是的,但还是缺少 APP 必要的少许 Server 端能力,比如连库的密码,Oauth2 的secretId 总不能写在前端吧, 所以简单的 Server 能力由 Serverless云函数补齐。ToC 业务的首屏秒开也是典型业务场景。
React 19
React 19 公测版最主要的新 Hooks 是 useActionState 和 use
-
useActionState 是将
<form>
action 这种新范式引入客户端逻辑的产物。 -
上文 async FC 内直接 await 也是 RSC 的特性, use() 也是将其引入客户端逻辑的结果
React 19 use()
这个相对来说还比较有用,但不是给业务代码使用的。
client component 不支持 async FC 里直接 await ,则 use() 用法就相当于 await, 和 RSC 里表现一致。
codesandbox: codesandbox.io/p/sandbox/r…
import { use, Suspense, useState } from "react";
import { fetcher } from "./fetcher";
//client component 不支持 async
function Comments() {
// 类似于 RSC 中 async FC 里直接 await
// fetcher(1) resolve 前打到组件树 Suspense
// 作为 use 参数的 promise 不能 FC 内联创建
const comments = use(fetcher(1));
return (
<>
<p>RenderContentUse</p>
<p>{comments?.title}</p>
</>
);
}
export function RenderContentUse() {
const [name, setName] = useState("");
// 同样 name 变一下 Comments loading 一下, 和 RSC 一致
return (
<>
<h3>Render Content using React 19</h3>
<Suspense fallback={<div>Loading...</div>}>
<input value={name} onChange={(event) => setName(event.target.value)} />
<Comments />
</Suspense>
</>
);
}
总得来说还需要处理额外的 cache 逻辑 ,也会报 warning
Warning: A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework.
也直接告诉你了 uncached , 不要直接用,推荐用 library or framework,或直接使用 SWR 这种库。
React 18 fiber 功能 useTransition
我们可以回溯性的看下 React 18 最重要的 fiber hooks useTransition ,使用它实现查询操作
codesandbox 示例 useTransition: codesandbox.io/p/sandbox/r…
import { useTransition, useState } from "react";
import { fetcher } from "./fetcher";
export function SearchContentUseTransition({}) {
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [error, setError] = useState(null);
const [isPending, startTransition] = useTransition();
const handleSubmit = async () => {
// fiber 低优先级更新用 startTransition 包裹
// https://react.dev/reference/react/useTransition
startTransition(async () => {
const { title, body } = await fetcher(name);
setTitle(title);
if (!title) {
setError(true);
} else {
setError(false);
}
});
};
return (
<div>
<h3>Search Content using React 18</h3>
<input value={name} onChange={(event) => setName(event.target.value)} />
<button onClick={handleSubmit} disabled={isPending}>
Search
</button>
{isPending && <p>pending...</p>}
{title && <p>{title}</p>}
{error && <p>404 not found</p>}
</div>
);
}
React 19 功能 useActionState
将上面 useTransition 例子改写为 useActionState 实现:
codesandbox : codesandbox.io/p/sandbox/r…
import { useTransition, useState, useActionState } from "react";
import { fetcher } from "./fetcher";
export function SearchContentUseActionState() {
const [payload, submitAction, isPending] = useActionState(
async (previousState, formData) => {
console.log("previousState", previousState);
const name = formData.get("name");
const payload = await fetcher(name);
return payload;
}
);
return (
<>
<h3>Search Content using React 19</h3>
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit" disabled={isPending}>
Search
</button>
{isPending && <p>pending...</p>}
{payload?.title && <p>{payload.title}</p>}
{!payload?.title && <p>404 not found</p>}
</form>
</>
);
}
- 从用户角度看 useActionState 就是把 useTransition 多包了一层,它有什么价值?
- react.dev/reference/r… useActionState 就是为client component 提供
<form>
action 范式的 adaptor - 关键点在于 react.dev/reference/r… 这个不只是扩展
<form>
, 而是相当于出了一整套数据 post 操作的标准,同时适用于 Client 和 Server 的写法范式,即同构范式。
- react.dev/reference/r… useActionState 就是为client component 提供
- 反题:现在有几个 UI 库是把
<form>
元素直接暴露在业务代码里使用的 (除了 NextJs 自己的 shadcn/ui)?如果我不写 SSR,纯客户端逻辑里使用这个 action 有什么收益?引入新的范式复杂度,也没有解决额外的问题?- 无力反驳,比 useTransition 更难用。从React 角度看可能是支持了前后端同构的范式,但从用户角度看成本高收益小基本没用,可见前途更渺茫。还是回到最初的问题,我们是否需要 SSR ,如果不需要则没什么价值。
- 挽尊的来看,也可以说吸取了 useTransition 的教训,这些新 Hooks 都是给库用的,并不是给用户用的。比如 antd 这种可以重构自己的
<form>
, react-query 可以封装新的 fiber hooks
还有两个新 hooks useFormStatus 和 useOptimistic ,比较简单,适合和 form action 这个范式配套使用,可以看下面文档,里面也有 codesandbox 示例。
总结
经过上文,可以预期的是如果不写SSR/不写工具库,这些新 hooks 范式还是不会影响到我的开发。即使是工具库作者,是否大规模重构来 follow 这些范式也是值得斟酌的,毕竟收益有限。
这些新 Hooks 的确有些挽尊式更新的意思,就是将 SSR 场景的各种新范式更新进了 React。只能说 v18 之后的 React 新功能不太应该放进 'react' 这个包里,叫 'react-server' 或 'react-next' 更合适。
总的来说, useTransition / useActionState 这些 hooks 都和 React 自己发明的范式太耦合,和 Signals (tc39 proposal) 这种通用范式距离越来越远。没有用户会无端在业务代码里添加额外的范式复杂度,它基本上没有收益(如果不用 SSR),只能是将项目和 React (现在是 NextJS/Vercel) 本身的利益捆绑的越来越深。
写文章本身也是一个学习的过程,也请读者能指出文章中的疏忽错漏之处。如果本文对你有所帮助,欢迎点赞收藏。
转载自:https://juejin.cn/post/7363650007256252431