在前端的富互動編輯中,穩定的撤銷 / 重做功能是使用者安全感的一大保障。設計實現這樣的特性時有哪些痛點,又該如何解決呢?StateShot 凝聚了我們在這個場景下的一些思考。
背景
如果產品經理拍腦袋決定要求給你的表單加上個支援撤銷的功能,怎樣一把梭把需求擼出來呢?最簡單直接的實現不外乎是個這樣的 class:
class History {
push () {}
redo () {}
undo () {}
}
複製程式碼
每次 push
的時候塞進去一個頁面狀態的全量深拷貝,然後在 undo / redo 的時候把相應的狀態拿出來就可以了。是不是很簡單呢?把所有的狀態依次儲存在一個線性的陣列裡,維護一個指向當前狀態的陣列索引足矣,就像這樣:
不過,在真實世界的場景裡,下面這些地方都是潛在的挑戰:
- 增量儲存 - 多條記錄裡不變的資料,沒必要重複吧?
- 按需記錄 - 編輯集中在同一頁,沒必要記錄其他頁面的狀態吧?
- 非同步記錄 - 別管使用者事件多瑣碎,只管 push 就行吧?
- 可定製性 - 別的地方也要用,耦合具體的資料結構不太好吧?
- 存取速度 - 儘量不能卡吧?
這些關注點中,儲存空間和存取速度是與實際體驗聯絡最緊密的指標。而對於這兩點,有一個堪稱銀彈的方案能夠給出理論上最優雅的實現: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% 以上。總的來說,它提供了:
- 開箱即用的無侵入性 API
- 對鏈式呼叫與 Promise 的支援
- 規則驅動的定製與優化策略
- < 2KB min + gzipped 的體積
- 100% 的測試覆蓋率
StateShot 已經在稿定科技的官方 GitHub 組織下開源,歡迎有歷史狀態管理需求的同學嚐鮮體驗 XD
對了,我們長期歡迎有興趣探索 Web 技術潛力的前端同學加入,有意請郵件 xuebi at gaoding.com 哈