Lesson 10:持久化 + 主题 — Zustand 中间件与状态架构设计
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 2(进阶篇)
- 推荐时长:75~120 分钟(首次学习)
- 先修要求:完成 Lesson 09,已有共享项目 store 和两个订阅视图
- 学习产出:持久化项目与主题偏好,配置Tailwind4手动暗色模式
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:利用 Zustand 中间件实现项目数据持久化存储和页面暗色主题切换。
📦 本节产出:带有本地化存储功能(刷新不丢数据)的项目看板,以及一个全局的主题切换开关。
一、什么是 Zustand 中间件 (Middleware)?
在 Redux 或 Zustand 中,状态的改变流是:触发 Action -> 修改 State -> 更新 UI。 Zustand 中间件包装 store 的创建逻辑,可增强 set、持久化或调试能力;它与 Redux 的 dispatch 中间件并非同一套接口。
Zustand 自带了多个非常实用的原生中间件:
persist:自动将 Store 数据保存到localStorage/sessionStorage。devtools:将状态变化同步给 Redux DevTools 浏览器插件。immer:支持用可变的方式写不可变数据(稍后会讲)。
二、使用 persist 持久化项目数据
在 Lesson 05 中,我们使用了 useEffect 手动监听 state 变化并 JSON.stringify 写入 localStorage。 使用 Zustand 的 persist 可以集中配置存储位置和需要保存的字段。
改造 useProjectStore.ts
// src/store/useProjectStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { useShallow } from 'zustand/react/shallow'
export interface Task {
id: string
title: string
status: 'todo' | 'in-progress' | 'done'
}
export interface Project {
id: string
name: string
icon: string
tasks: Task[]
}
interface ProjectState {
projects: Project[]
addProject: (name: string, icon: string) => void
deleteProject: (id: string) => void
}
// 🐻 用 persist 中间件包裹原有的 store 配置
const useProjectStore = create<ProjectState>()(
persist(
(set) => ({
projects: [
{ id: 'app-rebuild', name: 'App 重构计划', icon: '📱', tasks: [
{ id: 't1', title: '分析竞品', status: 'done' },
{ id: 't2', title: '画原型图', status: 'in-progress' },
]},
{ id: 'marketing-q3', name: 'Q3 营销', icon: '🎯', tasks: [] },
],
addProject: (name, icon) => set((state) => ({
projects: [...state.projects, { id: crypto.randomUUID(), name: name.trim(), icon, tasks: [] }]
})),
deleteProject: (id) => set((state) => ({
projects: state.projects.filter(p => p.id !== id)
}))
}),
{
name: 'task-manager-projects', // 存储在 localStorage 的 key 名称(必需)
storage: createJSONStorage(() => localStorage), // 默认就是 localStorage,可省略
partialize: (state) => ({ projects: state.projects }) // 可选:只持久化哪些字段
}
)
)
export default useProjectStore
export const useProjectList = () => useProjectStore(state => state.projects)
export const useProjectById = (id: string | undefined) =>
useProjectStore(state => state.projects.find(project => project.id === id))
export const useProjectActions = () => useProjectStore(useShallow(state => ({
addProject: state.addProject,
deleteProject: state.deleteProject,
})))现在,试着在浏览器中添加或删除一个项目,然后刷新页面。 persist 恢复的是 partialize 选中的 projects,方法仍来自当前 store 定义。这里使用同步 localStorage;浏览器清理、禁用存储或配额不足时不能保证保存成功,它也不会自动同步其他标签页。
三、实战:全局主题切换 Store
除了业务数据(项目、任务),我们还需要保存 UI 偏好状态,比如当前是“日间模式”还是“暗黑模式”。
这种状态非常适合单独建立一个 Store。
3.1 创建 useThemeStore.ts
// src/store/useThemeStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type Theme = 'light' | 'dark'
interface ThemeState {
theme: Theme
toggleTheme: () => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
}))
}),
{
name: 'app-theme', // 将主题配置单独持久化
partialize: state => ({ theme: state.theme }),
}
)
)3.2 同步状态到 <html> 标签
Tailwind v4 默认根据系统的 prefers-color-scheme 切换。要让 .dark 类控制主题,先在 src/index.css 中定义变体(保留其他样式):
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));然后在状态变化时更新 <html> 的 class。参见 Tailwind 暗色模式。
这是一个副作用,应该写在哪里? 最合适的地方是在应用的顶层包裹组件,比如 RootLayout.tsx。
// src/layouts/RootLayout.tsx(替换整个组件)
import { useEffect } from 'react'
import { NavLink, Outlet } from 'react-router'
import { useThemeStore } from '../store/useThemeStore'
export default function RootLayout() {
const theme = useThemeStore(state => state.theme)
const toggleTheme = useThemeStore(state => state.toggleTheme)
// 监听 theme 的变化,并修改真实的 DOM HTML class
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}, [theme])
return (
// 使用 Tailwind 的 dark: 前缀支持暗色模式
<div className="h-dvh flex flex-col bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
<header className="h-14 bg-indigo-600 dark:bg-indigo-900 px-6 flex items-center justify-between shadow-md shrink-0 transition-colors">
<nav className="flex gap-4 text-white">
<NavLink to="/">首页看板</NavLink>
<NavLink to="/projects">我的项目</NavLink>
<NavLink to="/settings">设置</NavLink>
</nav>
{/* 增加一个切换按钮 */}
<button
onClick={toggleTheme}
className="text-white bg-white/10 hover:bg-white/20 px-3 py-1.5 rounded-full text-sm font-medium backdrop-blur-sm"
>
{theme === 'light' ? '🌙 暗色' : '☀️ 亮色'}
</button>
</header>
<main className="min-h-0 flex-1 overflow-auto text-gray-900 dark:text-gray-100">
<Outlet />
</main>
</div>
)
}现在点击右上角的按钮,你的整个应用(只要写了 dark: 规则的地方)都会平滑过渡到暗色模式,并在本地缓存仍有效时恢复你的偏好。
TIP
结合 Tailwind v4 暗色模式: 在你的 css 或者通过 utility 类: bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100。 尽量使用这套统一范式:亮色背景配暗字,暗色背景配亮字。
四、🧠 深度专题:大型项目的状态架构设计
现在我们有了 useProjectStore 和 useThemeStore,那未来如果在电商项目里,是不是还会有 useUserStore,useCartStore...?
4.1 拆分还是合并?
本课把业务数据与主题偏好放在两个 store 中,便于分别管理持久化。单一 store 也可以用 slice 按领域组织,Redux Toolkit 就支持这种方式。
- 代码加载:是否分包取决于 import 和动态导入边界;拆成多个 store 不会自动带来代码分割。
- 订阅范围:多个 store 可以减少交叉订阅;一个 store 配合精确 selector 也能达到类似效果。
- 持久策略:主题适合本地保存,远端业务数据还需要缓存失效与同步机制,下一课会引入 TanStack Query。
4.2 Zustand 的 Immer 中间件 (拯救复杂嵌套)
在 Lesson 03 中我们强调了:React 更新状态必须是不可变更新。
如果你要在 Zustand 里更新一个"项目数组中的某一个项目里的某一个任务的状态":
// 更新方法片段:用 map 保留未变化的引用
updateTask: (projectId, taskId, status) => set((state) => ({
projects: state.projects.map(p =>
p.id === projectId
? {
...p,
tasks: p.tasks.map(t =>
t.id === taskId ? { ...t, status } : t
)
}
: p
)
}))Zustand 官方提供了 immer 中间件解决方案。Immer 的核心魔法是:你可以假装在使用 "可变操作" (push, = 赋值等),它在底层会自动帮你转换成不可变安全更新(通过 Proxy 代理拦截)。
npm install immer以下是独立的 Immer 示例,不替换上面的持久化 store。若合并进主线,需要保留原来的方法、selector 和 persist 配置。
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import type { Project, Task } from './useProjectStore'
type TaskExampleState = {
projects: Project[]
updateTask: (projectId: string, taskId: string, status: Task['status']) => void
}
export const useTaskExampleStore = create<TaskExampleState>()(
immer(set => ({
projects: [{ id: 'example', name: '示例', icon: '📋', tasks: [
{ id: 'task-1', title: '验证更新', status: 'todo' },
] }],
updateTask: (projectId, taskId, status) => set(state => {
const task = state.projects.find(p => p.id === projectId)
?.tasks.find(t => t.id === taskId)
if (task) task.status = status // 这里的 state 是 Immer draft
}),
}))
)注意! 只有在使用 Immer 的包裹下才能这么直接赋值!
五、练习
- 为
ProjectsLayout.tsx的侧边栏添加适配暗色模式的类名(如dark:bg-gray-900 dark:border-gray-800 dark:text-gray-300等)。 - 在
Board.tsx中也适配暗色的深灰色背景。 - (进阶挑战)尝试通过
window.matchMedia('(prefers-color-scheme: dark)')API 读取操作系统的偏好设置,实现真正的跟随系统! - (进阶挑战)使用
Zustand结合Immer,在Board.tsx里完成增加具体看版任务卡片(Task)的功能。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
使用 persist 中间件实现存储 | Zustand 中间件的概念 |
建立了全局 useThemeStore | 按业务领域拆解 Store 的架构理念 |
| 将副作用和 HTML Class 进行绑定 | 针对全站的 Tailwind Dark Mode 应用方式 |
| — | 极为强大的、拯救复杂深层对象嵌套的 Immer 库 |
六、常见坑:持久化版本升级与主题闪烁
6.1 persist 版本升级导致旧数据污染
当 store 结构变更时,老用户本地缓存可能与新结构不兼容。建议启用 version + migrate:
将下面三个选项合并进 useThemeStore 现有的 persist 配置;不要创建同名第二个 store。示例假设旧版把 theme 命名为 mode,迁移函数同时检查本地数据类型:
name: 'app-theme',
version: 2,
migrate: (persistedState: unknown) => {
const old = persistedState && typeof persistedState === 'object'
? persistedState as Record<string, unknown>
: {}
const value = old.theme ?? old.mode
return { theme: value === 'dark' ? 'dark' as const : 'light' as const }
},migrate 在存储版本与当前版本不一致时运行;它不是所有读取路径的数据验证器。若还需防御同版本缓存损坏,可在 merge 中验证字段。参见 persist 官方用法。
6.2 主题闪烁(FOUC)
仅在 React 挂载后再设置 dark 类,会出现“先亮后暗”的闪烁。 建议在 HTML 首屏脚本或服务端输出阶段提前注入主题类名。
6.3 验收标准(L1/L2/L3)
- L1:刷新页面后主题保持一致。
- L2:完成一次 store 字段重命名并通过
migrate兼容旧缓存。 - L3:消除主题闪烁并给出前后对比说明(录屏或截图)。