Redux進階系列3:如何設計action、reducer、selector

艾特老幹部發表於2017-09-07

Redux進階系列文章:

1. React+Redux專案結構最佳實踐 2. 如何合理地設計State

在前面兩篇文章中,我們介紹了Redux專案結構的組織方式和如何設計State。本篇,我們將以前面兩篇文章為基礎,繼續介紹如何設計action、reducer、selector。

依然以部落格專案為例,我們在第2篇中最後設計的state結構如下:

{
  "app":{
    "isFetching": false,
    "error": "",
  },
  "posts":{
    "byId": {
      "1": {
        ...
      },
      ...
    },
    "allIds": [1, ...],
  } 
  "comments": {
    ...
  },
  "authors": {
    ...
  }
}

複製程式碼

根據這個結構,我們很容易想到可以拆分成4個reducer分別處理app、posts、comments、authors這4個子state。子state相關的action和這個state對應的reducer放到一個檔案中,作為一個state處理模組。注意:本文定義的action、reducer、selector並不涵蓋真實部落格應用中涉及的所有邏輯,僅列舉部分邏輯,用以介紹如何設計action、reducer、selector。

state中的 app 管理應用狀態,應用狀態與領域狀態不同,領域狀態是應用用來顯示、操作的資料,一般需要從伺服器端獲取,例如posts、comments、authors都屬於領域狀態;而應用狀態是與應用行為或應用UI直接相關的狀態,例如當前應用中是否正在進行網路請求,應用執行時的錯誤資訊等。app 包含的應用狀態有:isFetching(當前應用中是否正在進行網路請求)和error(應用執行時的錯誤資訊)。對應的action可以定義為:

// 所在檔案:app.js
//action types
export const types = {
  const START_FETCH  : 'app/START_FETCH',
  const FINISH_FETCH : 'app/FINISH_FETCH',
  const SET_ERROR : 'app/SET_ERROR'
}

//action creators
export const actions = {
  startFetch: () => {
    return {type: types.START_FETCH};
  },
  finishFetch: ()=> {
    return {type: types.FINISH_FETCH};
  },
  setError: (error)=> {
    return {type: types.SET_ERROR, payload: error};
  }
}

複製程式碼

types定義了app模組使用的action types,每一個action type的值以模組名作為名稱空間,以避免不同模組的action type衝突問題。actions定義了該模組使用到的action creators。我們沒有直接匯出每一個action type和action creator,而是把所有的action type封裝到types常量,所有的action creators封裝到actions常量,再匯出types和actions這兩個常量。這樣做的好處是方便在其他模組中引用。(在第1篇中已經介紹過) 現在再來定義處理app的reducer:

// 所在檔案:app.js

export const types = {
 //...
}

export const actions = {
 //...
}

const initialState = {
  isFetching: false,
  error: null,
}

// reducer
export default function reducer(state = initialState, action) {
  switch (action.type) {
    types.START_FETCH: 
      return {...state, isFetching: true};
    types.FINISH_FETCH:
      return {...state, isFetching: false};
    types.SET_ERROR:
      return {...state, error: action.payload}
    default: return state;
  }
}
複製程式碼

現在,app.js就構成了一個基本的處理state的模組。

我們再來看下如何設計posts.js。posts是這幾個子狀態中最複雜的狀態,包含了posts領域資料的兩種組織方式:byId定義了部落格ID和部落格的對映關係,allIds定義了部落格在介面上的顯示順序。這個模組需要使用非同步action呼叫伺服器端API,獲取部落格資料。當網路請求開始和結束時,還需要使用app.js模組中的actions,用來更改app中的isFetching狀態。程式碼如下所示:

// 所在檔案:posts.js
import {actions as appActions} from './app.js'

//action types
export const types = {
  const SET_POSTS : 'posts/SET_POSTS',
}

//action creators
export const actions = {
  // 非同步action,需要redux-thunk支援
  getPosts: () => {
    return (dispatch) => {
      dispatch(appActions.startFetch());
      return fetch('http://xxx/posts')
        .then(response => response.json())
        .then(json => {
          dispatch(actions.setPosts(json));    
          dispatch(appActions.finishFetch());    
        });      
    }
  },
  setPosts: (posts)=> {
    return {type: types.SET_POSTS, payload: posts};
  }
}

// reducer
export default function reducer(state = [], action) {
  switch (action.type) {
    types.SET_POSTS:
      let byId = {};
      let allIds = [];
      /* 假設介面返回的部落格資料格式為:
      [{
        "id": 1,
        "title": "Blog Title",
        "create_time": "2017-01-10T23:07:43.248Z",
        "author": {
          "id": 81,
          "name": "Mr Shelby"
        },
        "comments": [{id: 'c1', authorId: 81, content: 'Say something'}]
    	"content": "Some really short blog content. "
	  }] 
	  */
      action.payload.each((item)=>{
        byId[item.id] = item;
        allIds.push(item.id);
      })
      return {...state, byId, allIds};
    default: return state;
  }
}
複製程式碼

我們在一個reducer函式中處理了byId和allIds兩個狀態,當posts的業務邏輯較簡單,需要處理的action也較少時,如上面的例子所示,這麼做是沒有問題的。但當posts的業務邏輯比較複雜,action型別較多,byId和allIds響應的action也不一致時,往往我們會拆分出兩個reducer,分別處理byId和allIds。如下所示:

// 所在檔案:posts.js
import { combineReducers } from 'redux'

//省略無關程式碼

// reducer
export default combineReducers({
  byId,
  allIds
})

const byId = (state = {}, action) {
  switch (action.type) {
    types.SET_POSTS:
      let byId = {};
      action.payload.each((item)=>{
        byId[item.id] = item;
      })
      return {...state, byId};
    SOME_SEPCIAL_ACTION_FOR_BYID:
      //...
    default: return state;
  }
}

const allIds = (state = [], action) {
  switch (action.type) {
    types.SET_POSTS:
      return {...state, allIds: action.payload.map(item => item.id)};
    SOME_SEPCIAL_ACTION_FOR_ALLIDS:
      //...
    default: return state;
  }
}
複製程式碼

從上面的例子中,我們可以發現,redux的combineReducers可以在任意層級的state上使用,而並非只能在第一級的state上使用(示例中的第一層級state是app、posts、comments、authors)

posts.js模組還有一個問題,就是byId中的每一個post物件,包含巢狀物件author。我們應該讓post物件只應用部落格作者的id即可:

// reducer
export default function reducer(state = [], action) {
  switch (action.type) {
    types.SET_POSTS:
      let byId = {};
      let allIds = [];
      action.payload.each((item)=>{
        byId[item.id] = {...item, author: item.author.id};
        allIds.push(item.id);
      })
      return {...state, byId, allIds};
    default: return state;
  }
}
複製程式碼

這樣,posts只關聯部落格作者的id,部落格作者的其他屬性由專門的領域狀態author來管理:

// 所在檔案:authors.js
import { types as postTypes } from './post'

//action types
export const types = {
  
}

//action creators
export const actions = {
  
}

// reducer
export default function reducer(state = {}, action){
  switch (action.type) {
    postTypes.SET_POSTS:
      let authors = {};
      action.payload.each((item)=>{
        authors[item.author.id] = item.author;
      })
      return authors;
    default: return state;
}
複製程式碼

這裡需要注意的是,authors的reducer也處理了posts模組中的SET_POSTS這個action type。這是沒有任何問題的,一個action本身就是可以被多個state的reducer處理的,尤其是當多個state之間存在關聯關係時,這種場景更為常見。

comments.js模組的實現思路類似,不再贅述。現在我們的redux(放置redux模組)目錄結構如下:

redux/
  app.js
  posts.js 
  authors.js
  comments.js
複製程式碼

在redux目錄層級下,我們新建一個index.js檔案,用於把各個模組的reducer合併成最終的根reducer。

// 檔名:index.js
import { combineReducers } from 'redux';
import app from './app';
import posts from './posts';
import authors from './authors';
import commments from './comments';

const rootReducer = combineReducers({
  app,
  posts,
  authors,
  commments
});

export default rootReducer;
複製程式碼

action和reducer的設計到此基本完成,下面我們來看selector。Redux中,selector的“名聲”不如action、reducer響亮,但selector其實非常有用。selector是用於從state中獲取所需資料的函式,通常在connect的第一個引數 mapStateToProps中使用。例如,我們在AuthorContainer.js中根據作者id獲取作者詳情資訊,不使用selector的話,可以這麼寫:

//檔名:AuthorContainer.js

//省略無關程式碼

function mapStateToProps(state, props) {
  return {
    author: state.authors[props.authorId],
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(AuthorContainer);
複製程式碼

這個例子中,因為邏輯很簡單,直接獲取author看起來沒什麼問題,但當獲取狀態的邏輯變得複雜時,需要通過一個函式來獲取,這個函式就是一個selector。selector是可以複用的,不同的容器元件,只要獲取狀態的邏輯相同,就可以複用同樣的selector。所以,selector不能直接定義在某個容器元件中,而應該定義在其關聯領域所在的模組中,這個例子需要定義在authors.js中。

//authors.js

//action types

//action creators

// reducer

// selectors
export function getAuthorById(state, id) {
  return state[id]
}
複製程式碼

在AuthorContainer.js中使用selector:

//檔名:AuthorContainer.js
import { getAuthorById } from '../redux/authors';

//省略無關程式碼

function mapStateToProps(state, props) {
  return {
    author: getAuthorById(state.authors, props.authorId),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(AuthorContainer);
複製程式碼

我們再來看一個複雜些的selector:獲取一篇部落格的評論列表。獲取評論列表資料,需要posts和comments兩個領域的資料,所以這個selector並不適合放到comments.js模組中。當一個selector的計算引數依賴多個狀態時,可以把這個selector放到index.js中,我們把index.js看做所有模組層級之上的一個根模組。

// index.js

// 省略無關程式碼

// selectors
export function getCommentsByPost(post, comments) {
  const commentIds = post.comments;
  return commentIds.map(id => comments[id]);
}
複製程式碼

我們在第2篇 如何合理地設計Redux的State講過,要像設計資料庫一樣設計state,selector就相當於查詢表的sql語句,reducer相當於修改表的sql語句。所以,本篇的總結是:像寫sql一樣,設計和組織action、reducer、selector。


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

Redux進階系列3:如何設計action、reducer、selector

相關文章