1. 程式人生 > >從時間旅行的烏托邦,看狀態管理的設計誤區

從時間旅行的烏托邦,看狀態管理的設計誤區

Redux 的狀態管理理念非常優雅,隨之附帶的時間旅行除錯支援也非常酷炫。但這個特性是否是傳說中的銀彈,又會給使用者帶來什麼額外的負擔呢?讓我們重新思考一下吧。

什麼是時間旅行?

在 2015 年的 React Europe 會議上,Dan Abramov 展示了通過 Redux DevTools 讓開發者在歷史狀態中自由穿梭,從而提升除錯體驗的 Demo,這個工具的使用體驗非常驚豔,也取得了非常好的反響。在此之後,Vuex 與 MobX 等狀態管理庫也陸續在它們的除錯工具中引入了對類似功能的支援。

我們可以認為,前端狀態管理領域中,狹義的『時間旅行』概念是在滿足了下面這幾個前提後,開發時在歷史狀態中任意回溯的功能:

  • 將區域性 state 統一到全域性 store 中做狀態管理。
  • 開發環境中安裝了與狀態管理庫配套的 DevTools,或引入了特殊的監控元件。
  • 開發環境中啟用了 Webpack 的 HMR 熱載入。

需要特別注意的是,這個功能完全是除錯時使用的。不過,由於這個能力給人的印象過於深刻,它也成為了許多人轉向 React + Redux 技術棧的主要理由之一:漂亮的概念模型加上漂亮的除錯體驗,這套方案簡直就是神器啊!而正如 React 第一個在瀏覽器裡實現了宣告式渲染一樣,Redux 也第一個在瀏覽器裡實現了理想中的除錯體驗,這些原創性的工作對前端領域的貢獻是非常大的。在下文中,我們對 React + Redux 一些潛在問題的分析,也是建立在尊重社群工作的基礎上的。

為什麼你不需要時間旅行?

在剛剛結束的 D2 上,筆者雖然沒有看到完全顛覆性的新輪子,但對於不少開放性的問題獲得了全新的答案。這其中的一個問題幫助筆者重新梳理了對前端的理解,並構成了本節最主要的論據。這個問題是:前端的複雜應用該如何分類?

傳統上,我們會將功能作為區分應用類別的維度。比如:管理後臺、活動 H5、聊天 IM、電商購物、視訊直播……我們有非常多細分領域,每個領域都有不同的業務痛點和側重點,這樣看來要想一通百通地『打通任督二脈』是很困難的。但有沒有更簡單的劃分方式呢?這裡,我們有了一個更簡單的答案,即將複雜的前端應用簡單地分為兩類:資料驅動事件驅動

資料驅動的前端應用

這類應用的業務複雜度完全來自於後臺無窮無盡的資料和複雜業務流程。比如,一個購物網站的瀏覽頁並沒有太多的輸入需要處理,但來自後端介面的商品資料可以是千人千面的;再比如 12306 的訂票平臺,雖然它的前端介面顯得簡陋,但整個業務流程的複雜度可能不是一個普通使用者甚至開發者所能想象的。概況地說,這類最多讓使用者填幾個表單和驗證碼的應用,業務邏輯裡的坑有多深常常只有摸過的同學才懂。這些應用都可以理解為是資料驅動的。

事件驅動的前端應用

相比之下,事件驅動的前端應用,其複雜度則來自於使用者的輸入事件。比如,一個富文字編輯器在編輯時就算完全不對接後臺介面,光是處理使用者的貼上、選中和鍵盤等事件,就可以成為傳說中的『天坑』;再比如一個 H5 版的《太鼓達人》遊戲只需要從後端拉取靜態的音樂資源,但使用者點選的節奏只要差上幾十毫秒,介面的狀態和最後的結果都可能完全不同。構建這類應用的時候,其難點主要來自於在大量不同型別的非同步事件可以任意地排列組合,使得可能的狀態空間極度膨脹而容易出錯——相信只要在頁面中同時維護過幾個定時器的同學都能理解。我們可以把這樣的應用歸類為事件驅動的。

時間旅行與應用分類

時間旅行的概念,和上面提及的兩種應用分類有什麼關係呢?這牽扯到很多技術選型中決定使用 Redux 的動機:Redux 開發工具能支援時間旅行,所以我們的應用在遇到類似需要回溯狀態的場景時,上 Redux 的風險更小。

這聽起來確實充分考慮了後期的拓展性,但它的問題在哪呢?一旦我們重新考慮了對應用的分類維度,那麼對時間旅行的能力就會出現截然不同的需求

  • 資料驅動的前端應用,幾乎完全不需要時間旅行的能力。由於來自後端的資料才是實質上的 Single Source of Truth,在前端基於狀態管理工具的回溯操作非常容易破壞這種對資料來源的依賴,導致前後端的狀態不一致。一個非常簡單的例子是:如果某管理後臺應用的表單頁支援了時間旅行,那麼對錶單提交事件的『旅行』重放顯然會帶來重複的 POST 請求,而這並不是一個冪等的操作,這時前端的時間旅行甚至會違背 RESTful 的理念。
  • 事件驅動的前端應用,非常重度依賴時間旅行類的技術。市面上幾乎所有的靠譜富文字編輯器,都維護了自己的一套撤銷棧——這就是時間旅行的核心功能!再比如,遊戲的進度儲存、讀取功能也是典型的時間旅行功能。對這類應用,時間旅行甚至是影響體驗的核心因素之一:一個撤銷後內容格式會出莫名其妙問題的富文字編輯器,對使用者還有什麼信賴感可言呢?至於一個讀取不了之前進度的遊戲就更不用說了。甚至,只要撤銷功能實現得好,使用者在遇上預期外行為乃至編輯器 bug 的時候,也能自己撤銷回去,然後嘗試其它的互動方式來達成目標——時間旅行是使用者體驗最後的守衛者!

從上面的討論中我們可以發現,只有對於事件驅動的前端應用,時間旅行的功能才有意義(並且還是極其重大的意義!)。而對於管理後臺等資料驅動的前端應用,時間旅行只是可有可無的錦上添花罷了——這個業務場景下,把時間旅行作為選擇 Redux 的重大理由,實在有些牽強。

相信很多同學看到這裡會 argue 說,在管理後臺業務中使用 Redux 是有很多成功案例的,難道你認為他們的架構師都是錯的嗎?並且,Redux 除了時間旅行外還有很多額外的好處,這些東西在決策時都比時間旅行重要得多呀!誠然,Redux 的流行程度已經證明它能夠支撐『大規模』的前端應用,但框架的設計一定是伴隨著 trade-off 的。 在一個不需要時間旅行的業務場景下,Redux 中為了實現時間旅行而引入的一些框架設計就會帶來額外的問題。 因此下面我們要探討的問題就是:Redux 為了率先實現時間旅行的特性,犧牲了哪些東西呢?

時間旅行技術棧有什麼負擔?

她那時候還太年輕,不知道所有命運贈送的禮物,早已在暗中標好了價格。

——《斷頭王后》

在剛剛發現 Redux 能夠徹底解決 React 中 props 層層傳遞的問題時,大家非常激動:哇你看這個無狀態的元件好優雅啊!哇你看只要全部狀態提到 store 裡,開發時我們就能隨便絲般順滑地回退啦!很快,兩條最佳實踐出現了:

  • 儘可能編寫無狀態元件,它們的狀態由全域性 store 管理。
  • 全域性 store 的資料結構應該儘量扁平。

那麼,按照這兩條最佳實踐開發出的應用,會存在什麼問題呢?

全域性狀態的反模式

在時間旅行的誘惑下,把全部狀態都交給 store 來管理,然後徹底幹掉 setState 實在是太有誘惑裡了:不僅能完美支援時間旅行,還能解決 React 裡一個貌似煩人的問題。然而把全部狀態交給 store 管理的時候,坑是少不了的,目前 Redux 在官方文件裡對此的意見是 There is no "right" answer for this,也就是說將全部狀態提到 store 中的實踐也可以認為是合理的。但真的是這樣嗎?

不知道有多少同學在初學程式設計的時候,聽到過前輩這樣的告誡:少用全域性變數。而 React 技術棧中看似高大上的全域性狀態,只不過是拿 Context 粉飾一新的全域性變數而已——你以為穿了件 store 的馬甲人家就不認識你了嗎?全域性變數該有的問題,全域性狀態一個都躲不掉:

  • 全域性狀態非常容易造成命名衝突,這在一個扁平化的 store 裡體現得非常明顯:各種 Redux 的二道販子封裝框架往往也喜歡定義一些自己的命名約定來保證『一致性』,殊不知如果命名這種事情都不能通過語言的作用域機制本身,而是需要靠脆弱的約定來保證的話,那顯然是在人為加重思維負擔:在沒有作用域機制的組合語言裡去用匈牙利命名法無可厚非,但在 2017 年的軟體工程裡還在維護這種層面的約定,真的不是在開歷史的倒車嗎?——當然不是了!組合語言能支援時間旅行嗎?
  • 全域性狀態很難表達巢狀的資料型別。在 Redux 全家桶裡更新 {a: {b: {c: {d: 1 }}}} 幾乎是必須藉助輔助工具的。對於一個富文字編輯器來說,如果想要表達『表格裡支援巢狀表格』的資訊,Redux 對應的原生 JSON 資料結構也顯得非常單薄,基本必須上 Immutable——不過為什麼我不直接使用 Immutable,跳過 Redux 這一層呢?筆者折騰過的 Slate.js 就是這麼做的。哦你說 Facebook 親生的 Draft.js 嗎?它用了 Immutable 沒錯,不過人家實現的是優雅的扁平資料結構,絕不支援表格這種偽需求的。
  • 全域性狀態的記憶體模型不符合經典的計算機體系結構。對於一個比瀏覽器中網頁複雜得多的桌面 GUI,每個視窗對應的程序,其對應的記憶體空間是相互獨立,還是混雜在一個支援時間旅行的『全域性狀態』裡呢?——這不正說明了桌面作業系統的落後嗎!Mac 和 Windows 這些老古董能像我們基於 Redux 寫的網頁這樣優雅地時間旅行嗎?

到此為止,對於 Redux 推崇的扁平全域性 store,我們已經有足夠多的理由來質疑了。雖然這麼設計 store 和時間旅行之間沒有直接的關係,但對『易於除錯、易於推理、易於理解』的『優雅』的全域性狀態,其誘惑很有可能讓開發者踏進更大的陷阱裡。這是值得擔心的。

當然了,Redux 確實解決了一個痛點問題,即深度巢狀的元件間狀態通訊的問題。但解決這個問題,並不代表著我們就必須把狀態全部提到全域性層面。這個問題的體現,可以簡單理解為: 在 A 元件裡實現的方法,觸發它的事件在 B 元件裡,而 C 元件又需要訂閱執行結果…… 這時候純 React 處理起來確實棘手,但只要將 store 放置在 A、B、C 三個元件中最頂級的一個裡——而不需要放置在全域性——而後通過 Context 的定製,就足夠解決這個問題了。

時間旅行與 Boilerplate

另一方面,對 Redux 普遍的一個詬病在於它的 Boilerplate 程式碼比較多,要發一個簡單的請求,都要 Action、Reducer、Middleware 走一波,思維負擔比較大。這個細節其實和時間旅行的實現原理之間有著微妙的關係,簡單來說,可以理解為 Redux 為了除錯體驗,犧牲了開發體驗

在 Dan Abramov 的演講裡,提及了 Webpack HMR 和 Redux DevTools 相結合所帶來的一個重要能力:一旦你更改了某個 Reducer 的程式碼,那麼所有的 Action 都會重新求值,更新狀態。

我們可以把 HMR 的粒度理解為函式級別的熱替換(此處筆者理解尚淺,有錯漏請務必指出),而 Redux 中實現狀態管理邏輯的最小粒度,恰好就是 Reducer 這樣的純函式。從而,對於 Dan 本人而言,在 Redux 的架構上實現這樣『只要發現某個函式被 patch 了,那麼就把所有 JSON 格式的 Action 重新跑一遍』的特性,就不需要什麼奇技淫巧的操作了——於是他在一週內就實現了 Redux DevTools,確實非常強!這時候的代價就是:使用 Redux 的開發者必須在開發階段使用這一套顯得繁重的機制,來使得 Dan 能輕鬆地改進除錯體驗……技術上的取捨沒有絕對的對錯,對於開發和除錯成本的權衡,這裡不做評論。

時間旅行並非開箱即用

除了 Redux 對時間旅行的支援方式帶來的一些問題以外,另外一種隱形的坑在於這種想法:『Redux DevTools 對時間旅行支援得很好,所以在我的應用裡整合這個功能應該也不難。』前文已經提及,在實現一個事件驅動型的前端應用時,時間旅行的功能確實特別重要。但實現這個特性的難度,恐怕不是拉進一個 Redux 就能簡單實現的。這裡以富文字編輯這個事件驅動型應用為例,列舉幾個業務中遇到的具體例子:

  • 在使用 Slate.js 時,撤銷棧在某些情況下會被意外清空。閱讀了原始碼後我們發現,當時的撤銷棧實現,會把編輯器初始化時的更改作為棧的第一項推進去。在嘗試撤銷掉這一項的時候,帶來的副作用會意外地破壞編輯器的計數邏輯,導致本應可以重做回去的內容丟失。這個 bug 我們已經提 PR 解決了,但類似的撤銷棧細節 Issue 還有不少。
  • 一些業務場景,在撤銷與重做時很難通過 push 和 pop 這樣基本的棧操作解決。譬如,在上傳圖片的過程中使用者仍然可以輸入文字,這時對『進度條進度變更』的撤銷事件操作,就會在撤銷棧中和使用者的輸入事件相互『夾雜』而加大撤銷的難度。
  • 對連續發生的輸入事件,需要做不同的去重處理。比如使用者連續地輸入了一行文字,那麼在撤銷時,就需要一次性將整行撤銷;而如果使用者緩慢地逐字輸入,那麼就應該逐字撤銷。

這些場景裡,針對每個案例的解決方案都和 Redux 的理念沒有太多關係。而對於一些複雜度更高的場景(如富文字編輯的實時協同),這時實現時間旅行的基礎就已經不再是簡單的撤銷棧 + 全量狀態替換,而是已經涉及到 OT 等足夠寫不少論文的高階演算法了。這樣看來,事件驅動型的應用裡,如果需要實現時間旅行型別的功能,阻礙有二:

  • Redux 原生的機制即便對這個需求的基礎情形,也沒有很針對性的解決方案。
  • 對於這個需求的進階情形,解決方案几乎完全和 Redux 完全無關。

因此這裡的問題總結而言也比較諷刺:在需要時間旅行特性的應用裡,Redux 除了引入它的一套約定外,幫不上什麼忙。再結合上文的討論,你可以發現對於時間旅行而言,它在資料驅動的應用裡基本不需要實現,而在事件驅動的應用裡實現時,Redux 的幫助也很有限……

我們有什麼替代方案?

這篇文章不是來推銷新輪子的,不過對於上文中的兩種應用場景,我們都確實地發現有更合適的狀態管理方案選擇。MobX 和 RxJS 是筆者之前有偏好的兩個庫,在重新審視場景後,會發現它們恰好各有所長:

MobX 與資料驅動應用

資料驅動的應用中,領域模型很可能非常細碎而繁多(比如對於每種不同的表單,都可以有自己的資料模型),而且對於每種領域模型,封裝出與之對應的增查改刪能力就基本足夠滿足需求了。這時候,MobX 狀態管理的抽象顯得非常自然:

  • 基於 class 的資料模型結構,可以非常輕鬆地封裝每種模型的增查改刪操作。並且可以非常方便地例項化多個不同 store 的例項,注入到所需的元件中。對於 store 間通訊,例項化子 store 時注入一個到 RootStore 的引用即可。
  • 基於 TS 的型別宣告遠比 Redux 裡原始的字串常量 + 原生 JS 物件要先進。
  • 基於依賴追蹤的更新機制能夠精確地做到在物件某個屬性更新時,按需更新元件。在一般的業務場景下,這比全量更改狀態再 Diff 的操作的效能要更好。作為參考,在一個大量重繪的場景下,Dan Abramov 親自操刀優化後的 Redux 實現,才基本達到了 MobX 開箱即用的水平。

需要注意的是,MobX 在重繪時的效能優勢是以訪問劫持後更大的記憶體佔用為代價的。關於這個 trade-off,筆者在 D2 上恰好也向分享 Web 優化的 UC 核心開發者講師諮詢了記憶體佔用對前端效能的影響。根據 dalao 的回覆,這方面主要的案例仍然是來自於大量下載圖片等明顯的反模式,而狀態管理中資料模型的記憶體消耗則不是一個影響效能的瓶頸點。從這個角度來看,MobX 在設計上的權衡與取捨可以認為是值得的。

RxJS 與事件驅動應用

事件驅動的前端應用中,對非同步邏輯的把握則顯得非常重要。這方面,redux-saga 一類的庫提供了一些處理非同步副作用的方式,但如果你瞭解了 RxJS,會發現 Saga 看似強大的能力在 Rx 的事件流思維模型面前,簡直就是玩具。

如果用資料驅動應用的思維來理解 RxJS,你只會感覺它的 API 十分沉重,侵入性很強。實際上,你需要在事件驅動的場景下來感受這一套理念的強大。這裡的一個例子,是每天等電梯時電梯的排程方式:電梯的狀態直接由使用者按下樓層按鈕的事件流所決定,這時通過 RxJS 的響應式程式設計能夠很合理地建模這個業務。作為從例子出發學習 RxJS 的教程,筆者之前撰寫過一篇《響應式程式設計入門:實現電梯排程模擬器》的專欄,還有一個配套的 Demo 實現,歡迎有興趣的同學閱讀。

總結

毫無疑問,時間旅行是一個強大的除錯特性。本文討論的是將時間旅行從除錯工具向業務中落地時,可能涉及的一些問題:資料驅動的前端應用對它的需求不大;Redux 實現時間旅行的特性帶來了一些反模式;實現時間旅行時要處理的其它技術細節大大超出了 Redux 所能處理的範疇等。作為替代,基於 OO 的狀態管理工具 MobX 和基於響應式程式設計的 RxJS 是筆者在不同場景下更青睞的。對於 GraphQL 等文中沒有涉及到的新輪子,希望有相關經驗的讀者 dalao 能不吝賜教。

本文看起來處處都在針對 Redux,雖然這裡確實存在一些利益相關(筆者始終不太喜歡它,對它的使用也不如 MobX、RxJS 甚至 Vuex 深),但文中的結論是以實際的場景作為支撐的,絕對沒有 Redux API 好難學所以它肯定很爛 這樣的想法。而 Redux 團隊的工作,也是非常值得尊敬的。如果文中有任何對 Redux 和時間旅行在理解上的偏差,希望讀者指出,我也非常願意根據討論去修正、優化自己的觀念。

最後的一點私貨,是筆者對前端『圈子』的一點理解:個人發現這個領域裡很多人對於日常使用的框架和工具有著一種盲目的崇拜情緒:不允許別人評論自己所用框架的問題;將框架的設計問題解釋成『你不好用是因為你水平不夠』的玄學問題;給同類工具直接貼上『不好』的標籤……或許這確實體現了某種對前端的『執著和熱愛』,但這也使得國內社群的討論氛圍相比國外,顯得很糟糕。筆者在面試時喜歡提的一個開放性問題是『你偏好的這個框架有哪些不好?』,這個問題不僅有區分度(許多表現平庸的候選人常常為了體現自己對框架的熟悉,直接回答『我覺得沒有什麼不好』……),並且反向的思考其實更有助於我們去結合實際場景,理解框架設計的原理和取捨。

感謝堅持看到這裡的你,希望本文能對你有所幫助~


更多專業前端知識,請上 【猿204