手写 Zustand:三十分钟带你搞懂状态管理库的核心原理

手写 Zustand:三十分钟带你搞懂状态管理库的核心原理

前言

React 状态管理库层出不穷,从 Redux、MobX 到 Recoil、Jotai、Zustand,每一个都号称能解决你的痛点。但在这么多选择里,Zustand 凭借极简的 API 和不到 1KB 的体积,在 GitHub 上狂揽 50k+ star,成为了当下最流行的轻量级状态管理方案。

js 复制代码
// Zustand 有多简单?三行搞定全局状态
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

我一直觉得,真正理解一个工具,不是看文档背 API,而是把它拆开看看里面到底是怎么转的。今天我们就从零开始,一步一步手写一个 Zustand。

读完这篇文章,你会彻底搞懂三件事:

  • 状态管理库怎么「存」状态
  • 发布订阅模式怎么驱动 React 组件「响应式」更新
  • create 函数和自定义 Hook 是怎么封装出来的

我们先把官方的 Zustand 放一边,从一个最简单的 store 开始写起。

第一步:一个最简陋的 store

抛开所有概念,状态管理说到底就三个操作------存、取、改

js 复制代码
// zustand2.js ------ 最朴素的版本
export function createStore() {
  let state = { count: 0 };

  const getState = () => state;

  const setState = (newState) => {
    state = newState;
  };

  return {
    getState,
    setState,
  };
}

来用一下,

js 复制代码
// App3.jsx
import { createStore } from './zustand';

export default function App() {
  const store = createStore();
  console.log(store.getState()); // { count: 0 }

  store.setState({ count: 10 });
  console.log(store.getState()); // { count: 10 }
}

就这么简单。一个闭包把 state 包起来,外面只能通过 getStatesetState 访问,避免了直接篡改。

闭包是 JavaScript 里最朴素的信息隐藏机制------外部函数作用域里的变量,内部函数可以一直访问,但外部代码摸不着。

但问题来了:改了状态,谁通知我呢?组件怎么知道数据变了要重新渲染?

第二步:发布订阅------让状态变更「可被感知」

Zustand 的核心机制就是发布订阅模式。store 是「发布者」,组件是「订阅者」。状态变了,store 挨个通知所有订阅者------「嘿,数据更新了,你们该干嘛干嘛」。

js 复制代码
// zustand.js ------ 加入订阅机制
export function createStore() {
  let state = { count: 0 };
  // Set 确保同一个订阅者不会被重复添加
  const listeners = new Set();

  const getState = () => state;

  const setState = (newState) => {
    state = newState;
    // 通知所有订阅者
    listeners.forEach((listener) => listener());
  };

  const subscribe = (listener) => {
    listeners.add(listener);
    // 返回一个取消订阅的函数
    return () => listeners.delete(listener);
  };

  return {
    getState,
    setState,
    subscribe,
  };
}

注意两个细节。

第一个,listeners 用的是 Set 而不是数组。因为同一个组件可能会多次调用 subscribe,用数组的话会存进去重复的函数,通知的时候同一个组件被触发多次,白白浪费渲染。Set 天然去重,完美解决。

第二个,subscribe 返回了一个 () => listeners.delete(listener)。这是订阅模式的标配------组件卸载的时候得把订阅取消掉,不然组件都没了,store 还在那喊着让你更新,内存泄漏就是这么来的。

来试一下,

js 复制代码
// App.jsx
import { createStore } from './zustand';

export default function App() {
  const store = createStore();

  // 订阅者 A
  store.subscribe(() => {
    console.log(`A 收到通知,最新 count: ${store.getState().count}`);
  });

  // 订阅者 B
  store.subscribe(() => {
    console.log(`B 收到通知,最新 count: ${store.getState().count}`);
  });

  store.setState({ count: 10 });
  // 控制台输出:
  // A 收到通知,最新 count: 10
  // B 收到通知,最新 count: 10
}

到这里,一个带订阅机制的状态容器已经有了。但现在的用法跟 React 组件还是割裂的------你得手动 subscribe,手动 getState,跟写原生 JS 似的。

Zustand 真正好用的地方,是它把 store 包装成了一个自定义 Hook,组件里直接 useXxxStore(state => state.count) 就能拿到数据,而且状态变了组件自动重渲染。接下来我们就实现这个。

第三步:create 函数------把 store 变成 Hook

回想一下 Zustand 的真实用法,

js 复制代码
const useXxxStore = create((set) => ({
  aaa: '',
  bbb: '',
  updateAaa: (value) => set({ aaa: value }),
}));

create 接收一个初始化函数,返回一个自定义 Hook useXxxStore。这个 Hook 既可以从 store 里取数据,又自带订阅能力(状态变了自动触发组件重渲染)。

我们来拆解 create 的实现思路:

  1. 内部调 createStore 拿到 store 实例
  2. 执行用户传入的初始化函数,把返回值作为初始状态
  3. 暴露 set 函数------接收部分状态,跟旧状态合并(不是直接覆盖)
  4. 返回一个 Hook useStore,内部用 useSyncExternalStore 订阅 store 变化
  5. useStore 支持传入 selector 函数来取局部状态
js 复制代码
import { useSyncExternalStore } from 'react';

export function create(createState) {
  // 底层还是我们之前写的 createStore
  const store = createStore();

  // 用户传入的 set 函数:接收部分状态,合并后写入
  const set = (partial) => {
    const nextState = partial(store.getState());
    store.setState(nextState);
  };

  // 执行用户传入的初始化函数,拿到初始状态
  const initialState = createState(set);
  store.setState(initialState);

  // 返回一个自定义 Hook
  function useStore(selector) {
    // useSyncExternalStore 是 React 18 专门给外部状态管理库开的后门
    // 它帮你搞定两件事:订阅 store 变化 + 触发组件重渲染
    return useSyncExternalStore(
      store.subscribe,           // React 帮你调用 subscribe
      () => selector(store.getState())  // 取当前状态
    );
  }

  return useStore;
}

这里面最关键的一行是 useSyncExternalStore

React 18 之前,想做一个外部的状态管理库让组件响应式更新,得自己折腾 useStateuseEffectforceUpdate 各种黑魔法,稍不注意就有 tearing 问题(同一个状态在同一个渲染帧里读到不同的值)。

React 18 直接给了 useSyncExternalStore,专门解决「外部 store 怎么接入 React 渲染周期」这个问题。第一个参数传 subscribe,第二个参数传 getSnapshot,React 帮你处理剩下的一切。

第四步:完善细节

上面的 create 还差点意思,我们来把剩下的边边角角补齐。

get 方法和 store API 暴露

create 的回调里,用户除了 set,还可能需要 get(直接读状态)和 store(拿到 store 实例本身),

js 复制代码
export function create(createState) {
  const store = createStore();

  const set = (partial) => {
    const nextState = partial(store.getState());
    store.setState(nextState);
  };

  const get = () => store.getState();

  // 把 set、get、store 都传给用户
  const initialState = createState(set, get, store);
  store.setState(initialState);

  function useStore(selector) {
    if (!selector) {
      // 不传 selector,默认返回整个状态
      return useSyncExternalStore(
        store.subscribe,
        () => store.getState()
      );
    }
    return useSyncExternalStore(
      store.subscribe,
      () => selector(store.getState())
    );
  }

  return useStore;
}

selector 的浅比较优化

目前每次 store 里任何状态变化,所有用到 useStore 的组件都会重渲染。真实 Zustand 里,useStore 默认会用浅比较来判断 selector 的返回值是不是真的变了,没变就不触发重渲染。这样用 useXxxStore(state => state.count) 的组件,在 aaa 变化时就不会重渲染。

这里就不展开了,感兴趣的同学可以去看 Zustand 源码里 useSyncExternalStoreWithSelector 的实现。

完整的实现

把上面的代码合并起来,就是我们的最终版本,

js 复制代码
import { useSyncExternalStore } from 'react';

// 底层 store:存状态、改状态、订阅
function createStore() {
  let state = { count: 0 };
  const listeners = new Set();

  const getState = () => state;

  const setState = (newState) => {
    state = newState;
    listeners.forEach((listener) => listener());
  };

  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  return { getState, setState, subscribe };
}

// 上层 create:封装成 Hook,接入 React 渲染周期
export function create(createState) {
  const store = createStore();

  const set = (partial) => {
    const nextState = partial(store.getState());
    store.setState(nextState);
  };

  const get = () => store.getState();

  const initialState = createState(set, get, store);
  store.setState(initialState);

  function useStore(selector) {
    if (!selector) {
      return useSyncExternalStore(store.subscribe, () => store.getState());
    }
    return useSyncExternalStore(store.subscribe, () =>
      selector(store.getState())
    );
  }

  return useStore;
}

总共不到 40 行代码,核心就三层:

职责 关键技术
createStore 存取状态 + 发布订阅 闭包 + Set + 观察者模式
create 封装成 Hook 接入 React useSyncExternalStore
selector 按需取值 + 渲染优化 函数式编程(把函数当参数传)

我们从中学到了什么

手写 Zustand 的过程,本质上是一次对 React 状态管理范式的拆解。

你会发现,所谓「状态管理库」,核心并不神秘------闭包存数据,发布订阅做通知,React 18 的 useSyncExternalStore 搭桥接入渲染周期。这三板斧搭起来,任何一个前端都能在半天之内写出自己的状态管理方案。

真正让 Zustand 流行起来的,不是它用了什么高深的技术,恰恰相反,是它的「足够简单」。没有 Provider 包裹、没有 action 类型枚举、没有中间件配置,就是一个函数,一个 Hook,完事。

下次在项目里遇到状态管理的需求,不妨想想你是不是真的需要搬出全套 Redux Toolkit。很多时候,理解了原理之后,几十行代码就能解决的问题,不值得引入几万行的依赖。


参考

相关推荐
神奇的程序员1 小时前
重构了自己5年前写的截图插件
前端·javascript·架构
橙淮1 小时前
从优化到安全再到未来 ——JavaScript 全维度技术指南
javascript
UXbot2 小时前
一人独立交付 UI + 前端:AI 驱动 UI 设计工具的五大功能模块深度评测
前端·低代码·ui·设计模式·交互
kobesdu2 小时前
【ROS2实战笔记-19】ROS2 生命周期节点的启动顺序、状态转换陷阱与热备方案
java·前端·笔记·机器人·ros·ros2
诚实可靠王大锤3 小时前
React Native 输入框与按钮焦点冲突解决方案(rn版本0.70.3)
前端·javascript·react native·react.js
kyriewen3 小时前
测试妹子让我写单测,我偷偷用AI一天干完一周的活
前端·chatgpt·cursor
2601_957780843 小时前
Claude Code 2026年最新部署指南:从环境搭建到技能扩展
前端·人工智能·ai编程·claude
zhangfeng11333 小时前
workbuddy 专家 “前端开发师” 结合nvidia-mistral-small-4-119b-2603 项目计划-前端界面开发.md
前端·人工智能·免费
IT_陈寒5 小时前
为什么Java的Stream并行处理反而变慢了?
前端·人工智能·后端