从项目里回看 React 生态:React 18/19 新特性、我们踩过的坑,以及那些"用旧写法写出的新思想

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 边界向上"抛"。

对我们项目意味着什么?------AttrFeatTreeSelectAndOr 里那些 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()

这个模式本身没错------这就是命令式子组件的经典桥梁------但它有两个隐性代价:

  1. 父子之间的数据流从 React 的"单向 props"跑到了"命令式方法",静态分析工具追不到
  2. getData 内部混合了"读当前值 + 校验 + 请求",测试起来重

现代 React 的两种优化方向:

方向 A:把状态提升到父组件(受控化) 。父组件持有 conditDataNewAndOr 只做 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:

  1. 引入 TanStack Query,收敛所有 GET 类请求(收益最大)
  2. 新表单/新状态一律用 useImmer 或 Zustand,不再写 cloneDeep + useState
  3. 升级到 React 19,把 forwardRef 全部干掉
  4. 灰度试点 React Compiler,从叶子组件开始
  5. useActionState 重构一批提交流程
  6. AuthWrapper 拆成 useAuth hook

学吧各位就,学无止境啊

相关推荐
FogLetter1 小时前
我真的写了个“诈尸式”缓存组件:手撕React KeepAlive
前端·react.js·面试
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-08-02
前端·神经网络·react.js·搜索引擎·前端框架
无人生还1 天前
从 Vue3 到 React · 快速上手系列第 9 篇:自定义 Hook(对标 Composables)
前端·vue.js·react.js
张元清1 天前
React useInfiniteScroll Hook:无限滚动轻松实现(2026)
javascript·react.js
浮生望1 天前
React组件化实战:从Todo应用洞悉useState状态管理与组件通信
react.js
先吃饱再说2 天前
编写一个颜色选择器之后,我理解了 React 工程化的三层架构
react.js·前端框架·前端工程化
先吃饱再说2 天前
别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了
react.js·前端框架
先吃饱再说2 天前
从“传事件”到“只传值”:React + TypeScript 组件 Props 设计的两次进化
react.js·前端框架·前端工程化
Seven_Ting2 天前
React-Hooks笔记
前端·笔记·react.js