10 分钟学习 React 状态库 - Zustand 用法和原理

大家好,我是风骨,本篇文章的主题是 React 状态库 - Zustand

这是一个基于 React hooks API 实现的轻量级状态管理库。当你的项目不算太大,又期望使用全局状态,它将是一个不错的选择。

通过阅读本篇文章,你将收获:

  1. 掌握 zustand 的用法及使用注意事项 - 减少不必要的重渲染
  2. 掌握 React useSyncExternalStore hook 用途及工作原理
  3. 掌握 zustand 数据驱动组件更新的原理

附 - 毛遂自荐:笔者当前离职在看工作机会,各位小伙伴如果有合适的内推机会,期待您的引荐 🤝

个人简介:男,27岁,专科 · 计算机专业,6 年前端工作经历,从事过 AIGC、低代码、动态表单、前端基建 等方向工作,base 北京 - 高级前端岗,vx 联系方式:iamcegz

1、基本用法

  1. 首先,初始化项目和安装 zustand

我们使用 Vite cli 初始化一个简易的 React + TypeScript 项目,并安装 zustand 库。

md 复制代码
# 使用 vite cli 创建项目
npm create vite@latest vite-zustand -- --template react-ts

# 安装 zustand 库
cd vite-zustand
npm install zustand
  1. 定义一个 store 状态仓库

新建 src/store/count.ts 文件,使用 zustand 提供的 create 函数创建一个用于 count 计数的 store:

tsx 复制代码
// src/store/count.ts
import { create } from "zustand";

type State = {
  count: number;
};

type Action = {
  inc: () => void;
  dec: () => void;
};

const useCountStore = create<State & Action>((set, get) => ({
  count: 1,
  inc: () => set((state) => ({ count: state.count + 1 })),
  dec: () => set(() => ({ count: get().count - 1 })),
}));

export default useCountStore;

create 函数使用介绍:

  • 输入:它接收一个函数,

    • 函数的参数是 setget 方法,set 方法用于更新值,get 方法用于获取值;
    • 函数的返回值是一个对象,在对象中定义 stateaction,将会暴露给外部组件去使用;
  • 输出 :它返回一个 react hook(代码中的 useCountStore),在函数组件可使用它来消费 store 中的 state 和 action。

  1. 在组件中消费 store

在函数组件中导入 useCountStore,从中解构出 state 和 action,作用和 setState hook 相似。

tsx 复制代码
// src/App.tsx
import useCountStore from "./store/count";

function App() {
  const { count, inc, dec } = useCountStore();

  return (
    <div>
      <h1>count is {count}</h1>
      <button onClick={() => inc()}>inc</button>
      <button onClick={() => dec()}>dec</button>
    </div>
  );
}

export default App;

到这里,我们成功在组件中接入了 zustand store,执行 npm run dev 启动项目操作页面上的按钮来体验一下。

  1. 在 store action 中执行异步操作

在定义 store 时,action 函数还支持书写 async/await 异步代码,比如在这里发起异步 API 请求拿到数据后再进行 state 更新。

PS:如果遵循纯函数原则,通常不建议在 action 中编写这些副作用(异步请求)操作。关于这一点小伙伴们有独特见解可以在评论区发起讨论。

定义一个异步函数 fetchCount

diff 复制代码
// src/store/count.ts
import { create } from "zustand";

type State = {
  count: number;
};

type Action = {
  inc: () => void;
  dec: () => void;
+ fetchCount: () => void;
};

const useCountStore = create<State & Action>((set, get) => ({
  count: 1,
  inc: () => set((state) => ({ count: state.count + 1 })),
  dec: () => set(() => ({ count: get().count - 1 })),
+ fetchCount: async () => {
+   // 模拟异步请求
+   const data = await new Promise<number>((resolve) => {
+     setTimeout(() => resolve(100), 1000);
+   });
+   set(() => ({ count: data }));
+ },
}));

export default useCountStore;

在组件中使用 fetchCount

tsx 复制代码
// src/App.tsx
import useCountStore from "./store/count";

function App() {
  const { count, inc, dec, fetchCount } = useCountStore();

  return (
    <div>
      <h1>count is {count}</h1>
      ...
      <button onClick={() => fetchCount()}>fetchCount</button>
    </div>
  );
}

export default App;

2、进阶用法

2.1、中间件

zustand 提供了 中间件机制 来扩展和增强操作 store 功能。比如 persist 中间件可以将 store state 持久化到 localStorage 中。

我们在 count store 中引入 persist 中间件,并采用 localStorage 存储形式定义一个 count-storage 存储名称:

tsx 复制代码
// src/store/count.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

type State = {
  count: number;
};

type Action = {
  inc: () => void;
  dec: () => void;
};

const useCountStore = create<State & Action>()(
  persist(
    (set, get) => ({
      count: 1,
      inc: () => set((state) => ({ count: state.count + 1 })),
      dec: () => set(() => ({ count: get().count - 1 })),
    }),
    {
      name: "count-storage", // 存储的名称
      storage: createJSONStorage(() => localStorage), // 存储的引擎: localStorage || sessionStorage
    }
  )
);

export default useCountStore;

persist 中间件会完成以下两件事情:

  • 在更新 state count 时同时将最新数据存储在 localStorage count-storage 中;
  • 在初始化 store 时会从本地存储中读取缓存,若读到则作为 state count 默认值。

有关其他中间件的使用,可阅读官方文档:zustand.docs.pmnd.rs/getting-sta...

2.2、减少不必要的组件重渲染

在使用 zustand 时,如果稍不注意,会导致一些 React 组件执行不必要的重渲染。让我们一起来看看!

现在我们有一个 store,存放了 主题 和 语言 两个 state。

tsx 复制代码
// src/store/config.ts
import { create } from "zustand";

interface State {
  theme: string;
  lang: string;
}

interface Action {
  setTheme: (theme: string) => void;
  setLang: (lang: string) => void;
}

const useConfigStore = create<State & Action>((set) => ({
  theme: "light",
  lang: "zh-CN",
  setLang: (lang: string) => set({ lang }),
  setTheme: (theme: string) => set({ theme }),
}));

export default useConfigStore;

接着,我们创建 主题 和 语言 组件来消费对应的 state。

tsx 复制代码
// src/components/Theme.tsx
import useConfigStore from "../store/config";

const Theme = () => {
  const { theme, setTheme } = useConfigStore();
  console.log("theme render");

  return (
    <div>
      <div>{theme}</div>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        切换
      </button>
    </div>
  );
};

export default Theme;


// src/components/Lang.tsx
import useConfigStore from "../store/config";

const Lang = () => {
  const { lang, setLang } = useConfigStore();
  console.log("lang render...");

  return (
    <div>
      <div>{lang}</div>
      <button onClick={() => setLang(lang === "zh-CN" ? "en-US" : "zh-CN")}>
        切换
      </button>
    </div>
  );
};

export default Lang;

同时,将这两个组件挂载到页面上。

tsx 复制代码
import Theme from "./components/Theme";
import Lang from "./components/Lang";

function App() {
  return (
    <div>
      <Theme />
      <Lang />
    </div>
  );
}

export default App;

现在我们启动项目回到页面,你会发现:当去 setTheme 修改 主题 时,语言组件也会重渲染,同样当去 setLang 修改 语言 时,主题组件也会重渲染

这是为什么呢?要搞懂这个需要我们阅读完下文「原理分析」

就目前,我们要优化这一问题,可以选用以下方式:

方式一:每个 useConfigStore 返回一个具体的 state。

tsx 复制代码
const Theme = () => {
  const theme = useConfigStore((state) => state.theme);
  const setTheme = useConfigStore((state) => state.setTheme);
  console.log("theme render");

  ...
};

缺点:书写乏味,需要编写很多重复的 useConfigStore。

方式二:返回一个仅包含需要的 state 的对象,注意需要使用官方提供的 Api -useShallow 进行对象浅比较,避免出现一些多次渲染导致栈溢出问题。

tsx 复制代码
import { useShallow } from "zustand/react/shallow";

const Theme = () => {
  const { theme, setTheme } = useConfigStore(
    useShallow((state) => ({
      theme: state.theme,
      setTheme: state.setTheme,
    }))
  );
  console.log("theme render");

  ...
};

3、原理分析

从使用上我们知道 zustand 主要有两个关键的函数来为组件提供状态:

  • 一是 create 函数,用于定义 stateaction
  • 二是 create 函数返回值 useBoundStore,用于桥接 React 组件和 store 数据。

3.1、create 函数

源码:

create 函数接收一个 createState 函数,创建了一个 storeApi,并返回一个 useBoundStore 函数。

tsx 复制代码
const create = (createState) => {
  const storeApi = createStore(createState);
  const useBoundStore = (selector) => useStore(storeApi, selector);
  Object.assign(useBoundStore, storeApi);
  return useBoundStore;
};

分析:

  • createStore 函数接收用户定义的 createState 函数,执行并拿到 stateaction,同时对外暴露一些用于操作 storeapi。源码如下:
tsx 复制代码
const createStore = (createState) => {
  let state;
  const listeners = new Set(); // 观察者模式的监听集合

  // 定义更新 state 方法
  const setState = (partial, replace) => {
    const nextState = typeof partial === "function" ? partial(state) : partial;
    // 使用 Object.is 比较两个对象是否是同一个引用地址,等同于使用全等 ===
    if (!Object.is(nextState, state)) {
      const previousState = state;
      // 更新 state,如果 replace 为 true,则直接替换,否则浅合并到 state 上
      state = repeat ? nextState : Object.assign({}, state, nextState);
      // notify 通知
      listeners.forEach((listener) => listener(state, previousState));
    }
  };

  // 定义获取 state 方法
  const getState = () => state;
  const getInitialState = () => initialState;

  // 观察者订阅函数,将 listener 添加到 listeners 集合中,并返回一个取消订阅的函数
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  // 操作 store 的 api
  const api = { setState, getState, getInitialState, subscribe };

  // 执行 createState 函数初始化 state,并赋值给 initialState 和 state
  const initialState = (state = createState(setState, getState, api));
  return api;
};

  • storeApi 是一个包含操作 store 的对象,包含以下方法:
    • subscribe 方法用于订阅观察者到 listeners 集合中,属于观察者模式思想,将来在更新数据的时候通知这些观察者;
    • setState 用于更新 state,同时通知 listeners 集合中的每一个观察者;
    • getState 则是获取最新的 state
tsx 复制代码
storeApi = { setState, getState, getInitialState, subscribe };

  • useBoundStore 它接受一个用户传入的 selector 函数,交给 useStore 函数进行处理,下文会详细介绍;
tsx 复制代码
const useBoundStore = (selector) => useStore(storeApi, selector);

  • storeApi 合并作为 useBoundStore 函数的成员属性,这意味着可以从 useBoundStore 中获取到 storeApi(不过实际的应用场景应该不多):
tsx 复制代码
useBoundStore.getState()
useBoundStore.setState(...)

小结:

create 函数定义了 state 及更新和获取 state 的方法,但还有两个疑问需要我们去探索:

  1. useStore 如何处理 selector 并返回用户需要的 store 数据?
  2. subscribe 在何时使用,来向 listeners 集合中添加观察者?

useStore 的实现会帮助我们解答疑惑!

3.2、useStore 函数

在 React 组件中 消费 store 会用到 useBoundStore(即 useCountStore),

tsx 复制代码
import useCountStore from "./store/count";
function App() {
  const { count, inc, dec } = useCountStore();
  ...
}

useBoundStore 本质是调用 useStore 并传递一个 selector 函数选择要读取哪些 store 状态,最后来返回 store 状态,我们来看看 useStore 的实现。

源码:

tsx 复制代码
const identity = (arg) => arg;
function useStore(api, selector = identity) {
  const slice = React.useSyncExternalStore(
    api.subscribe,
    () => selector(api.getState()),
  );
  return slice;
}

看起来实现很简单,使用了 React.useSyncExternalStore(直译为:同步外部 store 作为 state 数据源)。

如果你不了解 React.useSyncExternalStore 的作用,让我们一起来学习一下!

3.3、useSyncExternalStore hook

传统的组件状态 state 需要使用 useState、useReducer 等 API 定义,这样在数据更新时才能驱动组件重渲染

useSyncExternalStore 是 React18 新增的一个 hook,它的作用是:将外部的一个普通对象 state 接入到组件内,在数据更新时也能驱动组件重渲染

再回过来看 zustand,它的 state 数据定义就是一个普通对象,然而借助这个 API 就可以将 state 和 React 组件桥接在一起,完美实现:更新 state --> 驱动 React 组件 rerender。

3.3.1、用法

首先我们定义一个 state 普通对象,并配套实现一个观察者模式 listeners

ts 复制代码
let state = {
  x: 0,
  y: 0,
};
const listeners: (() => void)[] = [];

function subscribe(callback: () => void) {
  listeners.push(callback);
  return () => {
    listeners.splice(listeners.indexOf(callback), 1);
  };
}

接着,我们在 React 组件中使用 useSyncExternalStore hook 接入这个 state 及其 listeners

tsx 复制代码
function App() {
  const { x, y } = React.useSyncExternalStore(subscribe, () => state);
  return (
    <button onClick={() => setState({ x: x + 1 })}>更新 x: {x}</button>
  )
}
  • 参数一:一个用于注册观察者的 subscribe 方法,来监听 state 发生变化,自行驱动 App 组件重渲染;
  • 参数二:是一个函数,定义要返回的 state 值。

现在,我们定义一个 setState 方法,在更新 state 的同时,通知观察者数据更新了。

tsx 复制代码
const setState = (partial: Partial<typeof state>) => {
  state = { ...state, ...partial };
  listeners.forEach((listener) => listener());
};

回到页面,点击按钮,state.x 的值不断更新,同时组件也会进行重渲染,完美在组件中接入外部数据。

3.3.2、原理

我们思考一下 useSyncExternalStore 的核心是什么呢?

答案是 subscribe 观察者

在观察到数据更新的时候,它执行了类似 forceUpdate 的组件重渲染操作,调用底层的 scheduleUpdateOnFiber 方法开启更新流程,从而实现数据驱动视图更新

useSyncExternalStore简易版实现(以 mount 为例):

tsx 复制代码
function mountSyncExternalStore(subscribe, getSnapshot) {
  const fiber = currentlyRenderingFiber;
  const hook = mountWorkInProgressHook();

  const nextSnapshot = getSnapshot(); // 获取 state 的快照(值)
  hook.memoizedState = nextSnapshot;
  const inst = {
    value: nextSnapshot,
    getSnapshot,
  };
  hook.queue = inst;

  // !!! 在 useEffect 中执行 subscribe 来订阅 state
  mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
  
  return nextSnapshot; // 返回 state
}

// !!! 关键方法
function subscribeToStore(fiber, inst, subscribe) {
  // 在 store 数据发生变化时,执行 forceStoreRerender 方法,开启强制渲染更新流程
  const handleStoreChange = () => {
    if (checkIfSnapshotChanged(inst)) {
      forceStoreRerender(fiber);
    }
  };
  // Subscribe to the store and return a clean-up function.
  return subscribe(handleStoreChange);
}

// 检测前后 state 是否发生变化
function checkIfSnapshotChanged(inst) {
  const latestGetSnapshot = inst.getSnapshot;
  const prevValue = inst.value;
  try {
    const nextValue = latestGetSnapshot();
    return !Object.is(prevValue, nextValue);
  } catch (error) {
    return true;
  }
}

// 开启强制渲染更新流程
function forceStoreRerender(fiber) {
  // 标记 fiber lane 更新为 SyncLane 同步优先级
  const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
  if (root !== null) {
    // 开启调度更新
    scheduleUpdateOnFiber(root, fiber, SyncLane);
  }
}

核心是 subscribeToStore(),在 subscribe 订阅到外部 store 数据更新后,执行 forceStoreRerender(fiber) 开启新的更新流程。

同时,在这里我们解答一下上面遗留的疑问:「为什么 setTheme 修改主题,语言组件会重渲染」

  • 在没有传递 selector 时,(arg) => arg 会作为默认值,即将 store state 返回;
  • 从 store api setState 的实现可以得知,每次更新都会创建一个全新对象 {};
  • 重点:因为每次更新都是全新对象,在 checkIfSnapshotChanged() 中通过 Object.is() 比较前后 store state 发现引用地址不一样 ,因此语言组件也会触发 scheduleUpdateOnFiber 并在组件上标记上更新标识。
  • 因此解决办法便是:使用官方提供的 Api -useShallow 进行对象浅比较 。若语言组件依赖的 lang state 没有更新,在 checkIfSnapshotChanged() 这一层便不会触发更新流程。

3.4、流程图

总结:通过「观察者模式」,在 state 发生更新时,只会通知消费该 state 的组件进行重渲染,实现了按需更新,从而确保高效的性能。

下面绘制了一版简易流程图,辅助大家进行理解:

文末

感谢阅读。文章内容你觉得有用,可以点赞支持一下~

参考:

关于zustand的一些最佳实践\] [juejin.cn/post/731679...](https://juejin.cn/post/7316796505129091081 "https://juejin.cn/post/7316796505129091081")

相关推荐
超人不会飛1 分钟前
就着HTTP聊聊SSE的前世今生
前端·javascript·http
蓝胖子的多啦A梦4 分钟前
Vue+element 日期时间组件选择器精确到分钟,禁止选秒的配置
前端·javascript·vue.js·elementui·时间选选择器·样式修改
夏天想7 分钟前
vue2+elementui使用compressorjs压缩上传的图片
前端·javascript·elementui
The_cute_cat8 分钟前
JavaScript的初步学习
开发语言·javascript·学习
海天胜景11 分钟前
vue3 el-table 列增加 自定义排序逻辑
javascript·vue.js·elementui
今晚打老虎z15 分钟前
dotnet-env: .NET 开发者的环境变量加载工具
前端·chrome·.net
用户38022585982420 分钟前
vue3源码解析:diff算法之patchChildren函数分析
前端·vue.js
烛阴26 分钟前
XPath 进阶:掌握高级选择器与路径表达式
前端·javascript
小鱼小鱼干29 分钟前
【JS/Vue3】关于Vue引用透传
前端
JavaDog程序狗31 分钟前
【前端】HTML+JS 实现超燃小球分裂全过程
前端