用 TypeScript 定义数据契约,用 Zustand 实现业务流转

6 阅读10分钟

📖 引言:为什么我们需要状态管理?

在 React 的世界里,我们经常面临一个核心问题:跨组件通信

当你在组件 A 中登录了用户,如何让组件 B(比如导航栏)立刻知道“用户已登录”并显示用户名?如果数据只存在组件内部(useState),它就是“私有的”。我们需要一个全局可访问、响应式更新的“公共钱包”。

这就是状态管理器(如 Redux, Zustand)的用武之地。在本文中,我们将以 Zustand 为核心,结合 TypeScript,深入剖析那些看似简单却极易踩坑的细节。

我们将通过构建一个待办事项(Todo)应用用户登录系统来串联所有知识点。


第一模块:基石篇——TypeScript 类型定义的艺术

在开始管理状态之前,我们必须先定义好数据的“形状”。这就像盖房子前先画图纸。

1.1 接口(Interface)的定义与复用

我们首先定义应用中最核心的两种数据:Todo(待办事项)和 User(用户)。

// 定义一个 Todo 的结构
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

// 定义一个 User 的结构
export interface User {
  id: number;
  username: string;
  password: string;
}

💡 核心解析:

  • interface:它不是类(Class),它只是一个“契约”。它告诉编译器:“任何标为 Todo 的东西,必须有 id(数字)、title(字符串)和 completed(布尔值)”。
  • 类型安全:如果你试图给 title 赋值一个数字,TypeScript 会在编译阶段报错,而不是等运行时崩溃。

1.2 状态机思维:定义 Store 的结构

在 Zustand 中,Store 不仅仅存数据,还存修改数据的方法。我们需要定义一个“状态机”接口。

// 定义 TodoState:包含数据和行为
export interface TodoState {
  todos: Todo[]; // 数据:待办事项列表
  addTodo: (text: string) => void; // 行为:添加方法
  toggleTodo: (id: number) => void; // 行为:切换完成状态
  removeTodo: (id: number) => void; // 行为:删除方法
}

⚠️ 易错点 1:忘记定义函数类型
很多新手只定义数据(todos: Todo[]),却忘了定义函数。这会导致在组件中调用 addTodo 时,TypeScript 提示“找不到该属性”。在 Zustand 中,State 是“数据+逻辑”的集合体。

1.3 复杂状态的陷阱:嵌套与引用

看下面这个 UserState 的定义:

interface UserState {
  isLoggin: boolean; // 拼写错误陷阱!
  login: (user: { username: string; password: string }) => void;
  logout: () => void;
  user: User | null; // 关键点:可为空
}

⚠️ 易错点 2:可为空(Null)的处理
注意 user: User | null

  • 场景:应用刚启动时,用户还没登录。此时 userundefinednull
  • 后果:如果你只定义 user: User,那么你必须在初始化时提供一个完整的 User 对象(比如 user: {id: 0, username: '', password: ''})。这不仅麻烦,还可能导致逻辑错误(你以为用户登录了,其实只是默认值)。
  • 最佳实践:对于“可能不存在”的数据,永远加上 | null| undefined

❓ 答疑解惑环节 1

Q: 为什么要专门写一个 interface TodoState,直接在 create 里写不行吗?
A: 可以,但不推荐。 分离接口(Type)和实现(Logic)是大型项目的最佳实践。

  1. 可读性:看接口一眼就知道这个模块有哪些数据和方法。
  2. 复用性:如果另一个 Store 需要引用 Todo 的数据结构,可以直接 extends TodoState
  3. 维护性:当逻辑变得复杂时,分离类型能让代码不那么臃肿。

Q: User | nullUser | undefined 有什么区别?
A: 在 JavaScript 运行时,nullundefined 通常被视为“无值”。但在 TypeScript 语义上:

  • null 通常表示“有意的空值”(比如用户注销了,我特意把 user 设为 null)。
  • undefined 通常表示“未初始化”。
    在 Zustand 初始化时,两者效果一样。建议团队统一风格,通常推荐用 null 表示“无”。

第二模块:进阶篇——Zustand 的核心机制与持久化

定义好类型后,我们来创建 Store。Zustand 的核心理念是极简

2.1 创建 Store:Set 与 Get 的哲学

以计数器为例:

export const useCountStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 })
}))

💡 核心解析:

  • create<CounterState> :泛型注入,让 IDE 能自动提示 state.count
  • set 函数:这是 Zustand 的心脏。你不能直接修改 state(如 state.count++),你必须通过 set 告诉 Zustand “我要一个新的 state”
  • 函数式更新set((state) => ...)。当你需要基于旧状态计算新状态时(如加减),必须用函数形式,以防止闭包陷阱。

2.2 异步与中间件:让数据“过夜”

看下面的用户登录 Store:

import { persist } from 'zustand/middleware';

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      isLoggin: false,
      user: null,
      login: (user) => set({ isLoggin: true, user: { ...user, id: 1 } }),
      logout: () => set({ isLoggin: false, user: null })
    }),
    { name: 'user' } // 持久化的 key
  )
)

⚠️ 易错点 3:中间件的执行顺序
注意 persist 的写法。它是包裹在 create 的参数外面的。

  • 逻辑:Zustand 支持中间件(Middleware)。persist 是一个中间件,它监听所有的 set 操作,并自动将数据存入 localStorage
  • 坑点:如果不加 persist,刷新页面后,Store 会重置为初始值(0 或 null)。加了 persist 后,数据就像饼干一样“持久”保存了。
  • 配置{ name: 'user' } 对应浏览器 LocalStorage 里的 Key 名字。

2.3 状态更新的“不可变性”(Immutability)

TodoStateaddTodo 中:

addTodo: (text) => set((state) => ({ 
  todos: [...state.todos, { id: Date.now(), title: text, completed: false }] 
})),

⚠️ 易错点 4:直接修改数组
错误写法

const newTodos = state.todos;
newTodos.push({id: 1, title: text, completed: false});
set({ todos: newTodos }); // 大错特错!这修改了原始引用

后果:React 的更新机制依赖于“引用变化”。如果你直接 pushstate.todos 的内存地址没变,React 会认为数据没变,导致页面不刷新!
正确做法:使用扩展运算符 ... 创建一个新数组。

❓ 答疑解惑环节 2

Q: 为什么 login 函数里要写 { ...user, id: 1 }
A: 这是为了数据净化

  1. 场景:用户在登录表单输入 usernamepassword,这些数据传给 login
  2. 问题:表单数据可能没有 id 字段。
  3. 解决:在存入 Store 之前,利用对象扩展语法,给它加上一个 id: 1。这样保证存入的 user 符合 User 接口的定义(必须有 id)。这是一种防御性编程。

Q: persist 中间件会导致性能问题吗?
A: 在大多数场景下不会

  • persist 默认是在 set 之后异步写入 LocalStorage 的,不会阻塞主线程。
  • 注意:不要把太大(MB 级)的数据放进去,LocalStorage 读写较慢,且有容量限制(通常 5-10MB)。

第三模块:实战篇——React 组件的连接与渲染

有了 Store,现在看组件如何使用。

3.1 订阅与解构:最小化重渲染

App.tsx

function App() {
  // 1. 从 Store 中“取出”需要的变量和函数
  const { count, increment, decrement, reset } = useCountStore();
  
  return (
    <div className="card">
      {/* 2. 绑定事件 */}
      <button onClick={increment}> count is {count} </button>
      <button onClick={decrement}> -1 </button>
      <button onClick={reset}> Reset </button>
    </div>
  )
}

💡 核心解析:

  • useCountStore:这是一个 Hook。它让组件订阅了 Store 的变化。
  • 解构赋值:我们只取了 countincrement 等。Zustand 智能地做到了**“选择器(Selector)”机制。如果 Store 里还有其他数据(比如 todos)变了,但 count 没变,这个 App 组件不会重新渲染**。这是 Zustand 比 Redux 性能好的关键点之一。

3.2 事件处理与状态同步

⚠️ 易错点 5:异步操作中的状态滞后
假设你在 onClick 里连续调用 increment() 三次:

// 错误预期
onClick={() => {
  increment(); // 假设 count 从 0 变 1
  increment(); // 期望基于 1 变 2
  increment(); // 期望基于 2 变 3
}}

实际结果:可能只加了 1。

  • 原因set 是异步的。上面的代码在一次事件循环中连续触发了三次 set,它们可能都基于最初的 state(0)进行计算。
  • 解决:如果必须连续修改,应该在一次 set 里完成:
set((state) => ({ count: state.count + 3 }))

3.3 表单与状态的双向绑定

虽然你的代码中没有复杂的表单,但在 Todo 应用中,通常会有:

// 伪代码
<input 
  value={newTitle} 
  onChange={(e) => setNewTitle(e.target.value)} 
/>
<button onClick={() => addTodo(newTitle)}>Add</button>

这里 newTitle 是组件的本地状态(useState),而 todos 是全局状态。区分“本地 UI 状态”和“全局业务状态”是架构设计的关键。

❓ 答疑解惑环节 3

Q: 为什么在组件里不需要 useEffect 来监听 Store 的变化?
A: 因为 Zustand 的 Store Hook(如 useCountStore)内部已经帮你做了这件事。
当你调用 useCountStore() 时,它不仅返回当前值,还注册了一个监听器。一旦 Store 更新,它会强制组件重新执行 render。你只需要像使用普通变量一样使用它即可。

Q: 如果我想在用户登录成功后跳转页面,该在哪写代码?
A: 不要在 Store 里写跳转逻辑(如 window.location.href),这会让 Store 依赖浏览器 API,变得难以测试。
最佳实践

  1. Store 只负责把 isLoggin 改为 true

  2. 在组件中使用 useEffect 监听 isLoggin

    useEffect(() => {
      if (isLoggin) navigate('/home'); // 假设用了 react-router
    }, [isLoggin]);
    

第四模块:牛刀小试

💼 1:Zustand 与 Redux 的本质区别是什么?什么时候该用 Zustand?

参考回答思路:

  • 范式差异:Redux 强调“纯函数”、“Action”、“Reducer”和“单一状态树”,样板代码多,适合超大型复杂项目(如需要时间旅行调试)。Zustand 强调“Hooks 风格”和“中间件”,几乎没有样板代码,更符合现代 React 开发者的直觉。
  • 性能机制:Redux 默认使用 connectuseSelector 需要手动优化(shallowEqual)。Zustand 天然基于选择器(Selector),只有订阅的字段变化才会重渲染,性能开箱即用。
  • 结论:除非项目有极强的调试回溯需求或团队规范强制使用 Redux,否则对于中小型项目,Zustand 是更高效、更简洁的选择。

💼 2:在你的代码中,user: { ...user, id: 1 } 这一行为什么要用扩展运算符?直接传 user 不行吗?

参考回答思路:
这考察的是不可变性(Immutability)类型安全

  1. 类型补充:传入的 user 参数可能只包含 usernamepassword(来自表单),但 Store 定义的 User 接口要求必须有 id。扩展运算符允许我们在保留原有数据的同时,注入缺失的 id
  2. 引用隔离:直接使用传入的 user 对象可能会导致引用污染。使用 {...user} 创建了一个新对象,保证了 Store 内部状态的纯净,避免外部对象后续修改影响到 Store。

💼 3:如果我们的应用需要做服务端渲染(SSR),你写的 persist 代码会有问题吗?如何解决?

参考回答思路:
会有问题。

  • 原因persist 默认使用浏览器的 localStorage。在服务端(Node.js)环境中,没有 window 对象,也没有 localStorage。如果在 SSR 首屏渲染时直接执行这段代码,服务端会报错 ReferenceError: window is not defined

  • 解决方案

    1. 动态导入:在 SSR 环境下,延迟加载包含 persist 的 Store,或者在 Store 初始化时检测环境。

    2. 自定义 Storage:给 persist 传入一个自定义的 storage 配置。在服务端使用内存存储或 cookie,在客户端使用 localStorage。例如:

      const customStorage = {
        getItem: (name) => {
          // 服务端逻辑:从 cookie 读取
          // 客户端逻辑:从 localStorage 读取
        },
        setItem: (name, value) => {
          // 客户端写入 localStorage
        }
      }
      

🌟 结语

编程不仅仅是写代码,更是逻辑的构建与错误的规避

通过这篇博客,我希望你不仅学会了如何使用 Zustand 和 TypeScript,更重要的是理解了 “状态是唯一的真相源(Source of Truth)” 这一理念。

记住:优秀的代码不是写出来的,是重构出来的。 每一次对“易错点”的规避,都是你编程内功的一次提升。

祝你在前端开发的道路上,代码无 Bug,人生无坑!