zustand使用和源码分析
React项目一大痛点就是技术选型太混乱了,没有官方统一的技术栈(当然这也是React生态丰富的原因),就拿全局状态管理来说,我就经历了redux
、dva
、redux-toolkit
、mobx
等。
最近接手的项目里面使用了zustand
,这是最近两年比较火的react状态管理工具,之前没接触过,本文整理一下其使用方式,在查看源码的时候发现其实现比较简短精炼,因此也一并分析一下源码。
参考
使用
Zustand来自于德语,意思是“状态”。
基础用法
定义store
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
updateBears: (newBears) => set({ bears: newBears }),
}))
然后就可以在组件中通过selector获取state和action
function BearCounter() {
const bears = useStore((state) => state.bears)
return <h1>{bears} around here ...</h1>
}
function Controls() {
const bears = useStore((state) => state.bears)
const increasePopulation = useStore((state) => state.increasePopulation)
const removeAllBears = useStore((state) => state.removeAllBears)
return (
<Space size="large">
<Button type="primary" onClick={increasePopulation}>one up</Button>
{bears > 0 && <Button type="primary" danger onClick={removeAllBears}>clear</Button>}
</Space>
)
}
export default () => {
return (
<>
<BearCounter />
<Controls />
</>
)
}
当action触发state更新时,就会自动更新对应的组件。
zustand可以智能的收集当前组件订阅的state,只有订阅了的state发生了变化,才会触发组件的渲染,这在某些性能优化的场景下比较有用。
计算属性
zustand本身并没有计算属性的概念,但可以通过函数的形式实现计算属性的效果
const useStore = create((set, get) => ({
items: [],
totalPrice: () => get().items.reduce((sum, item) => sum + item.price, 0),
}))
然后通过函数调用获取对应的值
const totalPrice = useStore(state=>state.totalPrice)
totalPrice()
与Vue等计算属性的区别在于其中没有数据缓存的机制:即计算函数依赖的原始状态没有变化时,可以缓存上一次的计算结果,减少运行消耗。该机制需要由开发自己实现。
组件外使用
如果要在React组件之外使用store,比如封装的一个业务逻辑函数,那么可以使用store的getState,该方法会返回store上面的state和action
function test() {
const store = useStore.getState()
console.log(store.bears)
store.increasePopulation()
}
在组件内部,状态变化时会自动触发组件更新,则组件外部,如果想要监听某个状态的变化,可以通过subscribe
的形式
useStore.subscribe((state) => {
console.log('State changed:', state)
})
选择多个状态
上面这种useStore
的写法比较繁琐,有较多的模版代码
const bears = useStore((state) => state.bears)
const increasePopulation = useStore((state) => state.increasePopulation)
const removeAllBears = useStore((state) => state.removeAllBears)
但是写成下面这种解构赋值的形式会报错
function Controls() {
const { bears, increasePopulation } = useBearStore((state) => {
return {
bears: state.bears,
increasePopulation: state.increasePopulation
}
})
return <button onClick={increasePopulation}>one up {bears}</button>
}
在使用zustand时,当选择器函数返回的状态部分发生变化时,组件才会重新渲染。这里的关键是,zustand默认使用的是严格相等(===)来比较选择器返回的结果是否变化。
如果返回的是一个新对象,即使对象的属性值没有变化,每次调用选择器函数都会生成一个新的对象引用,导致React认为状态发生了变化,从而触发不必要的渲染。
根据文档介绍,zustand提供了useShallow
的方法来选择多个状态
import { useShallow } from 'zustand/react/shallow'
const { bears, increasePopulation } = useStore(useShallow((state)=>{
return {
bears: state.bears,
increasePopulation: state.increasePopulation
}
}) )
在v5之前的版本,可以使用shallow
这个工具方法,用于返回值的浅比较
import shallow from 'zustand/shallow'
const {...} = useStore(
(state) => {...},
shallow
)
另外一种减少选择器模版代码的方式是创建一个createSelectors
,参考文档
import { StoreApi, UseBoundStore } from 'zustand'
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never
const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
_store: S
) => {
let store = _store as WithSelectors<typeof _store>
store.use = {}
for (let k of Object.keys(store.getState())) {
;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
}
return store
}
然后使用
const useBearStore = createSelectors(useBearStoreBase)
// get the property
const bears = useBearStore.use.bears()
// get the action
const increase = useBearStore.use.increment()
固定的selector
selector是一个接收state作为参数的纯函数,因此是可以写在组件外部的
也就是说,下面这种写法
function BearCounter() {
const bears = useStore((state) => state.bears)
return <h1>{bears} around here ...</h1>
}
可以写成
const bearsSelector = (state) => state.bears
function BearCounter() {
const bears = useStore(bearsSelector)
return <h1>{bears} around here ...</h1>
}
这样可以避免频繁创建和回收内联的selector带来性能损耗(虽然比较少),参考这个issue的讨论,不过在zustand v4及之后的版本,这个优化带来的提升可以忽略,因为使用了useSyncExternalStore
,不再需要一个固定的selector了。
中间件
zustand的核心API create
方法的参数,是一个接受 set、get、store 的三个参数的函数。
因此只要对这个函数进行包装,做一些修改,就可以实现一个中间件
function withLog(stateCreator){
return (set, get, store)=>{
const setWithLog = (...args)=>{
console.log('set state',...args)
set.call(null,...args)
}
// 还可以对get等进行包装
return stateCreator(setWithLog, get, store)
}
}
const useStore = create(withLog((set,get) => ({})))
zustand内置了一些常用的中间件,如immer
、redux
、persist
等
immer
每次set之后,zustand都会创建一个新的state,为了避免同一个引用对象导致视图更新异常,往往需要对对象进行展开和合并的操作,比较繁琐
可以使用内置的immer
中间件,避免嵌套很深的对象的展开
import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
user: {
name: 'Alice',
age: 25,
address: { city: 'Tokyo' },
},
updateCity: (city) =>
set((state) => {
state.user.address.city = city // 直接修改 draft,无需展开
}),
}))
但是immer本身也有一定的性能消耗,需要根据实际业务做一些取舍。
源码分析
zustand的核心代码非常简单,就是一个工厂函数,返回一些暴露了getState、setState方法的hook函数,然后通过发布-订阅的机制在状态更新后通知视图更新
本次阅读的版本是目前最新的代码v5.0.3
目录结构
(有了AI工具之后,这些代码量比较小的仓库源码阅读非常简单)
下面是zustand 的源码结构,主要分为以下几个部分:
- 核心实现 :
vanilla.ts
:最核心的状态管理实现,提供了创建 store 的基础功能react.ts
:React 绑定层,提供了 useStore hook 的实现traditional.ts
:提供了支持自定义比较函数的 store 实现
- 中间件 (在
middleware
目录下):
- persist.ts :实现状态持久化
- immer.ts :集成 Immer 以实现不可变状态更新
- devtools.ts :集成 Redux DevTools
- redux.ts :提供 Redux 风格的操作方式
- subscribeWithSelector.ts :支持选择性订阅状态变化
- combine.ts :组合多个 store
- 工具函数 :
shallow.ts
:提供浅比较功能
建议按照以下顺序阅读源码:
首先阅读
vanilla.ts
,理解核心的状态管理实现:- createStore 函数的实现
- 状态更新机制
- 发布订阅模式
然后阅读
react.ts
,了解与 React 的集成:- useStore hook 的实现
- React 状态同步机制
最后可以选择性地阅读中间件实现,比如:
- persist.ts 了解状态持久化
- devtools.ts 了解开发工具集成
- immer.ts 了解不可变更新的实现 关键概念:
Store 创建:使用闭包保存状态
状态更新:支持部分更新和完全替换
订阅机制:使用 Set 存储监听器
中间件机制:通过类型系统支持扩展
预备知识:useSyncExternalStore
在 React 18 的并发渲染中,render 可能被中断和恢复,这可能导致使用外部数据源时出现不一致。
React.useSyncExternalStore
是 React 18 引入的一个新 Hook,主要用于安全地订阅外部数据源,如 Redux、MobX 或其他非 React 状态管理库)并在外部存储更新时同步更新 React 组件
useSyncExternalStore
接受三个参数(第三个参数可选):
subscribe
:一个函数,用于订阅外部存储的变化。它接收一个回调函数作为参数,并在存储变化时调用该回调函数,这会导致React调用getSnapshot并在需要的时候重新渲染组件。该函数需要返回一个取消订阅的函数。getSnapshot
:一个函数,用于获取当前的存储快照。它必须返回一个不可变值或缓存对象,React 会调用这个函数以获取最新的状态,如果返回的状态发生了变化,React就会重新渲染组件getServerSnapshot
(可选):一个函数,用于在服务器端渲染时获取存储快照。
import React, { useSyncExternalStore } from 'react';
// 模拟一个外部存储
const store = {
state: 0,
listeners: new Set(),
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
},
setState(newState) {
this.state = newState;
this.listeners.forEach((listener) => listener());
},
getState() {
return this.state;
},
};
function useStore() {
return useSyncExternalStore(
store.subscribe.bind(store),
store.getState.bind(store)
);
}
然后就可以安全地使用这个外部数据
function Counter() {
const state = useStore();
return (
<div>
<p>Count: {state}</p>
<button onClick={() => store.setState(state + 1)}>Increment</button>
</div>
);
}
export default Counter;
在更早期的React版本中,可以使用use-sync-external-store/shim/with-selector
来实现类似的功能
vanilla.ts
src/vanilla.ts
里面包含了zustand的核心实现,具体功能包括
状态管理 :
- 使用闭包保存 state
- 通过 Set 存储监听器,实现发布订阅模式
setState 实现 :
- 支持两种方式更新状态:直接传值或传入函数
- 使用 Object.is 进行状态比较,避免不必要的更新
- 支持部分更新和完全替换两种模式
订阅机制 :
- 提供 subscribe 方法添加监听器
- 返回取消订阅的函数
- 状态变化时通知所有监听器
类型系统 :
- 使用了复杂的类型系统来支持中间件机制(Mutate, StoreMutators 等)
- 支持类型推导和类型安全
//src/vanilla.ts
const createStoreImpl: CreateStoreImpl = (createState) => {
let state
const listeners: Set<Listener> = new Set()
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState)
: Object.assign({}, state, nextState)
// 通知更新
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState: StoreApi<TState>['getState'] = () => state
const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api as any
}
可以看出,整个实现非常简单,一个store就是一个普通的js对象,包含几个特殊的方法
这里的代码可以解释下面这种写法
function test() {
const store = useStore.getState()
console.log('before',store.bears)
// 这里是异步的
store.increasePopulation()
// 所以这里取到的值跟上面一致
console.log('after',store.bears)
// 这里可以获取到最新的值
console.log('after',useStore.getState().bears)
}
其本质是useStore.getState()
拿到的是一个state的快照,从源码可以看出,每次set之后,会返回一个新的state,因此,如果想要获取到最新的state,最好是都通过useStore.getState()
获取。
react.ts
该文件是React 绑定层,提供了 useStore hook 的实现,核心代码也很简短
export function useStore<TState, StateSlice>(
api: ReadonlyStoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any,
) {
// 订阅state的变化
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState()),
)
React.useDebugValue(slice)
return slice
}
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
const useBoundStore: any = (selector?: any) => useStore(api, selector)
// 可以在外部使用store相关的接口,如getState()等
Object.assign(useBoundStore, api)
return useBoundStore
}
export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
createState ? createImpl(createState) : createImpl) as Create
其内部使用了useSyncExternalStore
,并且将selector(api.getState())
作为当前的getSnapshot
,这样,只有对应的selector
返回值发生了变化,才会触发视图的更新。这也是zustand可以作为性能优化方案的原因之一。
创建useBoundStore
,可以作为一个hook调用
const bears = useStore(state=>state.bears)
也可以在组件外作为store的访问方法
const bears = useStore().getState().bears
zustand解决的react-redux的问题
参考:
Redux 中的 “Stale Props”(过时属性)和 “Zombie Children”(僵尸子组件)是两类典型的状态管理问题,主要源于 Redux 的设计机制(如 connect
高阶组件和手动订阅管理)。
Zustand 通过更现代的 React Hooks 和订阅优化,能够有效规避这些问题。
Stale Props
当父组件传递 props 给子组件,且子组件同时通过 connect
连接到 Redux store 时,如果父组件的 props 更新而 Redux 状态未及时同步,子组件可能使用过期的 props 和 Redux 状态的混合数据,导致渲染不一致。
// 父组件传递 `id`,子组件通过 Redux 获取数据
const Parent = ({ id }) => {
return <Child id={id} />;
};
// 子组件通过 connect 连接到 Redux
const Child = connect(
(state, ownProps) => ({ data: state.data[ownProps.id] }) // 依赖 ownProps.id
)(({ data }) => <div>{data}</div>);
在上面的代码中,若父组件的 id
变化,但 Redux 中 state.data[newId]
尚未加载完成,子组件可能短暂显示旧 id
对应的数据。
这是因为
- Redux 的
connect
高阶组件通过mapStateToProps
选择性订阅 store,更新逻辑与父组件 props 更新不同步。 - React 的渲染批次机制可能导致 Redux 状态和 props 更新的时序问题。
zombie-children
僵尸子节点专门指以下情况:
- 首先,挂载了多个嵌套且相关联的组件,导致子组件在父组件前订阅了
store
- 分发一个从
store
中删除数据的动作,例如删除一个 todo 项 - 父组件将因此停止渲染该子组件
- 但是,由于子组件先进行了订阅,它的订阅执行于父组件停止渲染它之前,当它基于 props 从
store
中读取一个值时,这个数据不复存在,并且如果读取逻辑不完善的话,则可能引发错误。
可以看出,zombie-children实际上是Stale Props中一种更特殊的场景。
重复渲染
如果connect
的mapState中,每次都返回了一个新的对象,即使数据内容相同,也会导致子组件的更新,这导致redux在react中也存在一些性能优化的问题。
上面的几种问题都是由于react-redux的connect机制导致的。zustand通过selector直接获取组件需要的state,可以保证只有订阅的数据更新了才会触发组件的更新,其内部使用的react内置的useSyncExternalStore
来实现
小结
zustand是最近两年比较火热的react状态管理工具,学习使用和了解其内部原理是很有必要的。
在初步使用了一下zustand,给我的感觉就像是react中的pinia一样,灵活、好用、性能优秀,待我进一步深入体验。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
