教程 zustand tutorial

中间件:Immer 简化状态修改

Zustand 与 Immer 库无缝集成,提供了一个 immer 中间件,使状态修改更加直观和简单。Immer 允许你通过直接修改一个"草稿"状态来创建新的不可变状态,而不需要手动编写复杂的不可变更新逻辑。

什么是 Immer 中间件?

Immer 是一个用于简化不可变数据处理的 JavaScript 库。Zustand 的 immer 中间件允许你:

  • 直接修改状态:使用熟悉的 mutable(可变)语法修改状态
  • 自动生成不可变状态:Immer 会根据你的修改自动创建新的不可变状态
  • 减少样板代码:避免手动编写 spread 操作符(...)和复杂的状态更新逻辑
  • 提高代码可读性:使状态更新逻辑更加直观和易于理解

安装与基本使用

安装依赖

immer 中间件是 Zustand 的核心功能之一,不需要额外安装。

基本用法

javascript
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer' // 导入 immer 中间件

// 创建一个带有 immer 中间件的 Store
const useTodoStore = create(
  immer(
    (set, get) => ({
      todos: [
        { id: 1, text: '学习 Zustand', completed: false },
        { id: 2, text: '掌握 Immer 中间件', completed: false },
      ],
      
      // 使用 Immer 修改状态
      addTodo: (text) => set((state) => {
        state.todos.push({ id: Date.now(), text, completed: false })
      }),
      
      toggleTodo: (id) => set((state) => {
        const todo = state.todos.find((todo) => todo.id === id)
        if (todo) {
          todo.completed = !todo.completed
        }
      }),
      
      updateTodoText: (id, text) => set((state) => {
        const todo = state.todos.find((todo) => todo.id === id)
        if (todo) {
          todo.text = text
        }
      }),
      
      removeTodo: (id) => set((state) => {
        const index = state.todos.findIndex((todo) => todo.id === id)
        if (index !== -1) {
          state.todos.splice(index, 1)
        }
      }),
    })
  )
)

在组件中使用

jsx
function TodoList() {
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore()
  const [newTodoText, setNewTodoText] = useState('')

  const handleSubmit = (e) => {
    e.preventDefault()
    if (newTodoText.trim()) {
      addTodo(newTodoText)
      setNewTodoText('')
    }
  }

  return (
    <div>
      <h1>Todo List</h1>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={newTodoText}
          onChange={(e) => setNewTodoText(e.target.value)}
          placeholder="添加新任务"
        />
        <button type="submit">添加</button>
      </form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => removeTodo(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

Immer 与普通 Zustand 的对比

让我们看看使用 Immer 中间件与不使用 Immer 中间件的区别:

不使用 Immer 的状态更新

javascript
const useTodoStore = create((set, get) => ({
  todos: [],
  
  // 普通方式:需要手动编写不可变更新逻辑
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), text, completed: false }],
  })),
  
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ),
  })),
  
  removeTodo: (id) => set((state) => ({
    todos: state.todos.filter((todo) => todo.id !== id),
  })),
}))

使用 Immer 的状态更新

javascript
const useTodoStore = create(
  immer((set, get) => ({
    todos: [],
    
    // Immer 方式:直接修改草稿状态
    addTodo: (text) => set((state) => {
      state.todos.push({ id: Date.now(), text, completed: false })
    }),
    
    toggleTodo: (id) => set((state) => {
      const todo = state.todos.find((todo) => todo.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    }),
    
    removeTodo: (id) => set((state) => {
      const index = state.todos.findIndex((todo) => todo.id === id)
      if (index !== -1) {
        state.todos.splice(index, 1)
      }
    }),
  }))
)

可以看到,使用 Immer 中间件后,状态更新代码更加简洁和直观,不需要手动编写复杂的不可变更新逻辑。

Immer 的核心概念:Draft(草稿)

当使用 Immer 中间件时,set 函数接收的回调函数会被传递一个"草稿"状态(draft),而不是实际的状态。你可以直接修改这个草稿状态,Immer 会根据你的修改自动创建一个新的不可变状态。

javascript
// state 是一个 draft(草稿)状态,你可以直接修改它
set((state) => {
  state.counter += 1
  state.user.name = 'John'
  state.todos.push({ id: 1, text: 'Learn Immer', completed: false })
})

Immer 使用了一种称为"代理"(Proxy)的技术来跟踪对草稿状态的所有修改。当你完成对草稿状态的修改后,Immer 会根据这些修改创建一个全新的不可变状态对象。

高级用法

嵌套对象修改

Immer 特别适合处理嵌套对象的状态更新:

javascript
const useUserStore = create(
  immer((set, get) => ({
    user: {
      id: 1,
      name: 'John Doe',
      profile: {
        email: 'john@example.com',
        address: {
          city: 'New York',
          country: 'USA',
        },
      },
    },
    
    // 直接修改嵌套对象
    updateEmail: (email) => set((state) => {
      state.user.profile.email = email
    }),
    
    updateCity: (city) => set((state) => {
      state.user.profile.address.city = city
    }),
  }))
)

与其他中间件结合使用

Immer 中间件可以与其他 Zustand 中间件(如 persist)结合使用:

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

const useTodoStore = create(
  persist(
    immer((set, get) => ({
      todos: [],
      addTodo: (text) => set((state) => {
        state.todos.push({ id: Date.now(), text, completed: false })
      }),
      toggleTodo: (id) => set((state) => {
        const todo = state.todos.find((todo) => todo.id === id)
        if (todo) {
          todo.completed = !todo.completed
        }
      }),
    })),
    {
      name: 'todo-storage',
    }
  )
)

条件修改

你可以在 Immer 回调中使用条件语句来控制状态修改:

javascript
const useCounterStore = create(
  immer((set, get) => ({
    count: 0,
    
    increment: () => set((state) => {
      state.count += 1
    }),
    
    incrementIfOdd: () => set((state) => {
      if (state.count % 2 !== 0) {
        state.count += 1
      }
    }),
    
    incrementIfBelow: (max) => set((state) => {
      if (state.count < max) {
        state.count += 1
      }
    }),
  }))
)

Immer 的性能考虑

Immer 虽然提供了便捷的状态修改方式,但也需要注意性能:

优势

  • 减少不必要的重新渲染:Immer 会智能地比较草稿状态和原始状态,只在实际发生变化时创建新对象
  • 高效的代理实现:Immer 的代理实现非常高效,对于大多数应用来说性能影响可以忽略不计

注意事项

  • 避免过度修改:虽然 Immer 很高效,但过度频繁地修改大型状态对象可能会影响性能
  • 合理使用选择器:结合 Zustand 的选择器功能,只订阅需要的状态部分
  • 考虑使用生产构建:在生产环境中使用 Immer 的生产构建版本,以获得更好的性能

最佳实践总结

  1. 优先使用 Immer 处理复杂状态:对于包含嵌套对象或数组的复杂状态,Immer 可以显著提高代码可读性和可维护性
  2. 保持状态更新的原子性:每个 set 调用应该代表一个完整的状态更新操作
  3. 避免在 Immer 回调中执行副作用:副作用(如 API 请求)应该在 Immer 回调之外处理
  4. 结合 TypeScript 使用:Immer 与 TypeScript 配合使用效果极佳,可以提供完整的类型安全
  5. 与其他中间件结合使用:根据需求,将 Immer 与 persist、devtools 等中间件结合使用

常见问题与解决方案

1. 忘记导入 immer 中间件

问题:直接使用 state.todos.push() 等可变语法,但没有导入和使用 immer 中间件

解决方案:确保导入并使用 immer 中间件:

javascript
import { immer } from 'zustand/middleware/immer'

const useStore = create(immer((set, get) => ({
  // Store 定义
})))

2. 在 Immer 回调外修改状态

问题:尝试在 set 回调外直接修改状态

解决方案:所有状态修改都必须在 set 回调内部进行:

javascript
// 错误:在 set 回调外修改状态
const todo = useTodoStore.getState().todos[0]
todo.completed = true

// 正确:在 set 回调内修改状态
useTodoStore.setState((state) => {
  state.todos[0].completed = true
})

3. 期望 Immer 处理异步操作

问题:尝试在异步函数中直接修改状态

解决方案:异步操作应该先获取数据,然后在 set 回调中使用 Immer 修改状态:

javascript
const useTodoStore = create(
  immer((set, get) => ({
    todos: [],
    isLoading: false,
    
    fetchTodos: async () => {
      set((state) => { state.isLoading = true })
      try {
        const response = await fetch('https://api.example.com/todos')
        const todos = await response.json()
        set((state) => {
          state.todos = todos
          state.isLoading = false
        })
      } catch (error) {
        set((state) => { state.isLoading = false })
        console.error('Failed to fetch todos:', error)
      }
    },
  }))
)

在接下来的章节中,我们将学习 调试神器:Redux DevTools,了解如何使用 Redux DevTools 来调试 Zustand Store。