標籤:

[譯]構建應用狀態時,你應該避免不必要的複雜性

Redux 做為一個 Flux 模型的實現需要我們明確思考應用程序內部的整體狀態,然後花費時間建模。事實證明,這未必是一項簡單的任務。它是混沌理論的一個典型例子,一個看似無害的蝴蝶翅膀振動在錯誤的方向可能導致颶風等一系列複雜的連鎖效應(譯註:蝴蝶效應)。下面提供了一個如何對應用程序狀態建模的實用提示列表,它們在保證可用性的同時,也能讓你的業務邏輯更加合理。

什麼是應用程序狀態?

根據維基百科 - 計算機程序在變數中存儲數據,其表示計算機存儲器中的存儲位置。在程序執行的任何給定時間點,這些存儲器位置中的內容被稱為程序的狀態。

就我們當前所討論的狀態而言,重要的是在這個定義中添加最小化。當對我們的應用程序的狀態進行建模以顯式控制時,我們將盡最大努力來用最少的數據表達應用可能處於的不同狀態,從而忽略程序中可以由這個核心所派生的其它動態變數。在 Flux 應用中,狀態保存在 store 對象內。通過調用不同的 action 對狀態進行修改,之後視圖組件監聽到狀態變化後自動在內部進行相應的重渲染處理。

Redux, 做為一個 Flux 的實現,額外添加了一些更嚴格的要求 - 例如將整個應用的狀態保存在一個單一的 store 對象,同時它是不可變的,通常(譯註:指狀態)也是可序列化的

如果你不使用 Redux,下面給出的提示也應該是有益的。 即使你不使用 Flux,它們也很有可能是有用的。

1. 避免根據服務端響應建模

本地應用程序狀態通常來自伺服器。 當應用程序用於顯示從遠程伺服器到達的數據時,通常很容易保持響應數據的結構。

考慮一個電子商務網店管理應用的示例,商家使用此應用來管理商店庫存,因此顯示產品列表是一個關鍵功能。產品列表源自伺服器,但需要將應用程序做為狀態保存在本地,以便在視圖內展現。讓我們假設從伺服器獲取產品列表的主 API 返回以下 JSON 結果:

{n "total": 117,n "offset": 0,n "products": [n {n "id": "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0",n "title": "Blue Shirt",n "price": 9.99n },n {n "id": "aec17a8e-4793-4687-9be4-02a6cf305590",n "title": "Red Hat",n "price": 7.99n }n ]n}n

產品列表作為對象數組到達,為什麼不將它們作為對象數組保存在應用程序狀態中?

伺服器 API 的設計遵循不同的原則,不一定與你想要實現的應用程序狀態結構一致。在這種情況下,伺服器的數組結構選擇可能與響應分頁相關,將完整列表拆分為更小的塊,因此客戶端可以根據需要下載數據,並避免多次發送相同的數據以節省帶寬。它們主要考慮的是網路問題,但一般來說,與我們的應用狀態關注點無關。

2. 首選映射而非數組

一般來說,數組不便於狀態的維護。考慮當特定產品需要更新或檢索時會發生什麼。例如,如果應用程序提供編輯價格功能,或者如果來自伺服器的數據需要刷新,則可能面臨的就是這種情況。遍歷一個大的數組來查找特定的產品比根據它的 ID 查詢這個產品要麻煩得多。

那麼推薦的方法是什麼? 使用主鍵為鍵值的映射類型做為查詢的對象。

這意味著來自上面示例的數據可以按以下結構存儲應用程序的狀態:

{n "productsById": {n "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {n "title": "Blue Shirt",n "price": 9.99n },n "aec17a8e-4793-4687-9be4-02a6cf305590": {n "title": "Red Hat",n "price": 7.99n }n }n}n

如果排序順序很重要,會發生什麼? 例如,如果從伺服器返回的訂單順序同時也是我們要給用戶呈現的順序。 對於這種情況,我們可以存儲一個額外的 ID 數組:

{n "productsById": {n "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {n "title": "Blue Shirt",n "price": 9.99n },n "aec17a8e-4793-4687-9be4-02a6cf305590": {n "title": "Red Hat",n "price": 7.99n }n },n "productIds": [n "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0",n "aec17a8e-4793-4687-9be4-02a6cf305590"n ]n}n

還有一點很有意思:如果我們需要在 React Native 的 ListView 組件中顯示數據,這個結構實際上效果很好。支持穩定行 ID 的推薦版 cloneWithRows 方法所需要的就是這種格式。

3. 避免根據視圖的需要進行建模

應用程序狀態的最終目的是展現到視圖中,並讓用戶覺得是一種享受。把狀態保存為視圖需要的形式看上去很有誘惑力,因為這能避免對數據進行額外的轉換操作。

讓我們回到我們的電子商務商店管理示例。 假設每個產品都可以是庫存或缺貨兩種狀態之一。我們可以將此數據存儲在產品對象的一個布爾屬性中。

{n "id": "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0",n "title": "Blue Shirt",n "price": 9.99,n "outOfStock": falsen}n

我們的應用程序需要顯示所有缺貨產品的列表。之前提到過,React Native ListView 組件期望使用調用它的 cloneWithRows 方法時傳遞兩個參數:行的映射和行 ID 的數組。我們傾向於提前準備好這個狀態,並且明確地保持這個列表。這將允許我們向 ListView 提供兩個參數,而不需要額外的轉換。我們最終得到的狀態對象結構如下:

{n "productsById": {n "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {n "title": "Blue Shirt",n "price": 9.99,n "outOfStock": falsen },n "aec17a8e-4793-4687-9be4-02a6cf305590": {n "title": "Red Hat",n "price": 7.99,n "outOfStock": truen }n },n "outOfStockProductIds": ["aec17a8e-4793-4687-9be4-02a6cf305590"]n}n

聽起來像個好主意,對吧? 好吧,事實證明,並不是。

像以前一樣,原因是,視圖有自己不同的關注點。視圖不關心保持狀態最小。具體來說,他們的傾向完全相反,因為數據必須為用戶布局服務。不同的視圖可以以不同的方式呈現相同的狀態數據,並且通常不可能在不複製數據的情況下滿足它們。

這把我們引入到下一個要點。

4. 避免在應用程式狀態中保存重複的數據

測試你的狀態是否持有重複數據有一種好辦法,就是檢查是否需要同時更新兩處數據來保證數據一致性。在上述缺貨產品示例中,假設第一個產品突然變為缺貨。 為了處理這個更新,我們必須將其在映射中的 outOfStock 欄位更改為 true,並將其 ID 添加到數組 outOfStockProductIds 之中 - 兩個更新。

處理重複數據很簡單。所有你需要做的是刪除其中一個實例。這背後的推理源於一個單一真理:如果數據僅保存一次,則不再可能達到不一致的狀態。

如果我們刪除 outOfStockProductIds 數組,我們仍然需要找到一種方法來準備這些數據以供視圖使用。這種轉換必須在數據被提供給視圖之前在運行時進行。Redux 應用中的推薦做法是在選擇器中實現此操作:

{n "productsById": {n "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {n "title": "Blue Shirt",n "price": 9.99,n "outOfStock": falsen },n "aec17a8e-4793-4687-9be4-02a6cf305590": {n "title": "Red Hat",n "price": 7.99,n "outOfStock": truen }n }n}nn// selectornfunction outOfStockProductIds(state) {n return _.keys(_.pickBy(state.productsById, (product) => product.outOfStock)); n}n

選擇器是一個純函數,它將狀態作為輸入,並返回我們想要消費的轉換後狀態。 Dan Abramov 建議我們將選擇器放在 reducers 旁邊,因為它們通常是緊耦合的。 我們將在視圖的 mapStateToProps 函數中執行選擇器。

刪除數組的另一個可行的替代方法是從映射中的每個產品里刪除庫存屬性。使用這種替代方法,我們可以將數組作為單一真理來源。實際上,根據提示#2 它可能會更好,將此數組更改為映射:

{n "productsById": {n "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {n "title": "Blue Shirt",n "price": 9.99n },n "aec17a8e-4793-4687-9be4-02a6cf305590": {n "title": "Red Hat",n "price": 7.99n }n },n "outOfStockProductMap": {n "aec17a8e-4793-4687-9be4-02a6cf305590": truen }n}nn// selectornfunction outOfStockProductIds(state) {n return _.keys(state.outOfStockProductMap); n}n

5. 不要將衍生數據存儲在狀態中

單一真理原則不僅對於重複數據適用。在商店中出現的任何衍生數據都違反了這條原則,因為必須對多個位置進行更新以保持狀態一致性。

讓我們在我們的商店管理示例中添加另一個要求 - 將產品放在銷售中並對其價格添加折扣的能力。該應用程序需要向用戶顯示過濾後的商品列表,所有產品列表,以及僅顯示沒有折扣的產品或僅顯示有折扣的產品。

一個常見的錯誤是在商店中保存 3 個數組,每個數組包含每個過濾器的相關產品的 ID 列表。由於 3 個數組可以從當前過濾器和產品映射中導出,更好的方法是使用類似於前面的選擇器來生成它們:

{n "productsById": {n "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {n "title": "Blue Shirt",n "price": 9.99,n "discount": 1.99n },n "aec17a8e-4793-4687-9be4-02a6cf305590": {n "title": "Red Hat",n "price": 7.99,n "discount": 0n }n }n}nn// selectornfunction filteredProductIds(state, filter) {n return _.keys(_.pickBy(state.productsById, (product) => {n if (filter == "ALL_PRODUCTS") return true;n if (filter == "NO_DISCOUNTS" && product.discount == 0) return true;n if (filter == "ONLY_DISCOUNTS" && product.discount > 0) return true;n return false;n }));n}n

在重新呈現視圖之前,對每個狀態更改執行選擇器。 如果您的選擇器是計算密集型,並且您關注性能,請使用 Memoization 技術來計算結果並在運行一次後緩存它們。 你可以去看看實現此優化能力的 Reselect 組件。

6. 規範化嵌套對象

總的來說,到目前為止,這些提示的基本動機是簡單性。狀態需要隨著時間的推移進行管理,我們希望儘可能使其無痛。當數據對象是獨立的,簡單性更容易維護,但是當有相互關聯時會發生什麼?

考慮我們的商店管理應用程序中的以下示例。我們想添加一個訂單管理系統,客戶在此可以單個訂單購買多個產品。讓我們假設我們有一個伺服器 API,它返回以下 JSON 訂單列表:

{n "total": 1,n "offset": 0,n "orders": [n {n "id": "14e743f8-8fa5-4520-be62-4339551383b5",n "customer": "John Smith",n "products": [n {n "id": "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0",n "title": "Blue Shirt",n "price": 9.99,n "giftWrap": true,n "notes": "Its a gift, please remove price tag"n }n ],n "totalPrice": 9.99n }n ]n}n

一個訂單包含幾個產品,因此我們需要對兩者之間的關係進行建模。我們已經從提示#1知道,我們不應該使用 API 的響應結構,這確實看起來有問題,因為它會導致產品數據的重複。

在這種情況下,一種好的方法是使數據標準化,並保持兩個單獨的映射 - 一個用於產品,一個用於訂單。由於這兩種類型的對象都基於唯一的 ID,因此我們可以使用 ID 屬性來指定關聯。生成後的應用程序狀態結構為:

{n "productsById": {n "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {n "title": "Blue Shirt",n "price": 9.99n },n "aec17a8e-4793-4687-9be4-02a6cf305590": {n "title": "Red Hat",n "price": 7.99n }n },n "ordersById": {n "14e743f8-8fa5-4520-be62-4339551383b5": {n "customer": "John Smith",n "products": {n "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {n "giftWrap": true,n "notes": "Its a gift, please remove price tag"n }n },n "totalPrice": 9.99n }n }n}n

如果我們想查找屬於某個訂單的所有產品,我們將遍歷 products 屬性的鍵。 每個鍵值是一個產品 ID。 使用此 ID 訪問 productsById 映射將為我們提供產品詳細信息。 此訂單特定的其他產品詳細信息(如 giftWrap)位於訂單下的 products 所映射的值中。

如果標準化 API 響應的過程變得乏味,可使用相應的輔助程序庫,如 normalizr,它接受一個模式做為參數並為你執行標準化數據的過程操作。

7. 應用程序狀態可以被視為內存資料庫

到目前為止,各種建模技巧我們都已經介紹了,大家應該比較熟悉了。

當建模傳統的資料庫結構時,我們避免重複和派生,使用主鍵(ID)用於映射相似的表中索引數據,並規範化多個表之間的關係。這幾乎就是我們之前所談論的全部東西。

像處理內存資料庫一樣處理應用程序狀態可以有助於你處於正確的思考方向,從而做出更好的結構化決策。

將應用狀態視為一等公民

如果說你從這篇文章的獲得了什麼東西,那就應該是它。

在命令式編程期間,我們傾向於視代碼為王,並且花費更少的時間擔心內部隱式數據結構(如狀態)的 「正確」 模型。我們的應用程序狀態通常被發現分散在各種管理器或控制器作為私有屬性,肆無忌憚的野蠻生長。

然而在聲明性的範式下情況是不同的。在像 React 這樣的環境中,我們的系統表現為對狀態的反應。狀態變身為一等公民,與我們編寫的代碼一樣重要。這是 Flux 裡面actions 對象存在的目的,同時也是 Flux 視圖的真理之源。

Redux 這類工具庫基於 Flux 構建,並且提供了一系列工具,例如引入不可變性讓我們擁有更好的應用狀態可預見性。

我們應該多花點時間思考我們的應用程序狀態。 我們應該清楚的認識到它的複雜度,以及相應的我們所需在代碼中維護它所需做出的努力。就像我們在寫代碼時一樣,我們應該重構它,而且是在它顯現出腐爛的跡象就開始。

推薦閱讀:

圖解 Flux

TAG:Flux | Redux |