React 之死·终章:一个 useRef,把闭包陷阱、依赖数组、漫天 rerender 全送走

读完这篇你能拿到什么

这是 《React 之死》专栏终章

前几篇《花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案》,我让你换 Signal 。这一篇正好反过来------我知道很多人受制于团队规范、历史包袱、组件库可移植性,一行 Signal 都不能用

没关系。这篇我保证:

  • 不改变 React 的组件模型和状态管理范式useStateuseEffect 和普通组件照常使用,只在需要时换少量 Hook / 组件入口;
  • 不碰 React-Compiler 那个黑盒,不依赖任何编译魔法,不装任何 Babel 插件;
  • 但你能拿到:
    • 事件回调不再踩陈旧闭包useCallback 里拿到旧 state);
    • 事件回调不用再维护依赖数组,不漏写、不因读值变化而重建引用;
    • 看得懂、控得住的过度渲染,不再面对漫天 rerender 抓破头皮;
    • 现成的 KeepAlive,切走再切回,表单、滚动位置、布局全保留;
    • Effect 的 async、首次跳过、只执行一次便利封装(严格生命周期场景仍有边界);
    • 一套 Vue 风格、带 KeepAlive 和全局路由守卫的路由

而这一切最重要的支点,是 React 自带的一个 API:useRef


赶时间的,老规矩 :第一章是完整的病理报告,只想拿药的直接跳 [§1.6 把 useRef 放进响应式家族里看](#§1.6 把 useRef 放进响应式家族里看 "#heading-7")------支点从那里开始立起来;连原理都懒得听、只要代码的,跳 [§1.8 useLatestCallback](#§1.8 useLatestCallback "#heading-9"),抄了就能跑。骂 React 的部分你们随时可以回来补看,它跑不了

一、闭包 + 依赖数组 + 漫天 rerender:React 的「祖传屎山」

1.1 病根:你只想「读一下」,React 偏要你「订阅它」

绝大多数人第一次被恶心到,都是在 useCallback 这儿

你定义一个函数,比如 onChange,它内部要用到一堆 state。注意------你只是读取 它们,并不想监听它们:

tsx 复制代码
const [keyword, setKeyword] = useState('')
const [list, setList] = useState<Item[]>([])

// 我只是想在点击时「读一下」当前的 keyword 和 list
const handleSubmit = useCallback(() => {
  submit(keyword, list)
}, [keyword, list]) // ← React:不行,你必须把它们供进来

React 这套核心逻辑就是这么轴:你函数里用了啥,依赖数组就必须列上啥,否则你拿到的就是上一个渲染周期的「快照」------经典闭包陷阱

1.2 反方:「手动加一下呗,还有 ESLint 自动 fix」

每次我吐槽这个,总有人跳出来:

「多写两个依赖怎么了?况且 eslint-plugin-react-hooks 还能自动帮你补全 deps」

听起来很合理。实际上呢?实际上你的代码会慢慢崩塌成不可控的屎山

因为这些 useCallback 基本都是服务于组件交互的------onChangeonSubmitonSelect。它们会被当作 props 传给子组件。于是问题来了:

一个「我本来只想读一下」的值变了,useCallback 就重建出一个新函数引用;这个新引用传给子组件,击穿子组件的 memo,子组件连同它的整棵子树跟着重渲染

你以为你在做性能优化,其实你在埋雷。规模一上来,叠上 Context、叠上层层嵌套,你某天打开控制台看到漫天 rerender 日志,根本不知道是哪个不起眼的依赖在背后点的火

判断一个 React 项目会不会烂,不用看架构图,数一下 useCallback 依赖数组的平均长度就行。超过三个,雷已经埋好了,只是还没人踩。

1.3 一次 setState 到底重渲染了什么?

setState 触发的重渲染,从持有这个 state 的组件开始、向下递归到它的所有子孙组件为止------不向上惊动祖先,也不横向波及兄弟

官方《Render and Commit》原文:"For subsequent renders, React will call the function component whose state update triggered the render. This process is recursive..." ------ react.dev/learn/rende...

中译: 在后续的每一次渲染中,React 会调用那个「因自身 state 更新而触发本次渲染」的函数组件。这个过程是递归的:被更新的组件返回了另一个组件,就接着渲染那个,一层层向下,直到没有更多嵌套组件为止。

不过你「漫天 rerender」的痛感也不是幻觉。 因为在 state 持有者的子树内 ,默认情况下所有子孙的 render 函数都会重新执行 ,不管它用没用到那个 state(看 PlainChild:1 → 2 → 3)。

而真实项目里,state 经常被放在很靠顶层的地方(App 根、顶层 Context Provider)------这时候「子树」≈「整个 app」,体感上就和「全量重渲染」没区别了。再叠上 1.2 里说的「内联回调击穿 memo」(看 MemoBadCb:1→2→3),重渲染范围像墨水一样越铺越大

  1. 「重新执行 render 函数」 ≠ 「重新操作 DOM」。 子孙的 render 都跑了(这才是真 CPU 开销),但只有真正变化的 DOM 节点才会进入 commit 阶段
  2. 「props 变了才重渲染」这句流行话本身也不严谨。 默认是「父重渲染 → 子重渲染」,跟 props 变没变无关;props 的浅比较(Object.is 逐个比)只有在 memo 包裹时,才作为「是否跳过」的判据

1.4 React 官方的态度:能不用 memo 就别用

那这个「内联回调击穿 memo」的连锁反应,官方怎么说?官方说:能不用 memo/useMemo/useCallback 就别用,除非你遇到了性能问题。 这不是我编的,是三个 API 页面的原话:

"You should only rely on useCallback as a performance optimization. If your code doesn't work without it, find the underlying problem and fix it first." ------ react.dev/reference/r...

中译: 你只应把 useCallback 当作一种性能优化来依赖;如果没有它你的代码就没法正常工作,那要先找出背后的根本问题、先把它修掉。

useMemomemo 两页是同一个句式,只是把名字换了一下)

这句话被无数人奉为圭臬,我听过太多遍了。每次听到我都想笑:

「你们是没写过代码吗?没接手过别人的代码吗?」

就算是你自己写的,规模起来之后,等你「遇到了性能问题」再回头排查------你面对的是漫天 rerender 日志、层层 Context、几十个组件,你猜要多久才能定位到是哪个少加的 memo?官方这话说得轻巧,好像性能问题是能「轻松事后排查」一样。本来 React 这套就难用,少一个 memo 整棵子树递归重渲染,你拿什么轻松排查?

说白了,这就是把复杂度甩锅给开发者:默认不优化,出了事你自己背

补一句客观的:官方文档现在在这几页顶部都加了提示------推荐用 React Compiler 来自动 memo,从而「少写手动优化」。也就是说官方自己也承认手写这套很烦,只是它给的解药是另一个黑盒(见下一节)

1.5 React-Compiler:从拍手叫好到删库逃离

多年以后,React 终于端出了 React-Compiler。我一开始是拍手叫好 的,还专门写了篇文章吹它:《四年!!React 你知道我这四年怎么过的吗

现在我只想说 "快端下去罢!!我不想再品鉴了"

它干的事,官方定调叫 automatic memoization(自动记忆化)

"React Compiler automatically optimizes your React application by handling memoization for you, eliminating the need for manual useMemo, useCallback, and React.memo." ------ react.dev/learn/react...

中译: React Compiler 会替你处理记忆化,从而自动优化你的 React 应用,让你不再需要手动写 useMemo、useCallback 和 React.memo。

原理是编译期分析你组件的数据流,按「reactive scope」给可记忆的值分配一个定长缓存数组(产物里那个 _c(size) / $[0]),首渲染填哨兵 Symbol.for("react.memo_cache_sentinel") 必然 miss,之后依赖没变就复用缓存。源码在这:react-compiler-runtime/src/index.ts

听起来很美。但很快我就觉得不对劲

第一,它在我手里出过 bug。 我用 @preact/signals + React-Compiler,响应式直接乱套,排查了一个多小时,最后把 React-Compiler 关掉就好了。

后来我用 AI 查 GitHub issue 印证了:这俩天生八字不合 ,同一个文件不能同时开 signals-react-transformbabel-plugin-react-compilerpreactjs/signals#652)。

一个非官方的 Signal 方案能跑得好好的,碰上官方编译器就崩------那还选什么?果断删掉 React-Compiler,逃离屎山,回归舒适区

第二,它号称「不是黑盒」,可那点透明度在真实工程里基本没用。

它确实有 Playground 能看编译产物、有 ESLint 规则、React DevTools 会给被优化的组件打个 ✨ 徽章。

但真实工程里,你不会为了排查一个诡异 bug,跑去把每个组件挨个粘进 Playground 看它到底编译成了啥。出了问题,你面对的就是一坨 _c() 缓存数组和「为什么这里没更新 / 那里多更新了」的玄学。它的「自动」程度越高,你对渲染过程的掌控就越弱

第三,也是最讽刺的。 React 当年标榜「拒绝魔法」------所以没有双向绑定、没有任何语法糖,什么都让你手动来。结果现在自己端出了一个全自动黑盒编译器。而且------

React-Compiler 正在被用 Rust 重写。已经发生并合并 的事:PR facebook/react#36173compiler Port React Compiler to Rust》已于 2026-06-09 合并进主仓 。据 PR 里 React 团队成员 rickhanlonii 的更新说明,它已在 Meta 生产环境投入使用------对 Meta 99.9% 的代码库产出与 TS 版完全一致,还顺带 ~25% 性能提升(PR 描述正文本身则仍标注 experimental / WIP)

最骚的是 PR 作者 Joe Savona 的原话:"The architecture was heavily guided by humans (me) but majority coded by AI . ... I used Claude for the initial work that got from zero to OSS tests being green."

中译: 架构主要由人类(也就是我)主导,但绝大部分代码由 AI 编写......从零到让开源测试全绿的那部分初期工作,我用的是 Claude。

没错,重写 React 编译器这事,初版主要是 AI(还是 Claude)写的。 (这里得把两件事分清、别搞混时效:React Compiler 本体 如今已在 react.dev 转正为 stable------官方原话「now stable and has been tested extensively in production」,不再是实验特性;而这次的 Rust 移植版目前仍标注 experimental / WIP。至于「它到底是什么」,官方的定位始终没变------「a light Babel plugin wrapper」,一个包着核心编译器的轻量 Babel 插件)

一个曾经拒绝一切魔法的框架,如今把自己的命脉交给一个「AI 写的、Rust 编的、普通开发者看不太懂运行细节」的黑盒。这条路,看来是要一直走到黑了

不用想了,Effect、依赖、闭包、Signals......能自己解决的,全自己解决,React 没救了,不用对以后的版本抱有任何期待

1.6 把 useRef 放进响应式家族里看

要理解为什么 useRef 是后面所有操作的支点,先把它放进「响应式原语」这个家族里对比一眼。

React 的 useRef 是家族里最寒酸的一员:一个引用稳定、变了也不触发重渲染的盒子,.current 读写,没有任何响应式,就是个纯对象。但也正因为「变了不触发渲染」,它是 React 里唯一能稳定持有可变值的逃生口。

Vue 的 ref 常被当成「Proxy 代理的响应式」,其实不是。翻 vuejs/core 源码ref() 返回的是一个 RefImpl 实例,本体是带 get value() / set value() 访问器 的普通对象,靠 getter 里 track()、setter 里 trigger() 收集依赖。

真正用 ES6 Proxy 的是 reactive();只有当 ref 里装的是 对象 时,内部才经 toReactive() 转成 reactive(),装基础类型时根本没有 Proxy。

Vue 之所以要你写 .value,是因为标准 JS 拦截不了普通变量的读取和重新赋值let x = 0; x = 1 劫持不了),只能把值包进对象、用属性的 getter/setter 来拦截,于是访问必须走 .value。官方文档写得很直白:

"In standard JavaScript, there is no way to detect the access or mutation of plain variables... The .value property gives Vue the opportunity to detect when a ref has been accessed or mutated." ------ vuejs.org/guide/essen...

中译: 在标准 JavaScript 里,没办法侦测普通变量的读取或修改......而 .value 这个属性,给了 Vue 一个侦测「ref 何时被访问、何时被修改」的机会。

这恰好照出 React setXxx 的代价:基础类型没法监听,就只能让你 手动调 setter、每次造新对象 ,写多层嵌套的不可变更新时尤其恶心------社区为此才冒出 Immerproduce + draft,可变写法产出不可变结果)、Mutative (更快的 Immer 替代)、valtio(Proxy 可变式状态)这一票库来擦屁股。

至于 ref 和 Signal 的关系:ref 本质上就是一种 signal,这点 Vue 官方直接背书------

《Reactivity in Depth》的 Connection to Signals 一节写道 "Fundamentally, signals are the same kind of reactivity primitive as Vue refs."原文

中译:「从根本上说,signal 与 Vue 的 ref 属于同一类响应式原语」)。

但要论辈分,这类「带依赖追踪的值容器」最早可追溯到 2010 年的 Knockout observable ,比 Vue 3 的 ref(2020)早了十年,把「signal」这个词做火的则是 Solid。TC39 的 Signals 提案 也是这么追溯的,背后站着 Angular、MobX、Preact、Solid、Svelte、Vue 一票框架作者。

ref 和现代 signal 是同源并行的同类实现,共同承自更早的 observable 传统。

绕这一圈只为说清一件事:useRef 是这个家族里响应式能力最弱的一个------弱到只剩「稳定持有一个值」。 useRef 穷得只剩一个盒子------但正因为穷到没有响应式,React 才懒得管它,它成了唯一的逃生口。

而接下来你会看到,正是这份「什么都不做」,让它成了 hack React 渲染的唯一支点。

1.7 hack React 渲染,基本只有这一个支点

要想在不触发 render 的前提下稳定持有可变值,React 里最直接的牌就是 useRef。围绕它常见两种玩法:

  • 玩法 1(重度): 把数据全塞进 ref,确保它变化不触发任何重渲染,然后自己接管响应式、手动操作 DOM。Signal 直接传进 JSX 跳过 VDOM 的优化,本质就是这个思路的工业级版本
  • 玩法 2(中度): 写个 Proxy 包一层,设置值时自动调一个空的 setState 触发更新,其余地方都依赖 ref 的稳定值渲染

但这两种都要改变你的写法。本文的主角走的是第三条、也是最克制的一条路 ------既然问题大多出在「useCallback 的引用稳定性」上,那我们就只在「回调」这一个点上动手术,其余写法一律不变

主角登场:useLatestCallback

1.8 终极解决方案:useLatestCallback

先看源码,就这么几行(react-tool/packages/hooks/src/memo.ts):

ts 复制代码
/**
 * 始终能获取最新值的 useCallback,无闭包陷阱
 */
export function useLatestCallback<T extends (...args: any[]) => any>(fn: T) {
  const latestFn = useLatestRef(fn)

  return useCallback((...args: Parameters<T>) => {
    return latestFn.current(...args)
  }, [latestFn]) as T
}

依赖的 useLatestRef 也就几行(ref.ts):

ts 复制代码
export function useLatestRef<T>(state: T) {
  const stateRef = useRef(state)
  // 用 useInsertionEffect 而非 useEffect:它在 commit 期、早于 layout/passive effect 执行,
  // 所以 ref 在 useLayoutEffect 阶段就已是最新值
  useInsertionEffect(() => {
    stateRef.current = state
  }, [state])

  return stateRef
}

注意那个 useInsertionEffect 而不是用 useEffect:它在 commit 期、早于 layout 和 passive effect 执行,所以 ref 在 useLayoutEffect 阶段已经指向 最近一次成功提交 的函数。

换成普通 useEffect,layout 阶段会多看到一帧旧值。它不会采用仍在并发 render、最后可能被 React 丢弃的候选函数------这正是它比 render 阶段写 ref 更稳的地方

但也要说清边界:React 官方把 useInsertionEffect 定位为 CSS-in-JS 库的样式插入时机;这里是在 React 18+ 中模拟原始 useEvent commit 语义的 userland shim,不是它的官方主用途。渲染期调用这个回调仍会读到上一次 commit 的逻辑,而 render 本来也应该保持纯粹

原理一句话讲透:

  1. 每次 render 都产生一份新的 fn,只有这次 render 成功 commit 后,才把它存进 latestFn.current
  2. 对外返回一个用 useCallback 缓存的转发函数,正常挂载期间引用保持稳定,不会因为业务依赖变化而重建;
  3. 稳定外壳调用 latestFn.current(...),因此拿到的是最近一次成功提交的逻辑,不会泄漏被中止的并发 render

于是你同时拿到了两个一直以来鱼与熊掌不可兼得的东西:引用在正常挂载期间稳定(不击穿下游 memo) + 闭包跟随最近一次 commit(拿得到界面当前对应的 state)

对普通事件处理器来说,用法和 useCallback 很像,但不用写依赖数组:

tsx 复制代码
import { useLatestCallback } from 'hooks'

// ✅ 事件回调不用 deps,始终调用最近一次 commit 的逻辑,引用还稳定
const handleSubmit = useLatestCallback(() => {
  submit(keyword, list)
})

// ❌ 对比一下 useCallback:漏写一个依赖就踩闭包,写全了引用又老变
const handleSubmit = useCallback(() => {
  submit(keyword, list)
}, [keyword, list])

这就是事件回调同时拿到「稳定引用 + 最新已提交闭包」的全部秘密。 你只是把事件用途的 useCallback 换成 useLatestCallback,连依赖数组都省了

这套写法已经被我固化进团队的 React 规范里:事件处理器、定时器和外部订阅回调用 useLatestCallback,不传依赖数组。函数用于 render,或函数身份本身就要表达一次输入快照时,仍然用 useCallback / useMemo。后面 AI 那节会讲到

1.9 等等,官方不是有 useEffectEvent 吗?

懂行的会问:React 19.2 不是转正了 useEffectEvent,干的不就是「引用读最新值」这件事?

是,但它被官方阉割了,限制大到没法当通用方案用。逐条对比(都有官方原文):

我的 useLatestCallback 官方 useEffectEvent
读到最新 props/state
引用稳定 ✅ 正常挂载期间稳定 ❌ 官方明说「引用每次渲染都刻意改变」(identity intentionally changes on every render
能传给子组件 ❌ 官方明说「不要把它们传给其他组件或 Hook」(Do not... pass them to other components or Hooks
适合作为普通事件回调 ❌ 只服务于 Effect 逻辑

"Effect Events can only be called from inside Effects or other Effect Events. Do not call them during rendering or pass them to other components or Hooks." ------ react.dev/reference/r...

中译: Effect Event 只能从 Effect 内部、或别的 Effect Event 内部调用;不要在渲染期间调用它,也不要把它传给其他组件或 Hook。

讽刺的是,最初的 useEvent RFC(reactjs/rfcs#220)设计的恰恰是通用版 :稳定引用、可当事件处理器、可传子组件------"A Hook to define an event handler with an always-stable function identity." (中译:「一个用来定义事件处理器的 Hook,其函数引用始终保持稳定」)。这正是我 useLatestCallback 想要的形态

不过历史要说准确:这份通用 RFC 后来被官方明确废弃。PR facebook/react#25881 把实验 API 改名为 useEffectEvent,主动把问题范围缩到「Effect 内的非响应逻辑」;普通事件回调的稳定引用被留给其他优化方案。所以现在的 useEffectEvent 不是通用 useEvent 落地失败,而是一个刻意收窄、也确实无法替代普通事件 handler 的专用 API

所以社区这些年一直在自己实现这玩意------ahooks 的 useMemoizedFn、usehooks-ts 的 useEventCallback,和我的 useLatestCallback 都属于「稳定外壳转发最新逻辑」这一类。我们做的,其实是把官方放弃的通用 useEvent 方向捡了回来

1.10 和 ahooks 的 useMemoizedFn 差在哪

既然是一类东西,那就比一比。先看 ahooks 的实现(alibaba/hooks):

ts 复制代码
const useMemoizedFn = <T extends noop>(fn: T) => {
  const fnRef = useRef<T>(fn)
  // 在 render 阶段同步把最新 fn 写进 ref
  fnRef.current = useMemo<T>(() => fn, [fn])

  const memoizedFn = useRef<PickFunction<T>>()
  if (!memoizedFn.current) {
    memoizedFn.current = function (this, ...args) {
      return fnRef.current.apply(this, args)
    }
  }
  return memoizedFn.current
}

思路和 useLatestCallback 属于同一类:一个稳定外壳,内部转发给 ref 里的 fn。最关键的区别,是「什么时候把最新 fn 写进 ref」:

ahooks useMemoizedFn 我的 useLatestCallback
写入最新 fn 的时机 render 阶段fnRef.current = useMemo(...) commit 期useLatestRef 里的 useInsertionEffect,早于 layout/passive)
事件处理器里用(99% 场景) ✅ 等价 ✅ 等价
useLayoutEffect 里调用 ✅ 拿到本次 render 写入值 ✅ 拿到最新 commit 值(insertion 早于 layout)
渲染期调用 能读本次候选值,但不该这样用 仍是上次 commit 的值;派生请直接计算或用 useMemo
并发 render 被中止 ref 可能已被候选 render 提前污染 不更新 ref,继续指向当前 UI 对应逻辑
动态 this apply(this, args) 保留 当前箭头函数实现不保留(普通 React handler 通常无影响)

顺带说一个很多人看不懂的细节:ahooks 那句 fnRef.current = useMemo(() => fn, [fn]) 不是性能优化

调用者传内联函数时,fn 每次都是新引用,useMemo 仍会重算并原样吐回 fn。它主要是给 React DevTools 打补丁:DevTools inspect 组件时会用 mock hooks 做 shallow render;

如果直接在 render 里写 ref,就可能把真实 ref 污染成闭包了 mock 更新函数的版本,导致一选中组件、回调就失灵(ahooks#728)。mock useMemo 会返回正常 render 留下的缓存值,刚好绕开这次污染

useLatestCallback 在 commit 的 insertion effect 里更新 ref,不会被 DevTools 的 mock render 或被中止的并发 render 提前污染。

具体错位是这样的:旧界面 A 仍可交互时,React 可能在后台 render 新界面 B;ahooks 已把共享 ref 改成 B 的候选回调,此时点击 A 可能跑进 B 的闭包。useLatestCallback 要等 B 真正 commit 才切换,因此 ref 始终和屏幕上已提交的 UI 对齐

两边也各有边界:ahooks 保留动态 this,而当前 useLatestCallback 的箭头函数不保留;useLatestCallback 的外壳来自 useCallback,按官方契约应把稳定身份当性能优化,而不是业务正确性的永久保证。事件处理器、layout effect、passive effect 这些正常调用点都能拿到最新 commit 值;不要在 render 中调用它

1.11 配套:setState 后立刻读不到新值?

闭包陷阱还有个经典变种:你 setCount 之后,下一行立刻读 count

拿到的还是旧值(因为 React 的 state 在本次渲染里是「快照」,不会中途变)

tsx 复制代码
const [count, setCount] = useState(0)
const fn1 = () => {
  setCount(count + 1)
  fn2() // count 仍是旧值 0
}
const fn2 = () => console.log(count) // 0

这个我在前几篇讲过,用 useGetState 救急:setCount.getLatest() 直接拿最新值,不用传回调、不用 useEffect 兜(源码:useGetState.ts

tsx 复制代码
import { useGetState } from 'hooks'

const [count, setCount] = useGetState(0)
const fn2 = () => console.log(setCount.getLatest()) // 1

至此,闭包陷阱(读最新值)、依赖数组(不用写了)、过度渲染(引用稳定守 memo) 三连击,全部用原生 React API 解决,写法零迁移成本


插曲:StrictMode 与「禁止条件 Hook」------React 把自己的脆弱,写成了你的规矩

聊到重渲染,必须插一段 StrictMode,因为它把 React 拧巴的设计哲学暴露得淋漓尽致

新建一个 React 项目(CRA、Next.js 默认都给你套上 <StrictMode>),打开控制台------每个 console.log 都打两遍 。组件渲染两次,useState/useMemo 的初始化函数跑两次,useEffect 变成 setup → cleanup → setup。第一反应都是:这什么玩意,调试全是双份日志,谁顶得住

它到底在干嘛

StrictMode 是开发环境专属、生产环境完全剥离 的检查器(官方文档),核心干两件事:

  1. 把「本该是纯函数」的东西调用两次 ------组件 render、useState/useMemo/useReducer 的初始化/更新函数。官方原话:"Your components will re-render an extra time to find bugs caused by impure rendering." (中译:「你的组件会额外重新渲染一次,以便揪出由『不纯渲染』引发的 bug」)。说白了:React 假设 你的 render 是纯的(同样输入 → 同样输出、零副作用),于是偷偷跑两遍,两遍结果不一致(你在 render 里 push 了 props、改了外部变量之类)就露馅

  2. 把 Effect 跑成 setup → cleanup → setup ------逼你写对称的 cleanup。真实动机不在「现在」而在「未来」:React 工作组 Discussion #19《Adding Reusable State to StrictMode》 讲得很直白------为了 Offscreen(即后来的 <Activity>)、Fast Refresh 这些「组件会被反复 mount/unmount、但 state 要保留」的场景,"components may 'mount' and 'unmount' more than once" (中译:「组件可能会不止一次地『挂载』和『卸载』」)。于是它在 dev 提前模拟一次「卸载再挂载」,把你那些忘写 cleanup 的订阅、定时器揪出来

换句话说,它检测的是 渲染纯度Effect 对称性,而不是很多人以为的「hook 稳定性」

它又蠢又烦------这点 React 自己都认

双份日志确实反人类,而且这话不用我说,React 自己就承认。 最早 React 18 是直接 吞掉 第二次渲染的所有 console.log,结果开发者更懵(日志莫名消失);于是又改成「不吞了、只在 DevTools 里把第二遍日志调暗 」(工作组 Discussion #96);可这套「DevTools 劫持 console」的方案又带出新毛病------调暗时它会把第二遍日志强制转成字符串,于是同一条 console.log 两遍长得不一样,还没法在控制台里展开对象逐字段比对。一个 dev 检查器,能把最基本的 console.log 折腾成来回改两版方案、还留个后遗症,本身就说明这设计有多别扭

更本质的问题是:为什么非要它不可? 因为 React 的核心模型太脆------render 必须纯、Effect 必须对称,否则并发渲染、Offscreen、Fast Refresh 全会冒诡异 bug。可 React 在语言层面对这些毫无保证 ,只能靠一个「把你代码跑两遍看崩不崩」的暴力检查器,外加一个 ESLint 插件来当纪委。它把自己模型的脆弱,转嫁成了你必须遵守的纪律

顺着同一条病根,还有那条更蠢的规矩:Hook 不能条件调用

「React 不能条件调用 Hook」这条铁律,和 StrictMode 同出一源

规矩(Rules of Hooks):"Don't call Hooks inside loops, conditions, nested functions..." (中译:「不要在循环、条件、嵌套函数里调用 Hook」)。为什么?官方在配套的 ESLint 规则页 说得直白:"React relies on the order in which hooks are called to correctly preserve state between renders."(中译:「React 依赖 Hook 被调用的顺序,来在多次渲染之间正确地保留 state」)

人话:React 压根不知道哪个 state 属于哪个 useState,全靠「第几个被调用」来对号入座。 每个组件的 hook 存成一条按调用顺序排的链表,第 1 个 useState、第 2 个 useEffect......全凭位置索引。一旦 if (x) useState(),某次渲染少调一个,后面全部错位,state 直接串号

根子在于:React 每次更新都把整个组件函数从头到尾重跑一遍,于是它必须有个办法在一次次调用之间「记住」上一次的 state。

它本可以让你手动传 key(useState('count') 那样),但太啰嗦;于是选了「按调用顺序自动对号」。这本质是一道二选一:要么手动编 key(丑),要么位置索引(脆)。React 挑了后者,代价就是永远不能条件调用、不能早返回后再调、不能在循环里调 hook,外加一个 ESLint 插件全天候盯梢

「手动编 key 也很蠢」这个直觉没错------但真正的问题是,别的框架根本不需要在这两个蠢选项里二选一,因为它们压根不重复执行组件:

  • Vuesetup() 只跑一次,ref/reactive 是绑在变量上的响应式,爱在哪声明在哪声明,条件、循环随意,不需要 key,也没有「调用顺序」一说
  • SolidJS :组件只运行一次 (官方原话 "a Solid component is only run once" ),既然不重跑,也就没有 React 那套「调用顺序」的包袱------createSignal/createEffect 可以条件调用、循环调用,signal 甚至能写在组件外面
  • Svelte 5$state 是编译期的 rune,let count = $state(0),state 就是个普通变量,像改普通变量一样改它,能出现在任何变量能出现的地方,没有「必须顶层、不能条件」那套心智负担

三家全都没这毛病,理由还是同一个:它们不像 React 那样每次更新都把组件函数重跑一遍。

「render 必须纯 + hook 靠调用顺序」这套脆地基,是 React 自己选的------然后为了兜住它,才有了 StrictMode 双跑体检、Rules of Hooks、和满屏 ESLint 警告。别人用响应式和编译把复杂度收进框架里,React 把它摊开,写成你每天要背的纪律

StrictMode 是能关的 (自己套的就删掉 <StrictMode>,Next.js 里 reactStrictMode: false)。但别为了「日志清爽」去关它------它揪出来的那些 bug,等你真上 <Activity>、上并发特性时是会炸的。真正该被拷问的从来不是 StrictMode,而是那个让 StrictMode 变得必要、脆到需要天天体检的核心模型


二、KeepAlive:一个基础到离谱、却拖到 2026 才有的需求

2.1 React 19.2 的 <Activity> 来了,但它会「杀掉」你的 effect

React 19.2(2025-10-01)总算出了官方版 KeepAlive:<Activity mode="visible | hidden">。隐藏时保留 DOM 和组件内部 state,切回来原样恢复。听起来美滋滋

但它有个致命问题:当你切到 hidden 时,它会执行你组件里所有 effect 的 cleanup 函数(useEffect 的返回值),切回来再重新跑一遍。而且------不可配置。 官方原文:

"When an Activity boundary is hidden, React will visually hide its children using the display: "none" CSS property. It will also destroy their Effects, cleaning up any active subscriptions. ... When the boundary becomes visible again, React will reveal the children with their previous state restored, and re-create their Effects." ------ react.dev/reference/r...

中译: 当一个 Activity 边界被隐藏时,React 会用 display: "none" 在视觉上把子组件藏起来;它还会销毁这些子组件的 Effect,清理掉所有仍活跃的订阅。 ......当边界再次可见时,React 会连同之前保留的 state 一起把子组件显示回来,并重新创建它们的 Effect

官方在 Troubleshooting 里甚至直说:"conceptually, you should think of 'hidden' Activities as being unmounted." ------ 概念上,hidden 就等于卸载

<Activity> 的 props 只有两个:childrenmode'visible' | 'hidden')。没有任何「保留 effect、别清理」的开关

这就和我的诉求直接冲突 了:很多业务场景里,我就是单纯想视觉上隐藏一下 ------订阅别断、定时器别停、effect 别重跑。结果 Activity 强制把它当卸载处理。它的设计哲学就是「隐藏 = 概念卸载 = 断开副作用以省资源」,这本身没错,但它不给你选。你想要「纯视觉隐藏」,用 Activity 就是用错了工具

2.2 「那你为什么不用 display: none?」

每次说到这,总有人问。问这个问题的,多半没写过多少复杂布局

display: none 的坑在于:被它隐藏的元素,拿不到正确的 DOMRect 尺寸信息 。你在组件 effect 里测宽高,量到的全是 0。做复杂布局(虚拟列表、画布、需要 measure 的动画)时,这是灾难------你只能写一堆 hack 去绕,代码脏得没法看

官方自己也在 19.2 博客里列了 display: none 的局限(隐藏组件仍会渲染/初始化、触发生命周期、拿不到正确尺寸......),这也是它最终要造 <Activity> 的理由。可这么基础的需求,拖到 2025 年底才有官方解,是真的搞笑

2.3 另一条路:Suspense + throw Promise

其实不用等官方。在 Activity 之前,我早就用 Suspense + 抛 Promise 实现了 KeepAlive。看源码(react-tool/.../KeepAlive.tsx):

tsx 复制代码
const Wrapper = memo<KeepAliveProps>(({ children, active }) => {
  const resolveRef = useRef<Function | null>(null)

  if (active) {
    resolveRef.current?.()
    resolveRef.current = null
  }
  else {
    throw new Promise((resolve) => {
      resolveRef.current = resolve
    })
  }

  return children
})

export const KeepAlive = memo(({ uniqueKey: key, active, children }) => {
  return (
    <Suspense fallback={ null }>
      <Wrapper active={ active }>{ children }</Wrapper>
    </Suspense>
  )
})

原理:

  • active === false 时,Wrapper 在 render 里 throw 一个 Promise → 被最近的 Suspense 捕获 → 显示 fallback(这里是 null,于是视觉上消失);
  • active === true 时,resolve 掉那个 Promise → Suspense 恢复渲染 → 子树的 state 原样还在

React 底层确实是用 CSS(切 display)把挂起的子树藏起来、保留状态的(React 18 工作组 Discussion #7《Behavioral changes to Suspense in React 18》 讲过这套隐藏机制)

它和 Activity 的根本差异,恰恰在副作用上:

Suspense + throw Promise 官方 <Activity>
隐藏方式 display: none display: none
state 保留
隐藏时清理 effect 只清理 layout effect(useLayoutEffect);普通 useEffect 不动 销毁全部 effect(含 useEffect

也就是说------Suspense 这条路,被隐藏子树里的普通 useEffect(订阅、定时器)会继续活着、不被清理。

这正好满足我「只想视觉隐藏、副作用别停」的诉求;代价是你得自己注意别留下后台泄漏。Activity 则相反,隐藏即清场。两条路没有绝对优劣,关键是 Activity 不给你选,而 Suspense 这套你能自己掌控

还有两个坑要提醒(查证后补的,免得你踩):

  1. throw promise 是 Suspense 的内部实现协议,不是官方文档化的公开 API 。React 19 官方推荐用 use() 来挂起,但 use() 要求 Promise 来自带缓存的源,自制 KeepAlive 得自己做缓存。当作进阶玩法用没问题,作为长期基石要心里有数
  2. 已挂载的子树再次 suspend 时,默认会闪一下 fallback ,除非更新被 startTransition / useDeferredValue 包裹

这套 KeepAlive 我后来还给它加了退场过渡动画 :失活时不立刻挂起,而是保留一个 exiting 窗口,让离场动画播完再真正藏起来(transition / onExited / direction,由 useDelayedActive 派生)。切页面不再是「啪」地闪走,而是能滑、能淡出

2.4 路由生态的 KeepAlive:到 2026 年依然「不内置」

因为 React 19.2 之前没有官方 KeepAlive,所以主流路由库全都不内置页面缓存 。这个结论我去年、前年都下过,今年(2026-06)专门重新查了一遍------依然成立,只是细节得更新:

  • react-router :v8 在 2026-06-17 发布,截至本文复核(2026-07-16)最新是 8.2.0 ,仍没有页面组件 KeepAlive。它 2022 年就收到过 issue #9350《Support Offscreen》,随后关闭、没有实现。它提供 loader/action、导航状态和 View Transition 等能力,但不负责保留已渲染的路由组件实例
  • TanStack Router :它有「缓存」字样,但缓存的是 loader 返回的数据 (有 staleTime/gcTime),不是已渲染的组件实例。组件级 keep-alive 仍然没有
  • 第三方react-activation(老牌 hack 版)正在变冷,React 19 兼容的 issue 还挂着 open;keepalive-for-react 还算活跃,且 v5 起可以选择接 Activity。但这些都是路由库之外的方案

方向很清楚:React 终于(19.2)补了 <Activity> 这个原语,但主流路由库到今天还是不内置 KeepAlive,要用还得自己包一层。 你们都不做带缓存的路由的吗?

为此我写了 @jl-org/react-router,API 往 Vue Router 上靠:

tsx 复制代码
import { createBrowserRouter, RouterProvider, Outlet } from '@jl-org/react-router'

const router = createBrowserRouter({
  routes: [
    { path: '/', component: lazy(() => import('./views/home')) },
    {
      path: '/dashboard',
      component: lazy(() => import('./views/dashboard')),
      meta: { title: 'Dashboard', requiresAuth: true },
    },
  ],
  options: {
    // ① 内置 LRU 页面缓存:白名单 + 上限,切走再回来表单/滚动全保留
    cache: { limit: 5, include: ['/', '/dashboard'] },
    // ② Vue 风格全局守卫,路由鉴权一处搞定
    beforeEach: async (to, _from, next) => {
      if (to.meta?.requiresAuth && !getUser()) { next('/login'); return }
      next()
    },
    afterEach: (to) => { document.title = to.meta?.title ?? 'App' },
  },
})

它提供了 React 生态里少见的三件套:

  • 内置 LRU KeepAlivecache.ts),include/exclude 白名单、cacheKey 和容量上限,底层正是上面那套 Suspense KeepAlive;router.clearCache() / router.deleteCache(matcher) 可以按需清理缓存;
  • Vue 风格全局守卫 beforeEach/beforeResolve/afterEachguard-manager.ts)+ Koa 风格中间件;
  • Router 实例即全局 APIrouter.navigate() / router.replace() / router.clearCache(),不用先 useXxx() 拿 hook

这么基础好用的东西,不知道为啥 React 生态没人做。你们写路由鉴权都靠「每个路由入口包一层组件判断」?真就反人类设计到底了。(详细对比见 README


三、Effect:async、首次跳过和只跑一次的便利封装

原生 useEffect 还有几个老毛病:回调不能是 async、首次挂载一定会执行(想跳过得自己写 isFirstRender ref)、想只跑一次也得手动挡

useCustomEffect 把这些常用控制集中到一个入口(lifecycle.ts):

ts 复制代码
useCustomEffect(
  async () => {
    const data = await fetchSomething()
    // async setup resolve 后返回 cleanup
    return () => cleanup(data)
  },
  [dep],
  {
    immediate: true, // false = 首次挂载不执行(等价 useUpdateEffect)
    once: false,     // true = 只执行一次
    effect: useEffect, // 也可换成 useLayoutEffect
  },
)

相比原生,它多了:async 回调(Promise resolve 后可得到 cleanup)/ 首次跳过(immediate: false)/ 只跑一次(once)/ 可换底层 effect hook / 内部用 useLatestRef 读取最新已提交闭包 。并衍生出 onMounteduseUpdateEffect 等顺手的别名

这不是取消与竞态管理器。async effect 仍要自己用 AbortController 或序号丢弃过期结果;cleanup 只有等 Promise resolve 后才能取得,新 effect 可能先于旧 cleanup 执行。当前实现还只用 instanceof Promise 识别原生 Promise,并且到 cleanup 时才挂 rejection handler,不适合 thenable、跨 realm Promise 或需要立即处理失败的任务

immediate / once 又是用 ref 挡次数:开发环境 StrictMode 会额外重放 Effect,immediate: false 可能在第二次 setup 执行,once: true 也可能在重放 cleanup 后不再重新 setup。因此它适合普通便利逻辑,不要拿来承载必须严格与挂载生命周期对称的订阅、连接和资源锁

配套还有个 useStable,专治「依赖数组里塞了个对象字面量,每次新引用导致 effect 反复执行」:

ts 复制代码
// 仅对「复杂对象/数组」用,基础类型别用(Object.is 本就高效,包它纯属多余开销)
const stableOptions = useStable({ a: 1, b: 2 })
useEffect(() => { /* ... */ }, [stableOptions]) // 内容没变就不重跑

四、AI 时代:把这套规范喂给 AI,让它替你写「正确的 React」

最后聊点 2026 年绕不开的:AI 写码

光有这些 hook 还不够------你得让 AI 每次都按这套规范写 ,而不是吐回官方那套 useCallback + 手填依赖数组的屎山

所以我把整套约定固化成了一个 Claude Code 的 React Skill ,放在我的 dotfiles 仓库里:github.com/beixiyo/dot....claude/skills/react/SKILL.md

它和 react-tool 模板项目是强绑定 的------skill 里列的全是 packages/hookspackages/compspackages/utils 这套 workspace 的自有 API(我没发 npm,就是模板项目,下游直接拷贝)

这个 skill 本质是 react-tool 的「工程宪法」,开头第一句就是「必读,涉及 React 必须先读全文再写代码」。核心强制规范包括:

  • 事件处理器、定时器和外部订阅回调用 useLatestCallback,不传依赖数组;
  • useStable 只对复杂对象用,基础类型严禁
  • 派生值不走 effect、事件不绕 effect、禁止链式级联 setState
  • 组件一律 memo + 具名导出、逻辑抽进 useXxx.ts、TailwindCSS + 设计 Token;
  • 业务代码强制 Signal,组件库(为可移植性)才用原生 + 自有 hooks

(skill 里我特意划了适用范围 :这些 useLatestCallback、业务 Signal 约定是给已经采用这套内部包的项目定的规矩;碰到没有这些内部包的外部 / 开源项目,它会先读项目自己的配置和代码风格,不硬套)

效果就是:AI 写事件回调时默认避开陈旧闭包和无意义的引用变化,同时在 render 函数、输入快照等确实需要 useCallback 的地方保留原生语义。规范即护栏------你把适用边界也写进 skill,AI 才不会把一种优化机械套到所有函数上


写在最后

《React 之死》写到这里,该收尾了

我从来不是为了骂街------JSX 是划时代的,组件化、声明式、单向数据流是 React 留给整个行业的遗产,这些我都认。我批评的是它那些反人类、又迟迟不修 的细节:闭包陷阱、人肉依赖数组、漫天 rerender、拖到 2025 才有的 KeepAlive、只解决 Effect 场景的 useEffectEvent、越走越黑的编译器黑盒

官方装死,那我们就自己把坑填了。好在------

这些坑,可以靠 useRef 加几十行明确、可检查的封装,把最折磨人的部分逐个填上

你不用换框架,也不用引入编译转换。把事件用途的 useCallback 换成 useLatestCallback,按场景接入 KeepAlive 和路由,再把这些边界一起喂给 AI------然后你就可以像个正常人一样写 React 了

源码都在这,自取:

四年了,React 还是那个 React。但至少,我们可以选择活得舒服一点

我到现在终于没有再写过原生的 useCallback------大约 React 的确是死了

---- ----

相关推荐
不好听6132 小时前
HTML 事件监听机制:从 DOM Level 0 到 React 合成事件
前端·react.js·html
wordbaby2 小时前
为什么刷新页面就 404?聊聊 SPA 路由与 Nginx 的那点事
前端
不好听6133 小时前
React 核心概念:JSX、组件与数据驱动
前端·react.js
触底反弹3 小时前
🔥 React 零基础入门(中):500 行屎山代码到组件化的蜕变
前端·javascript·react.js
不好听6133 小时前
React vs Vue:两大前端框架技术选型深度对比
前端·vue.js·react.js
GuWenyue3 小时前
Cursor黑盒拆解!1套LangChain.js手写Mini编程Agent,自动生成React项目,效率提升60%
前端·数据库·人工智能
GuWenyue3 小时前
传统Agent工具两大痛点!300行代码落地MCP跨语言工具,彻底解耦LLM与工具
前端·人工智能·算法
小林ixn4 小时前
从 onclick 到 React 合成事件,再到完整应用逻辑:一次前端架构的深度剖析
前端·react.js·前端框架
Csvn4 小时前
🧩「找不到模块」排查全记录——Monorepo 下 TypeScript 路径别名的 5 种「不通」与根治方案
前端