前端历史记录管理的实战方案与核心API应用

南宫永香 组件 阅读 2,493
赞 25 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?

历史记录这个功能,说小不小,说大不大。做后台系统、表单编辑器、可视化搭建工具时几乎绕不开——用户点错了、填错了、删错了,总得有个「撤回」按钮吧?我去年在做一个低代码画布组件时,光是历史记录模块就重构了三遍,踩的坑够写一篇《前端时间旅行事故报告》了。

前端历史记录管理的实战方案与核心API应用

今天不聊理论,直接上实战对比:我实际用过的四种方案,按「真正在项目里跑过」来排,不是网上抄来的概念方案。结论先甩出来:我目前最常用的是自研轻量版 UndoStack + JSON Patch(配合 Immer),其次是浏览器原生 history.pushState 的变种封装;React Router 的 useNavigate 和 Vue Router 的导航守卫……我试过,但只在纯路由跳转场景下用,一旦涉及状态快照,立刻放弃。

方案一:原生 History API(pushState/replaceState)

这个最“正统”,也最容易被高估。它天生适合 URL 变化 + 状态同步的场景,比如搜索页参数变更、筛选条件切换。但注意:它只管 URL 和 state 对象,**不帮你 diff、不帮你合并、不帮你限制步数、也不管你对象是不是可序列化**。

我第一次用它做「表单草稿回退」时,传了个带函数、Date、Map 的对象进去,JSON.stringify 直接报错,折腾半天才发现 pushState 的 state 必须是可序列化的 plain object。后来改成深克隆 + 过滤不可序列字段,结果又遇到性能问题——每次输入都 push 一次,10 秒内点了 30 下,history.length 跑到 30+,popstate 回调里还要手动还原整个表单状态……太重了。

代码长这样(精简版):

// 记录当前表单状态(需手动净化)
const saveToHistory = (formState) => {
  const cleanState = JSON.parse(JSON.stringify(formState)); // 粗暴但有效
  history.pushState({ type: 'form', data: cleanState }, '');
};

// popstate 处理(注意:这里没有撤销栈管理,全靠你手写)
window.addEventListener('popstate', (e) => {
  if (e.state?.type === 'form') {
    restoreForm(e.state.data);
  }
});

优点?零依赖,兼容性好。缺点?它根本不是为「状态快照」设计的,硬套就是给自己加戏。我现在只在「URL 即状态」的简单场景用它,比如搜索页分页、tab 切换。

方案二:React Router / Vue Router 的导航历史

Router 的 history 是基于原生的封装,API 更友好,但本质没变。React Router v6 的 useNavigate + createMemoryRouter 看起来能模拟,但我试了两个项目后放弃了。

问题很现实:Router 的历史栈是为「页面级跳转」服务的,它的 location.statenavigation 行为和表单编辑这种高频微操作完全不匹配。你想点一次「撤销」就回退一步?Router 默认是监听 URL 变化,你要自己触发 navigate(-1),然后还要拦截返回逻辑防止跳走……最后写出来的代码比自己搞个栈还乱。

另外,它和你的业务状态完全脱钩。你改了 form.value,router 不知道;你调了 navigate(-1),form.value 也不会自动同步。中间还得手动桥接一层,不如直接干个栈干净。

方案三:immer + 自研 UndoStack(我的主力方案)

这个是我目前在三个项目中稳定使用的方案,核心就两点:用 Immer 做不可变更新,用数组栈存 patch 或快照。我不存整个 state 对象(太占内存),而是存 diff —— 用 produce 生成的 patches,或者更简单点,存 shallow copy 后的 snapshot(对中小型对象够用)。

为什么选 Immer?因为不用手写 deepClone,也不用担心引用污染。UndoStack 就几十行代码,我自己维护,可控性强:

// UndoStack.js
export class UndoStack {
  constructor(maxSteps = 50) {
    this.history = [];
    this.redoStack = [];
    this.maxSteps = maxSteps;
  }

  push(state) {
    this.history.push(JSON.parse(JSON.stringify(state))); // 简单粗暴,或用 immer.toJS()
    if (this.history.length > this.maxSteps) {
      this.history.shift();
    }
    this.redoStack = [];
  }

  undo() {
    if (this.history.length <= 1) return null;
    const current = this.history.pop();
    this.redoStack.push(current);
    return this.history[this.history.length - 1];
  }

  redo() {
    if (this.redoStack.length === 0) return null;
    const state = this.redoStack.pop();
    this.history.push(state);
    return state;
  }
}

搭配 Immer 使用时,可以进一步优化成 patch 模式(减少内存占用),但对大多数表单类应用,shallow copy + JSON 序列化已经够快够稳。亲测 10w 字符的富文本编辑器,undo/redo 响应延迟 < 8ms。

坑点提醒:别在栈里存 DOM 节点、函数、class 实例——这我踩过三次,最后一次是在一个上传组件里不小心把 File 对象塞进去了,回退时报错「TypeError: Cannot serialize a Blob」……加个 JSON.stringify 的 try/catch 是基本修养。

方案四:第三方库(如 redux-undo、@radix-ui/react-history)

redux-undo 我用过半年,初期爽,后期累。它把 reducer 包了一层,所有 action 都要走它定义的结构,和我们已有的 thunk/saga 流程打架。而且它默认存整个 state 树,一个 2MB 的富文本内容往里一塞,内存直接飙到 400MB……Chrome 崩溃警告天天弹。

@radix-ui/react-history 是个新秀,API 很干净,但它只解决「导航历史」,不解决「状态快照」。文档里写着「for UI-level navigation」,翻译过来就是:别想拿它做表单撤销。

总结一句话:第三方库省事的前提是你用它的整套范式。一旦你有定制需求(比如合并连续输入、过滤无意义操作、自定义快捷键绑定),你就得看源码、提 PR、甚至 fork 改——那还不如自己写个栈

我的选型逻辑

现在我的标准很简单:

  • 纯 URL 导航 → 用原生 pushState 或 Router 内置能力
  • 表单/编辑器/画布等需要精细状态控制的 → 手写 UndoStack + Immer(或 Zustand 的 middleware 版本)
  • 大型复杂应用(比如在线 PPT 编辑)→ 上 jsondiffpatch + 增量 patch 存储,再加个防抖合并(连续输入 300ms 内只存一次)

没有银弹。我曾经为了图省事强行用 redux-undo 接入一个拖拽布局组件,结果发现它无法处理「拖动中实时预览」这种中间态,最终还是切回了自研栈。有时候多写 20 行代码,换来的是一年不修 bug 的安稳。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如怎么结合 IndexedDB 做持久化历史、怎么在 SSR 场景下初始化 undo 栈……后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论