大家好,我是桃翁。
「了解狀態管理庫需要解決的核心問題。以及大量湧現的現代庫是如何用新的方式解決這些問題。
」隨着 React 應用程序的規模和複雜性的不斷增長,如何管理可共享的全局狀態已經成為一個挑戰。通常建議是僅在真正需要時才引入全局狀態管理方案。
這篇文章將詳細討論全局狀態管理庫需要解決的核心問題。
了解這些潛在問題將有助於我們評估這些狀態管理「新浪潮」們所做的取捨。對於其他方面,最好從局部開始引入並只在需要時進行擴展。
React 本身並沒有為如何解決全局應用狀態共享提供任何明確的指導方案。因此,隨着時間的推移,React 生態圈已經積累了很多的方法和庫來解決這個問題。
因此在評估採用哪個庫或模式時,這可能會讓人感到困惑。
常見的方法是把它放在外層,並使用目前最主流的工具來處理。這就是我們看到的,早期大家廣泛使用 Redux 就是這種情況,其實許多應用並不需要它。
通過理解狀態管理庫使用上的問題,可以幫助我們更好地理解為什麼有這麼多不同的庫採用了不同的方法。
每個庫在解決不同的問題上都做了一些不同的取捨,導致在 API、模式以及思考狀態的概念模型上有許多不同。
我們接下來會看一下在 Recoil、Jotai、Zusand、Valtio 這些庫中所用到的現代方法和模式,以及其他類似 React tracked 和 React Query 的庫。看看他們是如何適應環境發展的。
最後當我們需要選擇一個對我們的應用真正有用的庫時,我們應該對準確評估這個庫實現上的取捨有更充分的準備。
全局狀態管理庫需要解決的問題「能夠從組件樹中的任何位置讀取存儲狀態。」這是狀態管理庫最基本的功能。
它允許開發人員將狀態保存在內存中,並避免大量屬性傳遞的問題。在 React 生態系統的早期,我們經常不合適地使用 Redux 來解決這個痛點。
實際上,當涉及到實際存儲狀態時,有兩種主要方法。
第一個是在 React 運行時內部。這通常是指利用 React 提供的 useState、useRef 或 useReducer 等 API 並結合 React 上下文來傳遞共享值。這裡最大的挑戰是如何正確的優化重複渲染問題。
第二個是 React 知識體系之外的問題,叫做模塊狀態。模塊狀態允許以類似單例的形式存儲狀態。這樣優化重複渲染問題會比較容易,只需要在狀態變更時選擇性的處理相關的訂閱。但因為它是內存中的單個值,所以不同的子樹不能有不同的狀態。
2. 「能夠寫入存儲狀態。」一個庫應該提供一個直觀的 API 來讀寫存儲中的數據。
一個直觀的 API 通常是符合現有心智模型的 API。因此這可能有點主觀,具體取決於庫的使用者是誰。
通常,心智模型中的衝突會導致使用上的阻力或者增加學習成本。在 React 中常見的心智模型衝突就是可變狀態與不可變狀態。
React 中將 UI 作為狀態函數的模型適用於引用相等以及通過不可變更新來檢測何時發生變化以便正確進行重新渲染的概念。但是 Javascript 本身是一種可變語言。
在使用 React 時,我們必須牢記引用相等之類的事情。這對於不習慣函數式概念的 Javascript 開發人員來說,可能是一個混亂的根源,也增加了學習 React 的成本。
Redux 遵循此模型,並要求所有狀態更新都以不可變的方式完成。做這樣的選擇需要權衡取捨。在這種情況下,一個常見的缺點是對那些習慣於可變方式更新的人來說必須要編寫大量樣板代碼來進行更新。
這就是為什麼像 Immer 這樣的庫很受歡迎的原因,它允許開發人員編寫可變形式的代碼(即使在底層更新還是不可變的)。
在新一波「post-redux」全局狀態管理方案中還有一些庫,例如 Valtio,允許開發人員使用可變形式的 API。
3. 「提供優化渲染的機制。」將 UI 作為狀態函數的模型應該既簡單又高效。
然而,當大規模的狀態發生變化時的協調過程是極其複雜的。這通常導致大型應用的運行時性能問題。
使用此模型,全局狀態管理庫需要檢測當狀態更新時何時進行重新渲染,並且僅重新渲染必要的內容。
優化這個過程就是狀態管理庫需要解決的最大挑戰之一。
通常採取兩種主要方法。
Valtio 就是一個示例庫,它在後台使用 Proxy 來自動跟蹤狀態變更並自動管理組件何時重新渲染。
4. 「提供優化內存占用的機制。」對於大型前端應用,大量不合理地管理內存可能會帶來問題。
特別是當用戶用低配置的設備訪問這些大型應用。
掛到 React 生命周期上的狀態意味着在組件卸載時更容易利用自動垃圾回收機制。對於像 Redux 這樣提倡單一全局狀態的庫,你需要自己管理它。因為它會持續保留對數據的引用,不會自動進行垃圾回收。
同樣,使用狀態管理庫將狀態存儲在 React 運行時之外意味着它不依賴於任何特定組件,可能需要手動管理。
「更多需要解決的問題:」除了上面這些基礎問題,在與 React 集成時還有一些常見問題需要考慮:
「與併發模式的兼容性。」 併發模式 允許 React 在渲染過程中「暫停」和切換優先級。以前這個過程是完全同步的。
將並發引入任何地方通常都會帶來一些邊緣場景。對於狀態管理庫,如果兩個組件從一個外部存儲中讀值,在渲染過程中這個值發生了變化,那麼兩個組件可能會讀到不同的值。
這被稱為「撕裂」。這個問題導致 React 團隊為庫創建者開發了 useSyncExternalStore 來解決這個問題。
「數據序列化。」 擁有完全可序列化的狀態是很有用的,這樣你就可以從某個存儲中保存和恢復應用狀態。一些庫會為你處理這個問題,而其他庫可能需要使用者做一些額外工作才能使用此能力。
「上下文丟失問題。」 對於 將多個 react 渲染混合在一起 的應用程序來說,這是一個問題。例如,你可能有一個同事使用了 react-dom 和 類似 react-three-fiber 的庫的應用。React 無法協調兩個獨立的上下文。
「過期的屬性問題。」 Hooks 解決了很多傳統類組件的問題。對此的取捨是要接受閉包帶來的一系列新問題。
一個常見問題是閉包里的數據在當前渲染周期中不再是「新鮮的」。這導致渲染到屏幕上的數據不是最新值。當碰到使用了依賴這些屬性來計算狀態的選擇器函數時就會產生問題。
「殭屍子組件問題。」 這是 Redux 的一個老問題,如果子組件首先掛載並在父組件之前連接到存儲,同時在父組件掛載之前發生狀態變更,就會導致數據不一致。
正如我們所見,全局狀態管理庫需要考慮很多問題和邊緣場景。
為了更好地理解 React 狀態管理的現代方法。我們可以回憶一下歷史,看看過去什麼痛點形成了我們今天稱之為「最佳實踐」的方法。
通常,這些最佳實踐是通在反覆試驗和試錯發現的。並且發現某些解決方案最終無法很好地適用。
從一開始,React 最初發布時的原始標語就是定位 MVC 模型 中的「視圖」。
它沒有包含如何構建或管理狀態的觀點。這意味着在處理前端應用中最複雜的部分時,開發人員只能靠自己。
在 Facebook 內部,使用了一種稱為「Flux」的模式,它有助於單向數據流和可預測的更新,這與 React 的「總是重新渲染」的模型相一致。
這種模式非常符合 React 的心智模型,並且在 React 生態系統的早期就流行起來。
Redux 的原始崛起Redux 是被廣泛採用的 Flux 模型的首批實現之一。
它提倡使用單一存儲,部分靈感來自 Elm 架構,而不是其他 Flux 實現中常見的多存儲。
在啟動一個新項目時,你不會因為選擇 Redux 作為狀態管理庫而被解僱。它還具有很酷的演示能力,例如很方便的實現撤消/重做功能和時間旅行調試能力。
整個模型至今都是簡單而優雅的。尤其是與 React 上一代的 MVC 風格框架例如 Backbone(大規模系統)相比。
雖然 Redux 對特定應用場景來說仍然是一個很棒的狀態管理庫。但是隨着時間的推移,以及整個社區的成長,Redux 遇到了一些常見的問題,導致它不再受歡迎:
小型應用中的問題
對於早期的很多應用,它解決了第一個問題。從樹中的任何位置訪問存儲狀態,避免了層層傳遞數據和函數來將數據更新到多個層級的痛苦。
對於獲取少量數據並且幾乎沒什麼交互的簡單應用來說,這通常太重了。
大型應用中的問題
隨着時間的推移,很多小型應用逐漸變成了大型應用。正如我們在實踐中發現的,前端應用中有許多不同類型的狀態。每個都有自己的一系列問題。
比如本地 UI 狀態、遠程服務器緩存狀態、url 狀態和全局共享狀態,以及更多不同類型的狀態。
例如,對於本地 UI 狀態,隨着應用的發展,在數據和更新數據的方法中進行屬性傳遞通常很快就會成為一個問題。為了解決這個問題,結合使用 組件組合模式 和 狀態提升 可以幫助你更好的度過這段時期。
對於遠程服務器緩存狀態,存在一些常見問題,例如請求去重、重試、輪詢、處理突變等等。
隨着應用的發展,Redux 傾向於吸收所有狀態,無論其是什麼類型,因為它提倡使用單一存儲。
這就會導致將所有東西都存儲在一個超大的單一存儲中。這往往會引出第二個問題,運行時性能優化。
因為 Redux 通常只處理全局共享狀態,所以很多這些子問題都需要反覆處理(或者通常無人關注)。
這導致形成一個大型單一存儲,在一個地方管理 UI 和遠程實體狀態之間的所有內容。
隨着應用的發展,這當然會變得非常難以管理。特別是在前端開發人員需要快速迭代的團隊中。解耦的處理獨立的複雜組件變得更加有必要。
隨着我們遇到更多這樣的痛點,慢慢的,在啟動新項目時默認使用 Redux 變得不受歡迎。
實際上,很多 Web 應用都是 CRUD(創建、讀取、更新和刪除)類型的應用,主要做的就是將前端與遠程狀態數據同步。
換句話說,值得花時間研究的主要問題是一系列與遠程服務器緩存相關的問題。包括如何獲取、緩存和同步服務器狀態。
它還包括許多其他問題,例如處理競態、失效和重新獲取過期數據、去重、重試、組件重新聚焦時重新獲取數據,以及相比 Redux 的樣板代碼更方便的改變遠程數據。
這些用例的樣板是沒必要且過於複雜的。特別是通常需要綁定使用的中間件例如 redux-saga 和 redux-observable。
就從客戶端獲取和改變數據的成本而言,這套工具鏈對於這些類型的應用來說都太重了。並且對這些相對簡單的操作來講也太複雜了。
轉向更簡單的方法隨着 hooks 和新的上下文 API 的出現。風向從使用像 Redux 這樣的重度抽象轉向使用新的 hooks API 的原生能力已經有一段時間了。通常是簡單的使用 useContext 並結合 useState 或者 useReducer。
對於簡單的應用程序,這是一種很好的方法。許多小型應用都可以這麼做。但是隨着應用的發展,這會帶來兩個問題:
值得一提的是一些現代用戶側的庫,例如 useContextSelector 旨在幫助解決此問題。同時 React 團隊也開始考慮 在未來作為 React 的一部分自動解決這個痛點。
用於解決遠程狀態管理問題的專用庫的興起對於大多數 CRUD 類型的 Web 應用,本地狀態與專用的遠程狀態管理庫相結合可以幫助你很好的解決問題。
在這趨勢中的示例庫包括 React query、SWR、Apollo 和 Relay。以及一些「革新」的 Redux 庫比如 Redux Toolkit 和 RTK Query。
這些是專門為解決遠程數據問題而構建的,這些問題如果單獨使用 Redux 來解處理的話通常會很複雜。
雖然這些庫對於單頁應用來說是很好的抽象。就獲取和改變數據所需的 Javascript 而言,它們仍然需要很多的開銷。作為一個 Web 構建者社區,Javascript 的實際成本 變得越來越重要。
值得注意的是,像 Remix 這樣的新興元框架已經解決了這個問題。通過提供對服務端優先的數據加載的抽象和聲明性突變,它不再需要引入一個專門的庫。它把「將 UI 作為狀態函數」的概念 擴展到客戶端 之外,包括後端遠程狀態數據。
全局狀態管理庫和模式的新浪潮對於大型應用,通常不可避免地需要有與遠程服務器狀態不同的全局狀態共享。
自下而上模式的興起我們可以看到之前的狀態管理解決方案(如 Redux)在他們的實現上比較「自上而下」。隨着時間的推移,它傾向於吸收組件樹頂部的所有狀態。狀態都在樹的頂部,下面的組件通過選擇器獲取它們需要的狀態。
在 構建面向未來的前端架構 中,我們看到了自下而上的模式在構建具有組合模式的組件方面的作用。
hooks 既提供也提倡了將可組合部件組合在一起形成更大整體的原則。使用 hooks,標誌着巨型單一全局存儲的狀態管理方法的轉變。走向自下而上的「微」狀態管理,強調通過 hooks 消費更小的狀態片段。
像 Recoil 和 Jotai 這樣的流行庫用他們的「原子」狀態概念來驗證了這種自下而上的方法。
原子是很小但完整的狀態單位。它們是狀態的一小塊,可以連接在一起形成新的派生狀態。這樣最終就會形成一個關係圖。
這套模型允許開發者以自下而上的方式逐步構建狀態。並可以通過只讓關係圖中已更新的原子狀態無效來優化重複渲染。
這與直接訂閱一個巨型的單一狀態形成對比,並可以儘量減少不必要的重複渲染。
現代庫如何解決狀態管理的核心問題下面是每個「新浪潮」中的庫為解決狀態管理中的核心問題所採用的不同方法的簡單總結。這些是我們在文章開頭定義的問題。
能夠從子樹中的任何位置讀取存儲狀態「手動優化」 通常意味着創建訂閱特定狀態片段的選擇器函數。這裡的好處是消費者可以對如何訂閱和優化訂閱該狀態的組件如何重新渲染進行細粒度控制。一個缺點是這是一個手動過程,容易出錯,並且有人可能會質疑這裡需要一些不必要的開銷,這不應該是 API 的一部分。
「自動優化」 是讓庫優化這個過程,該過程僅自動重新渲染必要的內容。這裡的優勢當然是更加方便,以及開發者能夠專注於實現功能而無需關心手動優化的方法。這樣做的一個缺點是,對開發者來說優化過程是一個黑盒,沒有暴露出口來手動優化某些部分,可能有人會覺得有點魔幻。
內存優化往往只是大型應用的問題。這在很大程度上取決於庫是在模塊級別存儲狀態還是在 React 運行時中存儲狀態。這還取決於你如何構建存儲狀態。
與大型單體存儲相比,小型獨立存儲的好處是,當所有訂閱的組件卸載時,它們可以自動進行垃圾回收。而大型單體存儲在沒有做合適的內存管理的情況下更容易出現內存泄漏。
關於什麼是最好的全局狀態管理庫,目前還沒有一個正確答案。這個問題很大程度上取決於你的應用需求以及構建它的人。
但是了解狀態管理庫需要解決的核心問題可以幫助我們評估現在和未來將出現的庫。
深入了解具體實現超出了本文的範圍。如果你有興趣深入研究,我推薦 Daishi Kato 的 React 狀態管理書,這是一個非常好的資源,它對本文中提到的一些較新的庫和方法進行了非常詳細的比較。
參考來源於知乎:https://zhuanlan.zhihu.com/p/541391922
」參考資料Garbage Collection in Redux Applications: https://developers.soundcloud.com/blog/garbage-collection-in-redux-applications
[2]React without memo: https://www.youtube.com/watch?v=lGEMwh32soc
[3]The zombie child problem: https://react-redux.js.org/api/hooks#stale-props-and-zombie-children
[4]useMutableSource -> useSyncExternalStore discussion: https://github.com/reactwg/react-18/discussions/86
[5]Proxy compare: https://github.com/dai-shi/proxy-compare
[6]useContextSelector: https://github.com/dai-shi/use-context-selector
[7]Data flow in Remix: https://remix.run/blog/remix-data-flow