从零打造 AI 智能表单 (番外):Zustand 数据持久化与 Hydration 适配

...
ReactNext.jsZustandOptimization

在之前的文章中,我们完成了 SmartForm 编辑器的核心拖拽功能。但在实际使用中发现了一个痛点:用户如果不小心刷新了页面,辛苦编辑的表单结构就会瞬间丢失。

为了解决这个问题,我们需要引入本地持久化 (Local Persistence) 机制。今天这篇番外主要记录如何利用 Zustand 的中间件快速实现这一功能,以及如何解决由此带来的 Next.js Hydration 问题。

1. 引入 Persist 中间件

Zustand 生态非常丰富,官方直接提供了 persist 中间件,可以轻松将 Store 的状态同步到 localStoragesessionStorageAsyncStorage 中。

修改 src/store/editorStore.ts

import { create } from "zustand"; import { persist } from "zustand/middleware"; // 1. 引入中间件 // ... EditorState 接口保持不变 ... export const useEditorStore = create<EditorState>()( persist( (set) => ({ blocks: [], selectedBlockId: null, // ... Actions ... addBlock: (type) => { /* ... */ }, updateBlock: (id, updates) => { /* ... */ }, }), { name: "smart-form-editor-storage", // 2. 指定 localStorage 的 key // 默认存储引擎就是 localStorage,所以不需要额外配置 } ) );

只需要这一层 wrapper,状态的保存和恢复就全自动完成了。刷新页面后,Zustand 会自动从 localStorage 读取数据并填充 Store。

2. 遇到的坑:Hydration Mismatch

在 Next.js (App Router) 中使用 persist 时,我遇到了经典的 "Text content does not match server-rendered HTML" 错误。

原因分析

  1. 服务端渲染 (SSR): Next.js 在服务端生成 HTML 时,不知道你的 localStorage 里有什么(localStorage 是浏览器独有的),所以服务端生成的 Editor 组件默认是空的(或者初始状态)。
  2. 客户端激活 (Hydration): 浏览器加载 JS 后,Zustand 立即从 localStorage 读取数据并更新状态。
  3. 冲突: React 发现服务端给的 HTML(空表单)和客户端第一次渲染的 Virtual DOM(有数据的表单)不一致,于是报错。

解决方案:延迟渲染

解决思路是:让组件在挂载 (Mount) 之后再显示依赖本地存储的内容。这样可以确保服务端渲染的和客户端初次渲染的(loading 状态)一致。

我在 Editor.tsx 中添加了一个 isMounted 检查:

export function Editor({ initialFormId }: EditorProps) { const { blocks, setBlocks } = useEditorStore(); const [isMounted, setIsMounted] = useState(false); // 1. 仅在客户端挂载后设置为 true useEffect(() => { setIsMounted(true); }, []); // ... 其他逻辑 ... // 2. 如果还没挂载,显示 Loading 或空占位 // 这确保了 SSR 和 CSR 的初始 HTML 结构一致 if (!isMounted) { return ( <div className="flex h-screen items-center justify-center bg-slate-50"> <Loader2 className="h-8 w-8 animate-spin text-indigo-600" /> </div> ); } return ( // ... 真正的编辑器 UI ... ); }

3. 优化初始化逻辑

除了技术实现,还需要考虑业务逻辑。当用户进入 /editor 页面(创建新表单)时,我们不应该总是清空数据,而是应该保留上次未保存的草稿。

但在原来的逻辑中,我们有一个 useEffect 可能会误伤:

useEffect(() => { if (initialFormId) { // 编辑模式:加载远程数据 fetchForm(initialFormId); } else { // 新建模式:原来这里会 setBlocks([]) 清空数据 // 优化后:我们删除了清空操作,只重置 ID setFormId(null); // 只有当完全没有数据时,才重置标题 if (blocks.length === 0) { setTitle("Untitled Form"); } } }, [initialFormId, setBlocks]);

这样,"新建表单" 实际上变成了 "继续编辑本地草稿",这是一个非常友好的体验提升。如果用户真的想重置,我们可以提供一个显式的 "清空画布" 按钮。

4. 总结

通过这次优化,我们:

  1. 利用 persist 中间件实现了零成本的数据持久化
  2. 通过 useHasMounted (isMounted) 模式解决了 Next.js Hydration Mismatch
  3. 优化了编辑器初始化逻辑,支持了断点续传式的编辑体验。

这些细节虽然不起眼,但对于生产力工具来说,是决定用户留存的关键因素。