Zustand 源码阅读计划(2)- JS 篇 - React 框架适配逻辑
为什么需要框架适配?
为了提升开发体验,能够更好地利用框架的特性和框架的调试能力
完整代码
'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,
() => selector(api.getState()),
() => selector(api.getInitialState())
);
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;
设计思路
现在 React 中想要封装使用一个 ui 无关的功能,一般用什么?答案是用 hook。
所以状态管理也很适合这样的管理模式,我们可以且应该提供一个 hook 供开发者调用。
同时,也不能局限开发者只能在 React 组件 中使用状态管理,我们也希望可以在普通的函数中能够使用状态管理,而之前实现过的框架无关的核心逻辑能为我们实现这个需求。
因此,对 React 框架的适配,我们需要至少做到以下目标:
- 提供一个 hook,利用好 React 的特性,让 React 组件能够安全、高效地获取 store 中的值
- 提供组件外调用的支持,将
vallina
实现与 hook 合并,支持两种使用方式,减少心智负担
React 组件更新
使用原生的 store,最大的一个问题就是,在 React 的机制下如何更新渲染。我们知道,如果直接使用 let
之类定义的变量,修改后是不会反应在 UI 上的。只有 setState
这类 hook 定义的才能修改后正常渲染。
所以我们应该将 store,使用 setState
之类的 hook 进行处理,以保障正常渲染更新。
总结一下
大前提:在 React 中,数据改变需要刷新组件才能正常渲染。 当前情况:我们实现了原生的 store。 处理方式:我们需要实现一个 hook,监听 store 的变化,并触发 react 的刷新
setState
和 useEffect
我们最容易想到的,就是使用 React 中的 setState
和 useEffect
。在 useEffect
中订阅 store,将获取的值使用 setState
存储,组件便会更新,在卸载的时候取消对 store 的订阅。
封装的 hook 示例如下:
import React, { useState, useEffect } from 'react';
const identity = (arg) => arg;
// 这是一个使用 `setState` 和 `useEffect` 的“传统”实现
function useLegacyStore(api, selector = identity) {
// 1. 使用 useState 创建一个本地 state,用于存储和展示数据
// 初始值直接从 store 中获取
// 有用户的选择器就调用选择器,没有就调用默认值 identity 返回全量数据
const [slice, setSlice] = useState(() => selector(api.getState()));
useEffect(() => {
// 2. 在 effect 中订阅 store 的变化
const unsubscribe = api.subscribe(() => {
// 4. 当 store 变化时,计算新的数据切片
const newSlice = selector(api.getState());
// 5. 检查新旧 slice 是否有变化,防止不必要的更新
// (这是一个重要的优化,Zustand 内部有类似 Object.is 的比较)
if (newSlice !== slice) {
// 6. 用新的 slice 更新本地 state,触发组件重渲染
setSlice(newSlice);
}
});
// 3. effect 的清理函数:在组件卸载时取消订阅
return () => {
unsubscribe();
};
// 依赖项数组:当 api 或 selector 变化时,需要重新执行 effect (重新订阅)
}, [api, selector, slice]); // slice 也加入依赖项以在步骤5中获取最新的slice进行比较
// 7. 返回当前的 state 切片
return slice;
}
可能有些人会比较迷惑怎么用,我也给大家举个例子。
由上一篇文章的内容,我们知道 vallina.createStore
传入一个函数,能返回一个 api
对象 { setState, getState, getInitialState, subscribe }
,这些就是一个 store 最核心的功能。
我们的 React Hook,就是使用这些功能来做一层更适用于 React 的封装。
'use strict';
var React = require('react');
var vanilla = require('zustand/vanilla');
const createImpl = (createState) => {
const api = vanilla.createStore(createState);
// 使用 setState + useEffect 实现的 useLegacyStore
const useBoundStore = (selector) => useLegacyStore(api, selector);
Object.assign(useBoundStore, api);
return useBoundStore;
};
const create = (createState) => createState ? createImpl(createState) : createImpl;
exports.create = create;
exports.useStore = useStore;
其实就是将完整代码中的 useStore
替换为我们实现的 useLegacyStore
。
那你应该就疑惑了,这两者的区别是什么呢?为什么不一样呢?那我们就来探索一下。
useSyncExternalStore
鉴于大部分人应该都没见过这个 api(包括我),所以我们先来讲解一下基础知识。
在 React 18 中,更新了并发特性。在并发模式下,React 可以暂停、恢复甚至放弃渲染,以便优先处理更高优先级的任务(如用户输入)。这种中断和恢复的机制,在与不受 React 控制的外部数据源(External Store)交互时,某些数据状态对 UI 有影响的时候,可能会引发内容“撕裂”(渲染不一致)的问题。
场景举例:
- React 开始渲染一个组件树,其中多个组件都依赖于同一个外部数据源(例如 Redux store)。
- 在渲染过程中,React 暂停了渲染,以响应一个紧急的用户操作。
- 在暂停期间,外部数据源的值发生了变化。
- React 恢复渲染,但此时,已经渲染的组件显示的是旧的数据,而接下来渲染的组件则显示了新的数据。例如一个禁用状态,刚开始的时候还没禁用,某些按钮能点击,后续渲染获取了禁用状态,后渲染的剩余按钮不能点击。
为了解决这个问题,提出了一个新的 api。既然流程不可控,那就弄一个流程可控的 api 出来。
useSyncExternalStore
的核心使命,就是提供一种同步的方式来读取外部数据源,并确保在整个 React 渲染周期中,所有组件读取到的都是同一个一致的快照(Snapshot),从而从根本上消除“撕裂”(渲染不一致)的问题。
用法:
const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
subscribe(callback)
: 这是一个函数,用于向外部数据源注册一个回调函数 (callback
)。当外部数据源发生变化时,必须调用这个callback
来通知 React 需要重新渲染。此函数还必须返回一个用于清理订阅的函数(unsubscribe
)。React 会在组件卸载时或subscribe
函数本身发生变化时调用这个清理函数。getSnapshot()
: 这是一个函数,它的职责是返回当前外部数据源的一个快照。这个快照应该是不可变的(immutable)。在一次渲染过程中,即使外部数据源发生了变化,getSnapshot
也要保证返回同一个快照,确保渲染的一致性。React 可能会在一次渲染中多次调用getSnapshot
,所以它必须是一个纯函数,且执行速度要快。getServerSnapshot()
(可选): 这是一个可选的函数,仅在服务端渲染(SSR)和混合渲染(Hydration)时使用。它的作用是在服务端生成组件时,获取初始的快照值,确保服务端渲染的内容与客户端首次渲染的内容一致,避免不匹配的警告。
在这里,subscribe(callback)
可能会让很多人还是想不明白如何实现通知更新的。我们来详细讲解一下流程。
export function useSyncExternalStore<Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot,
): Snapshot;
我们来看一下类型定义,可以看到,传入的 subscribe 函数,要求传入是一个函数,并且返回也是一个函数。这里传入的 subscribe
由 useSyncExternalStore
这个 hook 内部自行调用。
在组件创建的过程中,useSyncExternalStore
就会生成一个和该组件实例唯一绑定的 onStoreChange
,作用就是更新组件,可以等效于 forceUpdate
这种 hook。到了处理组件副作用阶段时,就会调用 subscribe
,将 onStoreChange
传入其中。这样,我们在 Vallina
中实现的 subscribe
js
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
参数 listener
的值就是 onStoreChange
,将其存入 listeners
,待状态发生变化,通知订阅者的时候执行,这样对应的组件就会通过 getSnapshot
去重新获取最新的数据快照,并更新渲染组件。
在组件卸载的时候会执行 subscribe
调用后返回的取消订阅的函数。
分析比较
setState
和 useEffect
在 React 17 以及之前是可以的,因为组件渲染是同步的,任何情况下都会正常渲染直到完成。但也并不是说这个方案就完全没问题,由于异步的特性,更新和组件渲染的时机并不能确保按照理想情况来进行。就可能会存在更新在渲染之后,这样会造成组件的多次渲染,影响性能开销。
而 useSyncExternalStore
是同步的,确保了组件渲染的时候,拿到的都是同一个值,不会出现“撕裂”的情况。
特性 | useState + useEffect | useSyncExternalStore |
---|---|---|
并发安全 | 容易出现“撕裂”现象 | 从根本上解决“撕裂”,保证渲染一致性 |
更新时机 | 更新是异步的,可能导致额外的渲染 | 同步读取,确保在渲染开始时就获取到最新的、一致的数据 |
性能 | 可能因为异步更新导致不必要的重复渲染 | 更高效,避免了不必要的渲染,因为 React 可以同步地知道状态是否改变 |
代码简洁性 | 需要手动管理状态和副作用,代码相对冗余 | 提供了更声明式、更简洁的 API 来订阅外部源 |
开发体验优化
'use strict';
var React = require('react');
var vanilla = require('zustand/vanilla');
const identity = (arg) => arg;
// 2️⃣
function useStore(api, selector = identity) {
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState())
);
// 3️⃣
React.useDebugValue(slice);
return slice;
}
const createImpl = (createState) => {
const api = vanilla.createStore(createState);
// 1️⃣
const useBoundStore = (selector) => useStore(api, selector);
// 4️⃣
Object.assign(useBoundStore, api);
return useBoundStore;
};
const create = (createState) => createState ? createImpl(createState) : createImpl;
exports.create = create;
exports.useStore = useStore;
1️⃣ 为什么要创建这个 useBoundStore
?我们可以注意到 useStore
是需要传入 api
的,这个值对于一个 store 实例来说,是固定不变的。那么我们就没必要在使用的时候每次都自己传这个值,在一开始的时候就固定传入。所以用 (selector) => useStore(api, selector)
这个形式包裹了一层,这样我们就只需要根据使用情况,是否传入 selector
就可以了。
2️⃣ selector
是什么呢?在使用过程中,我们并不是每次都需要完整的 state,大部分情况下,我们可能只需要其中的一小部分内容。这个时候,我们就可以传入一个选择器,从完整的 state 中,选出我想要的内容。也就是说 selector
是个函数,(state) => any
,开发者可以自由决定如何组织返回的部分内容,可以是单个值,计算值,对象或者数组等。
没有传递的默认情况下,就使用 identity
,返回完整的 state 内容。
3️⃣ 当使用 React 开发者工具时,它能支持你在查看组件的 Hooks 列表时,能够直接看到 useStore
当前返回的 slice
值,极大地方便了调试。
4️⃣ 除了适配 React 的 hook 形式,将原生的 api 也合并到了 hook 的对象中。这样创建的 store 既能在 React 组件中以 hook 的形式获取值,也可以在 js 或者 ts 中通过原生的方法来使用 store,极大提升了可用范围。
结语
在本篇文章中,我们学习了 zustand 如何对原生代码进行 react 的框架适配。对 原生 -> React 的流程更加熟悉了。
- 探究了两种实现封装的方式,并总结了两者的优缺点
- 学习了 React 18 提出的
useSyncExternalStore
的背景和意义,以及使用方法 - 学习了如何为开发者提供一个良好的开发体验