在前端的富交互編輯中,穩定的撤銷 / 重作功能是用戶安全感的一大保障。設計實現這樣的特性時有哪些痛點,又該如何解決呢?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 哈