StateShot - 无侵入的历史状态管理库

在前端的富交互编辑中,稳定的撤销 / 重作功能是用户安全感的一大保障。设计实现这样的特性时有哪些痛点,又该如何解决呢?StateShot 凝聚了咱们在这个场景下的一些思考。前端

背景

若是产品经理拍脑壳决定要求给你的表单加上个支持撤销的功能,怎样一把梭把需求撸出来呢?最简单直接的实现不外乎是个这样的 class:node

class History {
  push () {}
  redo () {}
  undo () {}
}
复制代码

每次 push 的时候塞进去一个页面状态的全量深拷贝,而后在 undo / redo 的时候把相应的状态拿出来就能够了。是否是很简单呢?把全部的状态依次存储在一个线性的数组里,维护一个指向当前状态的数组索引足矣,就像这样:git

stateshot-0

不过,在真实世界的场景里,下面这些地方都是潜在的挑战:github

  • 增量存储 - 多条记录里不变的数据,不必重复吧?
  • 按需记录 - 编辑集中在同一页,不必记录其余页面的状态吧?
  • 异步记录 - 别管用户事件多琐碎,只管 push 就行吧?
  • 可定制性 - 别的地方也要用,耦合具体的数据结构不太好吧?
  • 存取速度 - 尽可能不能卡吧?

这些关注点中,存储空间存取速度是与实际体验联系最紧密的指标。而对于这两点,有一个堪称银弹的方案可以给出理论上最优雅的实现:Immutable 数据结构。基于这样的数据结构,每次状态变动都能在常数时间内生成对新状态的引用,这些引用之间天生地共享未改变的内容:这就是所谓的结构共享了。面试

可是,Immutable 对架构的侵入性是很高的。只有在整个项目自底向上全盘采用它封装的 API 来更新状态时,你才有可能实现理想中的 undo / redo 能力。许多 Vue 甚至原生 JS 场景下司空见惯的形如 state.x = y 的直接赋值操做,都须要重写才能适配——这时还技术债的成本不亚于推倒重来。算法

因此,咱们有没有 Plan B 呢?数组

设计

在技术面试时,「深拷贝数据」可能已是道烂大街的题了。这个问题有种让不少人嗤之以鼻的写法:安全

copy = JSON.parse(JSON.stringify(data))
复制代码

它比起掘金里各类文章中「优雅的递归」实现的深拷贝,看起来不过是个奇技淫巧而已。可是,这种实现具有一个特别的性质:对于序列化出的字符串,咱们很容易计算出它的哈希值。因为相同的状态具有相同的哈希,故而只要咱们用哈希值做为 key,就能够很容易地用一个 Map 把每一个序列化后的状态「去重」,从而实现「多个相同状态只占用一份存储空间」的特性了。把这一操做的粒度细化到状态树中的每个节点,咱们就能获得一棵结构一致的树,其中每一个节点存储的都是原节点的哈希值:数据结构

stateshot-1

这样,只要将 State 树的结构转换为存储哈希索引的 Record 树,再将每一个节点序列化为 Chunk 数据块,就可以实现节点级的结构共享了。架构

使用

从这个简单的理念出发,咱们造出了 StateShot 这个轮子。它的使用方式很是简单:

import { History } from 'stateshot'

const state = { a: 1, b: 2 }

const history = new History()
history.pushSync(state) // 更经常使用的 push API 是异步的

state.a = 2 // mutation!
history.pushSync(state) // 再记录一次状态

history.get() // { a: 2, b: 2 }
history.undo().get() // { a: 1, b: 2 }
history.redo().get() // { a: 2, b: 2 }
复制代码

StateShot 会自动帮你处理好数据 → 哈希 → 数据的转换。不过这个示例看起来彷佛没什么特别的?确实,从保证易用性的角度出发,咱们把它设计成能够不作任何定制地直接使用,但你也能够 Opt-In 地按需进行更细粒度的优化。这就带来了规则驱动的概念。经过指定规则,你能够告诉 StateShot 如何遍历你的状态树。一条规则的结构大体以下:

const rules = [{
  match: Function,
  toRecord: Function,
  fromRecord: Function
}]

const history = new History({ rules })
复制代码

在规则中,咱们能够指定更细粒度的分块优化。例如对于下面的场景:

stateshot-demo

咱们轻微移动这个图片节点的位置,而它的 src 字段保持不变。对于这张 Windows XP 的桌面原图 Bliss,这个节点作了 Base64 后体积达到了 30M 的量级,若是在每次移动时都全量存储一个它的新状态,显然是个很大的负担。这时,你能够经过配置 StateShot 的规则,将单个节点分拆为多个不一样的 Chunk,从而将 src 字段与节点的其它字段分离存储,实现单个节点内更细粒度的结构共享:

stateshot-2

这对应于形如这样的规则:

const rule = {
  match: node => node.type === 'image',
  toRecord: node => ({
    // 将节点的 src 与其它字段拆分为两个 chunk
    chunks: [{ ...node, src: null }, node.src],
  })
  fromRecord: ({ chunks }) => ({
    // 从 chunk 数组中恢复出原状态
    ...chunks[0], src: chunks[1]
  })
}
复制代码

另一个很常见的场景出如今状态树存在「多页」的时候:若是用户只在某一个页面上编辑,那么全量对全部的页面状态作哈希计算显然是不合算的。做为优化,StateShot 支持指定一个 pickIndex 来决定要对根节点下的哪一个子节点作哈希,这时其它页面(即根节点的直接子节点)状态直接沿用上一条记录相应位置的浅拷贝便可。这时虽然一样存储了全量状态,但记录历史状态的开销便可获得显著的下降:

stateshot-3

这对应的 API 一样很简单:

history.push(state, 0) // 指定仅对 state 的第一个子节点作哈希
复制代码

差点忘了,它的 API 还支持链式调用和 Promise,在 8012 年它们多是「优雅」的标配了吧:

// 最终 get 前的 undo 与 redo 都是 O(1) 的
const state = history.undo().undo().redo().undo().get()

// 异步的节流延时能够经过 delay 参数控制
hisoty.push().then(/* ... */)
复制代码

总结

在稿定科技自研的编辑器中,咱们已经在使用 StateShot 了。在 benchmark 里,它作到了比原有的历史记录模块存取速度约 3 倍的提高(这主要是拜新的 MurmurHash 哈希算法替代了原有的 SHA-1 所赐)。而且,在基于它定制了细粒度的规则后,对单个元素连续作屡次拖拽等细微改动的场景下,快照的内存占用也下降了 90% 以上。总的来讲,它提供了:

  • 开箱即用的无侵入性 API
  • 对链式调用与 Promise 的支持
  • 规则驱动的定制与优化策略
  • < 2KB min + gzipped 的体积
  • 100% 的测试覆盖率

StateShot 已经在稿定科技的官方 GitHub 组织下开源,欢迎有历史状态管理需求的同窗尝鲜体验 XD

对了,咱们长期欢迎有兴趣探索 Web 技术潜力的前端同窗加入,有意请邮件 xuebi at gaoding.com

相关文章
相关标签/搜索