在前端的富交互编辑中,稳定的撤销 / 重作功能是用户安全感的一大保障。设计实现这样的特性时有哪些痛点,又该如何解决呢?StateShot 凝聚了咱们在这个场景下的一些思考。前端
若是产品经理拍脑壳决定要求给你的表单加上个支持撤销的功能,怎样一把梭把需求撸出来呢?最简单直接的实现不外乎是个这样的 class:node
class History {
push () {}
redo () {}
undo () {}
}
复制代码
每次 push
的时候塞进去一个页面状态的全量深拷贝,而后在 undo / redo 的时候把相应的状态拿出来就能够了。是否是很简单呢?把全部的状态依次存储在一个线性的数组里,维护一个指向当前状态的数组索引足矣,就像这样:git
不过,在真实世界的场景里,下面这些地方都是潜在的挑战:github
这些关注点中,存储空间和存取速度是与实际体验联系最紧密的指标。而对于这两点,有一个堪称银弹的方案可以给出理论上最优雅的实现:Immutable 数据结构。基于这样的数据结构,每次状态变动都能在常数时间内生成对新状态的引用,这些引用之间天生地共享未改变的内容:这就是所谓的结构共享了。面试
可是,Immutable 对架构的侵入性是很高的。只有在整个项目自底向上全盘采用它封装的 API 来更新状态时,你才有可能实现理想中的 undo / redo 能力。许多 Vue 甚至原生 JS 场景下司空见惯的形如 state.x = y
的直接赋值操做,都须要重写才能适配——这时还技术债的成本不亚于推倒重来。算法
因此,咱们有没有 Plan B 呢?数组
在技术面试时,「深拷贝数据」可能已是道烂大街的题了。这个问题有种让不少人嗤之以鼻的写法:安全
copy = JSON.parse(JSON.stringify(data))
复制代码
它比起掘金里各类文章中「优雅的递归」实现的深拷贝,看起来不过是个奇技淫巧而已。可是,这种实现具有一个特别的性质:对于序列化出的字符串,咱们很容易计算出它的哈希值。因为相同的状态具有相同的哈希,故而只要咱们用哈希值做为 key,就能够很容易地用一个 Map 把每一个序列化后的状态「去重」,从而实现「多个相同状态只占用一份存储空间」的特性了。把这一操做的粒度细化到状态树中的每个节点,咱们就能获得一棵结构一致的树,其中每一个节点存储的都是原节点的哈希值:数据结构
这样,只要将 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 })
复制代码
在规则中,咱们能够指定更细粒度的分块优化。例如对于下面的场景:
咱们轻微移动这个图片节点的位置,而它的 src
字段保持不变。对于这张 Windows XP 的桌面原图 Bliss,这个节点作了 Base64 后体积达到了 30M 的量级,若是在每次移动时都全量存储一个它的新状态,显然是个很大的负担。这时,你能够经过配置 StateShot 的规则,将单个节点分拆为多个不一样的 Chunk,从而将 src
字段与节点的其它字段分离存储,实现单个节点内更细粒度的结构共享:
这对应于形如这样的规则:
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
来决定要对根节点下的哪一个子节点作哈希,这时其它页面(即根节点的直接子节点)状态直接沿用上一条记录相应位置的浅拷贝便可。这时虽然一样存储了全量状态,但记录历史状态的开销便可获得显著的下降:
这对应的 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% 以上。总的来讲,它提供了:
StateShot 已经在稿定科技的官方 GitHub 组织下开源,欢迎有历史状态管理需求的同窗尝鲜体验 XD
对了,咱们长期欢迎有兴趣探索 Web 技术潜力的前端同窗加入,有意请邮件 xuebi at gaoding.com 哈