写这个功能之前,我先把结论摆在前面:撤销/重做这件事,绝大多数人第一反应是自己维护一个数组栈,push 当前值、pop 出来赋值,看起来三十行代码就能搞定。我给一个对话式前端项目(内部代号 Airi)接消息编辑功能时也是这么想的,结果第二周就被三个需求同时打回来——用户拖拽调整一条消息的顺序,中途每一步都被记进了历史;流式输出把栈瞬间撑爆;撤销之后列表渲染错位。后来我把手写栈整个拆掉,换成 VueUse 提供的useRefHistory,用它对一个ref状态做自动快照,才把这些边角问题一条条收干净。这篇就把这套方案完整摊开讲:它到底解决了什么、参数怎么配、时序上容易掉进哪个坑、以及在真实的消息流场景里怎么落地。如果你手上也有"用户编辑了一段结构化数据,希望能反悔"这类需求,不管技术栈细节如何,思路是通用的。
1. 撤销/重做真正难的地方,不在那个数组栈
1.1 手写一版最朴素的实现,以及它第二天就崩掉的原因
先说清楚手写版本长什么样,因为只有对照过,才知道为什么要换。最朴素的实现大概是这样:一个ref存当前值,一个普通数组undoStack,一个redoStack,每次值变化就undoStack.push(JSON.parse(JSON.stringify(current))),撤销时从栈顶弹一个出来赋回去,同时把当前值推进redoStack。这段代码在 demo 里跑得非常好,因为 demo 里的状态是一个数字、一个字符串,或者一个只有两三个字段的小对象。
崩掉的第一个信号来自状态形状的变化。Airi 里的消息对象有 id、角色、内容块数组、时间戳、状态标记,其中内容块还嵌套着附件引用。一旦状态从"扁平标量"变成"带嵌套引用的树",上面这段代码就有两个隐患同时爆发:一是深层修改不会触发快照(你得自己写深比较),二是JSON序列化会把Date、undefined、函数、循环引用全部搞坏。
第二个信号来自"谁来触发快照"。手写版本通常是在某个业务函数末尾手动调用pushHistory(),这意味着快照点散落在十几个地方,任何一处漏调,那条路径就没有历史。而且业务代码和数据历史强耦合,改一个字段要记得顺手补一行快照,这种约定在多人协作里基本撑不过一个迭代。
useRefHistory的价值就在这里:它把这个"监听-比较-快照-裁剪"的循环封装成一个组合式函数,你只需要给它一个ref,它负责在合适的时机往里塞记录,业务代码完全不用关心历史是怎么存的。这不是省几十行代码的问题,是把"历史"从业务逻辑里解耦出来的问题。
1.2 三个隐蔽的破坏点:引用共享、异步写入、批量变更
就算你坚持手写,也必须处理下面三件事,而这三件事恰好是新手最容易全部漏掉的。
引用共享。这是最阴的一个。如果你把对象直接push进历史数组,那么历史里存的不是"那一刻的快照",而是"那个对象的引用"。后续任何就地修改(比如msg.content.push(block))都会同时改掉历史里的记录,于是撤销时你拿到的是"被污染后的旧值",表现出来就是"点了撤销,界面纹丝不动"。修法只有两条:深拷贝,或者序列化后再反序列化。useRefHistory把这件事做成了开关,就是后文要重点讲的clone和dump/parse。
异步写入。Airi 的流式回复是分片追加的,一个 token 一个 token 地往同一个ref里拼。如果你的监听是同步的,那么"你好,我是助手"这八个字会产生八条历史记录,用户的撤销按钮点一次只回退一个字,体验极差。正确做法是把一段连续写入合并成一条记录,或者干脆在流式期间暂停追踪。
批量变更。有些操作天然是"一次语义、多次赋值",比如批量删除选中的三条消息、整体导入一份配置。如果监听器逐次触发,用户要连点三次撤销才能回到操作前,这不符合直觉——用户心里那一次操作,应该对应一次撤销。
这三个点在真实项目里往往同时出现。我在 Airi 里就遇到过"拖拽排序 + 流式输出 + 批量删除"三件事叠在一起的那一周,undoStack.length能从 0 飙到一百多,canUndo永远是true,用户按 Ctrl+Z 有一种"按了但没完全按"的迷茫感。
1.3 在对话式应用里,哪些 ref 配得上一条历史链
不是所有状态都该挂历史。判断标准其实就一个问题:这个状态被错误修改后,用户能不能靠"重新做一遍"低成本恢复?
能给肯定答案的,就别挂。比如"当前选中的会话 id"、"侧边栏展开状态"、"主题色"——选错了再点一次就行,挂历史反而是噪音,用户按撤销期望回退的是内容,结果回退的是"侧边栏开了又关"。
需要挂的是那些"重建成本高"的状态。在 Airi 里我最终只给三个ref配了历史:消息列表的结构与文本(核心)、角色卡配置(用户可能调了二十分钟参数)、提示词模板(长文本编辑)。其余一律不挂。
还有一个容易被忽略的维度:历史边界要跟用户的"操作单元"对齐。用户理解的"一步"是"我改了这一条消息",而不是"我按了三十次键"。所以给消息文本挂历史时,必须用防抖版本或者手动 commit,否则历史的粒度和用户心智模型完全错位。这个取舍在第三节会展开。
2. useRefHistory 的返回值和参数,逐项拆开看
2.1 一张参数表,以及每个参数不填会发生什么
先看最基本的一行调用:
import { ref } from 'vue' import { useRefHistory } from '@vueuse/core' const messages = ref<Message[]>([]) const { history, undoStack, redoStack, undo, redo, canUndo, canRedo, clear, commit, pause, resume, isTracking, batch, } = useRefHistory(messages, { deep: true, clone: true, capacity: 30, flush: 'pre', })返回值里需要分清楚两组:history是完整时间线(撤销栈和重做栈拼起来的结果,通常用来渲染"历史记录"面板),undoStack/redoStack是两段分开的栈。每个记录长这样:
interface RefHistoryRecord<T> { value: T timestamp: number }timestamp是个容易被浪费的字段,但它能做出很有价值的东西——比如撤销提示条上显示"3 秒前"、或者在历史面板里按时间分组。
参数表我按"不填会怎样"的角度列,这样更容易判断该不该配:
| 参数 | 默认值 | 不填的后果 | 建议 |
|---|---|---|---|
deep | false | 对象内部字段变化不触发快照,改了等于没改 | 状态是对象/数组就设true |
clone | false | 历史记录持有原引用,后续就地修改会污染快照 | 状态是对象/数组就设true |
capacity | 不限 | 栈无限增长,长时间使用内存持续上涨 | 按操作粒度设 20~50 |
flush | 'pre' | 组件更新前记录,一般够用,但同步写入场景会有偏差 | 高频场景看第 3 节 |
dump | 无 | 不做序列化,深拷贝能力强但体积大 | 状态能 JSON 化就用它换内存 |
parse | 无 | 同上 | 与dump成对出现 |
history | 内部新建 | — | 需要跨组件共享历史时传入外部 ref |
deepRef | 无 | — | 需要动态开关深度监听时用 |
表格里最需要强调的是前两行。deep和clone这两个开关,只开一个都不够,必须成对开,下一节专门说。
2.2 deep 与 clone 的搭配:这两个开关必须一起谈
很多人第一次用会掉进这个坑:设了deep: true,发现历史能记录深层变化了,但撤销时界面不动。原因是clone还是false。
理解这件事要先想清楚 VueUse 内部在干什么。它用watch(source, callback, { deep })监听源ref。deep: true解决的是"能不能感知到变化",但感知到之后,它要把当前的source.value存进栈里。如果clone: false,存进去的就是同一个对象的引用。而在 Vue 的响应式世界里,你对一个对象数组的常见操作往往是就地修改:
// 这种写法在不 clone 的情况下,会污染历史快照 messages.value[0].content = '新的内容'这行代码改的是引用指向的那个对象本身。历史栈里保存的"旧值"和"当前值"指向同一个对象,所以撤销时把旧值赋回去,赋回去的还是那个已经被改过的对象,视觉上完全没变化。
修法有两个,选一个就行:
// 方案 A:clone: true,让 VueUse 做深拷贝 useRefHistory(messages, { deep: true, clone: true }) // 方案 B:dump / parse,用序列化做拷贝 useRefHistory(messages, { deep: true, dump: (v) => JSON.stringify(v), parse: (v) => JSON.parse(v), })两者差别在于:clone: true走的是通用的深拷贝,能保留Date、Map、Set这些类型,但每次快照都完整复制一遍,状态大时开销明显;dump/parse走 JSON 序列化,体积小、速度快,但会丢掉undefined、函数、Date会变成字符串。Airi 的消息结构里带Date类型的时间戳,我一开始用了dump/parse,撤销后发现时间戳变成字符串,格式化函数直接报错。最后改成clone: true,因为消息数组最多也就几十条,拷贝开销可以接受。
注意:如果你选
dump/parse,务必确认状态里没有Date、Map、Set、undefined值,或者自己写一对能处理这些类型的dump/parse。
还有一个反向建议:如果你的状态真的很大(比如上千条消息),别指望clone硬扛。这时候更该做的是缩小历史边界——只给"当前正在编辑的那条消息"挂历史,而不是整个消息数组。这一点在第 4 节会讲。
2.3 capacity 到底设多少:一次真实的内存与体验权衡
capacity是历史栈的容量上限,超出后最旧的记录被丢弃。默认不限制,这在 demo 里没感觉,在真实项目里就是慢性内存泄漏——用户开着页面聊两小时,历史栈里躺着几千份消息数组的深拷贝。
设多少合适?我给 Airi 定的是 30,理由不是"30 这个数字吉利",而是照着用户的实际行为推出来的:一次编辑会话里,用户连续撤销超过 20 步的概率极低,超过 10 步就已经开始烦躁了。给 30 步的余量,既能覆盖"我改了一大段想从头回退"的场景,又不会让内存失控。
具体量级可以粗算:假设单条消息平均 500 字节,一次快照整个数组 30 条消息,约 15KB;capacity: 30意味着最多 450KB 的历史占用。这个量级对一个前端应用来说完全可以接受。但如果你把状态换成"整份角色卡配置(含头像 base64)",单次快照可能就 200KB,capacity: 30直接变成 6MB,这时候就该降到 10 甚至 5。
提示:
capacity设成0会直接关闭记录,这可以当作一个运行时开关来用——比如在"只读模式"下把容量置零,就不用额外维护pause状态。
另外补充一点使用体验上的细节:capacity会同时约束undoStack和redoStack。有些实现只限制撤销栈,导致用户撤销了一百次之后,重做栈里堆了一百条,内存反而更大。VueUse 在这点上处理得比较一致,不用额外操心。
3. 时序才是这个组合式函数的真正门槛
3.1 flush 三档的差别,用一段输入框场景说清楚
flush参数控制"什么时候把变化写进历史栈",可选'pre'、'post'、'sync',默认'pre'。这三个值不是玄学,理解它们的关键是想清楚"一次更新里,历史记录和 DOM 更新的先后顺序"。
'pre'是在组件重新渲染之前记录。绝大多数场景这就是最优解,因为你在历史里存的是"即将生效的新值",撤销时把旧值赋回去,Vue 会在下一轮渲染里更新 DOM,看起来就是一次干净的跳变。
'post'是在渲染之后记录。什么时候需要它?当你的状态依赖于 DOM 测量结果时——比如一个自动调整高度的文本域,高度值是在渲染后算出来的,用'pre'记录的可能是上一帧的旧高度。
'sync'是同步记录,每次赋值立刻写栈。这个选项危险但有用:如果你的状态更新不经过 Vue 的调度(比如在某些异步回调里),'pre'可能合并掉中间的若干次变化,只有'sync'能一条不漏。但代价是每次赋值都触发一次深比较 + 深拷贝,高频场景下会明显卡顿。
我在 Airi 里的实际取值是:消息数组用默认'pre',因为编辑操作都是用户交互触发的,走 Vue 的调度没问题;而那个自动测高的编辑框用了'post'。'sync'一次没用到——遇到需要同步的场景,我宁愿换成useManualRefHistory手动控制,控制感更强。
3.2 pause / resume / commit:把连续手势收成一条记录
这是我认为整个 API 里最被低估的一组方法。pause()暂停记录,resume()恢复,commit()手动打一个快照点。
它们解决的正是第 1 节提到的"批量变更"问题。典型场景是拖拽排序:用户按住一条消息往上拖,拖动过程中列表顺序在持续变化,如果全程记录,历史栈里会塞进几十条中间态,撤销时用户要点几十次。正确做法是:
const { pause, resume, commit } = useRefHistory(messages, { deep: true, clone: true, capacity: 30, }) function onDragStart() { pause() // 拖动开始,停止自动记录 } function onDragEnd() { commit() // 先把最终结果作为一条记录提交 resume() // 再恢复自动追踪 }注意commit()和resume()的顺序。commit()是把当前值立刻压进栈,resume()是恢复监听。如果你先resume(),由于 Vue 的调度时机,可能会多记一条重复记录,表现为"撤销一次没变化,要撤销两次才动"。这个顺序问题我在两个项目里都见人写反过,属于非常隐蔽的一类 bug——因为它只在某些调度时序下复现。
同样的模式适用于:滑块调色(拖动中不记,松手记一次)、批量导入(导入前 pause,导入完 commit)、连续删除(收集到一次操作里)。判断标准很清晰:这个操作对用户来说是"一个动作"还是"一串动作",前者用 commit 收口,后者才让它逐条记录。
3.3 batch 与流式写入:为什么历史栈会被瞬间填满
batch是另一个收口工具,用法是把多次赋值包进一个函数里,最后合并成一条记录:
const { batch } = useRefHistory(messages, { deep: true, clone: true }) function deleteSelected(ids: string[]) { batch(() => { for (const id of ids) { const index = messages.value.findIndex(m => m.id === id) if (index > -1) messages.value.splice(index, 1) } }) }这里如果不用batch,删三条消息就会产生三条历史记录,用户撤销一次只回来一条,体验上和"批量删除"这个语义完全不匹配。
流式写入是另一个极端。Airi 的助手回复是分片追加的,如果我们对"消息数组"用了deep: true并全程追踪,那么每追加一个分片就会产生一条完整快照。这有两个后果:一是栈瞬间填满(capacity: 30几秒就满),二是每次快照都要深拷贝整个数组,性能直线下降。
我的处理方式是:流式期间对这个 ref 完全 pause,等整个回复结束再一次性commit()。
async function streamReply(messageId: string) { pause() const target = messages.value.find(m => m.id === messageId) try { for await (const chunk of streamFromModel()) { target.content += chunk } } finally { commit() resume() } }try/finally这里不能省。流式过程中如果用户中断、或者接口报错,resume()就永远不会被调用,之后所有编辑都不再记录历史——这个 bug 表现得极其诡异:功能"昨天还好好的,今天突然不能撤销了",排查起来要从头捋所有pause的配对情况。
提示:
pause/resume一定要成对出现,且放在try / finally里。多于一次pause需要对应次数的resume才会真正恢复(内部用计数实现),这也是一个常见困惑点。
4. 把它接进 Airi 的消息编辑链路
4.1 状态结构:先决定历史边界,再写代码
落地之前有一件事必须先想清楚:历史挂在哪个 ref 上?这决定了后面所有的复杂度。
Airi 里最初我把历史挂在整棵会话树上——一个ref里装着会话、消息列表、分支信息。结果发现两个问题:一是每次快照都很大,性能吃紧;二是历史粒度和用户心智不对齐,用户在编辑一条消息时按撤销,期望回退的是那条消息,而不是"三分钟前切换了分支"。
后来我拆成了两层:
// 会话级状态:不挂历史 const session = ref<Session>({ id: '', title: '', branches: [] }) // 消息列表:挂历史,是编辑操作的主战场 const messages = ref<Message[]>([]) const history = useRefHistory(messages, { deep: true, clone: true, capacity: 30, flush: 'pre', })这里的关键取舍是:把"会被编辑的状态"和"结构性状态"分开。分支切换、会话标题这类信息,用户不需要撤销;而消息内容、消息顺序、消息删除,这些是必须能反悔的。分开之后,历史栈只关心它该关心的事,快照体积也可控。
如果你的项目里状态耦合得比较紧、拆不开,退一步的方案是给历史挂一个"投影"——用一个computed取出需要历史的子集,再用useRefHistory监听它。不过要注意,computed是只读的,撤销时得自己把值写回去,这条路我不太推荐,能拆分就拆分。
4.2 快捷键、按钮态与 canUndo / canRedo 的联动
canUndo和canRedo是响应式的布尔值,这是它比手写栈舒服的地方——你不用自己维护"栈是否为空"的判断,直接绑到按钮的disabled上就行:
<template> <button :disabled="!canUndo" title="撤销 (Ctrl+Z)" @click="undo"> 撤销 </button> <button :disabled="!canRedo" title="重做 (Ctrl+Shift+Z)" @click="redo"> 重做 </button> </template>快捷键这部分有一个必须处理的细节:输入框内的按键不能无条件拦截。用户在一个<textarea>里打字,Ctrl+Z 应该交给浏览器原生的文本撤销,而不是触发全局的列表撤销。处理方式是判断当前焦点元素:
function onKeydown(e: KeyboardEvent) { const isUndo = (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z' const isRedo = (e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z' if (!isUndo && !isRedo) return const el = document.activeElement as HTMLElement | null const isEditable = el?.matches('input, textarea, [contenteditable="true"]') if (isEditable) return // 让浏览器自己处理 e.preventDefault() isUndo ? undo() : redo() }[contenteditable="true"]这一项不能漏。现在很多富文本编辑器用的是contenteditable,它不是input也不是textarea,只判断标签名会漏掉。
另一个小细节:isTracking可以用来做一个视觉提示,比如在流式输出期间把撤销按钮置灰并加个 tooltip 说明"生成中不可撤销",比让用户按了没反应要友好得多。
4.3 撤销之后,远端数据不同步怎么办
这是把前端历史接到真实业务时绕不开的问题:本地撤销了,远端的消息还是旧的。Airi 是本地优先的架构,消息存在本地库,所以这个问题相对简单——撤销后触发一次持久化即可。
watch(messages, () => { persistToLocal(messages.value) }, { deep: true })但如果你的数据需要同步到服务端,情况就复杂了:撤销产生的"回退"在服务端看来可能是一次合法的更新,也可能是冲突。我见过两种处理方式,各有适用场景。
第一种是撤销只影响本地,提交时整体覆盖。用户撤销后不立即同步,等到用户点了保存或者发下一条消息时,把当前完整状态整体推上去。这种方式实现简单,适合编辑态和提交态本身就分离的场景。
第二种是每次撤销都发一次更新请求。适合协作场景,但需要服务端支持乐观更新和冲突解决,成本高得多。
我选的是第一种,因为消息编辑本来就是一个"改完才算数"的操作,中途回退没必要同步给远端。这里的关键不是技术选型,而是先想清楚:撤销对于你的业务来说,是一个 UI 状态还是一个数据变更。想清楚了,同步策略自然就有了。
5. 我在这个需求上踩过的坑,以及每个坑的修法
5.1 clone 没开,撤销看着像没生效
现象:点撤销按钮,按钮状态正常变化(canUndo从 true 变 false),但界面内容一动不动。
排查链路是这样的。先确认undo()被调用了——加日志,确认调到了。再看undoStack里的value是什么——打印出来发现,栈里存的值和当前值的内容一模一样,连后来改的内容都在里面。到这一步原因就明确了:栈里存的是引用,不是快照。
修法就是加clone: true。但这个坑值得单独拿出来说的原因是:它的表现和"撤销功能没实现"极其相似,很容易让人往错误的方向排查(去查事件绑定、查按钮状态、查 watch 是否生效)。判断依据其实很简单——如果canUndo能正确变化,说明监听和栈都在工作,问题一定出在快照本身。
5.2 撤销之后列表 key 冲突,DOM 内容错位
这个坑比上一个隐蔽得多。现象是:删掉一条消息,再撤销,消息回来了,但渲染的文本串到了别的行上。
原因是 Vue 的列表渲染用:key,而我用的是一个自增的递增 id。删除时分配了 id=5,撤销后历史里恢复的那条消息 id 也是 5,但列表里另一条后来新建的消息也拿到了 5(因为计数器没有跟着回滚)。两个同 key 的节点,Vue 的 diff 就会做出莫名其妙的事。
修法有两条,我选了第二条:
// 方案 A:让计数器也进入历史(麻烦,且容易漏) // 方案 B:不用自增 id,用不受回滚影响的唯一标识 function createMessage(): Message { return { id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, content: '', } }生成式 id 不受历史回滚影响,天然免疫这个问题。如果你的项目必须用自增 id,那就得把计数器也做成ref并纳入历史范围,但那样又会引入"id 重复使用"的其他问题,不划算。
提示:任何"外部计数器 + 历史快照"的组合都有这个隐患。凡是会进入历史的状态,它的标识符要么是生成式的,要么是随状态一起被快照的,不能是独立递增的。
5.3 刷新页面后历史归零
这个严格说不算useRefHistory的坑,而是预期管理问题。历史栈存在内存里,刷新即失,这是设计使然,不是 bug。
我最初的错误预期是"用户刷新后还能撤销",还为此试着把undoStack序列化进localStorage。试下来发现成本远大于收益:历史记录里是完整的状态快照,几十条加起来可能好几 MB,写入localStorage会卡;而且恢复之后时间戳、引用关系全都对不上,很容易恢复出一个半残的状态。
最后的处理是把预期交给用户:在撤销按钮旁边加一个轻量提示,或者在编辑态里用"未保存"的视觉标记,让用户知道"刷新会丢掉编辑历史"。这比硬做持久化要踏实。
如果你确实需要跨会话的历史,那更合适的思路不是存快照栈,而是存操作日志(见 6.3 的命令模式),日志体积小得多,也好做增量同步。
5.4 undo 到初始态时的边界判断
undoStack里存的其实是"每次变化后的值",初始值在栈底。当你一路撤销到栈只剩一条,再点撤销,canUndo会变成false,按钮自动禁用,这部分 VueUse 处理得没问题。
真正需要我自己处理的是另一件事:空状态下的语义。消息列表被撤到空数组时,界面应该显示什么?我最初的实现是显示一个空列表,看起来很怪。后来加了一层判断:messages.value.length === 0时渲染引导页("开始一段新对话"),而撤销到空态的路径也走这个分支,视觉上就是"退回到刚开始的样子",符合直觉。
这个细节提醒我一件事:撤销功能不是孤立的技术模块,它会把你的所有边界状态都走一遍,包括那些正常操作永远到不了的状态。接历史之前最好先把空态、单元素态、超长态都跑一遍,不然撤销会帮你把它们全暴露出来。
6. useRefHistory 不够用的三种情况
6.1 高频写入:换防抖/节流版本
如果你的状态是"跟着输入框实时变"的,直接用useRefHistory会产生海量记录。VueUse 提供了两个包装版本,本质是给记录动作加了时间窗口:
import { useDebouncedRefHistory, useThrottledRefHistory } from '@vueuse/core' // 停止输入 500ms 后才记录一次 const history = useDebouncedRefHistory(draft, { deep: true, clone: true, debounce: 500 }) // 每 800ms 最多记录一次 const history2 = useThrottledRefHistory(draft, { deep: true, clone: true, throttle: 800 })这两个的语义差别很关键。防抖是"等你停下来再记",节流是"隔一段时间记一次"。文本编辑场景应该用防抖——用户打字的过程中不需要中间快照,打完一段停下来才是一个有意义的"步"。而像画布拖拽这种持续产生变化的场景,节流更合适,因为它能保证在长动作里也有中间锚点。
我踩过的一个反直觉的点:防抖的debounce值不能设太大。最初我设了 1500ms,结果用户打完字立刻按 Ctrl+Z,发现撤销的是 1.5 秒前的内容——因为最新那次快照还没到时间。最后调到 400ms,兼顾了"合并连续打字"和"及时性"。
6.2 需要跨模块共享历史:手动历史 + 外部数组
默认情况下,useRefHistory在内部新建一个数组存历史。如果你的撤销按钮在一个工具栏组件里、状态在另一个编辑器组件里,两个组件都得拿到同一份历史,这时候就要用history参数传入一个外部ref:
// 在共享的 store 里 export const messageHistory = ref([]) // 编辑器组件 useRefHistory(messages, { deep: true, clone: true, history: messageHistory, }) // 工具栏组件只需要读 canUndo 的话,可以直接算 const canUndo = computed(() => messageHistory.value.length > 0)不过要注意,这种方式下undo/redo方法仍然只在调用useRefHistory的那个组件里可用。更干净的做法是把整个返回值包一层,放到一个共享的 composable 里,让所有用到的地方都从同一处取:
// composables/useMessageHistory.ts export function useMessageHistory() { const messages = useState<Message[]>('messages') // 或你自己的 store const h = useRefHistory(messages, { deep: true, clone: true, capacity: 30 }) return { messages, ...h } }关键点是这个 composable 只能在一个地方初始化,否则会创建出多份互不相干的历史,出现"编辑器撤销了但工具栏按钮状态没变"的诡异现象。
6.3 需要语义化撤销:命令模式才是终局
useRefHistory是快照式的:它不知道你做了什么,只知道"值变了"。这在大多数场景够用,但有两类需求它做不了。
第一类是撤销提示要有语义。快照方案只能显示"撤销上一步",做不出"撤销 删除消息"这种描述。用户对自己的操作是有记忆的,能告诉他"你要撤销的是什么",体验会好一大截。
第二类是需要和外部系统协同。比如撤销一次删除,除了恢复本地状态,还得给服务端发一个"恢复"请求。快照方案下你不知道这次变化是删除还是编辑,没法决定发什么请求。
这两种情况都指向命令模式:把每次操作封装成一个带undo()/redo()的对象,历史栈里存的是命令对象而不是状态快照。
interface Command { label: string do: () => void undo: () => void } function deleteMessage(id: string): Command { const index = messages.value.findIndex(m => m.id === id) const removed = messages.value[index] return { label: `删除消息`, do: () => messages.value.splice(index, 1), undo: () => messages.value.splice(index, 0, removed), } }代价很明显:每个操作都要单独写do和undo,代码量比快照方案多好几倍,而且undo写错了很难被发现(因为正向操作看着是对的)。所以我给自己定的判断标准是:先上useRefHistory,等真的需要语义描述或者外部协同了,再动命令模式。为了"撤销提示好听一点"提前上命令模式,性价比很低。
最后分享一个我在 Airi 里保留的小设计:撤销栈里除了值和时间戳,我还额外维护了一个平行数组记录操作描述,做法是在每次业务操作前push一个字符串。两者长度不一定严格对齐,所以展示时用了时间戳做匹配兜底。这个方案不完美,但成本几乎为零,在"用户点撤销前想确认一下"这个场景里够用了。