Redux進階系列2: 如何合理地設計State

艾特老幹部發表於2017-08-26

Redux是一個非常流行的狀態管理解決方案,Redux應用執行過程中的任何一個時刻,都是一個狀態的反映。可以說,State 驅動了Redux邏輯的運轉。設計一個好的State並非易事,本文先從設計State時最容易犯的兩個錯誤開始介紹,然後引出如何合理地設計State。

錯誤1:以API為設計State的依據

以API為設計State的依據,往往是一個API對應一個子State,State的結構同API返回的資料結構保持一致(或接近一致)。例如,一個部落格應用,/posts介面返回部落格列表,返回的資料結構如下:

[
  {
    "id": 1,
    "title": "Blog Title",
    "create_time": "2017-01-10T23:07:43.248Z",
    "author": {
      "id": 81,
      "name": "Mr Shelby"
    }
  }
  ...
]
複製程式碼

我們還需要檢視一篇部落格的詳情,假設通過介面/posts/{id}獲取部落格詳情,通過介面/posts/{id}/comments獲取部落格的評論,返回的資料結構如下:

{
    "id": 1,
    "title": "Blog Title",
    "create_time": "2017-01-10T23:07:43.248Z",
    "author": {
      "id": 81,
      "name": "Mr Shelby"
    },
    "content": "Some really short blog content. "
}
複製程式碼
[
  {
    "id": 41,
    "author": "Jack",
    "create_time": "2017-01-11T23:07:43.248Z",
    "content": "Good article!"
  }
  ...
]
複製程式碼

上面三個介面的資料分別作為3個子State,構成應用全域性的State:

{
  "posts": [
    {
      "id": 1,
      "title": "Blog Title",
      "create_time": "2017-01-10T23:07:43.248Z",
      "author": {
        "id": 81,
        "name": "Mr Shelby"
      }
    },
    ...
  ],
  "currentPost": {
    "id": 1,
    "title": "Blog Title",
    "create_time": "2017-01-10T23:07:43.248Z",
    "author": {
      "id": 81,
      "name": "Mr Shelby"
    },
    "content": "Some really short blog content. "
  },
  "currentComments": [
    {
      "id": 1,
      "author": "Jack",
      "create_time": "2017-01-11T23:07:43.248Z",
      "content": "Good article!"
    },
    ...
  ]
}
複製程式碼

這個State中,posts和currentPost存在很多重複的資訊,而且posts、currentComments是陣列型別的結構,不便於查詢,每次查詢某條記錄時,都需要遍歷整個陣列。這些問題本質上是因為API是基於服務端邏輯設計的,而不是基於應用的狀態設計的。比如,雖然獲取部落格列表時,已經獲取了每篇部落格的標題、作者等基本資訊,但對於獲取部落格詳情的API來說,根據API的設計原則,這個API依然應該包含部落格的這些基本資訊,而不能只是返回部落格的內容。再比如,posts、currentComments之所以返回陣列結構,是考慮到資料的順序、分頁等因素。

錯誤2:以頁面UI為設計State的依據

既然不能依據API設計State,很多人又會走到另外一個反面,基於頁面UI設計State。頁面UI需要什麼樣的資料和資料格式,State就設計成什麼樣。我們以todo應用為例,頁面會有三種狀態:顯示所有的事項,顯然所有的已辦事項和顯示所有的待辦事項。以頁面UI為設計State的依據,那麼State將是這樣的:

{
  "all": [
    {
      "id": 1,
      "text": "todo 1",
      "completed": false
    },
    {
      "id": 2,
      "text": "todo 2",
      "completed": true
    }
  ],
  "uncompleted": [
    {
      "id": 1,
      "text": "todo 1",
      "completed": false
    }
  ],
  "completed": [
    {
      "id": 2,
      "text": "todo 2",
      "completed": false
    }
  ]
}
複製程式碼

這個State對於展示UI的元件來說,使用起來非常方便,當前應用處於哪種狀態,就用對應狀態的陣列型別的資料渲染UI,不用做任何的中間資料轉換。但這種State存在的問題也很容易被發現,一是這種State依然存在資料重複的問題;二是當新增或修改一條記錄時,需要修改不止一個地方。例如,當新增一條記錄時,all和uncompleted這兩個陣列都要新增這條新增記錄。這種型別的State,既會造成儲存的浪費,又會存在資料不一致的風險。

這兩種設計State的方式實際上是兩種極端的設計方式,實際專案中,完全按照這兩種方式設計State的開發者並不多,但絕大部分人都會受到這兩種設計方式的影響。請回憶一下,你是否有過把某個API返回的資料原封不動的作為State的一部分?又是否有過,為了元件渲染方便,專門為某個元件的UI定義一個State?

合理設計State

下面我們來看一下應該如何合理地設計State。最重要最核心的原則是像設計資料庫一樣設計State。把State看做一個資料庫,State中的每一部分狀態看做資料庫中的一張表,狀態中的每一個欄位對應表的一個欄位。設計一個資料庫,應該遵循以下三個原則:

  1. 資料按照領域(Domain)分類,儲存在不同的表中,不同的表中儲存的列資料不能重複。
  2. 表中每一列的資料都依賴於這張表的主鍵。
  3. 表中除了主鍵以外的其他列,互相之間不能有直接依賴關係。

這三個原則,可以翻譯出設計State時的原則:

  1. 把整個應用的狀態按照領域(Domain)分成若干子State,子State之間不能儲存重複的資料。
  2. State以鍵值對的結構儲存資料,以記錄的key/ID作為記錄的索引,記錄中的其他欄位都依賴於索引。
  3. State中不能儲存可以通過已有資料計算而來的資料,即State中的欄位不互相依賴。

按照這三個原則,我們重新設計部落格應用的State。按領域劃分,State可以拆分為三個子State: posts、comments、authors,posts中的記錄以部落格的id為key值,包含title、create_time、author、comments,同樣的方式可以設計出comments、authors的結構,最終State的結構如下:

{
  "posts": {
    "1": {
      "id": 1,
      "title": "Blog Title",
      "content": "Some really short blog content.",
      "created_at": "2016-01-11T23:07:43.248Z",
      "author": 81,
      "comments": [
        352
      ]
    },
    ...
  },
  "comments": {
    "352": {
      "id": 352,
      "content": "Good article!",
      "author": 41
    },
    ...
  },
  "authors": {
    "41": {
      "id": 41,
      "name": "Jack"
    },
    "81": {
      "id": 81,
      "name": "Mr Shelby"
    },
    ...
  }
}
複製程式碼

現在這個State看起來是不是很像有三張表的資料庫呢?但這個State還有不滿足應用需求的地方:鍵值對的儲存方式無法保證部落格列表資料的順序,但對於部落格列表,有序性顯然是需要的。解決這個問題,我們可以通過定義另外一個狀態postIds,以陣列格式儲存部落格的id:

{
  "posts": {
    "1": {
      "id": 1,
      "title": "Blog Title",
      "content": "Some really short blog content.",
      "created_at": "2016-01-11T23:07:43.248Z",
      "author": 81,
      "comments": [
        352
      ]
    },
    ...
  },
  "postIds": [1, ...],
  "comments": {
    "352": {
      "id": 352,
      "content": "Good article!",
      "author": 41
    },
    ...
  },
  "authors": {
    "41": {
      "id": 41,
      "name": "Jack"
    },
    "81": {
      "id": 81,
      "name": "Mr Shelby"
    },
    ...
  }
}
複製程式碼

這樣,當顯示部落格列表時,根據postIds獲取列表順序,然後根據部落格id從posts中獲取部落格的資訊。這個地方有些同學可能有疑惑,認為posts和postIds都儲存了id資料,違反了不同State間不能有重複資料的原則。但其實這並不是重複資料,postIds儲存的資料是部落格列表的順序,只不過“順序”這個資料是通過部落格id來體現的。這和一張表的主鍵同時可以用作另外一張表的外來鍵,是同樣的道理。同樣需要注意的是,當新增加一條部落格時,posts和postId這兩個狀態都要進行修改。這看似變得麻煩,不如直接使用一個陣列型別的狀態操作簡單,但是當需要修改某一篇部落格的資料時,這種結構就有了明顯的優勢,而且直接使用陣列儲存狀態,會存在物件巢狀層級過深的問題,想象下訪問評論的內容,需要通過類似posts[0].comments[0].content三層結構才能獲取到,當業務越複雜,這個問題越突出。扁平化的State,才具有更好的靈活性和擴充套件性。

截至目前為止,我們的State都是根據後臺API返回的領域資料進行設計的,但實際上,應用的State,不僅包含領域資料,還需要包含應用的UI邏輯資料,例如根據當前是否正在與伺服器通訊,處理頁面的載入效果;當應用執行出錯時,需要顯示錯誤資訊等。這時,State的結構如下:

{
  "isFetching": false,
  "error": "",
  "posts": {
    ...
  },
  "postIds": [1, ...],
  "comments": {
    ...
  },
  "authors": {
    ...
  }
}
複製程式碼

隨著應用業務邏輯的增加,State的第一層級的節點也會變得越來越多。這時候我們往往會考慮合併關聯性較強的節點資料,然後通過拆分reducer的方式,讓每一個子reducer處理一個節點的狀態邏輯。這個例子中,我們可以把posts、postIds進行合併,同時狀態名做了調整,把isFetching、error作為全域性的UI邏輯狀態合併:

{
  "app":{
    "isFetching": false,
  	"error": "",
  },
  "posts":{
    "byId": {
      "1": {
        ...
      },
      ...
    },
    "allIds": [1, ...],
  } 
  "comments": {
    ...
  },
  "authors": {
    ...
  }
}
複製程式碼

這樣,我們就可以定義appReducer、postsReducer、commentsReducer、authorsReducer四個reducer分別處理4個子狀態。至此,State的結構設計完成。

總結一下,設計Redux State的關鍵在於,像設計資料庫一樣設計State。把State看作應用在記憶體中的一個資料庫,action、reducer等看作操作這個資料庫的SQL語句。


歡迎關注我的公眾號:老幹部的大前端,領取21本大前端精選書籍!

Redux進階系列2: 如何合理地設計State

相關文章