React.js学习react native

探索Redux最佳实践--The Redux Way in 广发

2017-04-14  本文已影响577人  ronniegong

本文首发于前端之巅社区,作者本人,原文链接探索Redux的最佳实践

The Redux Way in 广发证券

1. 前言

广发证券金钥匙是一个连接用户和投资顾问、为用户提供专业投资咨询服务的的产品。基于Angular 1.x和Ionic,我们为用户和投顾分别提供了覆盖PC Web、Mobile Web和Android/iOS客户端的系列产品。

前端的发展日新月异,React Native/Weex/微信小程序等技术方案进一步扩展了前端技术的应用范围。在金钥匙项目中,我们相继推出了小程序版金钥匙有问必答服务,同时采用React Native替代Ioinc,重构金钥匙项目客户端。这一过程中,对于在前端项目中如何优雅的管理应用中的数据状态进行了深入的思考。我们选择Redux作为应用状态管理工具,从多个角度探索其最佳实践,本文分享我们在探索过程中的一些总结。

2. 探索 Best Practice in Redux

在以往基于Angular 1.x的开发经验中,有几个问题总是出现在我脑海中

在Angular 1.x项目中我们没能很好的解决上述问题,在开始使用React Native重构项目时,迫切的希望解决上述问题,一番了解,我们选择了Redux。

2.1 什么是Redux

Redux是前端应用的状态容器,提供可预测的状态管理,其基本定义可以用下列公式表示

(state, action) => newState

借用一张经典图示,可以进一步理解Redux主要元素和数据流向。

2.2 Redux异步方案选型

Redux自身action结构简单,没有定义异步方法部分的支持内容。然而异步请求是前端应用中重要部分,如何管理异步请求,怎样在社区的各式异步相关中间件中选择,是首先需要解决的问题。

2.2.1 Without Middleware

最初使用Redux时会有疑问,必须借助中间件才能完成异步请求吗?并不是这样,可以像下例中进行异步操作

//action creator
function loadData(dispatch,userId){
    dispatch({type:'LOAD_START'})
    asyncRequest(userId).then(resp=>{
        dispatch({type:'LOAD_SUCCESS',resp})
    }).catch(error=>{
        dispatch({type:'LOAD_FAIL',error})
    })
}

//component
componentDidMount(){
    loadData(this.props.dispatch,this.props.userId)
}

虽然上述代码示例可以完成工作,但是有下面几个问题

2.2.2 Redux Thunk

Redux提供了中间件机制,而Redux Thunk是Redux官方文档中用到的异步组件,使用Redux-Thunk完成上述异步请求

//action creator
function loadData(userId){
    return  dispatch => {
        dispatch({type:'LOAD_START'})
        asyncRequest(userId).then(resp=>{
            dispatch({type:'LOAD_SUCCESS',resp})
        }).catch(error=>{
            dispatch({type:'LOAD_FAIL',error})
        })
    }
}

//component
componentDidMount(){
    this.props.dispatch(loadData(this.props.userId));
}

相比不使用之前中间件,使用Redux Thunk后,在组件中不再关注action creator中是否需要dispatch/getState参数,不再关注dispatch的是异步还是同步的方法。

当使用中间件完成异步请求时,action在应用中流程如下所示

2.2.3 Redux Promise Middleware

上例中采用Redux Thunk进行异步请求,这一个简单的请求过程,我们需要主动的触发请求的开始、成功和失败状态,当应用中有大量这类简单请求时,项目中会充满这种重复代码。

针对这一问题,可以采用Redux Promise Middleware来简化代码

//action creator
function loadData(userId){
    return {
        type:types.LOAD_DATA,
        payload:asyncRequest(userId)
    }
}

//component
componentDidMount(){
    this.props.dispatch(loadData(this.props.userId));
}

Redux Promise Middleware中间件会帮助我们处理异步请求的状态,为当前action type添加PEDNGING/FULFILLED/REJECTED三种状态,根据异步请求的结果触发不同状态。

Redux Promise Middleware中间件适用于简化简单请求的代码,开发中推荐混合使用Redux Promise Middleware中间件和Redux Thunk。

2.2.4 Redux Saga

Redux Saga可以理解为一个和系统交互的常驻进程,其中,Saga可简单定义如下

Saga = Worker + Watcher

采用Redux Saga完成异步请求,示例如下

//saga
function* loadUserOnClick(){
    yield* takeLatest('LOAD_DATA',fetchUser); 
} 

function* fetchUser(action){
    try{
        yield put({type:'LOAD_START'});
        const user = yield call(asyncRequest,action.payload);
        yield put({type:'LOAD_SUCCESS',user});
    }catch(err){
        yield put({type:'LOAD_FAIL',error})
    }
}

//component
<div onclick={e=>dispatch({type:'LOAD_DATA',payload:'001'})}>load data</div>

相比Redux Thunk,使用Redux Saga有几处明显的变化

除开上述这些不同点,Redux Saga真正的威力,在于其提供了一系列帮助方法,使得对于各类事件可以进行更细粒度的控制,从而完成更加复杂的操作

简单列举如下

2.2.5 Redux Observable

Redux Observable是基于RxJS的用于处理异步请求的中间件,可简单定义如下:

Redux Observable = Epic( Type + Operators )

Redux Observable关注Redux中的action,理念是action in ,action out。用Redux Observable完成异步请求示例如下

//epic
const loadUserEpic = action$ => 
    action$.ofType('LOAD_DATA')
        .map(()=>({type:'LOAD_START'}))
        .mergeMap(action =>
          ajax.getJSON(`/api/users/${action.payload}`)
            .map(user => {type:'LOAD_SUCCESS',user})
            .catch(error => Observable.of({
                type: 'LOAD_FAIL',error
            }))
        );

//component
<div onclick={e=>dispatch({type:'LOAD_DATA',payload:'001'})}>load data</div>

借助RxJS的各种操作符和帮助方法,Redux Observable也能实现对各类事件的细粒度操作,比如取消、限频、延迟请求等。

Redux Observable与Redux Saga适用于对事件操作有细粒度需求的场景,同时他们也提供了更好的可测试性,当你的应用逐渐复杂需要更加强大的工具时,他们会成为很好的帮手。由于Redux Observable基于RxJS,相对来说学习曲线更高。

2.3 Redux应用状态划分

如何设计应用状态的数据结构是一个值得思考的问题,在实践中,我们总结了两点数据划分的指导性原则,应用状态扁平化和抽离公共状态。

2.3.1 应用状态扁平化

在我们的项目中,有联系人、聊天消息和当前联系人对象。在Angular 1.x 项目中,数据结构如下

{
contacts:[
    {
        id:'001',
        name:'zhangsan',
        messages:[
            {
                id:1,
                content:{
                    text:'hello'
                },
                status:'succ'
            }
        ]
    },
    {
        id:'002',
        name:'lisi',
        messages:[
            {
                id:2,
                content:{
                    text:'world'
                },
                status:'fail'   
            }
        ]
    }
],
selectedContact:{
        id:'001',
        name:'zhangsan',
        messages:[
            {
                id:1,
                content:{
                    text:'hello'
                },
                status:'succ'
            }
        ]
    }
}

采用上述数据机构,带来几个问题

将数据扁平化、解除耦合,得到如下数据结构

{
contacts:[
    {
        id:'001',
        name:'zhangsan'
    },
    {
        id:'002',
        name:'lisi'
    }
],
messages:{
    '001':[
        {
           id:1,
           content:{
               text:'hello'
           },
           status:'succ'
       },
       ...
    ],
    '002':[
        {
           id:3,
           content:{
               text:'haha'
           },
           status:'succ'
       }
    ]
},
selectedContactId:'001'
}

相对于之前的问题,上述数据结构具有以下优点

在开发过程中,我们可以主动将数据扁平化,或者使用normalizr工具,依据定义的schema设计应用的数据结构。

2.3.2 抽离公共状态

在领域对象之外,往往还有另外一些与请求过程相关的状态数据,如下所示

{
  user: {
    isError: false, // 加载用户信息失败
    isLoading: false, // 加载用户中
    ...
    entity: { ... },
  },
  messages: {
    isLoading: true, // 加载消息中
    nextHref: '/api/messages?offset=200&size=100', // 消息分页数据
    ...
    entities: { ... },
  },
  authors: {
    isError: false, // 加载作者失败
    isLoading: false, // 加载作者中
    nextHref: '/api/authors?offset=50&size=25', // 作者分页数据
    ...
    entities: { ... },
  },
}

上述数据结构中,我们按照功能模块将状态数据内聚。

采用上述结构,会导致我们需要写很多基本重复的action,如下所示

{
  type: 'USER_FETCH_ERROR',
  payload: {
    isError,
  },
}

{
  type: 'USER_IS_LOADING',
  payload: {
    isLoading,
  },
}

{
  type: 'MESSAGES_IS_LOADING',
  payload: {
    isLoading,
  },
}

{
  type: 'MESSAGES_NEXT_HREF',
  payload: {
    nextHref,
  },
}

{
  type: 'AUTHORS_FETCH_ERROR',
  payload: {
    isError,
  },
}

{
  type: 'AUTHORS_IS_LOADING',
  payload: {
    isLoading,
  },
}
...

我们分别为usermessageauthor定义了一系列action,而他们作用类似,代码重复。为解决这一问题,我们可以将这类状态数据抽离,不再简单的按照功能模块内聚,抽离后的状态数据如下所示

{
  isLoading: {
    user: false,
    messages: true,
    authors: false,
    ...
  },
  isError: {
    userEdit: false,
    authorsFetch: false,
    ...
  },
  nextHref: {
    messages: '/api/messages?offset=200&size=100',
    authors: '/api/authors?offset=50&size=25',
    ...
  },
  user: {
    ...
    entity: { ... },
  },
  messages: {
    ...
    entities: { ... },
  },
  authors: {
    ...
    entities: { ... },
  },
}

采用这一结构,可以避免定义大量相似的action type,编写重复的action

2.4 如何修改应用状态

将应用状态数据不可变化是使用Redux的一般范式,有多种方式可以实现不可变数据的效果,这里我们分别尝试了Object.assign、Immutable.js和Seamless-Immutable.js。

2.4.1 Object.assign/Spread Operator

最初我们使用Object.assign或者Spread Operator来修改数据,在Reducer中使用Spread Operator修改数据的简单示例如下

function todoApp(state = initialState ,action){
    switch (action.type){
        case SET_VISIBILITY_FILTER:
            return {...state,visibilityFilter: action.filter}
        default:
            return state;
    }
}

随着使用的深入,发现这一方式有如下问题

2.4.2 Immutable.js

带着上述问题,我们了解到Immutable.js并开始使用它进行应用状态数据的修改。

Immutable.js为人称道的是它的基于共享数据结构、而非深度复制所带来的数据修改时的高性能,但是在我们的使用过程中,发现其易用性不够友好,使用体验并不美好。

var obj = {foo: "original"};
var notFullyImmutable = Immutable.List.of(obj);

notFullyImmutable.get(0) // { foo: 'original' }

obj.foo = "mutated!";

notFullyImmutable.get(0) // { foo: 'mutated!' }

2.4.3 Seamless-Immutable.js

前面提到的使用Immutable.js过程中的问题,使得我们在开发中不断的需要停下来思考当前写法是否正确,于是我们继续尝试,最后选择使用Seamless-Immutable.js来帮助实现不可变数据。

Seamless-Immutable.js意为无缝的Immutable,与Immutable.js不同,他没有自定义新的数据结构,其基本使用如下所示

var array = Immutable(["totally", "immutable", {hammer: "Can’t Touch This"}]);

array[1] = "I'm going to mutate you!"
array[1] // "immutable"

array[2].hammer = "hm, surely I can mutate this nested object..."
array[2].hammer // "Can’t Touch This"

for (var index in array) { console.log(array[index]); }
// "totally"
// "immutable"
// { hammer: 'Can’t Touch This' }

JSON.stringify(array) // '["totally","immutable",{"hammer":"Can’t Touch This"}]'

根据我们的使用体验,Seamless-Immutable.js易用性优于Immutable.js。但是在选择使用他之前,有一点需要了解的是,在数据修改时,Seamless-Immutable.js性能低于Immutable.js,当数据嵌套层级越深,数据量越大,性能差异越明显。所以这里需要根据业务特点来做选择,我们的业务没有大批量的深度数据修改需求,所以易用性比性能更重要。

2.5 组织Redux代码结构

学习使用Redux过程中,通常我们会将Redux几个主要元素按类型划分文件目录,通常我们按照如下方式组织代码文件

|--components/
|--constants/
 ----userTypes.js
|--reducers/
 ----userReducer.js
|--actions/
 ----userAction.js

严格遵循这一模式并无不可,不过在有些场景下,使用其他模式组织代码结构可能更加灵活、便利。

2.5.1 Redux Ducks

通常我们的actionreducer都是一一对应,同时也会共用一个action type,于是会有一个很自然的想法,与其将actionreducertype分离在各自目标的单独文件,为什么不将他们合并到一起呢?

|--components
 |--redux
  ----userRedux

合并后的userRedux被称为Redux Duck,这是经典的鸭子类型的应用。

合并后的代码示例如下

//types
const LOAD   = 'LOAD';
const CREATE = 'CREATE';
const UPDATE = 'UPDATE';
const REMOVE = 'REMOVE';

//reducer
export default function reducer(state = {}, action = {}) {
    switch (action.type) {
        // do reducer stuff
        default: return state;
    }
}

//action
export function loadUser() {
    return { type: LOAD };
}

export function createUser(data) {
    return { type: CREATE, data };
}

export function updateUser(data) {
    return { type: UPDATE, data };
}

export function removeUser(data) {
    return { type: REMOVE, data };
}

2.5.2 按模块组织文件

随着项目规模的增长,代码文件逐渐增多,当actions目录下文件越来越多时,找到目标文件变成了一个稍显麻烦的事情。在这种场景下,按模块组织代码文件,将模块相关的代码聚合在一起,更加适合大型项目的开发。

|--modules/
 ----users/
 ------userComponent.js
 ------userRedux.js
 ----messages/
 ------messageComponent.js
 ------messageRedux.js

与此同时,根据我们的使用经验,鸭子模式与传统模式应当灵活的混合使用。当业务逻辑复杂,actionreducer各自代码量较多时,按照传统模式拆分可能是更好的选择。此时可以如下混合使用两种模式

|--modules/
 ----users/
 ------userComponent.js
 ------userConstant.js
 ------userAction.js
 ------userReducer.js
 ----messages/
 ------messageComponent.js
 ------messageRedux.js

3. 总结

随着对Redux使用的逐渐深入,我们对Redux几个主要内容的最佳实践进行了一番探索,最终形成了以下几点经验,作为我们现在的指导性原则

如果觉得有帮助,可以扫描二维码对我打赏,谢谢

上一篇下一篇

猜你喜欢

热点阅读