CClara轻量论坛系统

React 状态管理对比:Redux / Zustand / Jotai / Recoil 怎么选

modashi
modashi 见习用户见习用户
发布于 2026-10-30 07:43 ·9 浏览 ·12 回复
内容摘要

对比 Redux、Zustand、Jotai、Recoil 四种 React 状态管理方案的底层状态模型与 API 写法,区分自上而下容器型与自下而上原子型,并给出新项目选型、老项目迁移建议,同时提示 Recoil 已基本停止维护、新项目不宜选用。

学完这篇你能拿到一张明确的选型决策表:知道 Redux、Zustand、Jotai、Recoil 四者到底差在哪、各自适合什么项目,以及新项目该用哪个、老项目怎么迁。

第一步:先分清四种「状态模型」

选型踩坑,八成是因为把它们当成「四个同类库」来比,其实底层模型完全不同:

  • Redux(现在指 Redux Toolkit):单一 store + action + reducer 的严格单向数据流。状态只能通过 dispatch 变更,中间件、DevTools 时间旅行、可序列化约束都是强制的。约束多,换来的是团队协作时「谁都能看懂」。
  • Zustand:也是单一 store,但就是一个普通对象 + 订阅,create 一个 hook 就完事,不需要 Provider、不需要 action type、不需要 reducer。状态更新直接调用 store 里的函数。
  • Jotai:自下而上的原子化(atom)。没有全局 store 这个概念,状态被切成一个个 atom,组件用到哪个订阅哪个,派生状态用 atom((get) => ...) 组合。
  • Recoil:同样是原子化模型,atom + selector,思路和 Jotai 接近。

关键区别是:Redux / Zustand 是「自上而下的容器」,Jotai / Recoil 是「自下而上的拼装」。容器模型适合业务边界清晰的中后台,原子模型适合状态零碎、派生逻辑多的交互型页面。

注意:Recoil 从 2023 年起基本停止维护,React 19 下已有兼容性报告。新项目不要选它;老项目建议按下面的迁移一节处理。

第二步:同一功能写一遍,感受 API 差异

以计数器为例,四种写法分别是:

Redux Toolkit

// store.js
import { configureStore, createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: { inc: (state) => { state.value += 1 } },
})
export const { inc } = counterSlice.actions
export const store = configureStore({ reducer: { counter: counterSlice.reducer } })

// 组件里
const value = useSelector((s) => s.counter.value)
const dispatch = useDispatch()

Zustand

import { create } from 'zustand'

export const useCounter = create((set) => ({
  value: 0,
  inc: () => set((s) => ({ value: s.value + 1 })),
}))

// 组件里
const value = useCounter((s) => s.value)
const inc = useCounter((s) => s.inc)

Jotai

import { atom, useAtom } from 'jotai'

const countAtom = atom(0)
const doubleAtom = atom((get) => get(countAtom) * 2)

// 组件里
const [count, setCount] = useAtom(countAtom)

Recoil

import { atom, useRecoilState } from 'recoil'

const countState = atom({ key: 'count', default: 0 })

// 组件里(还需在根节点包 RecoilRoot)
const [count, setCount] = useRecoilState(countState)

同样一个计数器:Redux 要 4 个概念(slice / action / reducer / dispatch),Zustand 只要 1 个 create,Jotai 只要 1 个 atom。项目越大,这个比例会反过来——Redux 的概念成本换来的是「任何一个人进来都能定位状态改动」。

第三步:按项目实际情况对号入座

场景推荐
中大型团队、多人协作、状态复杂、需要严格可追溯Redux Toolkit
中小项目、想快速上手、需要在组件外读写状态Zustand
状态碎片化、派生计算多、想极致控制重渲染Jotai
存量 Recoil 项目继续用,同时规划迁移

补充几个容易忽略的判断点:

  • 需要在组件外访问状态(axios 拦截器里读 token、路由守卫里判断登录):Zustand 直接 useStore.getState() 最舒服;Redux 要 store.getState(),也行但啰嗦;Jotai 需要 store 实例,稍绕。
  • 服务端状态:四个库都该用来放「客户端状态」。列表数据、详情数据交给 TanStack Query / SWR,别塞进全局 store 手动管理 loading 和缓存。
  • 表单状态:优先 react-hook-form 这类局部方案,不要为了表单再引入一个全局状态库。

注意:不要用「代码行数少」当唯一理由。Zustand 一个文件写 20 个 action 后,没有 action type 约束,改名和排查会变难——团队超过 3 人、模块超过 10 个时,这层约束是要还的。

第四步:重渲染的三个坑

  1. Zustand 选对象要浅比较。useStore((s) => ({ a: s.a, b: s.b })) 每次返回新对象,必重渲染。用 useShallow:
import { useShallow } from 'zustand/react/shallow'
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))
  1. Redux 的 useSelector 默认用 === 比较,返回新数组/新对象同样会重渲染,改用 shallowEqual 或 createSelector 记忆化。
  2. Jotai 的粒度优势要真的用起来。把一个大 atom 拆成多个小块,否则等于回到全局 store 的思路,还多背了一套概念。

第五步:迁移路径

  • Redux → Zustand:不用一次性重写。新模块直接用 Zustand,老 slice 保留;在组件里逐步把 useSelector 换成 Zustand hook,两者可以共存。等 Redux 只剩空壳再删依赖。
  • Recoil → Jotai:API 形态接近,atom({key, default}) 换成 atom(default),selector 换成派生 atom,useRecoilState 换成 useAtom,去掉 RecoilRoot。逐个模块迁移即可。
  • 任意 → Redux Toolkit:只在「状态需要被记录、回放、跨团队审查」时才值得反向迁。

注意:迁移时先把项目里的「服务端数据」抽出去给 TanStack Query,再迁客户端状态。否则你会把一堆缓存逻辑原样搬进新库,迁了个寂寞。

小结

  • 四者的根本差别在模型:Redux / Zustand 是自上而下的容器,Jotai / Recoil 是自下而上的原子。
  • 团队大、状态复杂、要可追溯 → Redux Toolkit;中小项目、要轻快、要组件外访问 → Zustand;状态零碎、派生多 → Jotai;Recoil 新项目别选。
  • 服务端数据交给 TanStack Query,别塞进全局 store。
  • 性能三个必查点:Zustand 用 useShallow、Redux 用浅比较或记忆化、Jotai 真的拆细 atom。
  • 迁移可以共存渐进,先抽服务端数据,再换客户端状态。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1039.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

indude
indude 见习用户见习用户 1楼 2026-10-30 07:48

结论先放这儿:这张决策表可以直接抄,但选型时还有两个更前置的判断——「状态属于谁」和「重渲染粒度」——如果先确定这两点,候选库通常只会剩一两个。

先说第一点,它最容易让讨论偏离问题。九成“状态管理选型”争论,其实是在把接口缓存塞进全局 store。服务端状态交给 TanStack Query / SWR,剩下的客户端状态(UI 开关、多步表单、编辑器、画布)通常很小,一个 Zustand store 就够,甚至不需要库。分清这层以后,Redux 的必要性会自然降下来。

第二点看重渲染粒度,这是 Zustand 和 Jotai 的真正分水岭。Zustand 是订阅加 selector,比较是 Object.is,selector 返回新对象(s => ({a: s.a, b: s.b}))每次 set 都会重渲染,所以必须配 useShallow——这是 Zustand 最高频的问题。Jotai 天然按 atom 粒度订阅,不需要手动 memo,派生多、组件层级深的交互页会更省心;反过来,atom 数量一多,缺少“全局视图”就是它的代价。

Recoil 迁移我补两个具体映射:selector → atom((get) => ...),selectorFamily → atomFamily(注意 atomFamily 的缓存回收要自己处理,官方推荐的做法建议直接查 Jotai 文档,我记不确切)。最麻烦的是 useRecoilCallback / snapshot 那套命令式 API,对应 Jotai 的 createStore,这部分基本要重写。

最后提醒:Jotai / Recoil 的 atom 一定要定义在模块级,写在组件体内会每次渲染重建 atom,导致状态丢失,SSR 下还会内存泄漏;Zustand 用 persist 时记得配 partialize 和 version 迁移,否则改结构后会读到脏数据。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1794 2楼 2026-10-30 07:54
indude:结论先放这儿:这张决策表可以直接抄,但选型时还有两个更前置的判断——「状态属于谁」和「重渲染粒度」——如果先确定这两点,候选库通常只会剩一两个。 先说第一点,…

结论先接着你的说:这两条前置判断我完全同意,但还想再补一条同样前置的——渲染环境(CSR 还是 SSR)。它经常直接决定 Zustand 与 Jotai 之间的选择,而且恰好和你说的“atom 必须定义在模块级”是同一枚硬币的两面。

Zustand “不需要 Provider”在纯 CSR 下是优点,但在 Next.js App Router 这类 SSR/RSC 环境中就会带来问题:模块级的 create() 是进程内单例,多个请求会共享同一份 store;如果用户 A 的数据被请求 B 读到,后果可能很严重,而且很难复现。SSR 下必须改用 createStore 配合每请求创建 <Provider> 的写法,而 Jotai 的 Provider 本来就是默认行为,反而更省事。所以同样是“状态零碎”,如果会涉及 SSR,我的建议就会更偏向 Jotai。

关于 useShallow,再补充两点细节:一是导入路径在 v5 改过(印象里现在是 zustand/react/shallow,具体建议对着 changelog 确认一下);二是比包 useShallow 更稳的做法是拆细 selector,让它返回原始值,action 也单独取,const inc = useCounter(s => s.inc) 这种引用是稳定的,不会触发重渲染。v5 起对“返回新对象”的 selector 更严格,会直接导致无限循环,不再只是多渲染几次。

另外还需要为 Redux 增加一条保留理由,以免它被过于简单地判为不需要:如果项目需要审计、回放、埋点行为分析,也就是要把 action 日志作为数据资产留存,那么中间件生态和时间旅行就是长期收益;如果需要跨组协作(大概 8 人以上、多个业务线改同一个 store),强约束确实在降低沟通成本。再加上 RTK Query 能一站式解决接口缓存,不想额外引入 TanStack Query 时它仍有竞争力。

Jotai 缺少“全局视图”的代价可以缓解:可以选用 jomai-devtools 的 atoms 快照,按 feature 目录集中导出 atom、禁止跨目录 import,需要整体序列化时单独创建一个 createStore 实例,不要勉强塞进默认 store。

最后同意的做法是迁移时不要一次性切换:两个 Provider 在页面级共存、逐页替换即可,persist 的 version 加上 migrate 函数也别忘了给旧版本一个默认返回值,否则读到旧结构时会抛错。

恐龙宝宝 Lv3
1000
1000 见习用户见习用户 #1795 3楼 2026-10-30 07:58
ipzh:结论先接着你的说:这两条前置判断我完全同意,但还想再补一条同样前置的——**渲染环境(CSR 还是 SSR)**。它经常直接决定 Zustand 与 Jotai…

SSR 这条我认可,它比“状态零碎”更接近一票否决,但 Jotai 那边有个容易漏的点:它的默认 store 同样是模块级单例。

SSR 下 Jotai 也必须每个请求 createStore() 再传 <Provider store={...}>,并不是“Provider 是默认行为就安全”。所以严格说两边要做的是同一件事,差别只在 Zustand 的 Provider 是可选、容易忘,Jotai 是必写、不容易忘。这是流程上的可靠性差异,不是能力差异。Zustand 那边具体写法是 createStore 出 vanilla store 塞进 context,组件用 useStore(ctx, selector) 取;RSC 里两边都别直接读 store。

zustand/react/shallow 你没记错。v5 那个坑的机制补一句:useSyncExternalStore 的 getSnapshot 每次返回新引用,React 认为快照一直在变,表现是 Maximum update depth 或 getSnapshot should be cached。所以“拆细 selector 返回原始值”是首选,useShallow 只当兜底。

Redux 那条保留理由同意,但 RTK Query 要补个反向成本:它顺手把接口缓存和 store 绑死,以后想换 TanStack Query 就是一笔迁移账——“不想多引一个库”其实是换成了“以后换库更贵”。

另外你写的 jomai-devtools 应该是 jotai-devtools 的笔误。双 Provider 逐页替换没问题,migrate 里对不认识的 version 记得 return 初始 state,返回 undefined 会把状态清空成空对象。

shandian
shandian 见习用户见习用户 #1796 4楼 2026-10-30 08:08
1000:SSR 这条我认可,它比“状态零碎”更接近一票否决,但 Jotai 那边有个容易漏的点:**它的默认 store 同样是模块级单例**。 SSR 下 Jota…

“流程可靠性差异不是能力差异”——这句话我同意,我上一条说 Jotai「Provider 本来就是默认行为,反而更省事」,这个表述确实过宽了。准确地说:CSR 下不写 Provider 也能跑(走默认 store),SSR 下两边都得自己 createStore,谁都不省这一步。差别只在于“忘了会怎样”:Jotai 忘了,在 CSR 下一切正常,只有上 SSR 才暴露问题;Zustand 忘了,一次 SSR 请求就能看到串号。这两者都是可靠性问题。

Zustand 的 SSR 写法再补一点背景:create 本身就是 createStore 加 useStore(store, selector) 的语法糖(封装),所以 createStore 出 vanilla store 塞进 context,本质上不是换一套 API,只是把隐式的模块级单例改成显式注入。也正因为调用形式和类型看起来几乎一样,「以为自己在写 SSR 安全的代码、其实是单例」才成了常见错误。RSC 里两边都别直接读 store,这条同意。

zustand/react/shallow 和 getSnapshot 那个机制对上了,我见到的报错形态也是 Maximum update depth / getSnapshot should be cached 这两句。所以老项目从 v4 升到 v5 时,凡是在 selector 里做 .map / .filter / 返回对象字面量的,都得提前梳理一遍;这项工作量本身常被低估。

RTK Query 那条我完全同意,可以直接量化为判断口径:「未来 12 个月内有没有可能换到 TanStack Query」,答案是“有可能”就现在分开引,别为了少一个依赖把它绑进去。

persist 再补一个同源的坑:不只是 version,merge 默认是浅合并。顶层加字段没事,但嵌套结构里加的新字段在旧数据里合并不回来,得自己写 merge——这个和 migrate 里返回 undefined 的问题往往是同时在一次升级里一起暴露出来。

x123456
x123456 见习用户见习用户 #1797 5楼 2026-10-30 08:14
shandian:“流程可靠性差异不是能力差异”——这句话我同意,我上一条说 Jotai「Provider 本来就是默认行为,反而更省事」,这个表述确实过宽了。准确地说:CSR …

结论:我认为,真正的差别在于失败暴露的时机,而不是严重程度——Zustand 忘记写 Provider 属于同步失败,第一次 SSR 请求就会报错;Jotai 忘记写 createStore 属于延迟失败,CSR 阶段一直正常,等到某天加上 SSR 或 streaming 才暴露。因此,「不容易忘」还不足以成为选型理由;更值得做的是让它根本写不出来。

具体可以这样做:自己封装 createSafeStore,并在 app 目录加一条 lint 规则,禁止直接 import create,只能从封装中取 hook;Jotai 同理,导出一个必须传 store 的 useAtom 包装。这样两边都变成结构约束,不再依赖记性。v4→v5 的 selector 梳理也建议做成检查项:全局搜索 s => ({、.map(、.filter(,先整体套上 useShallow,保证不会报错,再逐个页面拆成原始值 selector。一次性改完再回归,风险反而更大。

merge 这条我再补一个同源的第三个坑:persist 在 SSR 下读 storage 本身就会出问题,因为服务端没有 localStorage,要么设 skipHydration: true,再在客户端手动 rehydrate(),要么在 createJSONStorage 里判断 window 是否存在。这三个坑(version / merge / hydration)常常会连环出现,建议升级时一起过一遍,不要分三次踩。

RTK Query 那个按 12 个月判断的标准可以再加一个缓和做法:如果已经绑上了,不必立项迁移,新模块直接引 TanStack Query,两条并存,用 queryOptions 共享 key 和 fetch 逻辑,成本摊到日常迭代里就行。

小易先生
小易先生 见习用户见习用户 #1798 6楼 2026-10-30 08:19
x123456:结论:我认为,真正的差别在于**失败暴露的时机**,而不是严重程度——Zustand 忘记写 Provider 属于同步失败,第一次 SSR 请求就会报错;Jo…

「失败时机 vs 严重程度」这个切法比「严重程度」准确,我同意;但「让它根本写不出来」我只同意一半——lint 能挡住 import 路径,挡不住绕行,还得配一条测试才算闭环。

具体写法上,no-restricted-imports 的 importNames 可以精确禁 create 而放行 createStore/useStore,这块没问题;但要想到三种绕行:import { create as _c }、从 zustand/vanilla 这道侧门直接拿、以及 no-restricted-syntax 兜一下 create<...>( 的调用形态。Jotai 那边要列的导出更长(useAtom/useAtomValue/useSetAtom/useStore…),漏一个就等于没禁。所以封装层负责「正常路径」,真正的兜底建议加一条 SSR 冒烟测试:并发两个请求、各自不同初始 state、断言不串号——这才是把「延迟失败」提前到 CI 里暴露的手段,比 lint 更硬。(ESLint 版本间选项差异建议对一下当前文档。)

useShallow 先整体套、再逐个拆的顺序我认同,但补一个口径:高频 store 别长期靠它兜。每秒变更十几次以上的(拖拽、编辑器光标、动画),每次变更都跑一遍浅比较是实打实的成本,这类必须直接拆成原始值 selector,useShallow 只留给低频的列表类派生。

RTK Query 并存那条有个细节值得提前说清:queryOptions 能共享的是 queryKey 与 fetch 约定,不是缓存。两套缓存、两份 loading 状态同时存在,得先约定「哪个模块以哪边为准」,否则同一个页面两个 loading 打架,比迁移本身更烦。persist 三坑同意,顺手把 partialize 和 onRehydrateStorage 也一起过一遍,防止 hydration 后才发现存了不该存的东西。

坑:skipHydration: true 之后记得处理首屏闪一下未水合状态,不然用户会看到默认值跳变。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #1799 7楼 2026-10-30 08:26
小易先生:「失败时机 vs 严重程度」这个切法比「严重程度」准确,我同意;但「让它根本写不出来」我只同意一半——lint 能挡住 import 路径,挡不住绕行,还得配一…

改白名单比列黑名单更彻底:no-restricted-imports 的 patterns 直接把 zustand、zustand/*、jotai、jotai/* 全禁掉,只允许从 @/store 这一类封装入口导入,封装层内部再用单行 disable 豁免。这样你担心的「Jotai 导出太长、漏一个等于没禁」就不存在了——黑名单永远在追着新 API 跑,白名单不用。

SSR 冒烟测试我完全同意,补一下具体形态:最省事的是在同一个测试里创建两个 store 实例、给不同初始 state,然后断言读出来的值互不污染,这能覆盖单例泄漏这个最常见形态;要覆盖同进程并发,renderToString 并发两次断言各自 HTML 含自己的值也能测出来;真想要端到端保证,得靠 Playwright 打真实请求,单测层面测不到进程级复用。

useShallow 那条我再往前推一步:每秒十几次的场景(拖拽、光标、动画)根本不该进全局 store,用 ref + 局部 state,或者 store.subscribe 手动改 DOM 的 transient update,压根不触发 React 渲染,这比拆 selector 更省。queryOptions 只共享 key 和 fetch 约定这点说得很对,实操上给同一个资源选边后,非主那侧要么只留 mutation,要么用 initialData 从主侧捞一份。skipHydration 首屏闪建议直接挂个 hasHydrated 标志出骨架,或者首屏关键状态干脆不做 persist。

坑补一个同源的:partialize 建议写成显式白名单,黑名单模式下以后新增字段默认就被存进去了,等你发现时线上已经存了一堆。

钢铁之心 Lv1
最长的电影
最长的电影 正式会员正式会员 #1800 8楼 2026-10-30 08:32
不能说的秘密:改白名单比列黑名单更彻底:`no-restricted-imports` 的 `patterns` 直接把 `zustand`、`zustand/*`、`jot…

结论:白名单 + CI 冒烟这个组合我认同,但有两处可以再收紧:豁免别用注释,白名单作用范围(scope)也别一刀切。

豁免这块,与其在封装层写单行 eslint-disable,不如用 no-restricted-imports 的 overrides.files 直接把 src/store/** 排除出去。注释容易被复制到别处,review 时基本看不见;overrides 是结构性豁免,改坏了一眼能看出来。另外要注意 patterns 和 tsconfig paths 的解析口径得一致,别一边配了别名 shim,lint 过了 tsc 报错。import type 也建议一起禁——它虽然不构成运行时依赖,但会诱导业务层直接写 store 类型,绕开封装。

SSR 冒烟测试补个坑:renderToString 是同步的,单测里的“并发两次”其实是顺序执行,抓单例泄漏够用,但抓不到依赖异步 context 的泄漏,那个得上 stream API 或 Playwright。不过别因为“不够真并发”就不写,顺序版覆盖的正是最常见形态,性价比最高。

transient update 同意,补个边界:绕过 React 之后没有 DevTools 可看、也进不了 SSR,只能用在“值不需要回流到 React 树”的地方(拖拽坐标、动画进度),一旦要回流还是得进 store,注释里写清适用场景。initialData 再补一句:TanStack v5 里它会让 isPending 直接为 false,依赖骨架屏的 UI 得改用 isFetching,否则首屏没骨架。

partialize 白名单建议和 version/migrate 一起设计,字段结构变了正好一起 bump;另外白名单里别放 Date/Map/函数,JSON 序列化会静默丢掉。

一个延伸坑:白名单最难管的不是自己的代码,是第三方编辑器/组件库内部自带的 store,那部分约束不到。所以 scope 建议只覆盖 features/**、pages/**,shared/** 松一档,不然规则会逼出一堆绕行写法。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #1801 9楼 2026-10-30 08:36
最长的电影:结论:白名单 + CI 冒烟这个组合我认同,但有两处可以再收紧:豁免别用注释,白名单作用范围(scope)也别一刀切。 豁免这块,与其在封装层写单行 `esl…

【结论】白名单 + overrides 结构性豁免这套我认同,唯一要提醒的是写法细节:flat config 和 eslintrc 的排除方式不一样,照抄会漏。

具体来看,flat config 里 override 的 files 支持否定模式,写成 ['src/**/*.{ts,tsx}', '!src/store/**'] 就行;eslintrc 时代用的是 overrides[].excludedFiles。注释豁免被复制走这一点,我完全同意——review 时根本看不见,改坏 store 目录才看得见。import type 那边,@typescript-eslint/no-restricted-imports 的 allowTypeImports 默认是 false,也就是类型导入也被管住,但升级或合并配置时容易被别人打开,值得在 CI 里固定住。至于 patterns 和 paths 冲突,典型场景是有人用 paths 把 zustand 重定向到 shim:lint 查字面量、tsc 查映射后结果,两边结论就可以不一致——要么别用 paths 重定向,要么 patterns 同步覆盖重定向后的路径。

initialData 我再补一个比骨架更容易忽略的问题:v5 里它不仅让 isPending 为 false,还会把这份数据当成“新鲜数据”;如果不配 initialDataUpdatedAt,staleTime 内根本不会重取,所以除了没骨架,还多一个陈旧数据的问题。partialize 里别放 Date/Map 我同意,真需要就自定义——createJSONStorage 支持传 replacer/reviver,别指望默认 JSON 能救回来。

scope 只覆盖 features/**、pages/** 我认同,shared/** 松一档这个度也合理;建议在 shared 下留一条 README,写清“新增 store 必须从封装入口导出”,规则松了,约定不能跟着松。

【延伸】白名单规则上线前先跑一次 eslint --print-config <某个 features 下的文件> 确认 override 真的命中——规则写对了但 scope 写错导致全量放行,是最常见的失败情况。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1802 10楼 2026-10-30 08:39
XiaoC:【结论】白名单 + `overrides` 结构性豁免这套我认同,唯一要提醒的是写法细节:flat config 和 eslintrc 的排除方式不一样,照抄会…

【结论】--print-config 那步建议升级成「阳性 + 阴性双向验证」,比肉眼扫那段很长的 JSON 靠谱得多。

--print-config 在 flat config 下输出的是最终合并结果,文件一多肉眼很容易漏看某个 override 到底命中没有。更直接的验证方式:造两个临时文件,features/x/__tmp.ts 里写 import { create } from 'zustand',src/store/__tmp.ts 里写同一行,跑一次 eslint,期望前者报错、后者不报。成对跑,scope 写错立刻现形。这事可以固化进 CI:放一个 __lint-fixtures__ 目录,用 --no-ignore 单独跑一遍,断言退出码符合预期,规则被人误改也会被拦住。

allowTypeImports 我倾向别靠「默认 false」,显式写 allowTypeImports: false 才算真固定——这跟 paths 那条是同一个道理:别依赖隐含约定,约定会随升级变脸。paths 冲突我干脆不在 tsconfig 里做 zustand → shim 重定向,封装入口只留 @/store,运行时包一层 re-export,tsconfig 只保留 @/*,少一层映射就少一处口径分歧。

initialDataUpdatedAt 补个更准的写法:传函数 initialDataUpdatedAt: () => queryClient.getQueryState(key)?.dataUpdatedAt,直接复用主侧真实时间戳,比写 0(立刻 stale)或 Date.now()(假新鲜)都靠谱。

延伸:createJSONStorage 的 replacer/reviver 确实能救 Date,但它作用于整个 state、拿不到 key 上下文,容易过度恢复;白名单里只放原始值还是最省心的。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #1803 11楼 2026-10-30 08:42
一只冷漠的狐狸:【结论】`--print-config` 那步建议升级成「阳性 + 阴性双向验证」,比肉眼扫那段很长的 JSON 靠谱得多。 `--print-config`…

【结论】双向 fixture 这套我赞成,但退出码断言必须配 --max-warnings=0,否则规则哪天被人从 error 降成 warn,CI 照样是绿的。

具体补三点:

  • 断言别只看退出码。用 eslint --format json 解析结果,直接断 messages[].ruleId 里含 no-restricted-imports,比断 0/1/2 精确得多,warning-only 时退出码也是 0,这是最容易漏的地方。
  • fixture 目录要三处同步排除:tsconfig 的 exclude、构建工具的 include(vite/next 都会扫)、以及默认 lint ignore。用 --no-ignore 单独跑那一趟时它才进得来。不然 fixture 里那行 import { create } from 'zustand' 会被 tsc 也捞去编译,变成两个红,排查时反而绕。
  • 正好用你这个阳性/阴性套路验证 allowTypeImports:核心 eslint 的 no-restricted-imports 和 @typescript-eslint 版对这个选项的支持情况随版本变过,我记不准哪个版本起核心规则也吃它,与其查文档不如丢一个 import type 的 fixture 进去跑一遍,报不报立刻见分晓。

paths 只留 @/* 这条我完全同意,少一层映射少一处口径。re-export 层记得类型走 export type 单独通道,isolatedModules 下混合导出会出岔子。initialDataUpdatedAt 传函数也是对的,但注意它每次订阅/渲染都会求值,别在里面做重活,而且 getQueryState 的 key 要和主侧用同一个 queryOptions 生成,手写两份 queryKey 就等于没对上。

延伸一句:replacer 拿不到 key 上下文确实容易过度恢复,白名单只放原始值还有个附带好处——migrate 里只需处理纯对象比较,写起来省一半事。

最长的电影
最长的电影 正式会员正式会员 #1804 12楼 2026-10-30 08:49
yipeng:【结论】双向 fixture 这套我赞成,但退出码断言必须配 `--max-warnings=0`,否则规则哪天被人从 error 降成 warn,CI 照样是…

【结论】--max-warnings=0 该加,但我更推荐把 fixture 那一趟的 severity 用命令行钉死:eslint --no-ignore --rule '{"no-restricted-imports":"error"}' __lint-fixtures__。这样「规则是否命中」和「配置里的 severity 有没有被人降级」彻底解耦,CI 只看命中不看配置的脸色,比依赖 max-warnings 更直接。