React 19 正式版落地后,整个前端圈的"心智模型"其实又轻轻挪了一步:从"手动 memo/useCallback"到"编译器托管",从"loading/error 三件套"到"Actions +
use()",从"forwardRef + useImperativeHandle"到"ref 就是普通 prop"。这些变化不是新语法糖,而是在重新回答"React 到底是什么"。这篇博客不是新特性罗列。我打算结合我们项目(一套中后台风控平台,React 18.2 + Vite + Ant Design 5 + AntV X6 + Redux Toolkit + ahooks)里几个亲手封装过的组件,把当前 React 生态最新的位置、我们代码里用旧写法碰巧写对的地方、以及未来演进的空间,一次讲清楚。既是学习笔记,也是给自己的 codebase 做一次"当代化体检"。
一、当下 React 生态在哪儿?一张速览
先把坐标画出来,后面所有讨论都在这张图上:
| 层次 | 稳定选择 | 我们项目当前 | 未来趋势 |
|---|---|---|---|
| 语言核心 | React 19(Stable 2024.12) | 18.2 | 19 |
| 优化编译 | React Compiler(RC) | 未使用 | 引入后可下掉大量 memo/useCallback |
| 数据取数 | React Query / SWR / RSC + use() |
手写 hooks + axios | 迁 TanStack Query |
| 表单 | react-hook-form / rc-field-form | AntD 内部 rc-field-form | 保持 |
| 全局态 | Zustand / Jotai / RTK | RTK + react-redux | 中后台里 RTK 还挺够用 |
| 路由 | React Router 6/7 / TanStack Router | react-router 6.14 | 6.x 已足够,或升 7 |
| 构建 | Vite / Rspack / Turbopack | Vite | 保持 |
| 元框架 | Next / Remix / TanStack Start | 无(纯 SPA) | 中后台没必要上 |
一句话总结:React 19 让"运行时更薄、编译时更厚",让并发 API 从"高级选修"变成"日常必用",让数据获取从组件外挪回组件内。而中后台 SPA 项目,其实是最能享受这些变化红利的场景------不用为 SSR/RSC 头疼,只用挑对我们有价值的部分吃下去。
二、React 19 到底带来了什么?(挑对我们有用的讲)
省略"官方 What's New 目录",我按"对已有项目的实际改动量"排序:
2.1 ref 现在是普通 prop:forwardRef 大部分场景可以下岗
我们项目里最典型的模式是这样的(NewAndOr 组件):
jsx
// 老写法:18.x
export default function NewAndOr(props) {
const { andOrRef, /* ... */ } = props
useImperativeHandle(andOrRef, () => ({
getData: async () => { /* ... */ },
setData: (data) => { /* ... */ },
}))
}
// 消费方
const ref = useRef()
<NewAndOr andOrRef={ref} />
注意到我们没有用 forwardRef------而是自己起了个 prop 叫 andOrRef 。这在过去被认为是"奇技淫巧",一堆 ESLint 规则不认。但在 React 19 里,这正是官方主推的写法 :函数组件可以直接把 ref 当作普通 prop 接收:
jsx
// React 19 写法
export default function NewAndOr({ ref, /* ... */ }) {
useImperativeHandle(ref, () => ({ getData, setData }))
}
// 消费方
<NewAndOr ref={myRef} /> // 就是普通的 ref prop 传递
我们代码里那种"再包一层 andOrRef 别名"的做法,反过来说明当时同事的直觉是对的:函数组件本来就该能直接拿 ref 。React 19 只是把这层官方语义补上。升级后可以把项目里所有 xxxRef prop 全部改回原生 ref,删掉一层间接。
2.2 use():把 Promise/Context 变成"可暂停"的值
jsx
// 传统写法(我们项目里到处都是)
const [data, setData] = useState()
useEffect(() => {
fetchAttrTree().then(setData)
}, [])
if (!data) return <Spin />
return <Tree data={data} />
// React 19 写法
const data = use(fetchAttrTreePromise) // 组件内直接"拿到"这个 Promise 的结果
return <Tree data={data} />
配合 <Suspense fallback={<Spin />}> 把加载态外提。这不是语法糖,是心智模型的转变:数据的"未就绪"状态不再是组件内的 if/else,而是通过 Suspense 边界向上"抛"。
对我们项目意味着什么?------AttrFeatTreeSelect、AndOr 里那些 useEffect + Promise + setState 的三段式,未来可以整理成:
jsx
const attrFeature = use(getAttrFeatureCached(masterFlowNo))
配合数据缓存层(比如 TanStack Query 的 queryClient.ensureQueryData 返回 Promise),组件里的 loading 分支能直接删掉一大半。代码量往往下降 40%+,可读性大幅提升。
2.3 Actions + useActionState + useOptimistic:把"提交表单"讲成一段故事
我们项目里"点击测试→loading→接口返回→弹结果"这种流程,写法是:
jsx
const [testLoading, setTestLoading] = useState(false)
const handleTest = async () => {
setTestLoading(true)
try {
const res = await runTest(payload)
if (res.code === '0') { /* ... */ } else { /* ... */ }
} finally {
setTestLoading(false)
}
}
在 React 19 里,这段可以写成:
jsx
const [result, submitAction, isPending] = useActionState(async (prevState, formData) => {
return runTest(formData)
}, null)
<form action={submitAction}>
<Button htmlType="submit" loading={isPending}>测试</Button>
</form>
useOptimistic 更是把"提交前先假装成功"这个模式做成了一等公民------在批量操作、点赞、抢占式 UI 上非常好用。中后台里我们有大量"提交策略草稿→拉最新版本→刷新列表"的流程,这套 API 能把状态机压到最低。
2.4 React Compiler(RC):useMemo/useCallback 大规模下岗
我们项目里到处都是这样的写法:
jsx
const memoized = useMemo(() => heavy(x), [x])
const onClick = useCallback(() => doSth(y), [y])
写多了会有一种"这不就是给编译器打工吗"的感觉。React Compiler 就是替这个感觉兜底:它在编译期静态分析,自动插入等价的 memoization。开发者只写普通 JS,编译器保证 render 稳定性。
对我们代码库的实际影响:80% 的手写 memo 可以删掉 ,包括我们在 NewAndOr 里那些 const renderConditItem = (item, index) => { ... } 每次 render 都新建的函数------编译器会自动稳住它们,ReactSortable 也不会再因为函数引用变了而误判。
(编译器目前仍是 RC 状态,接入建议先在小模块灰度、观察 lint 告警------它对"违反 React 规则的代码"要求比较严,比如条件里调 hook、setState 里读 state 之类。)
2.5 useSyncExternalStore 稳定 + Concurrent 特性成年
这不是 19 独有,但值得点名。我们项目里有一个 PlatformContext(租户/风险域/环境),过去写成 Context + Provider 层层往下传。但Context 的一个致命问题是:任何一个订阅了 Provider value 的组件,在 value 变化时都会 rerender------无法做"细粒度订阅"。
解决方案就是 useSyncExternalStore。它是 Zustand、Jotai、Redux 现代版本底层用的同一个 hook:
js
// 建一个 tiny store(不用装 Zustand 也能自己写)
const createStore = (initial) => {
let state = initial
const listeners = new Set()
return {
getState: () => state,
setState: (partial) => {
state = { ...state, ...(typeof partial === 'function' ? partial(state) : partial) }
listeners.forEach(l => l())
},
subscribe: (l) => (listeners.add(l), () => listeners.delete(l)),
}
}
// 组件里精细订阅
function useStore(selector) {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(store.getState()),
)
}
我们项目现在的 useSelector(react-redux)底层其实就是这套。理解了 useSyncExternalStore,你能读懂现代所有 React 状态库的实现------它们都在这 30 行代码上盖房子。
三、回看我们项目里那些封装,用今天的眼光重新审视
下面挑六个具体组件/模式,一个个看:当时的选择、放到今天怎么看、如果重写会怎么改。
3.1 ErrorBoundary:唯一一个"class 组件不可替代"的场景
我们的实现:
jsx
export default class ErrorBoundary extends React.Component {
state = { error: null }
static getDerivedStateFromError(error) { return { error } }
render() {
return this.state.error
? <Result status="error" title="页面出错了"><pre>{this.state.error.stack}</pre></Result>
: this.props.children
}
}
这段 class 是要留下来的 ------React 19 都发了,官方仍然没提供 hook 版的 ErrorBoundary。因为函数组件没有"componentDidCatch 时刻",捕获必须发生在 render 阶段之外。业界的 react-error-boundary 库也是内部用 class 包一层。
现代化的改法只在如何补充信息上 :加 componentDidCatch 把错误上报到 Sentry,加 resetKey 支持"点重试后清 error":
jsx
componentDidCatch(error, info) {
Sentry.captureException(error, { extra: info })
}
结论:ErrorBoundary 是"class 组件的最后堡垒",可以放心继续用 class 写。
3.2 AuthWrapper:HOC 时代的产物,可以退休了
原实现(简化):
jsx
export default function AuthWrapper({ auth, children, workDomainAuth = true }) {
const roles = useSelector(({ common }) => common?.userInfo?.roles || [])
return workDomainAuth && isCanOption(roles, auth) ? children : null
}
它现在是"函数组件形态的 HOC"。可以更进一步------做成 hook 优先,组件是 hook 的语法糖:
jsx
// 核心逻辑用 hook 表达
export const useAuth = (auth, workDomainAuth = true) => {
const roles = useSelector(({ common }) => common?.userInfo?.roles || [])
return workDomainAuth && isCanOption(roles, auth)
}
// 组件形态只是薄薄一层
export default function AuthWrapper({ auth, children, workDomainAuth = true }) {
return useAuth(auth, workDomainAuth) ? children : null
}
好处:业务代码里那些"我要根据权限决定按钮 loading/disabled 而不是显隐"的场景 ,直接 const canPublish = useAuth('strategy.publish') 就够了,不用 <AuthWrapper> 层层包。
3.3 useImperativeHandle 暴露的组件方法:还有没有更好的方式?
NewAndOr 里通过 useImperativeHandle 对外暴露了 getData / setData。父组件通过 ref 拿到这个"命令式接口",在提交时调 andOrRef.current.getData()。
这个模式本身没错------这就是命令式子组件的经典桥梁------但它有两个隐性代价:
- 父子之间的数据流从 React 的"单向 props"跑到了"命令式方法",静态分析工具追不到
getData内部混合了"读当前值 + 校验 + 请求",测试起来重
现代 React 的两种优化方向:
方向 A:把状态提升到父组件(受控化) 。父组件持有 conditData,NewAndOr 只做 onChange 回调。校验逻辑作为独立函数暴露:
jsx
// 独立纯函数
export const validateCondition = async (conditData, ctx) => { /* ... */ }
// 父组件
const [condit, setCondit] = useState(initial)
<NewAndOr value={condit} onChange={setCondit} />
const handleSubmit = async () => {
const result = await validateCondition(condit, ctx)
if (result.code === '0') submit(result.graphJSON)
}
好处:validateCondition 是纯函数,单测/复用都容易,React 19 里配合 useActionState 更顺滑。
方向 B:保留 imperative,但把 ref 通过普通 prop 传(React 19 语法):
jsx
function NewAndOr({ ref, /* ... */ }) { /* ... */ }
<NewAndOr ref={andOrRef} />
我个人建议新组件优先方向 A,老组件(改动风险大的)沿用方向 B 升级即可。
3.4 useEffect + useRef 状态机 vs useReducer vs useSyncExternalStore
useCodeCompletion 里我们用了 5 个 useRef 做状态机(防抖闸门、竞态 id、in-flight 引用、"正在应用"标志、logId)。为什么不用 state?------因为这些"状态"变了不应该触发 rerender:
js
const canTriggerRef = useRef(false)
const isApplyingCompletionRef = useRef(false)
const inflightAbortControllerRef = useRef(null)
const latestRequestIdRef = useRef(0)
const lastLogIdRef = useRef(null)
这是 ref 最正确的用法:"实例级"可变数据,不参与视图。
如果这个状态机继续变复杂(比如加入用户开关切换、模型切换),我会考虑改成 useReducer:
js
const [machine, dispatch] = useReducer(completionReducer, initial)
useReducer 相比 5 个 ref 的优点是动作可枚举 ,能被日志中间件包住做 replay。而如果这个状态需要跨组件共享 (比如"补全 loading" 状态既要在编辑器里显示又要在 AI Chat 面板里显示),我会把它抬到 useSyncExternalStore 支撑的 store 里去------这就是从 useState → useReducer → external store 的进化路径。
3.5 数据获取:现在最痛的一块
项目里数据获取代码长这样(省略):
jsx
useEffect(() => {
let cancelled = false
const load = async () => {
setLoading(true)
try {
const res = await getTreeDataByConfig(params)
if (!cancelled) setData(res)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [params])
每个组件 20 行左右,全项目类似逻辑写了几百处。这是我目前最想重构的部分。
引入 TanStack Query 之后:
jsx
const { data, isLoading } = useQuery({
queryKey: ['treeData', params],
queryFn: () => getTreeDataByConfig(params),
staleTime: 5 * 60_000,
})
收益:
- 请求去重(同一时刻组件树里 N 个组件问同一份数据只发 1 次)
- 后台自动刷新(stale-while-revalidate)
- 错误/loading 状态开箱即用
- 与 React 19 的 Suspense/
use()天然兼容(可以直接use(queryClient.ensureQueryData(...)))
代价 :改造工作量大 + 全局缓存 key 命名要有规范。但我认为这是所有中后台项目最应该优先做的一件事------数据层的一致性和渲染层的一致性同样重要。
3.6 ReactSortable + cloneDeep:可以被 immer 优雅替代
NewAndOr 里增删改条件都是 cloneDeep(conditData) + 局部 mutate + setCondition(newdata)。放到今天更 idiomatic 的写法是用 useImmer:
jsx
import { useImmer } from 'use-immer'
const [conditData, setConditData] = useImmer(initial)
const addCondit = (groupIndex, index, type) => {
setConditData(draft => {
const row = type === 'copy'
? { ...draft.conditions[groupIndex].conditions[index], id: generateid() }
: emptyRow()
draft.conditions[groupIndex].conditions.splice(index + 1, 0, row)
})
}
好处:只写"你要改什么",Immer 帮你写"React 需要什么"。性能上 immer 用 Proxy 只克隆真正改到的路径,对我们那个最多 50 条条件的场景,比 cloneDeep 快一个数量级。
坦白说,cloneDeep 我们当时选它是因为团队都熟、心智负担为零。今天新写的组件我一定会用 immer。
四、构建、TS 与工程生态:Vite 时代要不要再折腾?
我们项目在 Vite 5 上,冷启动 < 3s,HMR < 200ms。继续留在 Vite 是正确选择。Rspack / Turbopack / Farm 都不错,但对已经跑得挺快的项目没有强升级动力。
TypeScript 侧值得一提的是**import type + verbatimModuleSyntax**------它让编译产物更干净,同时让"类型误当运行时"这类错误在编译期就报出来。这也是新版 Vite 模板的默认配置。
再往上一层,类型即接口契约 这件事一定要做------我们用 openapi-typescript 从后端 OpenAPI 生成 TS 类型:
bash
openapi-typescript ./openapi.json -o src/types/api.d.ts
后端字段一改,前端立刻编译报错。运行时的 undefined bug 会显著下降------这个投资我在多个中后台项目都验证过,回报率极高。
五、React 生态未来 12 个月我关注什么
① React Compiler 转 Stable:一旦 Stable,会推动大部分项目删除 useMemo/useCallback。是"lint 规则一键 fix"级别的变革。
② TanStack Query 5 + Suspense/use() 深度融合:数据获取的官方推荐路径正在被明确。
③ Server Components / Server Actions(Next 15):中后台里不一定用,但**"边界感"这个概念会渗透到 SPA**------哪些计算在服务器、哪些在客户端,会越来越清晰。
④ TanStack Router:类型安全的路由。React Router 7 也在补齐,赛道很热闹。中后台项目值得试。
⑤ Zustand v5 / Jotai / Signals 提案 :全局态方案还会继续百花齐放。我个人的判断:中后台项目 Redux Toolkit 依然稳,业务复杂度到一定量级 Redux 的可观测性无可替代;小项目直接 Zustand。
六、写在最后:技术选型的一点感悟
回看我们项目里那些 2022 年写的组件------一些放到今天依然正确(比如把 X6 用 class 封装、比如 ErrorBoundary 用 class),一些明显是"当时最好的选择"但不再是"今天最好的选择"(cloneDeep、HOC、命令式 ref 桥梁),还有一些甚至歪打正着提前踩中了 React 19 的方向(比如把 ref 当普通 prop 传)。
这让我形成一个信念:跟得住 React 生态的最好方式,不是每次新特性出来都急着重构,而是持续吸收"新的心智模型",让新代码自然而然写在正确的方向上。旧代码等到有明显收益时再动。
具体到这个项目,我给自己排了一份未来 3 个月的技术演进小 backlog:
- 引入 TanStack Query,收敛所有 GET 类请求(收益最大)
- 新表单/新状态一律用
useImmer或 Zustand,不再写 cloneDeep + useState - 升级到 React 19,把
forwardRef全部干掉 - 灰度试点 React Compiler,从叶子组件开始
- 用
useActionState重构一批提交流程 - AuthWrapper 拆成
useAuthhook
学吧各位就,学无止境啊