Redux进阶系列3:如何设计action、reducer、selector

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本大前端精选书籍!

image
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,706评论 4 366
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,002评论 1 301
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,462评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,375评论 0 216
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,763评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,849评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,033评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,768评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,490评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,734评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,204评论 1 264
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,566评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,227评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,137评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,934评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,926评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,774评论 2 274

推荐阅读更多精彩内容