实现原理
在上篇《zustand 从原理到实践 - 原理篇(1)》中,我的阐述还是比较笼统的,下面进行更加细致和具体的原理剖析。
vanilla 层
vanilla 层是 zustand 的核心代码,它不依赖任何的视图层框架。vanilla 层的代码主要包括了 store 实例的创建、状态更新、订阅者管理等功能。简单来说,它就实现了一个核心 API:createStore。我们拨开迷雾看真章。直接看 zustand 在运行时的 js 源码:
js
'use strict'
const createStoreImpl = (createState) => {
let state
const listeners = /* @__PURE__ */ new Set()
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial
if (!Object.is(nextState, state)) {
const previousState = state
state = (
replace != null
? replace
: typeof nextState !== 'object' || nextState === null
)
? nextState
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState = () => state
const getInitialState = () => initialState
const subscribe = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api
}
const createStore = (createState) =>
createState ? createStoreImpl(createState) : createStoreImpl
exports.createStore = createStore
熟悉 redux 源码的人一眼就能感受到一种熟悉的感觉。对,这里面的源码基本上就是采用了 redux createStore 的源码框架,只是做了一些简化。感兴趣的可以翻看我 5 年前写的redux源码解析。
核心原理就是利用了 javascript「闭包」的语言特性来实现了 store 实例的创建。被闭包的变量无非就是两个:
- state - 树形结构的状态
- listeners - 订阅者集合(Set)
对这两个变量进行最终实现了 store 实例的四个 API:
- setState()
- getState()
- getInitialState()
- subscribe()
也许用闭包的方式来实现 store 实例的创建,从理解的角度来看不那么直观。那么,我们完全可以用面向对象的方式来实现 store 实例的创建。下面就是我实现的一版:
js
// 面向对象版 Store(纯 JS)
class Store {
#state
#listeners = new Set()
constructor(createState) {
const api = {
setState: this.#setState.bind(this),
getState: this.#getState.bind(this),
getInitialState: () => createState(api.setState, api.getState, api),
subscribe: this.#subscribe.bind(this),
}
this.#state = api.getInitialState()
}
#setState(partial, replace) {
const next = typeof partial === 'function' ? partial(this.#state) : partial
if (!Object.is(next, this.#state)) {
const prev = this.#state
this.#state =
(replace ?? (typeof next !== 'object' || next === null))
? next
: Object.assign({}, this.#state, next)
this.#listeners.forEach((l) => l(this.#state, prev))
}
}
#getState() {
return this.#state
}
#subscribe(listener) {
this.#listeners.add(listener)
return () => this.#listeners.delete(listener)
}
// 对外暴露与源码相同的 API
getState = this.#getState
setState = this.#setState
subscribe = this.#subscribe
getInitialState = () => this.#state
}
// 工厂函数,保持同构
export function createStoreImpl(createState) {
return new Store(createState)
}
看到了没,面向对象的 createStore API 会更加直观。 zustand 的 createStore 函数的实现无非就是实现了四个对外暴露的方法:
- setState() - 更新状态,并通知所有的订阅者
- getState() - 获取当前最新的状态
- getInitialState() - 获取初始状态
- subscribe() - 订阅状态变化
熟悉 js 设计模式的人一看看到了,zustand 的 createStore 函数的实现采用了经典的「发布-订阅」模式。具体来说:
- 首先有一个
listeners数组,用来存储所有的订阅者。 - 当调用
subscribe方法的时候,就会把当前的订阅者 push 到listeners数组中。 - 当调用
setState方法的时候,就会遍历listeners数组,用当前最新的 state 和旧的 state 作为参数调用所有的订阅者。
小结
vanilla 层的核心代码就是一个 createStore() 函数实现,它一览无遗,十分精简。但是麻雀虽小,五脏俱全。它利用了「闭包」的语言特性和「发布-订阅」模式实现了 store 实例的四个核心 API:setState、getState、getInitialState、subscribe。
通常情况下,createStore() 并不是为用户代码为准备的,它的消费者主要是 react binding 层的 create 函数。而这个create 函数才是用户代码 import { create } from 'zustand'; 语句里面的那个create 函数。
另外要提的一点是,我们要放点注意力到 getState() 和 subscribe() 身上,因为它们是衔接 vanilla 层和 react binding 层的桥梁。至于原理,我们接下往下看。
react binding 层
老样子,我们直接看 zustand 编译后的运行时 js 源码:
js
'use strict'
var React = require('react')
var vanilla = require('zustand/vanilla')
const identity = (arg) => arg
function useStore(api, selector = identity) {
const slice = React.useSyncExternalStore(
api.subscribe,
React.useCallback(() => selector(api.getState()), [api, selector]),
React.useCallback(() => selector(api.getInitialState()), [api, selector]),
)
React.useDebugValue(slice)
return slice
}
const createImpl = (createState) => {
const api = vanilla.createStore(createState)
const useBoundStore = (selector) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
const create = (createState) =>
createState ? createImpl(createState) : createImpl
exports.create = create
exports.useStore = useStore
从上面的代码,我们可以看到日常用来创建 store 实例的 create 函数,它的实现就是调用了 createImpl 函数。而在 createImpl 函数中,我们可以看到它调用了 vanilla.createStore 函数,来创建了一个 store 实例。这里所谓的 api 变量就是我口中的「store 实例」。
最后,我们可以看到,我们在 react 组件中调用 useStore 函数就是createImpl 函数返回的 useBoundStore 函数。
在上面的章节中,我们反复提到 zustand 可以在非 react 环境中去触发 react 组件的更新。秘诀有二:
- 秘诀之一就是这行代码:
Object.assign(useBoundStore, api);。借助「函数即对象 」的语言特性,zustand 把 store 实例的所有 API 都挂载到了useBoundStore函数,向外暴露出去了。这样,我们在非 react 环境中通过调用useBoundStore.setState()函数来触发 react 组件的更新。
不过实话说回来,
useBoundStore.setState()的这种使用方式(包括我在内)很多人都不太习惯。因为在 react 开发者的心智模型中,useBoundStore是一个 hook 函数。你现在又让我把它当对象,而且还用在非 react 组件的环境中,这多少是「违反」了 hook 的使用规则。
上面提到了秘诀之一,那秘诀之二是什么?秘诀之二就藏在了 useStore 函数中。
- 秘诀之二在
useStore函数中,我们可以看到它本质上就是调用了React.useSyncExternalStorehook 函数。秘诀之二就是 zustand 很敏锐地发现了useSyncExternalStore能够支持对外部数据源的响应的特性。 于是乎,zustand 通过调用useSyncExternalStore函数,把 store 实力的subscribe方法和getState方法顺利地把传进去,勾住了 react 的内核。
至此,zustand 和 react 成功地绑定在一块了。
zustand 跟 react 的桥接原理
换句话来问,那么就是:"useSyncExternalStore 这个 hook 是如何实现对外部数据源的响应呢?"。其实翻看官网的useSyncExternalStore 的 API 文档,就会发现,useSyncExternalStore 这个 hook 函数的第一个参数就是 subscribe(订阅函数),第二个参数就是 getSnapshot(获取状态快照函数)。而 zustand 正是利用了这两个参数,把 store 实例的 subscribe 方法和 getState 方法传递给了 react 的内核。然后,react 内核在组件初始挂载(mount)的时候去利用 subscribe 方法往 zustand 的订阅者数组中添加了一个新的订阅者(listener)。最后,在用户调用 zustand store 实例的 setState() 方法的时候,zustand 内部会先比较 store 的新旧 state 值,如果是发生了变化,它就通知 react。react 再次调用 zustand store 实例的 getState 方法来获取最新的状态快照,对比新旧快照。如果新旧状态快照的值不一样,那么 react 就会把当前的组件标记为「需要更新的」,并接着触发一次组件更新的调度请求。
三方通讯框架(用户代码, zustand, react)
以上的桥接原理讲得比较笼统,下面我从三方通讯的视角,来进一步阐述一下这里面的原理。我会给出时序图,然后结合编译后的源码来逐步讲解。
熟悉 react 原理的人都知道,react 内部实现是把应用划分了两个阶段的:mount 阶段和 update 阶段。一个 react 应用的生命周期首先经历 mount 阶段,然后再经历 update 阶段。
mount 阶段
首先,我们看看 mount 阶段,这三方是怎么通讯的:
并将 store 实例,useStore 函数实例和 selector闭包其中 zustand-->>用户代码: 返回 useBoundStore() 用户代码->>用户代码: 调用 useBoundStore() API 用户代码->>zustand: 调用 useStore() API zustand->>react: 调用 useSyncExternalStore() API react->>react: 1. 调用 getState(), 把首次的 snapshot 保存在 fiber react->>react: 2. 创建新的 listener react->>react: 3. 组件挂载后,执行 subscribe(listener) react->>zustand: 实现对 zustand store 的订阅
上面讲到了,用户侧调用的 create() 函数,实际上调用的是 react binding 层的 createImpl() 函数。
js
'use strict'
var React = require('react')
var vanilla = require('zustand/vanilla')
const identity = (arg) => arg
function useStore(api, selector = identity) {
const slice = React.useSyncExternalStore(
api.subscribe,
React.useCallback(() => selector(api.getState()), [api, selector]),
React.useCallback(() => selector(api.getInitialState()), [api, selector]),
)
React.useDebugValue(slice)
return slice
}
const createImpl = (createState) => {
const api = vanilla.createStore(createState)
const useBoundStore = (selector) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
const create = (createState) =>
createState ? createImpl(createState) : createImpl
exports.create = create
exports.useStore = useStore
简单来说,createImpl() 函数就干了三件事:
- 调用
vanilla.createStore()来创建 store 实例 - 创建
useBoundStore函数实例,并将 store 实例,useStore函数实例和selector闭包其中 - 返回
useBoundStore函数实例
在这个节点上,用户代码都是跟 zustand 内核在打交道。当用户在组件内部去调用 useBoundStore() 函数时,流程就可以进入到 react 内部了。因为 useBoundStore() 函数内部,就是调用了 useStore() 函数。而 useStore() 函数内部,就是调用了 React.useSyncExternalStore() 函数。所以,我们的目光可以聚焦到下面的几行代码上:
js
function useStore(api, selector = identity) {
const slice = React.useSyncExternalStore(
api.subscribe,
React.useCallback(() => selector(api.getState()), [api, selector]),
React.useCallback(() => selector(api.getInitialState()), [api, selector]),
)
// ......
return slice
}
到这里,我们就可以看到,zustand store 实例的 subscribe 方法和 getState 方法被传递给了 react 的内核。那 react 内核是如何消费这两个 API 的呢?往下深挖,我们就来到了 useSyncExternalStore 这个 hook 的内部实现了。正如我之前的文章【react】react hook运行原理解析 里面提到的,hook 函数也是有 mount 和 update 阶段的。不同的阶段,开发者所消费的 hook 函数是指向不同的实现。回归到 useSyncExternalStore这里,当前我们在聊应用的 mount 阶段,所以,我们就得看源码里面的 mountSyncExternalStore 函数实现。下面,我把跟本次研究主题相关的代码直接摆出来:
js
function mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) {
var fiber = currentlyRenderingFiber$1
var hook = mountWorkInProgressHook()
var nextSnapshot
var isHydrating = getIsHydrating()
if (isHydrating) {
// hydration 的相关逻辑代码忽略
// ......
} else {
nextSnapshot = getSnapshot()
// 没有及时缓存 getSnapshot() 的返回值相关警告代码跳过
// ......
// 因为渲染是可中断的,所以在 commit 之前要进行一致性检查
// 相关代码跳过
}
// Read the current snapshot from the store on every render. This breaks the
// normal rules of React, and only works because store updates are
// always synchronous.
hook.memoizedState = nextSnapshot
var inst = {
value: nextSnapshot,
getSnapshot: getSnapshot,
}
hook.queue = inst
// Schedule an effect to subscribe to the store.
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe])
// 入队一个 effect 对象用于在每一次组件 render 之后去做 `inst` 字段值的更新
// 这里的代码也可以不看
// Schedule an effect to update the mutable instance fields. We will update
// this whenever subscribe, getSnapshot, or value changes. Because there's no
// clean-up function, and we track the deps correctly, we can call pushEffect
// directly, without storing any additional state. For the same reason, we
// don't need to set a static flag, either.
// TODO: We can move this to the passive phase once we add a pre-commit
// consistency check. See the next comment.
// fiber.flags |= Passive
// pushEffect(
// HasEffect | Passive$1,
// updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
// undefined,
// null,
// )
return nextSnapshot
}
上面的源码中,我已经把跟本次研究主题不相关的代码已经移除或者注释掉了。从上面的代码我们可以看出,我们一路透传进来的 zustand store 实例的subscribe 方法和 getState 方法,最终都被传递给了 subscribeToStore 这个函数。而 subscribeToStore 函数就是实现订阅功能的关键:
js
function subscribeToStore(fiber, inst, subscribe) {
var handleStoreChange = function () {
// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceStoreRerender(fiber)
}
} // Subscribe to the store and return a clean-up function.
// 这里的 `subscribe` 就是 zustand store 实例的 `subscribe` 方法
// 这里用了「控制反转」的设计模式
return subscribe(handleStoreChange)
}
subscribeToStore 函数主要是干了两件事:
- 创建一个新的 listener -
handleStoreChange函数; - 调用 store 实例的
subscribe方法实现对 zustand store 实例的监听;
那什么时候去订阅 zustand store 实例呢?我们可以从 mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]) 这行代码可以看出。Again,因为当前是应用的 mount 阶段,所以,mountEffect 函数就是 mount 阶段的 useEffect 函数。所以答案就是:当组件 mount 时,subscribeToStore 函数就会被调用,从而实现对 zustand store 实例的监听。
正如上面时序图所表明的那样,除了这个绑定的动作外,useSyncExternalStore 在 mount 阶段还做了另外一件小事。那就是调用了 getSnapshot() 方法,得到一个初始的 snapshot 值,存储在了 useSyncExternalStore 所对应的 hook 对象上。那么在下一次比较的时候,这个 snapshot 值就是 prevSnapshot 了。
update 阶段
当用户间接(调用 zustand store 实例的 action 方法最终还是调用 setState 方法)或者直接调用 zustand store 实例的 setState 方法时,zustand 会计算出最新的 state,然后拿最新 state 跟之前的 state 进行对比。如果发现有变化,就会通知所有的订阅者,通知订阅者状态已经发生了变化。这个过程是发生在zustand store 实例的 setState 方法里面的。以下代码为证:
js
'use strict'
const createStoreImpl = (createState) => {
let state
const listeners = /* @__PURE__ */ new Set()
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial
if (!Object.is(nextState, state)) {
const previousState = state
state = (
replace != null
? replace
: typeof nextState !== 'object' || nextState === null
)
? nextState
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
//.....
return api
}
可以看出另外一个细节,zustand 是采用原生的 Object.is 方法来比较新旧 state 是否发生了变化的。对于这点,我们得到的启示是: 对于引用类型的 state,我们调用 setState 方法时,应该避免直接修改它的属性,而是应该创建一个新的对象(这个启示对于熟悉 react 原理的人来说是很司空见惯了)。这样可以确保 zustand 能够正确地检测到 state 的变化。关于值变化的检测算法,zustand 跟 react 内部实现是一致的。
我们可以看到 setState 方法实现的最后一行的代码:listeners.forEach((listener) => listener(state, previousState))。zustand 一旦觉得 store 实例的 state 发生了变化,就会通知所有的订阅者,而这里面当然包括 react 在 mount 阶段注册的订阅者(handleStoreChange):
javascript
function subscribeToStore(fiber, inst, subscribe) {
var handleStoreChange = function () {
// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceStoreRerender(fiber)
}
} // Subscribe to the store and return a clean-up function.
return subscribe(handleStoreChange)
}
react 被通知后,它并不会马上就触发一个更新请求。而是再进行一次新旧 state 值的比较:
javascript
function checkIfSnapshotChanged(inst) {
var latestGetSnapshot = inst.getSnapshot
var prevValue = inst.value
try {
var nextValue = latestGetSnapshot()
return !objectIs(prevValue, nextValue)
} catch (error) {
return true
}
}
所谓的 latestGetSnapshot 是 react 这边考虑了不同的 rerender 周期,传给了 useSyncExternalStore 的 getSnapshot 方法引用发生了变化。而在实际 zustand 侧,它传入的引入是不变的。所以,这里我们可以直接理解就是 zustand store 实例的 getState 方法。
接下来的逻辑就是很明显了。判断之前,立刻调用一次 getState 方法去获取最新的 state 值。判断新旧 state 是否发生了变化的检测算法依旧是 Object.is 方法。如果判断值已经发生了变化,则触发一个组件更新请求。
这里我们可以得到一个深刻的认知,那就是:zustand 在判断值是否发生变化所采用的算法方面是对齐了 react 内部实现的,也就是都是
Object.is方法。关于这一点,不熟悉的同学可以翻看我的触摸 react 的命门 - 值的相等性比较(上篇)和触摸 react 的命门 - 值的相等性比较(下篇)。
javascript
function forceStoreRerender(fiber) {
var root = enqueueConcurrentRenderForLane(fiber, SyncLane)
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp)
}
}
最后,我们简地展开说说 react core 这边是如何向 scheduler 发起一个更新请求的。步骤有二:
- 首先,先向更新队列中添加一组同步优先级的更新请求 meta(包含四要素:fiber,update 对象, lane, hook queue);
- 然后,正式向 scheduler 层发起一个更新请求。这里稍微展开来讲一下。react core 这边会把一个组件更新请求包装为一个 task。同步优先级的更新请求会使用
performSyncWorkOnRoot(rootFiber)包装为一个同步 task,异步优先级的更新请求会使用performConcurrentWorkOnRoot(rootFiber)包装为一个同异步 task。最后通过调用 scheduler 的调度函数来把组件更新这个任务交给 scheduler, 让它决定在何时去真正进入整个组件更新流程(render + commit)。
接下来,在 render 阶段,useSyncExternalStore 会被再次调用。react core 这边还会调用一次 getSnapshot 方法(也就是 zustand store 实例的 getState 方法)。然后,还是会比较新旧 snapshot 的值是否发生了变化,如果发现有变化,就主要做三件事:
- 更新存放在 fiber 上的 prevSnapshot 值;
- 将当前组件 markup 为真正需求更新的(主要是为了性能优化的目的)。
- 返回当前最新的 snapshot 值。
javascript
function updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) {
var fiber = currentlyRenderingFiber$1
var hook = updateWorkInProgressHook() // Read the current snapshot from the store on every render. This breaks the
// normal rules of React, and only works because store updates are
// always synchronous.
var nextSnapshot = getSnapshot()
// ......
var prevSnapshot = hook.memoizedState
var snapshotChanged = !objectIs(prevSnapshot, nextSnapshot)
if (snapshotChanged) {
hook.memoizedState = nextSnapshot
markWorkInProgressReceivedUpdate()
}
// .......
return nextSnapshot
}
小结
上面我们通过了两个阶段(mount 阶段和 update 阶段)来剖析了 zustand 跟 react cored 的桥接原理 (依赖原生 API useSyncExternalStore 对订阅外部数据源的支持),并使用时序图来展示了三个参与方(用户代码, zustand, react core)的交互流程。这个原理其实可以用三句话总结为:
- zustand store 实例创建 - 当用户调用
create方法时候,zustand 会创建一个 store 实例,并使用useBoundStore函数实例将其闭包保存在内存中; - mount 阶段 - react core 利用
useSyncExternalStore实现了对 zustand store 实例的订阅。当 store 实例的状态发生变化时,zustand 会通知 react core 之前订阅所注册的 listener。react 内部会再判断一次新旧值是否发生变化。如果发现有变化,就会往 scheduler 层发起一个更新请求。 - update 阶段 - react core 还会调用
getSnapshot方法(也就是 zustand store 实例的getState方法)。然后,还是会比较新旧 snapshot 的值是否发生了变化。如果发现有变化,就会更新 hook 对象的 memorized 值,最后返回给用户代码。
前前后后,总共做了三次的「值的比较」:zustand 调用一次 getState 方法, react core 这边调用了两次 getState 方法来进行新旧 snapshot 值的比较。这三次的值的比较算法都是用 Object.is 方法。如此看来,整个流程中决定是否要「变」的算法是保持一致的。
按需渲染
正如前面的「特点」所说,zustand 是实现了「按需 rerender」了。当然,前提是你也要在调用 useBoundStore 函数时,传入一个 selector 函数才行。
那为什么要想实现按需 rerender 就必须要传递一个 selector 函数呢?因为假设你不传递的话, zustand 默认会采用 (state) => state 这个 selector 函数。而 zustand 里面的实现明确是指出,你要想让 zustand store 通知所有的订阅者,你调用 setState 方法时,必须要传递一个「新」的 state 值。
重点来了,这个「新」是体现在「引用」是新的(当然, 这个结论是建立在绝大部分 zustand store 的 state 都是一个引用类型的复合对象)。 佐证以上结论的代码在:
js
const createStoreImpl = (createState) => {
let state
const listeners = /* @__PURE__ */ new Set()
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial
if (!Object.is(nextState, state)) {
const previousState = state
state = (
replace != null
? replace
: typeof nextState !== 'object' || nextState === null
)
? nextState
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
// ......
return api
}
从上面的代码我们可以看出,只有在你调用 setState 方法时,传递的是一个新引用,zustand 才会去更新 store 的 state 值。而无论你指定的是否是 replace 模式,最终,zustand store 实例的 state 值,都是一个新的引用。
那这就意味着什么呢?不妨先嚼一嚼下面的这段代码:
js
function useStore(api, selector = identity) {
const slice = React.useSyncExternalStore(
api.subscribe,
React.useCallback(() => selector(api.getState()), [api, selector]),
React.useCallback(() => selector(api.getInitialState()), [api, selector]),
)
// ......
return slice
}
从上面的剖析我们知道,() => selector(api.getState()) 就是 zustand 提交给 react core 的 getSnapshot 方法。当 zustand store 通知所有的订阅者时,就会调用这个 getSnapshot 方法来获取最新的 state 值。如果我们不传入 selector 函数,那么在 react core 在计算最新 snapshot 值的时候就是相当于拿到的是 zustand store 实例的计算出来的最新 state 值:
js
const selector = (arg) => arg
const nextSnapshot = selector(api.getState()) = api.getState()
因为此时调用 api.getState() 必定是拿到一个 zustand 新计算出来的 state 值。根据 useSyncExternalStore 的实现,react core 就会为当前的组件发起一次强制 render 的请求。
回到上面的问题:"那这就意味着什么呢?"。这意味着只要你有一个 useBoundStore 函数在调用的时候没有传入 selector,那么在任意一个地方的任意一个 setState() 的调用都会导致这个组件会发生重渲染,而不管你在当前组件所消费的 state 值是否有发生变化。比如下面的写法中的 <ComponentB />就会导致不必要的重渲染:
js
// 定义一个 Zustand store
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
name: 'Alice',
increment: () => set((state) => ({ count: state.count + 1 })),
setName: (name) => set({ name }),
}))
// 组件 A:只关心 count
function ComponentA() {
const count = useStore((state) => state.count) // 使用 selector,只订阅 count
console.log('ComponentA rendered')
return <div>Count: {count}</div>
}
// 组件 B:没有使用 selector,直接获取整个 state
function ComponentB() {
const state = useStore() // 没有使用 selector,获取整个 state
console.log('ComponentB rendered')
return <div>Name: {state.name}</div>
}
// 父组件:包含两个子组件
function App() {
return (
<>
<ComponentA />
<ComponentB />
</>
)
}
export default App
- ComponentA 使用了 selector,只订阅了 count 字段。当 count 发生变化时,ComponentA 会重新渲染;当 name 发生变化时,ComponentA 不会重新渲染。
- ComponentB 没有使用 selector,直接获取了整个 state。那么当我调用
setState方法去更新 count 的时候,ComponentB 会重新渲染。而这正是不必要的渲染 - 因为我组件内部根本就没有使用到 count 这个字段。
通过 selector 函数,我们告知了 react core 对比新旧 snapshot 值的时候是从 state 这颗树上摘下哪个分支的值来做比较。只有当这个分支的值发生了改变,react core 才能将当前组件标记为需要 rerender。这就是 zustand + react 借助 selector 函数实现按需 rerender 的原理。
总结
作为简化版的 redux 或者是说 redux 的继任者,zustand 具有以下三个特点:
- 灵活
- 简单
- 高性能
因此,zustand 是一个非常优秀的状态管理库,特别适合中小型规模的前端项目。
在源码层面,zustand 采用平台无关的源码实现架构,包括了两层:
- vanilla 层;
- react binding 层
vanilla 层主要是利用「闭包」和「发布订阅」模式来实现了 zustand store 实例的创建,状态更新,和通知所有的订阅者。这里的代码跟 redux core 的核心思想是一模一样的。
在 react binding 层主要是借助 react 原生 API useSyncExternalStore 来两将 zustand core 和 react core 桥接到一块。
如此一样,使用 zustand 作为状态管理类库,要想触发一个组件重渲染,必须要过两个关卡:
- 调用
setState(newState)时候传入的 newState 值必须是一个新引用或者setState((prevState)=> newState)情况下(prevState)=> newState返回的必须是一个新引用。 - 当 zustand 通知所有的 listener 的时候,react core 还会进行一次的新旧 snapshot 的比较。只有新旧 snapshot 不同,才会触发组件的重渲染。
最后,我们剖析了 zustand 实现按需 rerender 的原理。本质上就是在上面的第二个关卡中,我们通过指定一个 selector 函数 来告知 react core 应该从 state 这树上摘下哪个分支的值来做新旧值的比较。