状态不是存哪都行
状态管理题的失分点很少是"不会用 API",而是说不清为什么这么设计:mutation 为什么必须同步、reducer 为什么必须返回新对象、Context 为什么不能替代它。这份逐条讲清代价。
01先看通知机制三个库的差别全在"怎么知道该更新谁",抓住这点其他都是派生。
02认准唯一真相源同一份数据存两处,迟早不一致。能算出来的不要存。
03分层再选型组件局部、页面、全局、服务端四层,塞错层比用错库更麻烦。
1
全局状态管理到底在解决什么★
不是"组件之间传值"那么简单
组件通信只是表象。全局状态管理真正解决的是三件事:跨层级共享、状态变更可追踪、以及"什么时候该重新渲染"。第三件才是框架之间最大的差别——Vuex 靠响应式追踪,Redux 靠不可变 + 全量对比,Zustand 靠手动订阅。
◐ 可交互:改一个字段,看谁跟着重新渲染
store
count0
user—
themelight
组件 A
订阅 count
0 次渲染
组件 B
订阅 user
0 次渲染
组件 C
订阅整个 store
0 次渲染
组件 C 订阅了整个 store,所以任何字段变化它都会重渲染 —— 这就是没有 selector 的代价。
不用状态管理时的典型问题
- props 层层透传,中间组件被迫接收无关数据
- 兄弟组件通信要往上提到共同父级,父级变成"数据中转站"
- 同一个数据在多处各存一份,改了一处忘了另一处
- 出问题时不知道是谁改的,只能到处打断点
用状态管理之后
- 任何组件直接读 store,不用透传
- 写入集中在 action,可以打日志、加限制
- 单一数据源,不会出现多份不一致
- 每次变更都有记录,排查有据可依
- 三个框架的根本差异在"怎么通知更新":Vuex / Pinia 用响应式系统,改哪个属性就只通知依赖它的组件;Redux 要求你返回新对象,然后靠 selector 比较决定是否重渲染;Zustand 是订阅模式,你选哪块数据就只在它变时通知。
- Vue 里"不用状态管理"也能跨组件:
reactive导出一个模块级对象,import 到哪都能用,且响应式正常。所以小项目真的不必上 Pinia。 - React 里不行:模块级变量改了不会触发渲染,必须有一套订阅机制。这就是为什么 React 生态的状态库那么多——它们在补框架本身缺的那一块。
面试怎么答全局状态管理不只解决跨层级传值,更重要是集中写入和变更可追踪。三个框架的差别在通知更新的机制上:Vuex 和 Pinia 靠响应式系统精确追踪依赖,Redux 靠不可变更新加 selector 比较,Zustand 靠手动订阅。Vue 里其实用 reactive 导出一个模块对象就能跨组件共享,小项目不一定需要 Pinia;React 里模块变量不会触发渲染,所以必须有订阅方案。
2
Vuex:那套约束为什么要存在
mutations 不是冗余设计
Vuex 的核心是四件套 + 一条纪律:state 存数据、getters 算派生、mutations 同步改、actions 跑异步。纪律是只能通过 mutation 改 state(开启严格模式后直接改会报错)。这条约束换来的是"每次变更都可被记录",devtools 的时间旅行就靠它。
- 为什么 mutation 必须同步:devtools 在每次 mutation 后拍快照。如果 mutation 里有异步,快照时机就对不上,时间旅行会错乱。这不是风格问题,是工具链的前提。
- 严格模式的代价:它靠深度 watch 整个 state 来检测非法修改,大对象上开销明显。所以只在开发环境开。
- Vuex 4 之后还有必要用吗:官方已经推荐新项目用 Pinia。Vuex 4 仍在维护,但不会再有大功能。老项目还是 Vuex,所以面试仍会问。
- 模块化的坑:
namespaced: true之后调用要带命名空间dispatch('user/login')。忘记开会导致不同模块的 action 重名互相覆盖。
面试怎么答Vuex 是 state 加 getters 算派生、mutations 同步改、actions 跑异步,一条纪律是只能通过 mutation 改 state。这条约束的意义在于让每次变更都能被 devtools 记录成快照,实现时间旅行,所以 mutation 必须是同步的。严格模式靠深度 watch 检测非法修改,只建议开发环境开。新项目官方推荐 Pinia,但老项目仍在 Vuex,所以还是会被问。
3
Pinia:为什么能砍掉 mutations★
Vuex 5 的形态提前来了
Pinia 的改动就一句话:action 既能同步也能异步,不再需要 mutation。它靠两层变化实现——每 store 一个独立响应式对象(没有单一 rootState)、以及 action 内部修改被 devtools 的
$onAction 拦截记录。约束放开了,记录能力没丢。Pinia 与 Vuex 的四处关键差别1. mutations Pinia 没有,action 直接改 state
2. 模块化 Vuex 靠嵌套 modules + namespaced
Pinia 每个 store 天然独立,互相 import
3. TypeScript Vuex 的类型推导很差,要手写
Pinia 的 setup 写法类型自动推导
4. 代码分割 Vuex 的 store 是单例,难以按需加载
Pinia 的 store 是函数,用到才初始化
storeToRefs为什么必须用:解构const { count } = store拿到的是普通值快照,不是响应式引用。Pinia 的 state 是reactive包的,解构就脱离代理了。而 action 是普通函数,解构没问题。- setup 写法 vs options 写法:setup 写法就是组合式函数,能复用、能嵌套 store、类型推断好;options 写法更接近 Vuex 的写法,迁移简单。新项目用 setup。
- store 之间互相引用:直接 import 另一个 store 的
useXxx()并在函数体里调用。不要在模块顶层调用,否则会因初始化顺序报错。
面试怎么答Pinia 砍掉 mutations 是因为它每个 store 是独立的响应式对象,不需要单一 rootState 来统一提交;action 里改 state 之后,devtools 通过 $onAction 钩子记录下来,所以可追踪性没丢。相比 Vuex 还多了三大优势:模块天然独立不用 namespaced、TypeScript 类型能自动推导、store 是函数所以能按需加载。要注意解构 state 必须用 storeToRefs,否则丢响应式。
4
Redux:不可变和中间件★
为什么要返回新对象
Redux 唯一硬性要求是reducer 必须是纯函数并返回新对象。原因是它靠"引用变没变"来判断更新——同一个引用就跳过重渲染,所以不能原地改。所有副作用(请求、日志、路由跳转)都放到中间件里,因为 reducer 必须纯。
中间件的洋葱结构dispatch(action)
│
├─ middleware1 前半段
│ ├─ middleware2 前半段
│ │ ├─ reducer 执行
│ │ └─ middleware2 后半段
│ └─ middleware2 结束
└─ middleware1 后半段
所以中间件能:
在 reducer 前后各做一件事(日志、耗时统计)
决定要不要 next(action)(拦截)
改造 action 再传下去(thunk 就是这样)
- 为什么一定要返回新对象:React-Redux 的
connect和useSelector用引用比较判断是否变化。原地改的话引用没变,组件就不更新——表面看是"数据变了但界面没变"。 - 无变化必须返回原引用:这不算偷懒,是明确优化。
default: return state能让订阅者直接跳过。 - RTK 里为什么能写
state.push:它内部用了 Immer,写法是"假装原地改",实际生成新的不可变对象。所以看着违反规则,其实没违反。 - 为什么要中间件而不是在组件里做:副作用集中管理、可复用、可组合。而且 reducer 纯净之后,时间旅行和状态回放才成立。
- Redux Toolkit 是现在的标准:
configureStore自带 thunk、devtools、Immer。手写createStore已经是老写法了。
面试怎么答Redux 要求 reducer 是纯函数返回新对象,因为它靠引用比较判断更新,原地改的话引用不变组件就不渲染。副作用不能放 reducer,所以请求、日志这些都放中间件,中间件的三层柯里化结构让它能在 reducer 前后各插一段逻辑,也能拦截或改造 action。现在标准写法是 Redux Toolkit,内部用 Immer 所以能写成像原地修改,实际仍生成新对象。
5
Zustand / Jotai:订阅模型★
React 状态库为什么越来越多
Zustand 的核心是一个 store 加一个 selector:组件通过 selector 声明"我只关心这块数据",只有它变了才重渲染。Jotai 更进一步,把状态拆成一个个原子,组件依赖哪个原子就订阅哪个,粒度天然更细。两者都不需要 Provider。
三种模型的更新粒度对比Redux:整棵树是一个对象
任何 action 都可能让 useSelector 重新执行
→ 靠 selector 的返回值比较来过滤
→ 写不好就整页重渲染
Zustand:整棵树也是一个对象,但订阅是逐组件的
selector 返回值变了才通知这个组件
→ 比 Redux 少了 Provider 和 connect 的样板
Jotai / Recoil:状态拆成原子
组件只依赖用到的原子
→ 一个原子变,只通知订阅它的组件
→ 粒度最细,但原子多了要管理依赖图
- selector 返回新对象会一直重渲染:
useStore(s => ({ a: s.a, b: s.b }))每次都返回新对象,引用永远不等。要么分开写两次,要么用useShallow做浅比较。这是 Zustand 最常见的坑。 - 不需要 Provider 是优点也是缺点:好处是少一层嵌套、能在组件外使用;坏处是不好做"每个子树一份独立 store"(要做也能做,用 createContext 传 store 就行)。
- 为什么 React 生态状态库特别多:因为 React 本身没有响应式系统,只有"状态变了重渲染整棵子树"。所有状态库都在补"精确更新"这块。Vue 天然有,所以 Vue 生态就只有 Pinia 一个主流方案。
面试怎么答Zustand 是一个 store 加 selector 的订阅模型,组件声明关心哪块数据,只有这块变了才重渲染,而且不需要 Provider。Jotai 把状态拆成原子,组件依赖哪个原子就订阅哪个,粒度更细。要注意 selector 返回新对象会导致无限重渲染,因为引用永远不相等,要拆开写或者用浅比较。React 生态状态库多,本质是因为 React 没有响应式系统,需要库来补精确更新这一块。
6
Context 不是状态管理★
它能传值,但不能高效更新
Context 解决的是透传问题,不是更新问题。value 一变,所有
useContext 的组件全部重渲染,没有 selector 也没有 bailout。所以正确用法是:Context 只放低频变化的值(主题、用户信息、i18n),高频状态交给状态库。- 为什么拆 Provider 有用:把"变化的值"和"稳定的函数"分开。React 的 setState 引用是不变的,所以
value={setList}永远不会导致重渲染。 useMemo包 value 只能省一部分:如果依赖数组里放了 list,list 一变 memo 就失效,起不到隔离作用。真正要做的是结构上拆开。- Context 的另一个用途是依赖注入:把 store 实例、api 客户端、主题配置传下去,让子组件不依赖具体实现。这类值几乎不变,非常适合。
- Redux 早期也有这个问题:
connect就是为解决"只更新用到那块数据的组件"而存在的,后来useSelector代替了它。
面试怎么答Context 解决的是跨层级传值,不是高效更新。它的 value 一变,所有消费组件都会重渲染,没有 selector 机制。所以正确做法是 Context 只放变化频率低的值,比如主题、当前用户、i18n、以及依赖注入用的 store 实例;高频变化的状态交给 Zustand 或 Redux 这类带订阅的库。如果一定要用 Context 装高频状态,至少要把稳定的 setState 和变化的值拆成两个 Provider。
7
状态该分几层★
不是所有数据都该进 store
按生命周期和共享范围分四层:组件局部状态、页面/路由状态、应用全局状态、服务端状态。塞错层是状态管理最常见的病——把服务端数据当全局状态存,就会出现"该刷新的时候不刷新、不该刷新的时候乱刷"。
四层各放什么【组件局部】useState / ref
输入框的临时值、hover 状态、展开收起
判据:只这一个组件用
【页面/路由】query 参数 或 页面级 store
筛选条件、分页、tab
判据:同一页面内多个组件用,换页面该丢
【应用全局】Pinia / Redux / Zustand
登录用户、权限、主题、全局配置
判据:跨页面存在,换页面不能丢
【服务端状态】React Query / SWR / vue-query
列表数据、详情数据
判据:数据源在服务端,需要缓存、失效、重试
- 服务端状态为什么不该放全局 store:它有缓存过期、并发去重、失败重试、焦点重取这些专属逻辑。手写进 store 里会变成几百行样板代码,而这些正是 React Query 这类库存在的理由。
- 重复状态是最大的坑:同一份数据既在 store 又在组件 state 里,改了一处两处不一致。判断标准是"有没有唯一真相源"。
- 能算出来的不要存:
total能由list算出来就不要存,否则每次改 list 都要记得同步 total,迟早漏。用 getters / computed / selector 算。 - URL 也是一种状态存储:会被分享、需要刷新保留的,放 URL 比放 store 正确。这条在路由那份里展开讲过。
面试怎么答状态按生命周期分四层。组件局部的用 useState 或 ref,只这一个组件用;页面级的筛选条件和分页放 URL 或页面级 store;跨页面的登录信息、权限、主题放全局 store;服务端数据单独一层,用 React Query 这类库管,因为它需要缓存、失效、重试、去重,塞进全局 store 会变成一堆样板代码。判断标准是唯一真相源,能算出来的不要存。
8
解构丢响应式:Vue 侧的高频坑
一个坑衍生出三个 API
Vue 的响应式靠 Proxy 拦截属性访问。一旦把属性值解构成普通变量,就绕过了代理,拿到的是当时的值,后续变化不会同步。Pinia 因此提供了
storeToRefs,Vue 3 因此需要 toRefs——这两个 API 存在的唯一理由就是这件事。- 为什么
reactive解构会丢而ref不会:ref本身就是个对象,解构出来还是那个对象,.value的访问仍然经过 getter。而reactive是 Proxy 包的对象,解构属性相当于读了一次值。 toRefs只处理第一层:嵌套对象解构后还是丢。所以深层嵌套最好别解构,或者用toRef单独转某一条。props解构同理:Vue 3.5 之前const { title } = defineProps()会丢响应式,3.5 之后编译器做了处理所以可以了。面试说到这个细节会加分。- 反过来,React 没有这个问题:React 每次渲染都是重新执行函数,拿到的就是最新的值。但代价是闭包陷阱——回调里捕获的可能是旧值,这个坑在 React 页里讲。
面试怎么答Vue 的响应式靠 Proxy 拦截属性访问,解构相当于读了一次值,脱离了代理,所以后续变化不同步。reactive 解构会丢,ref 不会,因为 ref 本身是对象。所以 Pinia 提供 storeToRefs 专门给 state 和 getters 用,action 是普通函数不用包。toRefs 只处理第一层,嵌套对象解构后还是会丢。React 没有这个问题,因为每次渲染都重新执行函数,但它有闭包陷阱。
9
持久化与跨标签同步
刷新丢状态怎么救
持久化就是写一份到 localStorage/sessionStorage,启动时读回来。跨标签页同步靠
storage 事件——注意它只在本标签之外触发,改自己的 localStorage 不会收到自己的事件。- token 不该只放 localStorage:它会被 XSS 直接读走。更稳的是 httpOnly cookie(JS 读不到)配合 CSRF 防护。localStorage 适合放不敏感的用户偏好。
- sessionStorage 的边界是标签页:同一标签内刷新保留,新开标签就没了。适合放"这次会话内有效"的东西,比如多步表单的中间态。
- 持久化要带版本号:数据结构变了之后,旧格式读进来会炸。存的时候带
version,读到不匹配就丢弃重来。 - 别把整个 store 都持久化:列表数据、临时 UI 状态存进去只会让 localStorage 膨胀,而且读出来可能是过期数据。只 persist 明确需要的字段。
- storage 事件不冒泡:只在 window 上监听,不要试图在 document 上绑。
面试怎么答持久化就是把需要的字段写进 localStorage,启动时读回来,Pinia 和 Zustand 都有现成插件,但一定要用白名单只存需要的字段,并且带版本号避免结构调整后读旧数据出错。跨标签同步靠 window 上的 storage 事件,要注意它只会在其他标签页触发,自己写不会收到自己的事件。另外 token 不建议只放 localStorage,因为它能被 XSS 读走,更稳的是 httpOnly cookie。
10
什么时候不该上全局状态
过度设计比没有设计更常见
三条明确的"不需要":只有一个组件用、可以从现有状态算出来、以及服务端数据。前两条用局部状态即可,第三条交给数据请求库。中小项目里,一个模块级
reactive / 一个自定义 hook 就够替代 Pinia。替代方案,按复杂度递增1. 一个组件用 → useState / ref
2. 父子几层 → props(两三层内其实可接受)
3. 兄弟或跨两三层 → 状态提升到共同父级
4. 一个小模块内共享 → 模块级 reactive 对象 / 自定义 hook + Context
5. 跨多个页面、需追踪 → Pinia / Zustand
6. 服务端数据 → React Query / SWR / vue-query
判断依据不是"数据多不多",而是:
有多少个地方要读写?写的地方能不能收敛到一处?
- 模块级
reactive的问题:它在整个应用生命周期内只有一份,做 SSR 时会串数据——不同请求共享同一个对象。这是 SSR 项目必须用 Pinia(每请求新建实例)的原因。 - 状态提升的代价:父组件会变成"数据中转站",自己的渲染范围被放大。超过两层就该考虑 Context 或 store。
- 不用库也有成本:没有 devtools、没有统一日志、没有时间旅行。团队超过三五个人时,一致性比省代码更重要。
- 面试里的加分点:能说清"这个项目我没用状态库,因为共享状态只有两处,用 Context 加一个 hook 就够了,而且避免了 SSR 串数据的问题"——这比"我们用了 Redux"有说服力。
面试怎么答是否需要全局状态,判断标准不是数据量大小,而是有多少地方读写、写操作能不能收敛到一处。一个组件用就局部状态,两三层父子用 props 或状态提升,一个小模块共享可以用模块级 reactive 或自定义 hook,跨页面且需要追踪才上 Pinia,服务端数据交给 React Query 这类库。要注意模块级 reactive 在 SSR 下会串数据,所以 SSR 项目必须用能按请求新建实例的方案。
11
速答清单
考前十分钟只看这一页
01全局状态管理真正解决什么?
跨层级共享、变更可追踪、以及精确控制"什么时候重渲染"。第三点是各框架差别最大的地方。
02Vuex 的 mutation 为什么必须是同步的?
devtools 在每次 mutation 后拍快照做时间旅行。有异步的话快照时机对不上,回放会错乱。
03严格模式有什么代价?
靠深度 watch 整个 state 检测非法修改,大对象上开销明显,所以只建议开发环境开。
04Pinia 为什么能去掉 mutations?
每个 store 是独立响应式对象,不需要统一提交;action 直接改 state,devtools 通过 $onAction 记录,可追踪性没丢。
05Pinia 解构为什么会丢响应式?
state 是 reactive 包的,解构等于读了一次值就脱离代理。要用 storeToRefs 转成 ref,action 是普通函数不用包。
06Redux 为什么必须返回新对象?
它靠引用比较判断更新,原地改引用不变就不会重渲染。无变化时返回原 state 是明确优化。
07Redux 中间件的三层柯里化结构?
store => next => action。让它在 reducer 前后各插一段逻辑,也能拦截或改造 action。
08RTK 里能写 state.push 为什么不算违反不可变?
内部用了 Immer,写法看起来像原地改,实际生成新的不可变对象。
09Zustand 的 selector 返回新对象会怎样?
无限重渲染。每次返回的对象引用都不一样。要拆成多次调用或者用 useShallow 做浅比较。
10Context 能替代状态管理吗?
不能。value 一变所有消费者全部重渲染,没有 selector。只适合放低频值:主题、用户信息、依赖注入的实例。
11状态应该怎么分层?
组件局部、页面/URL、应用全局、服务端数据四层。服务端数据用 React Query 这类库,不要塞进全局 store。
12为什么服务端数据不该放 store?
它需要缓存过期、并发去重、失败重试、焦点重取这些专属逻辑,手写进 store 就是几百行样板代码。
13模块级 reactive 在 SSR 里有什么问题?
整个应用生命周期只有一份,不同请求共享同一对象会串数据。SSR 必须用每请求新建实例的方案。
14跨标签页同步靠什么?
window 的 storage 事件。注意它只在本标签之外触发,自己写 localStorage 收不到自己的事件。
15token 放 localStorage 有什么风险?
XSS 能直接读走。更稳的是 httpOnly cookie 配合 CSRF 防护,localStorage 只放不敏感的偏好。
16中小项目可以不上状态库吗?
可以。模块级 reactive 或自定义 hook 加 Context 能覆盖大部分场景,但 SSR 项目和多人协作要考虑一致性。