# react项目开发中重置redux state的思考与实践

王剑

写在前面:

  • 上个月开始写的文章,今天才写完,为什么?你猜啊!
  • 阅读本文需要对redux有一定实践经验,对redux中间件的执行过程有一定了解,本文只会简单提一下相关细节
  • 本文用到的redux相关包有react-redux,redux-actions等
  • 本文所造轮子应该会不适用于一些结构比较神奇的state tree,希望本文能起到一个抛砖引玉的效果

场景

开发中经常会遇到需要重置redux中state的情况(比如组件卸载时),其实这可以说是使用redux带来的副作用?注意这里说的是redux store中的state,不是react组件自身的state。 在获取redux store存储的state时一般我们使用react-redux的connect方法,但重置的时候似乎没有什么特别好的办法,于是我们就写了一个actionCreator, 再写一个对应的reducer,然后在组件的componentWillUnmount时dispatch一下,比如如下伪代码:

// 加入重置代码
// reducer.js
export const exampleState = handleActions({  
  'resolve example state'(state, action) {
    // do something
    return {...newState}
  },
  'reset example state'(state, action) {
    // 返回初始state
    return {...initState}
  }
}, initState)

// action.js
export const exampleAction = createAction('resolve example state')  
// 创建重置的action creator
export const resetExampleAction = createAction('reset example state')

// page.js
@connect(
  state => ({
    exampleState: state.exampleState
  })
)
export default class Page extends Component {  
  ...
  componentWillUnmount() {
    // 组件unmount时重置
    this.props.dispatch(resetExampleAction())
  }
  render(){
    return (
      <div>
        .....
      </div>
    )
  }
}

这么写的优势很明显,简单粗暴,通俗易懂,好的,完结撒花,继续搬砖去了...才怪。如果只是一个两个组件有这个需求,当然是没有问题的,但是更多时怎么办(比如我遇到的,一个需求好几十个组件都需要重置state,当时我的内心是崩溃的), 难道要一个一个加么?作为一只懒惰的猿,本能的表示拒绝,于是我打开了快...百...google,但是也许是我的搜索方式不对,并没有找到合适的轮子,既然找不到,那就自己造吧。OK,正文开始。

本文并不算短,代码较多,大佬们可以直接拉到最底部看最终实现及使用方法。

需求整理

首先我真的是一个懒惰的程序猿,所以我希望轮子足够简单好用,我不想写那么多actionCreator, 也不想写那些reducer,如果有一个方法,导入之后,在需要的时候调一下,就能重置相应的state就好了,能作为一个高阶组件当然就更棒了,所以需求整理如下:

  • 轮子应该是一个简单易用的方法
  • 最好能作为一个高阶组件

其实满足了第一条就等于满足了第二条,所以我们需要讨论的只有第一条。

思路与解决

这个方法要重置state,肯定要知道是哪个state,也要知道这个state应该重置成什么样,也就是state树建立的时这个state的样子,比如上面伪代码里initState的样子,所以很容易想到传一个key,传一个初始状态,嗯,美滋滋,看起来还挺简单的,但是细想的话这么实现可以当然可以,但是很明显这样并不合理,一是繁琐,二是如果state格式在后续有什么变化,很可能清理state那段代码会静静的躺在角落里被人遗忘(什么?你说单独拉一份初始state作为一个模块,在使用的时候导入?你说的好有道理啊,我竟无言以对)。我真的太懒,我不想传那么多参数,其实我一个参数都不想传,嗯,然而思来想去发现并不可行,于是退而求其次,需求就变成了:

  • 一个传state key的方法

小伙伴们看到这里肯定会有疑问:

  • action怎么办,reducer呢?
  • 不传state的初始状态,怎么让它变成它初始的样子

小伙伴们都知道,redux中要修改store里的state,只能dispatch action(如果你不知道,我其实更好奇是什么在驱使你看这篇文章到现在,难道是我的颜值?),所以这个轮子方法肯定是需要dispatch action的,于是问题就变成了:

  • 这个方法怎么得到并调用dispatch方法
  • 这个action怎么设计
  • 这个reducer怎么写以及写在哪
  • 如何知道state的初始状态

首先:怎么得到dispatch方法呢?或者说怎么把dispatch方法存起来,让这个轮子能调到呢?勤劳的小伙伴们如果看过redux源码的话肯定想到了,redux中间件其实就是在一层层的增强dispatch方法,在中间件内部我们不仅可以访问dispatch方法,还可以访问getState方法,也就是说,在中间件内部我们是可以访问到整个state树的,聪明的小伙伴们是不是想到了什么?如果想到了,那现在可以暂停下来,不要继续往下看了,闭上眼睛思考五分钟,思考一下刚才的思路是否可行,思考完再来对比下与本文实现方式有什么不同,如本文开头所说,抛砖引玉。

------------------------- 五分钟过后? -------------------------

贴一段 redux-thunk(点我跳转gayhub看源码) 的源码:

function createThunkMiddleware(extraArgument) {  
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      // 判断传入的action是一个函数,
      // 则将dispatch方法传给action,由action函数内部去调用dispatch
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

相信有的小伙伴已经想明白了,我们只需要写一个中间件,类似于 redux-thunk 一样将dispatch方法传给另一个方法,那这个方法就可以调用dispatch方法,但是小伙伴们肯定注意到了,redux-thunk 在传递dispatch方法时,是每次dispatch的调用都会先判断,再决定是否传递dispatch的,很明显,我们的期望是传递一次,将方法保存起来调用就好,所以我们这个中间件需要换一种写法,伪代码:

export let resetFunc = () => {/*这个花括号里可以写点警告什么的,比如提示需要先安装中间件*/}  
const reduxResetMiddleware = ({ dispatch, getState }) => next => {  
  let func = dispatch => arg => {
    ...
    ...
    ...
    // 在这个方法体内就可以调用dispatch了

  }
  resetFunc = func(dispatch)
  return action => next(action);
}

简单解释一下这段代码,resetFunc方法就是暴露出去的用来清理state树指定state的方法,在最初始时它是一个空函数,在中间件安装完之后,它就变成了我们需要的清理state用的方法,那什么时候安装的中间件呢? 贴一小段 redux applyMiddleware(点我跳转)方法的源码:

const middlewareAPI = {  
  getState: store.getState,
  dispatch: (...args) => dispatch(...args)
}
chain = middlewares.map(middleware => middleware(middlewareAPI))

// 中间件是在执行compose这行代码时安装的,
// 简单提一下,redux中间件需要传入一个dispatch方法,然后返回另一个包装过后的dispatch方法,
// 上面redux-thunk源码中的 next 参数就是传入的dispatch,
// 返回的那个函数就是包装过后的dispatch,
// compose在这里的作用就是将所有的redux中间件顺序进行这步操作,
// 将上一个中间件返回的dispatch传到下一个中间件,最后得到最终的dispatch
dispatch = compose(...chain)(store.dispatch) 

compose只会在创建store时执行一次,非常符合需求,store创建完成之后,上面的resetFunc方法就变成了可以调用dispatch的方法,OK,如何调用dispatch完美解决。接下来就是action以及初始state的问题,前面提到了,传入参数是state key,那么它们自然是作为payload存在的,前面也提到了,中间件内部可以访问getState方法拿到state tree,而redux createStore(点我跳转)方法在调用时是会dispatch一个初始action以构建初始state tree的,请注意,安装中间件是在构建初始state tree之后进行的,而且是紧接着执行,所以我们自然可以通过它拿到初始的state tree(就在上面那段伪代码的func方法里),我们需要做的就是在此时此刻把它保存起来。

写到这里,每个state key对应的初始状态我们就可以轻松的拿到了,拿到之后就可以dispatch更新对应的state,但是还差一个reducer,OK,现在来解决这最后一个问题。关于reducer,我个人觉得,我们没有必要,也不应该根据各个state key批量生成一堆的reducer(万一是嵌套的state呢?),只要一个就好,reducer写法没有难点,在用法上,无法在构造rootReducer的时候使用类似 react-router-redux 的routing的方式作为rootReducer的子reducer,因为传入它的state必须是整个state tree,类似routing的话,在构建state tree的时候,中间件还未安装,无法将整个state tree作为初始state(不过其实并不是说代码无法实现,如果你导入redux的ActionTypes.INIT,执行的时候判断一下,返回一个空对象,然后在安装中间件的时候把拷贝到的初始state tree合并到这个空对象,此时state tree中依然保存了该对象的引用,是可以达成效果的,但是你都这么机智了,却真的打算这么做么?),后续传进去的state也就无法实现需求了,所以我们只能对rootReducer包一层,so,轮子半残代码如下,小伙伴们也可以戳我看源码

import { cloneDeep, isArray, isObject, isUndefined } from 'lodash/fp'

// 用来保存复制的初始state
let cacheInitialState = {};

// 自定义的重置的action type
let resetActionType = '';

const defaultResetActionType = '@@redux-reset-state/RESET';

// 缓存state,返回reset方法
function createResetReduxStateFunction({ dispatch, getState }) {  
  cacheInitialState = cloneDeep(getState());
  return (payload) => {
    dispatch({
      type: resetActionType,
      payload: payload
    })
  }
}
function throwPayloadError() {  
  throw new Error('Expected the arg to be a array or an object which has a array-type property named `stateKeys`')
}

// 如果是用于reset的action,则清理对应的state,否则走rootReducer
export function composeRootReducer(rootReducer) {  
  return (state, action) => {
    const { type, payload } = action;
    if(type === resetActionType) {
      const stateKeys = isArray(payload) 
                        ? payload 
                        : (isObject(payload) && isArray(payload.stateKeys)) 
                          ? payload.stateKeys 
                          : throwPayloadError();
      const newState = { ...state };
      stateKeys.forEach(key => {
        !isUndefined(cacheInitialState[key]) && (newState[key] = cloneDeep(cacheInitialState[key]));
      })
      return newState;
    }
    return rootReducer(state, action);
  }
}

// 真正的用于清理state的方法,未安装本轮子中间件时被调用的话,什么也不干,可以报个警告之类
// 由于有些小伙伴的打包工具版本可能较低,没有实现es6的模块导出,
// realResetReduxState被重新赋值之后,外界可能无法感知,故需要这么一个方法
let realResetReduxState = () => {};

// 对外暴露的用于清理state的方法
export const resetReduxState = (...arg) => realResetReduxState(...arg);

function createResetMiddleware(actionType = defaultResetActionType) {  
  resetActionType = actionType;
  return ({ dispatch, getState }) => next => {

    // 安装中间件之后重新给realResetReduxState赋值,使它可以清理state
    realResetReduxState = createResetReduxStateFunction({ dispatch, getState });

    return action => next(action);
  }
}

// 中间件本体
const resetMiddleware = createResetMiddleware();

// 允许自定义action type
resetMiddleware.withResetActionType = createResetMiddleware;

export { resetMiddleware as default }  

简单的使用示例如下:

// store.js
import { createStore, applyMiddleware } from 'redux'  
import rootReducer from 'your/local/reducer/path'  
import { composeRootReducer, resetMiddleware } from 'your/local/path/redux-reset-state'

export default function configure(initialState) {  
  const createStoreWithMiddleware = applyMiddleware(
    resetMiddleware,
  )(createStore)

  const composedReducer = composeRootReducer(rootReducer);
  const store = createStoreWithMiddleware(composedReducer , initialState)
  return store
}

// page.js
import reduxResetHOC  from 'your/local/path/redux-reset-state'

@connect(
  state => ({
    exampleState: state.exampleState,
    exampleState2: state.exampleState2
  })
)
@reduxResetHOC(['exampleState', 'exampleState2'])
export default class Page extends Component {  
  ...
  render(){
    return (
      <div>
        .....
      </div>
    )
  }
}

除了这堆半残代码,还封装了一个高阶组件(如上),会在组件unmount的时候自动清理相应的state,用法有点类似于connect,传一个state key的数组进去就可以,在connect的时候,你已经知道你连接了哪个state,对着需要清理的state key ctrl + c && ctrl + v就可以。

由于开发中经常会见到历时代码里有一些不那么纯的reducer,所以代码里会进行深拷贝,而且初始state很少有非常复杂的对象,清理state的操作也并没有那么频繁,所以这部分性能损耗应该不至于对体验造成多大的影响(欢迎拍砖)。

写在最后

轮子代码并不完善,比如嵌套state的处理,比如使用了 immutable.js 的话能否正常工作,所以没有发成npm包(完善的readme都没有,发什么包 /捂脸),不怕死的小伙伴可以手动下载下来用用看。当然,欢迎各路大佬们提pr。

最后,作为一只弱鸡,有几个问题:

  • reducer里直接使用外部复制保存的cacheInitialState是否合理,有大佬科普一下么?
  • redux中的state key广义上来讲是其实也是一个actionType?
  • 好像没了

(第一次发文章,终于写完了,hin激动啊,万一火了呢?有可能被大佬翻牌子么?还是睡觉吧,不睡觉怎么做梦)