初识 Zustand:一个强大而灵活的状态管理库

本篇对应小册《深入浅出 React 状态管理库》第 8 章。

什么是一个好的库呢?在我看来,就是它使用起来一定要符合直觉,没有那么多的概念。

Zustand 就是一个非常符合这个原则的库,如果你亲身体验过 Zustand,相信你一定会爱上它,因为它用起来实在是太简单了!当然,社区对它的赞美也不绝于耳,比如 Issue

在本章节中,我们会介绍 Zustand 的基本用法与思想,更细节部分参考官网

从一个案例开始

本章中通过完成一个 todos 需求带领大家快速入门 Zustand,代码参见项目中路径:/examples/zustand/todos,最终效果如下:

这是一个初始版本,在后续 React Devtools 章节会带领大家结合这个案例进一步进行性能优化。

安装 Zustand

js 复制代码
// npm
npm install zustand 
// yarn
yarn add zustand 
// pnpm
pnpm add zustand

为了让页面更好看一些,我们同时也安装一下 antd@react-spring/web

创建 store

然后通过 Zustand 的 create 可以创建一个 Store:

js 复制代码
import { create } from 'zustand'

const useStore = create((set) => ({
  filter: "all",
  todos: [],
  setFilter(filter) {
    set({ filter });
  },
  setTodos(fn) {
    set((prev) => ({ todos: fn(prev.todos) }));
  },
}));

可以看到,我们通过 create 创建了一个 Store,包含了:

  • filter 代表选择项,可以选择 all 代表全部工作,completed 代表已完成项,incompleted 代表待完成项。
  • todos 代表代办事项,其中每个 todo item 中包含了 title 代表事项名称和 completed 代表是否完成。
  • setFilter 用来修改 filter 数据。
  • setTodos 用来修改 todos 数据。

如下,可以看到整个页面大致分为三块,下面我们分别来对每一部分进行实现。

App 组件

js 复制代码
const App = () => {
  const { setTodos } = useStore();
  const add = (e) => {
    e.preventDefault();
    const title = e.currentTarget.inputTitle.value;
    e.currentTarget.inputTitle.value = "";
    setTodos((prevTodos) => [
      ...prevTodos,
      { title, completed: false, id: keyCount++ },
    ]);
  };

  return (
    <form onSubmit={add}>
      <Filter />
      <input name="inputTitle" placeholder="Type ..." />
      <Filtered />
    </form>
  );
}

<App /> 组件中返回内容被 <form></form> 包裹,并提供一个 add 函数,当用户在表单内的一个 input 字段中按下回车键(Enter)时会执行 onSubmit 回调函数。

当执行 add 时,将 <input /> 内容插入到 Zustand Store 里,并清空内容。

Filter 组件

js 复制代码
import { Radio } from "antd";

const Filter = () => {
  const { filter, setFilter } = useStore();
  return (
    <Radio.Group onChange={(e) => setFilter(e.target.value)} value={filter}>
      <Radio value="all">All</Radio>
      <Radio value="completed">Completed</Radio>
      <Radio value="incompleted">Incompleted</Radio>
    </Radio.Group>
  );
};

这里提供了三个 radio,点击每个按钮时更新 filter 字段。

Filtered 组件

js 复制代码
const Filtered = () => {
  const { todos, filter } = useStore();
  const filterTodo = todos.filter((todo) => {
    if (filter === "all") return true;
    if (filter === "completed") return todo.completed;
    return !todo.completed;
  });
  const transitions = useTransition(filterTodo, {
    keys: (todo) => todo.id,
    from: { opacity: 0, height: 0 },
    enter: { opacity: 1, height: 40 },
    leave: { opacity: 0, height: 0 },
  });
  return transitions((style, item) => (
    <a.div className="item" style={style}>
      <TodoItem item={item} />
    </a.div>
  ));
};

<Filtered /> 组件包含了全部的 Todo 列表,我们可以把每个 Todo 项单独拆成一个组件 <TodoItem />,让代码更干净一些。

<Filtered /> 组件里读取 Store 的 todosfilter 字段,并根据 filter 字段筛选 todos

TodoItem 组件

js 复制代码
import { CloseOutlined } from "@ant-design/icons";

const TodoItem = ({ item }: { item: Todo }) => {
  const { setTodos } = useStore();
  const { title, completed, id } = item;

  const toggleCompleted = () =>
    setTodos((prevTodos) =>
      prevTodos.map((prevItem) =>
        prevItem.id === id ? { ...prevItem, completed: !completed } : prevItem,
      ),
    );

  const remove = () => {
    setTodos((prevTodos) => prevTodos.filter((prevItem) => prevItem.id !== id));
  };

  return (
    <>
      <input type="checkbox" checked={completed} onChange={toggleCompleted} />
      <span style={{ textDecoration: completed ? "line-through" : "" }}>
        {title}
      </span>
      <CloseOutlined onClick={remove} />
    </>
  );
};

对于每个 Todo 项,我们可以决定是否完成,以及是否取消,分别对应 toggleCompleted 函数以及 remove 函数。

至此整个 Todo List 项目就完成了。

和 Immer 一起工作

假如现在有一个复杂的状态,这个状态对象嵌套了多层级,我们称之为nestedObject。如下:

js 复制代码
const nestedObject = {
  deep: {
    nested: {
      obj: {
        count: 0,
      },
    },
  },
};

如果我们想要更新这个状态,应该怎么做?

js 复制代码
const useStore = create((set) => ({
  nestedObject,
  updateState() {
    set(prevState => ({
      nestedObject: {
        ...prevState.nestedObject,
        deep: {
          ...prevState.nestedObject.deep,
          nested: {
            ...prevState.nestedObject.deep.nested,
            obj: {
              ...prevState.nestedObject.deep.nested.obj,
              count: ++prevState.nestedObject.deep.nested.obj.count,
            },
          },
        },
      },
    }));
  },
}));

好吧,实在是太复杂了,而且稍有不慎就会出问题。其实,我们可以借助 Immer 来优化这个问题,最终上面的代码将被优化为:

js 复制代码
import { produce } from 'immer'

const useStore = create((set) => ({
  nestedObject,
  updateState() {
    set(produce(state => {
      ++state.nestedObject.deep.nested.obj.count;
    });
  },
}));

这样就清爽多了!但是如果你的业务涉及到服务端渲染,则不建议使用 Immer,因为相比于传统的对象解构这会带来更多的 CPU 消耗,这部分我们在"深入浅出Immer"一节中会进一步探讨。

状态选取

前文提到,我们可以基于 create 创建的 hooks 来读取 Store 的状态,但是这有一个缺点,就是当状态发生变化时,即使在该组件中没有使用到,也会发生 re-render。

我们创建一个场景来模拟这个行为,代码见/packages/zustand/__tests__/re-render.test.tsx

还是借上面 Todo 的例子:

js 复制代码
let renderCount = 0;

const Display = () => {
  renderCount++; // 每次re-render就会增加1
  const { todos } = useStore();
  return (
    <div>
      {todos.map((todo) => (
        <div>title: {todo.title}</div>
      ))}
    </div>
  );
};

const Control = () => {
  const { setFilter } = useStore();
  return <button onClick={() => setFilter("completed")}>dispatch</button>;
};

const App = () => (
  <>
    <Display />
    <Control />
  </>
);

创建了一个 <Control /> 组件用来更新 filter 字段,借助 @testing-library/react 来模拟点击情况,借助 Jest 的断言来观察 <Display /> 的 re-render 情况:

js 复制代码
expect(renderCount).toBe(2);

我们发现 <Display /> re-render 了两次,即按钮的点击带来了额外的重新渲染,很显然这会导致性能上的问题。<Display /> 组件只用到了 todos 字段,对于其他字段的变更不应该导致 <Display /> 发生 re-render。

我们将原先的:

js 复制代码
const { todos } = useStore();

改为:

js 复制代码
const todos = useStore((state) => state.todos)

重新运行测试:

js 复制代码
expect(renderCount).toBe(1);

但我们会发现有些开发者经常会忘记向 useStore 中传入 selector,从而带来性能问题。Zustand 官方提供了一种方式,即使用 crateSelectors 来自动生成 selector:

js 复制代码
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
}

这样上面 Todos 代码会变为:

js 复制代码
const useStoreBase = create((set) => ({
  filter: "all",
  todos: [],
  setFilter(filter) {
    set({ filter });
  },
  setTodos(fn) {
    set((prev) => ({ todos: fn(prev.todos) }));
  },
}));

const useStore = createSelectors(useStoreBase);

// 获取属性
const bears = useBearStore.use.todos()

// 获取方法
const increment = useBearStore.use.increment()

可以看到这样就不容易产生漏传 selector 的问题了,因为 crateSelectors 内部已经包了一层传好的。

浅层比较 shallow

默认情况下,当状态变化时,会先根据传入的 selector 函数计算最新的状态,之后会以 Object.is 的形式来对比与上一次计算的状态是否一致来决定是否触发 re-render。

举个例子:

js 复制代码
const { todos, setFilter } = useStore((state) => ({
  todos: state.todos,
  setFilter: state.setFilter,
}))

当第一次渲染时会调用这里的 useStore 计算一次状态,这时候计算好的状态是一个包含了 todossetFilter 的对象。接下来我们更新 Store 里的 filter 字段,这时候 Zustand 会再次调用回调函数,计算一遍状态,生成一个新的对象。然后以 Object.is 的形式来对比前后两次对象是否发生变化,很显然虽然 todossetFilter 都没有变,但是对象的引用改变了,Object.is 仍然会返回 false

这种问题通常的解决方案是浅层比较 ,即对比对象里层的todossetFilter 是否改变了,Zustand 提供了一个浅层比较的实现 shallow,我们基于此来改写上述代码:

js 复制代码
import { shallow } from 'zustand/shallow'

const { todos, setFilter } = useStore(
  (state) => ({
    todos: state.todos,
    setFilter: state.setFilter,
  }),
  shallow,
)

这样就不会带来额外的 re-render 了~

另外,Zustand 也提供了一个 hook useShallow 来解决上面的问题:

js 复制代码
const { todos, setFilter } = useStore(
  useShallow((state) => ({
    todos: state.todos,
    setFilter: state.setFilter,
  })),
)

也就是说,useShallow 是另一种解决 re-render 的方案,也是目前 Zustand 的推荐用法。

处理异步操作

Zustand 是一个非常灵活的状态管理库,可以轻松地与异步代码结合使用。 让我们直接来看一个例子

js 复制代码
import { useEffect } from "react";
import { create } from "zustand";

const useStore = create((set) => ({
  todos: null,
  error: null,
  fetchData: async () => {
    try {
      const res = await fetch(`https://jsonplaceholder.typicode.com/todos`);
      const todos = await res.json();
      set({ todos });
    } catch (error) {
      set({ error });
    }
  },
}));

export default function App() {
  const { todos, fetchData, error } = useStore();

  useEffect(() => {
    fetchData();
  }, []);

  if (!todos) return <div>Loading...</div>;

  if (error) return <div>{error.message}</div>;

  return (
    <div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
}

在这个例子中,fetchData 是一个异步函数,用来拉取 todos 数据并填充到 Zustand Store 中, App 组件中则根据不同的状态来判断是渲染 loading、error、还是 todos。

Zustand 推荐的使用姿势

在"前置知识"这一章节中我们介绍了 Flux 的基本概念,Zustand 推荐了一种最佳实践,灵感就来自于 Flux。

  1. 单一 Store:对于一个应用的全局数据应该放到一个单一的 Zustand Store 中。

  2. 使用set/setState来更新状态:Zustand 提供了create API 接收一个回调函数,这个回调函数会接收 set 用来更新状态,我们在更新 Store 数据的时候需要用这个 set,这样在更新状态时才能够正确地通知 View 完成更新。

  3. 对于 Zustand 可以不需要像其他利用 Flux 理念的库一样通过派发 action 来完成状态的更新,而是在create回调函数中集成各种 dispatchers 即可,因此 Zustand 的理念和传统的 Flux 有一些区别。

但 Zustand 也提供了一种方案来支持 Flux:

js 复制代码
const types = { increase: 'INCREASE', decrease: 'DECREASE' }

const reducer = (state, { type, by = 1 }) => {
  switch (type) {
    case types.increase:
      return { grumpiness: state.grumpiness + by }
    case types.decrease:
      return { grumpiness: state.grumpiness - by }
  }
}

const useGrumpyStore = create((set) => ({
  grumpiness: 0,
  dispatch: (args) => set((state) => reducer(state, args)),
}))

const dispatch = useGrumpyStore((state) => state.dispatch)
dispatch({ type: types.increase, by: 2 })

在上面中我们通过类似于 react-redux 一样定义了一个reducer,然后在 create 回调函数中定义了一个dispatch函数,并将先前的状态与参数传入到reducer中来计算最新的状态。

总结

通过本节课的学习,你可以学到:

  • 如何基于 Zustand 写一个 Todo 案例;
  • 利用 Immer 简化你的代码;
  • 状态选取 / 浅层比较;
  • Zustand 中如何处理异步操作;
  • 推荐 Zustand 的使用姿势。

在本章节中我们从一个 Todo 案例开始,介绍了 Zustand 的基本用法,并且通过单测模拟了在不同场景下 Zustand 的 re-render 情况,相信大家能够通过对代码仓库的练习进一步提升对 Zustand 的理解。

在下一章节中,我们将实现 Zustand 的核心部分。

相关推荐
weixin_382395233 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__3 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.4 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~5 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华6 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子8 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅8 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni8 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学10 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端