Zustand 从入门到精通:我的工程实践笔记

13 阅读5分钟

Zustand 从入门到精通:我的工程实践笔记

目标读者:已经会用 Zustand,但想把它用得"稳"和"可演进"的 React 开发者


引言

我写这份笔记的目的很简单:把 Zustand 从"会用"推进到"用得稳、用得可演进"。 我不会在这里重复 API 文档式的讲解,而是用一个完整的 Todo Demo,把我认为最重要的工程实践串起来:订阅边界、actions/state 分离、persist 的坑、派生状态(computed)怎么做,以及为什么我尽量不用 useShallow。

Zustand 的 API 文档很简单,几行代码就能跑起来。但在真实项目中,如何组织 store?如何避免不必要的重渲染?持久化有哪些坑?派生状态怎么做?

我用一个完整的 Todo Demo,把这些问题串起来,分享一套在项目中验证过的最佳实践。


1. 我最终的 Demo(你可以直接跑)

这份代码是"我认为比较稳"的 Zustand 用法:

  • 组件只订阅自己需要的数据
  • actions/state 分离
  • persist 只持久化纯数据(避免函数丢失)
  • 派生值用 selector 直接订阅(类似 Vue computed 的效果)
  • 表单用 react-hook-form + zod

0.1 useTodoStore.ts(Zustand store)

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

export interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
}

interface TodoActions {
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  removeTodo: (id: string) => void;
  clearCompleted: () => void;
}

type TodoStore = TodoState & { actions: TodoActions };

const useTodoStore = create<TodoStore>()(
  persist(
    immer(set => ({
      todos: [],
      actions: {
        addTodo: (text: string) =>
          set(state => {
            state.todos.push({
              id: crypto.randomUUID(),
              text,
              completed: false,
            });
          }),
        toggleTodo: (id: string) =>
          set(state => {
            const todo = state.todos.find((todo: Todo) => todo.id === id);
            if (todo) todo.completed = !todo.completed;
          }),
        removeTodo: (id: string) =>
          set(state => {
            state.todos = state.todos.filter((todo: Todo) => todo.id !== id);
          }),
        clearCompleted: () =>
          set(state => {
            state.todos = state.todos.filter((todo: Todo) => !todo.completed);
          }),
      },
    })),
    {
      name: 'todos-storage',

      /**
       * 关键:persist 只持久化可序列化的数据。
       * 我只存 todos,不存 actions(函数不能 JSON 序列化,否则会出现 addTodo is not a function)
       */
      partialize: state => ({ todos: state.todos }),
    }
  )
);

/**
 * 我不直接导出 useTodoStore(避免误订阅整个 store),
 * 而是导出语义化 hooks,集中 selector 逻辑。
 */
export const useTodos = () => useTodoStore(s => s.todos);
export const useTodoActions = () => useTodoStore(s => s.actions);

/**
 * 派生值:类似 Vue computed。
 * 组件如果只关心 count,就只订阅这个 selector,而不是订阅 todos。
 */
export const useIncompleteCount = () =>
  useTodoStore(s => s.todos.reduce((acc, t) => acc + (t.completed ? 0 : 1), 0));

0.2 schema.ts(zod schema)

import { z } from 'zod';

export const todoSchema = z.object({
  todoText: z.string().trim().min(1, '请输入待办项'),
});

export type TodoValues = z.infer<typeof todoSchema>;

0.3 Demo.tsx(React 组件:AddTodo / TodoList / Footer)

import { Button } from '../../components/ui/button';
import clsx from 'clsx';

import {
  useIncompleteCount,
  useTodoActions,
  useTodos,
} from '@/store/useTodoStore';

import { useForm } from 'react-hook-form';
import { todoSchema, type TodoValues } from './schema';
import { zodResolver } from '@hookform/resolvers/zod';

const AddTodo = () => {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<TodoValues>({
    resolver: zodResolver(todoSchema),
    defaultValues: {
      todoText: '',
    },
  });

  const { addTodo } = useTodoActions();

  const onSubmit = (values: TodoValues) => {
    addTodo(values.todoText);
    reset();
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="my-2 flex justify-between"
    >
      <div className="flex min-h-15 flex-col">
        <input
          className="rounded-xl border border-gray-600 px-2 py-1 text-sm"
          placeholder="请输入任务"
          {...register('todoText')}
        />
        {errors.todoText && (
          <span className="mt-1 ml-1 text-xs text-red-500">
            *{errors.todoText.message}
          </span>
        )}
      </div>
      <Button type="submit" variant="outline">
        添加
      </Button>
    </form>
  );
};

const TodoList = () => {
  const todos = useTodos();
  const { toggleTodo, removeTodo } = useTodoActions();

  return (
    <>
      {todos.map(todo => (
        <div key={todo.id} className="mt-2 flex justify-between">
          <div>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span
              className={clsx(
                'ml-2',
                todo.completed && 'text-gray-400 line-through'
              )}
            >
              {todo.text}
            </span>
          </div>
          <Button type="button" onClick={() => removeTodo(todo.id)}>
            删除
          </Button>
        </div>
      ))}
    </>
  );
};

const Footer = () => {
  const { clearCompleted } = useTodoActions();
  const incompleteCount = useIncompleteCount();

  return (
    <>
      <div className="my-2 flex justify-end">
        <span>未完成数量:{incompleteCount}</span>
      </div>
      <Button type="button" onClick={clearCompleted} className="rounded-full">
        删除已完成
      </Button>
    </>
  );
};

const Demo = () => {
  return (
    <div className="mt-2 ml-2 flex w-100 flex-col rounded-xl bg-gray-100 px-3 py-2">
      <h1 className="self-center font-bold">TODO LIST</h1>
      <AddTodo />
      <TodoList />
      <Footer />
    </div>
  );
};

export default Demo;

2. 我如何理解 Zustand 的"进阶点"

2.1 订阅边界是第一性原理

Zustand 组件更新的触发条件,我用一句话描述:

我订阅的 selector 返回值发生变化(Object.is(prev, next) === false),组件就重渲染。

所以我做的所有"最佳实践",都在回答: 我到底让组件订阅了什么?它的返回值稳定吗?


3. 为什么我不直接导出 store hook

我刻意不导出 useTodoStore 给业务组件使用,而是只导出:

  • useTodos()
  • useTodoActions()
  • useIncompleteCount()

原因:

  1. 避免误订阅整个 store

    • useTodoStore() 相当于订阅 state => state,任何字段变化都会触发 re-render
  2. selector 逻辑集中

    • 以后我想改变数据结构(比如从数组换成 map),只用改 hook,不用全局搜索替换
  3. 语义更强

    • 业务组件里读起来像"声明依赖",而不是"实现细节拼装"

4. 为什么我把 actions 和 state 分离

我把 store 组织成:

{ todos, actions: { ... } }

这让我在组件里形成一种稳定模式:

  • 订阅数据:const todos = useTodos()
  • 触发事件:const { addTodo } = useTodoActions()

这里有个我认为很"关键但容易忽略"的点:

actions 是稳定引用,它们不会随 todos 更新而改变。

因此: 只订阅 actions 的组件(例如 AddTodo)不会因为 todos 更新而重渲染。 这让渲染边界非常清晰。


5. persist 的坑:为什么我必须使用 partialize

我亲自踩过这个坑:addTodo is not a function。 本质原因:

  • persist 会把 state JSON 序列化
  • 函数无法被序列化
  • rehydrate 后 actions 被覆盖成非函数对象

所以我必须写:

partialize: state => ({ todos: state.todos });

我的经验是:

persist 只存"数据",绝对不要存"行为"。


6. "computed" 在 Zustand 里怎么做?

Vue 有 computed,我在 Zustand 里用 selector 来模拟同等效果:

export const useIncompleteCount = () =>
  useTodoStore(s => s.todos.reduce((acc, t) => acc + (t.completed ? 0 : 1), 0));

然后在 Footer 里只订阅这个派生值:

const incompleteCount = useIncompleteCount();

我特别强调这一点,因为这体现了"订阅最小依赖"的思想: Footer 不关心 todos 具体内容,只关心 count,所以我不订阅 todos。


7. 为什么我尽量不用 useShallow

useShallow 的作用我理解为:

当 selector 被迫返回一个新对象/数组({} / [])时,用 shallow compare 抵消引用变化。

但在这个 Demo 里,我通过:

  • 原子 selector(useTodos, useTodoActions, useIncompleteCount
  • actions/state 分离

已经避免了"返回新对象"的场景。 因此我几乎不需要 useShallow。

如果我确实需要一次性返回多个 state 值,我才会用:

useTodoStore(useShallow(s => ({ a: s.a, b: s.b })));

8. 最终沉淀的"写 Zustand 的规则集"

自己定的规则基本是这些:

  1. 组件不直接用 store hook
  2. 导出语义化 hooks,集中 selector
  3. actions/state 分离
  4. persist 只持久化纯数据(partialize 必写)
  5. 派生状态优先用 selector(而不是强迫订阅原始 state)
  6. 默认不用 useShallow,只有在必须返回 object/array 时使用
  7. 能让组件依赖更小,就让依赖更小

9. 结语:我认为 Zustand 的"进阶"是什么

对我而言,Zustand 的进阶不是掌握多少 middleware, 而是我能否持续回答这两个问题:

  1. 这个组件到底订阅了什么?
  2. 我的 selector 返回值是稳定的吗?