介绍
Zustand 是一款小巧、快速且可扩展的 Bearbones 状态管理解决方案。它拥有基于 Hooks 的便捷 API,既不繁琐也不固执己见,但又具备足够的约定俗成,既清晰明了又具有 Flux 特性。
别因为它长得可爱就小瞧它,它可是有爪子的!我们花费了大量时间来解决常见的陷阱,例如令人头疼的“僵尸小孩”问题、 React 并发问题以及混合渲染器之间的上下文丢失问题 。它可能是 React 领域中唯一一个能完美解决所有这些问题的状态管理器。
僵尸小孩问题
僵尸小孩”(Zombie Child)问题是 React 状态管理库(如 Redux、Zustand 等)中一个经典的数据一致性与渲染顺序问题。
简单来说,它指的是:一个子组件在被父组件卸载(移除)之前,因为 Store 数据更新而抢先进行了一次“错误的”重新渲染,导致读取不存在的数据而报错。
这个组件就像一个本该“死”去(卸载)但还在“动”(渲染)的僵尸,所以被称为“僵尸小孩”。
- 问题发生的场景
通常发生在以下情况同时满足时:
父子组件依赖同一份数据的不同部分(例如:父组件依赖列表 ID 列表,子组件依赖具体的 Item 数据)。
子组件独立订阅了 Store(使用了 useSelector 或 connect)。
数据被删除(某个 Item 从 Store 中移除)。
通知顺序或渲染机制导致子组件比父组件先响应更新。
const state = {
users: {
1: { id: 1, name: "Alice" },
2: { id: 2, name: "Bob" }
}
}父组件 (UserList):
父组件负责读取所有 ID 并渲染子组件。
const UserList = () => {
// 获取所有用户 ID:[1, 2]
const userIds = useSelector(state => Object.keys(state.users));
return (
<ul>
{userIds.map(id => <UserItem key={id} id={id} />)}
</ul>
);
};子组件 (UserItem):
子组件根据 ID 去 Store 里获取具体的用户信息。
const UserItem = ({ id }) => {
// 问题出在这里!
// 当 id=1 的用户被删除时,state.users[1] 变成了 undefined
const user = useSelector(state => state.users[id]);
// 试图访问 undefined.name -> 抛出错误,应用崩溃
return <li>{user.name}</li>;
};触发“僵尸小孩”的流程:
用户点击“删除 Alice”按钮,触发 Action DELETE_USER(1)。
Redux Store 更新,state.users[1] 被移除。
Redux 订阅系统通知所有监听组件(父组件和子组件)。
如果子组件比父组件先运行 Selector:
UserItem(id=1) 运行 useSelector:state.users[1] 返回 undefined。
组件代码执行 user.name,导致 Cannot read property 'name' of undefined。
应用崩溃。
- 正常预期(如果没崩溃):
父组件 UserList 重新渲染,发现 ID 列表里已经没有 1 了。
父组件不再渲染 UserItem(id=1),该组件被卸载。
为什么叫“僵尸”?
因为在第 4 步时,从逻辑上讲 UserItem(id=1) 已经“死”了(不应该存在于 UI 上),但因为它还没来得及被父组件“埋葬”(卸载),它不仅“活”过来了,还咬了你的应用一口(报错)。
为什么子组件比父组件先运行 Selector:
这是一个非常深刻的问题,触及了 React 和外部状态管理库(Redux/Zustand)交互的核心机制。
简单来说,根本原因在于:Redux Store 的更新通知机制是“扁平”的,而 React 的组件渲染机制是“树状”的。
当 Redux Store 发生变化时,它并不知道组件的父子层级关系,它只是单纯地遍历所有订阅者并通知它们。
以下是详细的底层原因分析:
- 订阅列表是“扁平”的 (Flat Listeners)
在 Redux 的内部实现中,所有的订阅者(使用 useSelector 或 connect 的组件)都被保存在一个简单的数组或链表中。
Store 的视角:[组件A, 组件B, 组件C, ...]
React 的视角:<组件A> <组件B> <组件C /> </组件B> </组件A>
当 dispatch 发生时,Store 会遍历这个列表通知所有订阅者。Store 无法保证先通知父组件,再通知子组件。它甚至不知道谁是父谁是子。
如果遍历顺序恰好是先通知到了子组件(或者父子组件几乎同时收到通知),子组件就会立即执行 Selector 代码来计算“我是否需要重新渲染”。
- Selector 执行发生在“渲染前” (Check before Render)
这是最关键的一点。“运行 Selector”不等于“组件重新渲染”。
为了性能优化,useSelector(以及 React-Redux 的 connect)会在 React 真正开始渲染组件 之前,先执行一次 Selector 函数。
流程如下:
Action 触发:Store 数据更新了(Item 1 被删除了)。
通知订阅者:所有组件收到通知。
子组件响应:子组件收到通知,它的第一反应是:“数据变了吗?我需要更新 UI 吗?”
执行 Selector:为了回答这个问题,子组件 必须立刻运行 Selector,拿最新的 Store State 和自己当前的(旧的)Props 进行计算。
const item = state.items[props.id]
此时:state 是新的(没有 Item 1),props.id 是旧的(还是 1)。
BOOM! 报错发生。
此时,父组件可能也收到了通知,甚至父组件可能已经排队准备更新了,但 React 的渲染是异步的/批处理的,而 Selector 的执行通常是同步响应订阅事件的。在父组件来得及把子组件“卸载”之前,子组件已经急不可耐地跑了一遍 Selector。
React 并发问题
React 的并发(Concurrency)是指 React 在同一时间段内处理多个更新任务的能力。注意,这并不意味着并行执行(Parallelism,即同一时刻在多核 CPU 上同时运行),而是指任务可以中断、暂停、恢复和交替执行。
在 React 18 之前(Legacy Mode),渲染是同步且不可中断的。一旦开始渲染,React 就会一直执行直到页面更新完成,期间浏览器无法响应用户的交互(如点击、输入),这可能导致掉帧或卡顿。
并发模式(Concurrent Mode)下,React 就像一个聪明的调度员,它知道哪些任务更紧急(如用户输入),哪些任务可以缓一缓(如数据图表渲染),从而保证界面始终流畅响应。
核心概念:撕裂(Tearing)
在并发渲染下,最著名的“并发问题”就是 撕裂 (Tearing)。
什么是撕裂?
视觉上的撕裂是指 UI 的一部分显示了状态 A,而另一部分显示了状态 B。在 React 中,这是指在一次渲染过程中,组件读取到了不一致的数据状态。
为什么会发生?
因为并发渲染是可以中断的。
React 开始渲染组件树的一部分(读取了外部 Store 的值 v1)。
中断:React 暂停渲染,把控制权交还给浏览器(处理点击事件或网络回调)。
外部更新:在暂停期间,外部 Store(如 window.innerWidth、Redux、Zustand)发生了变化,值变成了 v2。
恢复:React 继续渲染剩余的组件。
问题:剩下的组件读取到了新的值 v2。
结果:同一个页面上,上半部分显示 v1 对应的内容,下半部分显示 v2 对应的内容。这就是“撕裂”。
举例说明:撕裂(Tearing)
假设我们有一个简单的应用,显示当前的“主题颜色”(外部变量)。
外部数据:
// 这是一个外部 store,React 无法感知它的变化
let themeColor = 'blue';
function updateColor(*newColor*) {
themeColor = newColor;
}React 组件:
function App() {
return (
<div>
<Header /> {/* 读取 themeColor */}
<ExpensiveList /> {/* 一个很重的组件,渲染很慢 */}
<Footer /> {/* 读取 themeColor */}
</div>
);
}并发渲染下的灾难流程:
开始渲染:React 开始渲染 App。
渲染 Header:Header 组件读取 themeColor,得到 'blue'。渲染出蓝色的头部。
渲染 ExpensiveList:ExpensiveList 很复杂,React 计算了一会儿,决定暂停(yield)一下,把主线程让出来给浏览器处理紧急任务。
外部干扰(用户交互):在 React 暂停期间,用户点击了一个按钮,触发了 updateColor('red')。现在 themeColor 变成了 'red'。
恢复渲染:React 忙完了其他事,回来继续渲染剩下的组件。
渲染 Footer:Footer 组件读取 themeColor,得到 'red'(因为它直接读的外部变量)。渲染出红色的底部。
提交到屏幕:React 把生成的 DOM 更新到屏幕上。
结果:用户看到了一个蓝色的头和一个红色的脚。这就是视觉撕裂。
解决方案:useSyncExternalStore
为了解决这个问题,React 18 引入了 useSyncExternalStore。它的作用是告诉 React:
“嘿,我依赖这个外部数据源。如果你在渲染过程中发现这个数据源变了,那这次渲染就废了(Throw away),必须用新数据重新从头渲染一次,绝对不能展示不一致的中间状态。”
它强制 React 在读取这个 Store 时采用同步的方式,或者在并发渲染检测到不一致时强制重试,从而保证了视觉的一致性(虽然牺牲了一点点并发带来的性能优势,但正确性永远是第一位的)。
react commit阶段不是同步渲染的吗,可中断的不是render阶段的fiber树生成吗
- 澄清:Render vs Commit
Render 阶段(可中断、异步):
这是 React 调用你的组件函数(FunctionComponent())、运行 Hooks、生成 Fiber 节点、进行 Diff 算法(Reconcile)的过程。
关键点:这个阶段纯粹是计算,没有实际的 DOM 操作。
并发特性:因为只是在内存里算数,所以 React 可以算一会儿、停一会儿(yield),甚至算了一半发现数据过时了直接扔掉重算。
Commit 阶段(不可中断、同步):
这是 React 把计算好的变更(Effect List)真正应用到 DOM 上(appendChild, replaceChild),并执行 useLayoutEffect 和 useEffect(调度)的阶段。
关键点:一旦进入这个阶段,必须一气呵成,绝对不能停,否则用户会看到 UI 闪烁或 DOM 结构不完整。
- 为什么“撕裂”依然会发生?
你可能会问:“既然 Commit 阶段是同步的,DOM 更新是一次性完成的,那为什么还会出现头部是蓝色、底部是红色的情况?”
答案是:因为组件读取数据的动作(Read)发生在 Render 阶段。
让我们回到之前的例子,精确地看它发生在哪个阶段:
Render 阶段开始:
React 开始调用组件函数生成 Fiber 树。
Render :
调用 Header() 函数。
代码执行:const color = store.themeColor; (此时读到 'blue')。
生成 Header 的 Fiber 节点:{ type: 'div', props: { color: 'blue' } }。
中断(Yield):
React 发现时间片用完了,或者有更高优先级的任务,暂停 Render 阶段。
外部更新(Interruption):
浏览器处理点击事件,执行 store.themeColor = 'red'。
注意:此时 React 还没进 Commit 阶段,屏幕上啥也没变。
Render 阶段恢复:
React 继续构建剩下的 Fiber 树。
- Render :
调用 Footer() 函数。
代码执行:const color = store.themeColor; (此时读到 'red',因为它是直接读外部变量)。
生成 Footer 的 Fiber 节点:{ type: 'div', props: { color: 'red' } }。
Render 阶段结束:
Fiber 树构建完成。树里 Header 是蓝的,Footer 是红的。
Commit 阶段(同步):
React 把这棵“畸形”的树同步更新到 DOM。
用户看到了撕裂的 UI。
如何被修改的数据是useState()数据,是不是就不会有这种问题
是的!如果数据完全由 React 的 useState (或 useReducer, Context) 管理,就不会出现“撕裂”问题。
这是因为 React 的 State 具有 “快照(Snapshot)” 特性,且 React 内部完全掌控了 State 的版本管理。
为什么 useState 不会撕裂?
当你在 React 中触发 setState 时,React 会开启一次新的更新。在这个更新的 整个 Render 阶段(无论是否被中断),React 都会保证组件读取到的 state 值是 固定不变的。
即使在 Render 中途暂停了,用户点击按钮触发了第二次 setState,React 也会根据优先级决定是:
丢弃当前的渲染,用新状态重新开始(此时所有组件都读到新值)。
暂存新状态,继续用旧状态把当前的渲染做完(此时所有组件都读到旧值)。
无论哪种情况,React 保证在同一个渲染周期(Render Pass)内,所有组件看到的 State 是一致的。
举例说明:useState 的一致性
我们把之前的“撕裂”例子改成用 useState:
function App() {
// 此时 themeColor 是 React 内部管理的 state
const [themeColor, setThemeColor] = useState('blue');
return (
<div>
{/* 把 state 通过 props 传下去 */}
<Header color={themeColor} />
{/* 这是一个非常慢的组件 */}
<ExpensiveList />
<Footer color={themeColor} />
{/* 一个按钮触发更新 */}
<button onClick={() => setThemeColor('red')}>变红</button>
</div>
);
}
function Header({ color }) {
return <div style={{ color }}>Header: {color}</div>;
}
function Footer({ color }) {
return <div style={{ color }}>Footer: {color}</div>;
}并发渲染流程(安全版):
初始状态:themeColor 是 'blue'。
开始 Render:React 开始构建 Fiber 树。
Render :读取 props.color -> 'blue'。
中断(Yield):React 暂停,处理浏览器事件。
用户交互:用户点击按钮,调用 setThemeColor('red')。
关键点:React 接收到了这个更新请求,但在当前的这次渲染任务中,它不会直接修改正在使用的 themeColor 变量(因为闭包/快照特性)。
React 此时面临选择:
情况 A(高优先级打断):认为变色很紧急。React 会扔掉刚才算了一半的 Fiber 树(Header 是 blue 的那个),立即用 'red' 从头开始重新渲染。
结果:Header 读到 red,Footer 读到 red。一致(全红)。
情况 B(低优先级排队):认为变色不紧急(比如是 startTransition 触发的)。React 会先把刚才那个 'blue' 的渲染任务做完。
React 继续渲染 ,此时它用的还是这一轮渲染闭包里的值 'blue'。
结果:Header 读到 blue,Footer 读到 blue。一致(全蓝)。
等这次提交到屏幕后,React 紧接着马上开始下一轮渲染,把界面变成全红。
TIP
为什么外部 Store 会出问题?
因为外部 Store 通常是 Mutable(可变的) 的。
而 React 的 useState 是基于 Immutable(不可变) 和 闭包 的。
基本使用
你的 store 就是一个钩子!你可以把任何东西放进去:基本类型、对象、函数。set函数会合并状态。
import { create } from 'zustand'
const useBear = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
updateBears: (newBears) => set({ bears: newBears }),
}))你可以在任何位置使用此钩子,无需提供程序。选择你的状态,使用该钩子的组件会在该状态改变时重新渲染。
function BearCounter() {
const bears = useBear((state) => state.bears)
return <h1>{bears} bears around here...</h1>
}
function Controls() {
const increasePopulation = useBear((state) => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}Typescript
import { create } from "zustand";
type State = {
count: number;
};
type Actions = {
increment: (qty: number) => void;
decrement: (qty: number) => void;
};
type Action = {
type: keyof Actions;
qty: number;
};
type Dispatch={
dispatch:(action:Action)=>void
}
const countReducer = (state: State, action: Action) => {
switch (action.type) {
case "increment":
return { count: state.count + action.qty };
case "decrement":
return { count: state.count - action.qty };
default:
return state;
}
};
export const useCountStore = create<State & Dispatch>((set) => ({
count: 0,
dispatch: (action: Action) => set((state) => countReducer(state, action)),
}));更新状态
使用 Zustand 更新状态非常简单!只需调用提供的set函数并传入新状态,它就会与 store 中已有的状态进行浅合并
深度嵌套对象
如果你有一个像这样的深度状态对象:
type State = {
deep: {
nested: {
obj: { count: number }
}
}
}使用展开运算符
normalInc: () =>
set((state) => ({
deep: {
...state.deep,
nested: {
...state.deep.nested,
obj: {
...state.deep.nested.obj,
count: state.deep.nested.obj.count + 1
}
}
}
})),使用Immer来更新嵌套值
immerInc: () =>
set(produce((state: State) => { ++state.deep.nested.obj.count })),不可变状态和合并
与 React 类似useState,我们需要以不可变的方式更新状态。
以下是一个典型的例子:
import { create } from 'zustand'
const useCountStore = create((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}))该set函数用于更新 store 中的状态。由于状态是不可变的,因此应该这样写:
set((state) => ({ ...state, count: state.count + 1 }))然而,由于这是一种常见模式,set实际上合并了状态,我们可以跳过这...state部分:
set((state) => ({ count: state.count + 1 }))替换标志
要禁用合并行为,您可以指定一个replace布尔值,set如下所示:
set((state) => newState, true)typescript使用指南
在组件中使用 Store
在组件内部,你可以读取状态并调用操作。选择器(s) => s.bears只会订阅你需要的内容。这可以减少重新渲染并提高性能。JavaScript 也能做到这一点,但使用 TypeScript 时,你的 IDE 会自动补全状态字段。
import { useBearStore } from './store'
function BearCounter() {
// Select only 'bears' to avoid unnecessary re-renders
const bears = useBearStore((s) => s.bears)
return <h1>{bears} bears around</h1>
}重置商店
注销或“清除会话”后重置属性类型很有用。我们使用它typeof initialState来避免重复定义属性类型。TypeScript 会在initialState属性类型更改时自动更新。与 JavaScript 相比,这更安全、更简洁。
import { create } from 'zustand'
const initialState = { bears: 0, food: 'honey' }
// Reuse state type dynamically
type BearState = typeof initialState & {
increase: (by: number) => void
reset: () => void
}
const useBearStore = create<BearState>()((set) => ({
...initialState,
increase: (by) => set((s) => ({ bears: s.bears + by })),
reset: () => set(initialState),
}))
function ResetZoo() {
const { bears, increase, reset } = useBearStore()
return (
<div>
<div>{bears}</div>
<button onClick={() => increase(5)}>Increase by 5</button>
<button onClick={reset}>Reset</button>
</div>
)
}提取类型
Zustand 提供了一个名为 StoreType 的内置辅助函数ExtractState。这对于测试、实用函数或组件属性非常有用。它返回 store 的状态和操作的完整类型,而无需手动重新定义它们。提取 Store 类型:
// store.ts
import { create, type ExtractState } from 'zustand'
export const useBearStore = create((set) => ({
bears: 3,
food: 'honey',
increase: (by: number) => set((s) => ({ bears: s.bears + by })),
}))
// Extract the type of the whole store state
export type BearState = ExtractState<typeof useBearStore>在实用函数中:
// util.ts
import { BearState } from './store.ts'
function logBearState(state: BearState) {
console.log(`We have ${state.bears} bears eating ${state.food}`)
}
logBearState(useBearStore.getState())多重选择器
有时你需要访问多个属性。从选择器返回一个对象可以让你一次访问多个字段。但是,直接从该对象中解构属性可能会导致不必要的重新渲染。为了避免这种情况,建议使用 @getScreen 包裹选择器useShallow,这样可以防止在选定值保持浅相等时重新渲染。这比订阅整个 store 更高效。TypeScript 确保你不会意外地拼写错误 @getScreen``bears或 @ getScreen food。有关 @getScreen 的更多详细信息,请参阅API 文档useShallow。
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
// Bear store with explicit types
interface BearState {
bears: number
food: number
}
const useBearStore = create<BearState>()(() => ({
bears: 2,
food: 10,
}))
// In components, you can use both stores safely
function MultipleSelectors() {
const { bears, food } = useBearStore(
useShallow((state) => ({ bears: state.bears, food: state.food })),
)
return (
<div>
We have {food} units of food for {bears} bears
</div>
)
}带有选择器的派生状态
并非所有值都需要直接存储——有些值可以根据现有状态计算得出。您可以使用选择器来派生值。这可以避免重复存储,并保持数据存储的精简。TypeScript 确保 bearsis 是一个数字,因此运算是安全的。
import { create } from 'zustand'
interface BearState {
bears: number
foodPerBear: number
}
const useBearStore = create<BearState>()(() => ({
bears: 3,
foodPerBear: 2,
}))
function TotalFood() {
// Derived value: required amount food for all bears
const totalFood = useBearStore((s) => s.bears * s.foodPerBear) // don't need to have extra property `{ totalFood: 6 }` in your Store
return <div>We need ${totalFood} jars of honey</div>
}中间件
combine中间件
这个中间件将初始状态和操作分离,使代码更简洁。TypeScript 会自动从状态和操作中推断类型,无需额外的接口。这与 JavaScript 不同,JavaScript 缺乏类型安全机制。这种风格在 TypeScript 项目中非常流行。更多详情请参阅API 文档。
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
interface BearState {
bears: number
increase: () => void
}
// State + actions are separated
export const useBearStore = create<BearState>()(
combine({ bears: 0 }, (set) => ({
increase: () => set((s) => ({ bears: s.bears + 1 })),
})),
)devtools中间件
这个中间件将 Zustand 连接到 Redux DevTools。您可以检查变更、回溯时间并调试状态。它在开发过程中非常有用。即使在这里,TypeScript 也能确保您的操作和状态仍然经过类型检查。更多详情请参阅API 文档。
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface BearState {
bears: number
increase: () => void
}
export const useBearStore = create<BearState>()(
devtools((set) => ({
bears: 0,
increase: () => set((s) => ({ bears: s.bears + 1 })),
})),
)persist中间件
这个中间件会将你的数据存储在本地localStorage(或其他存储位置)。这意味着即使页面刷新,你的数据也不会丢失。这对于需要持久化的应用来说非常有用。在 TypeScript 中,状态类型保持一致,因此不会出现运行时意外情况。更多详情请参阅API 文档。
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface BearState {
bears: number
increase: () => void
}
export const useBearStore = create<BearState>()(
persist(
(set) => ({
bears: 0,
increase: () => set((s) => ({ bears: s.bears + 1 })), // <-- тип явно
}),
{ name: 'bear-storage' }, // localStorage key
),
)异步操作
操作可以异步执行,以便获取远程数据。这里我们获取熊的数量并更新状态。TS 强制执行正确的 API 响应类型(BearData)。在 JS 中,你可能会拼写错误count——TS 会防止这种情况发生。
import { create } from 'zustand'
interface BearData {
count: number
}
interface BearState {
bears: number
fetchBears: () => Promise<void>
}
export const useBearStore = create<BearState>()((set) => ({
bears: 0,
fetchBears: async () => {
const res = await fetch('/api/bears')
const data: BearData = await res.json()
set({ bears: data.count })
},
}))多家门店
你可以为不同的领域创建多个 store。例如,一个BearStorestore 管理熊,另一个 storeFishStore管理鱼。这样可以保持状态隔离,使大型应用程序更易于维护。使用 TypeScript,每个 store 都有其严格的类型——你不会意外地将熊和鱼混在一起。
import { create } from 'zustand'
// Bear store with explicit types
interface BearState {
bears: number
addBear: () => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 2,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
}))
// Fish store with explicit types
interface FishState {
fish: number
addFish: () => void
}
const useFishStore = create<FishState>()((set) => ({
fish: 5,
addFish: () => set((s) => ({ fish: s.fish + 1 })),
}))
// In components, you can use both stores safely
function Zoo() {
const { bears, addBear } = useBearStore()
const { fish, addFish } = useFishStore()
return (
<div>
<div>
{bears} bears and {fish} fish
</div>
<button onClick={addBear}>Add bear</button>
<button onClick={addFish}>Add fish</button>
</div>
)
}