程序员

Reducer 最佳实践,Redux 开发最重要的部分

reducer就是实现(state, action) => newState的纯函数,也就是真正处理state的地方。值得注意的是,Redux并不希望你修改老的state,而且通过直接返回新state的方式去修改。

在讲如何设计reducer之前,先介绍几个术语:
✦ reducer:实现(state, action) -> newState的纯函数,可以根据场景分为以下好几种
✦ root reducer:根reducer,作为createStore的第一个参数
✦ slice reducer:分片reducer,相对根reducer来说的。用来操作state的一部分数据。多个分片reducer可以合并成一个根reducer
✦ higher-order reducer:高阶reducer,接受reducer作为参数的函数/返回reducer作为返回值的函数。
✦ case function:功能函数,接受指定action后的更新逻辑,可以是简单的reducer函数,也可以接受其他参数。

reducer的最佳实践主要分为以下几个部分
✦ 抽离工具函数,以便复用。
✦ 抽离功能函数(case function),精简reducer声明部分的代码。
✦ 根据数据类别拆分,维护多个独立的slice reducer。
✦ 合并slice reducer。
✦ 通过crossReducer在多个slice reducer******享数据。
✦ 减少reducer的模板代码。

接下来,我们详细的介绍每个部分

如何抽离工具函数?

抽离工具函数,几乎在任何一个项目中都需要。要抽离的函数需要满足以下条件:
✦ 纯净,和业务逻辑不耦合
✦ 功能单一,一个函数只实现一个功能
由于reducer都是对state的增删改查,所以会有较多的重复的基础逻辑,针对reducer来抽离工具函数,简直恰到好处。

// 比如对象更新,浅拷贝
export const updateObject = (oldObj, newObj) => {
    return assign({}, oldObj, newObj);
}
// 比如对象更新,深拷贝
export const deepUpdateObject = (oldObj, newObj) => {
    return deepAssign({}, oldObj, newObj);
}

工具函数抽离出来,建议放到单独的文件中保存。

如何抽离 case function 功能函数?

不要被什么case function吓到,直接给你看看代码你就清楚了,也是体力活,目的是为了让reducer的分支判断更清晰。

// 抽离前,所有代码都揉到slice reducer中,不够清晰
function appreducer(state = initialState, action) {
    switch (action.type) {
        case 'ADD_TODO':
            ...
            ...
            return newState;
        case 'TOGGLE_TODO':
            ...
            ...
            return newState;
        default:
            return state;
    }
}

// 抽离后,将所有的state处理逻辑放到单独的函数中,reducer的逻辑格外清楚
function addTodo(state, action) {
    ...
    ...
    return newState;
}
function toggleTodo(state, action) {
    ...
    ...
    return newState;
}
function appreducer(state = initialState, action) {
    switch (action.type) {
        case 'ADD_TODO':
            return addTodo(state, action);
        case 'TOGGLE_TODO':
            return toggleTodo(state, action);
        default:
            return state;
    }
}

case function就是指定action的处理函数,是最小粒度的reducer。
抽离case function,可以让slice reducer的代码保持结构上的精简。

如何设计slice reducer?

上一篇 关于state的博客 已经提过,我们需要对state进行拆分处理,然后用对应的slice reducer去处理对应的数据,比如article相关的数据用articlesReducer去处理,paper相关的数据用papersReducer去处理。
这样可以保证数据之间解耦,并且让每个slice reducer保持代码清晰并且相对独立。
比如好奇心日报有articles、papers两个类别的数据,我们拆分state并扁平化改造

{
    // 扁平化
    entities: {
        articles: {},
        papers: {}
    },

    // 按类别拆分数据
    articles: {
        list: []
    },
    papers: {
        list: []
    }
}

为了对state.articles和state.papers分别进行管理,我们设计两个slice reducer,分别是articlesReducer和papersReducer

// ------------------------------------
// Action Handlers
// ------------------------------------
const ACTION_HANDLERS = {
    [UPDATE_ARTICLES_LIST]: updateArticelsList(articles, action)
}
// ------------------------------------
// reducer
// ------------------------------------
// !!!值得注意的是,对于articlesReducer来说,它并不知道state的存在,它只知道state.articles!!!
// 所以articlesReducer完成的工作是(articles, action) => newArticles
export function articlesReducer(articles = {
    list: []
}, action) {
    const handler = ACTION_HANDLERS[action.type]

    return handler ? handler(articles, action) : articles
}

// papersReducer类似,就不贴代码了。

由于我们的state进行了扁平化改造,所以我们需要在case function中进行normalizr化。

根据state的拆分,设计出对应的slice reducer,让他们对自己的数据分别管理,这样后代码更便于维护,但也引出了两个问题。
✦ 拆分多个slice reducer,但createStore只能接受一个reducer作为参数,所以我们怎么合并这些slice reducer呢?
✦ 每个slice reducer只负责管理自身的数据,对state并不知情。那么articlesReducer怎么去改变state.entities的数据呢?
这两个问题,分别引出了两部分内容,分别是:slice reducer合并、slice reducer数据共享。

如何合并多个slice reducer?

redux提供了combineReducer方法,可以用来合并多个slice reducer,返回root reducer传递给createStore使用。直接上代码,非常简单。

combineReducers({
    entities: entitiesreducer,

    // 对于articlesReducer来说,他接受(state, action) => newState,
    // 其中的state,是articles,也就是state.articles
    // 它并不能获取到state的数据,更不能获取到state.papers的数据
    articles: articlesReducer,
    papers: papersReducer
})

传递给combineReducer的是key-value 键值对,其中键表示传递到对应reducer的数据,也就是说:slice reducer中的state并不是全局state,而是state.articles/state.papers等数据。

如果解决多个slice reducer间共享数据的问题?

slice reducer本质上是为了实现专门数据专门管理,让数据管理更清晰。那么slice reducer间如何共享数据呢?

举个例子,我们异步获取article的时候,会附带将comments也带过来,那么我们在articlesReducer中怎么去维护这份comments数据?

// 不好的方法
// 我们通过两次dispatch来分别更新comments和article
// 缺点是:slice reducer之间严重耦合,代码不容易维护
dispatch(updateComments(comments));
dispatch(updateArticle(article)));

那么有什么更好的办法呢?我们能不能在articlesReducer处理之后,将action透传给commentsReducers呢?看看如下代码

// 定义一个crossReducer
function crossReducer(state, action) {
    switch (action.type) {
        // 处理指定的action
        case UPDATE_COMMENTS:
            return Object.assign({}, state, {
                // 这儿是关键,相当于透传到commentsReducer,然后让commentsReducer去处理对应的逻辑。
                // 这样的话
                // crossReducer不关心commentsReducer的逻辑
                // articlesReducer也不用去关心commentsReducer的逻辑
                comments: commentsReducer(state.comments, action)
            });
        default:
            return state;
    }
}

let combinedReducer = combineReducers({
    entities: entitiesreducer,
    articles: articlesReducer,
    papers: papersReducer
});

// 在其他reducer处理完成后,在进行crossReducer的操作
function rootReducer(state, action) {
    let tempstate = combinedReducer(state, action),
        finalstate = crossReducer(tempstate, action);

    return finalstate;
}

当然,我们可以使用reduce-reducers这个插件来简化上面的rootReducer。

import reduceReducers from 'reduce-reducers';

export const rootReducer = reduceReducers(
    combineReducers({
        entities: entitiesreducer,

        articles: articlesReducer,
        comments: commentsReducer
    }),
    crossReducer
);

原理很简单,先执行某些slice reducer,执行完成后,再去执行crossReducer,而crossReducer本身不做任何的工作,只负责调用关联reducer,并且把数据传到关联reducer中。

如何减少reducer的样板代码?

每次写action/action creator/reducer,都会写很多相似度很高的代码,我们是否可以通过一定封装,来减少这些样板代码呢?
比如我们定义一个createReducer的函数,用来创建slice reducer。如下所示:

function createReducer(initialState, handlers) {
    return function reducer(state = initialState, action) {
        if (handlers.hasOwnProperty(action.type)) {
            return handlers[action.type](state, action)
        } else {
            return state
        }
    }
}

const todosreducer = createReducer([], {
    'ADD_TODO': addTodo,
    'TOGGLE_TODO': toggleTodo,
    'EDIT_TODO': editTodo
});

也可以使用现成的比较好的方案,比如:redux-actions。给个简单的示例,更多的可以查看官方文档。

// 定义action及action creator
const {
    increment,
    descrement
} = createActions({
    INCREMENT: (val) => val,
    DECREMENT: (val) => val
});

// 定义reducer
const reducer = handleActions({
    INCREMENT: (state, action) => ({
        counter: state.counter + action.payload
    }),

    DECREMENT: (state, action) => ({
        counter: state.counter - action.payload
    })
}, { counter: 0 });

减少样板代码之后,代码一下就变得清晰多了。

总结说点啥?

reducer的设计相对于state和action来说要复杂很多,他涉及拆分、合并、数据共享的问题。
本文介绍了怎样最佳实践的去设计reducer,按照上面的步骤下来,可以让你的reducer保持结构简单。

✦ 抽离工具函数,这个不用多说。
✦ 抽离case function,让slice reducer看起来更简洁。其中case function是最小粒度的reducer,是action的处理函数。
✦ 拆分slice reducer,这个是和state拆分匹配的,拆分slice reducer是为了实现专门数据专门管理,并且让slice reducer更加便于维护。
✦ 合并slice reducer,createStore只能接受一个reducer作为参数,所以我们用combineReducer将拆分后的slice reducer合并起来。先拆分再合并其实更多是为了工程上的便利。
✦ 使用crossReducer类似的功能,可以实现slice reducer间数据共享。
✦ 减少reducer的样板代码,这个不多说,使用redux-actions就挺好,但不建议新人这样做。

实际开发中,我个人更喜欢将action和reducer写在一个文件中,并且将redux相关的代码全部放到统一的目录中。
结合上一篇博客讲的 state设计,Redux基本的架构雏形就出来了,当然可以继续深入,比如结合按需加载、路由、数据持久化等等。