React状态管理工具对比

React状态管理工具对比

Posted by SkioFox on March 11, 2021

dva

简介:dva 首先是一个基于 redux 和 redux-saga 的数据流方案,然后为了简化开发体验,dva 还额外内置了 react-router 和 fetch,所以也可以理解为一个轻量级的应用框架

  • 易学易用,仅有 6 个 api,对 redux 用户尤其友好,配合 umi 使用后更是降低为 0 API
  • elm 概念,通过 reducers, effects 和 subscriptions 组织 model
  • 插件机制
  • 支持 HMR

背景:

  • duck方式组织的redux 概念太多,并且 reducer, action 都是分离的
  • 编辑成本高,需要在 reducer, action 之间来回切换
  • 不便于组织业务模型 (或者叫 domain model) 。比如我们写了一个 userlist 之后,要写一个 productlist,需要复制很多文件
  • saga 书写太复杂,每监听一个 action 都需要走 fork -> watcher -> worker 的流程
  • 模版代码太多

解决问题:

  • dva 是 framework,不是 library,类似 emberjs,会很明确地告诉你每个部件应该怎么写,这对于团队而言,会更可控。
  • dva 实现上尽量不创建新语法,而是用依赖库本身的语法

快速上手:

npm install dva-cli -g
dva new dva-quickstart

cd dva-quickstart
npm start

数据流向:

数据的改变发生通常是通过用户交互行为或者浏览器行为(如路由跳转等)触发的,当此类行为会改变数据的时候可以通过 dispatch 发起一个 action,如果是同步行为会直接通过 Reducers 改变 State ,如果是异步行为(副作用)会先触发 Effects 然后流向 Reducers 最终改变 State,所以在 dva 中,数据流向非常清晰简明,并且思路基本跟开源社区保持一致(也是来自于开源社区)

const app = dva();

app.model({
  namespace: 'count',
  state: { // state
    record: 0,
    current: 0,
  },
  reducers: { // reducers
    add(state) {
      const newCurrent = state.current + 1;
      return { ...state,
        record: newCurrent > state.record ? newCurrent : state.record,
        current: newCurrent,
      };
    },
    minus(state) {
      return { ...state, current: state.current - 1};
    },
  },
  effects: { // saga 中的effect
    *add(action, { call, put }) {
      yield call(delay, 1000);
      yield put({ type: 'minus' });
    },
  },
  subscriptions: {
    keyboardWatcher({ dispatch }) {
      key('⌘+up, ctrl+up', () => { dispatch({type:'add'}) });
    },
  },
});

app.use(require('dva-immer').default());

源码分析:基于1.2.1,后面的版本代码做了拆分,拆分成了dva、dva-core、dva-immer等库,但是核心功能还是基本没变

export default function createDva(createOpts) {
  const {
    mobile,
    initialReducer,
    defaultHistory,
    routerMiddleware,
    setupHistory,
  } = createOpts;

  /**

- Create a dva instance.
   */
  return function dva(hooks = {}) {
    // history and initialState does not pass to plugin
    const history = hooks.history || defaultHistory;
    const initialState = hooks.initialState || {};
    delete hooks.history;
    delete hooks.initialState;

    const plugin = new Plugin();
    plugin.use(hooks);

    const app = {
      // properties
      _models: [],
      _router: null,
      _store: null,
      _history: null,
      _plugin: plugin,
      // methods
      use,
      model,
      router,
      start,
    };
    return app;

    /**
     * Register a model.
     *
     * @param model
     */
    function model(model) {
      this._models.push(checkModel(model, mobile));
    }

    // 动态注册reducer
    function injectModel(createReducer, onError, unlisteners, m) {
      m = checkModel(m, mobile);
      this._models.push(m);
      const store = this._store;

      // reducers
      store.asyncReducers[m.namespace] = getReducer(m.reducers, m.state);
      store.replaceReducer(createReducer(store.asyncReducers));
      // effects
      if (m.effects) {
        store.runSaga(getSaga(m.effects, m, onError));
      }
      // subscriptions
      if (m.subscriptions) {
        unlisteners[m.namespace] = runSubscriptions(m.subscriptions, m, this, onError);
      }
    }

    // 动态删除某个reducer
    function unmodel(createReducer, reducers, _unlisteners, namespace) {
      const store = this._store;

      // Delete reducers
      delete store.asyncReducers[namespace];
      delete reducers[namespace];
      store.replaceReducer(createReducer(store.asyncReducers));
      store.dispatch({ type: '@@dva/UPDATE' });

      // Cancel effects
      store.dispatch({ type: `${namespace}/@@CANCEL_EFFECTS` });

      // unlisten subscrioptions
      if (_unlisteners[namespace]) {
        const { unlisteners, noneFunctionSubscriptions } = _unlisteners[namespace];
        warning(
          noneFunctionSubscriptions.length === 0,
          `app.unmodel: subscription should return unlistener function, check these subscriptions ${noneFunctionSubscriptions.join(', ')}`,
        );
        for (const unlistener of unlisteners) {
          unlistener();
        }
        delete _unlisteners[namespace];
      }

      // delete model from this._models
      this._models = this._models.filter(model => model.namespace !== namespace);
    }

    // 存储传入的router数组
    function router(router) {
      invariant(typeof router === 'function', 'app.router: router should be function');
      this._router = router;
    }

    // 启动方法
    function start(container) {

      // 主动调用一次model方法,注册一个reducer
      model.call(this, {
        namespace: '@@dva',
        state: 0,
        reducers: {
          UPDATE(state) { return state + 1; },
        },
      });

      // get reducers and sagas from model
      const sagas = [];
      const reducers = { ...initialReducer };
      for (const m of this._models) {
        reducers[m.namespace] = getReducer(m.reducers, m.state);
        if (m.effects) sagas.push(getSaga(m.effects, m, onErrorWrapper));
      }

      // extra reducers
      const extraReducers = plugin.get('extraReducers');

      // extra enhancers
      const extraEnhancers = plugin.get('extraEnhancers');

      // create store
      const extraMiddlewares = plugin.get('onAction');
      const reducerEnhancer = plugin.get('onReducer');
      const sagaMiddleware = createSagaMiddleware();
      let middlewares = [
        sagaMiddleware,
        ...flatten(extraMiddlewares),
      ];
      if (routerMiddleware) {
        middlewares = [routerMiddleware(history), ...middlewares];
      }
      let devtools = () => noop => noop;
      if (process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION__) {
        devtools = window.__REDUX_DEVTOOLS_EXTENSION__;
      }
      const enhancers = [
        applyMiddleware(...middlewares),
        devtools(),
        ...extraEnhancers,
      ];
      const store = this._store = createStore(
        createReducer(),
        initialState,
        compose(...enhancers),
      );

      function createReducer(asyncReducers) {
        return reducerEnhancer(combineReducers({
          ...reducers,
          ...extraReducers,
          ...asyncReducers,
        }));
      }

      // extend store
      store.runSaga = sagaMiddleware.run;
      store.asyncReducers = {};

      // store change
      const listeners = plugin.get('onStateChange');
      for (const listener of listeners) {
        store.subscribe(listener);
      }

      // start saga
      sagas.forEach(sagaMiddleware.run);

      // setup history
      if (setupHistory) setupHistory.call(this, history);

      // run subscriptions
      const unlisteners = {};
      for (const model of this._models) {
        if (model.subscriptions) {
          unlisteners[model.namespace] = runSubscriptions(model.subscriptions, model, this,
            onErrorWrapper);
        }
      }

      // inject model after start
      this.model = injectModel.bind(this, createReducer, onErrorWrapper, unlisteners);

      this.unmodel = unmodel.bind(this, createReducer, reducers, unlisteners);

      // If has container, render; else, return react component
      if (container) {
        render(container, store, this, this._router);
        plugin.apply('onHmr')(render.bind(this, container, store, this));
      } else {
        return getProvider(store, this, this._router);
      }
    }

    // //////////////////////////////////
    // Helpers

    function getProvider(store, app, router) {
      return extraProps => (
        <Provider store={store}>
          { router({ app, history: app._history, ...extraProps }) }
        </Provider>
      );
    }

    function render(container, store, app, router) {
      const ReactDOM = require('react-dom');
      ReactDOM.render(React.createElement(getProvider(store, app, router)), container);
    }

    function checkModel(m, mobile) {
      // Clone model to avoid prefixing namespace multiple times
      const model = { ...m };
      const { namespace, reducers, effects } = model;

      invariant(
        namespace,
        'app.model: namespace should be defined',
      );
      invariant(
        !app._models.some(model => model.namespace === namespace),
        'app.model: namespace should be unique',
      );
      invariant(
        mobile || namespace !== 'routing',
        'app.model: namespace should not be routing, it\'s used by react-redux-router',
      );
      invariant(
        !model.subscriptions || isPlainObject(model.subscriptions),
        'app.model: subscriptions should be Object',
      );
      invariant(
        !reducers || isPlainObject(reducers) || Array.isArray(reducers),
        'app.model: reducers should be Object or array',
      );
      invariant(
        !Array.isArray(reducers) || (isPlainObject(reducers[0]) && typeof reducers[1] === 'function'),
        'app.model: reducers with array should be app.model({ reducers: [object, function] })',
      );
      invariant(
        !effects || isPlainObject(effects),
        'app.model: effects should be Object',
      );

      function applyNamespace(type) {
        function getNamespacedReducers(reducers) {
          return Object.keys(reducers).reduce((memo, key) => {
            warning(
              key.indexOf(`${namespace}${SEP}`) !== 0,
              `app.model: ${type.slice(0, -1)} ${key} should not be prefixed with namespace ${namespace}`,
            );
            memo[`${namespace}${SEP}${key}`] = reducers[key];
            return memo;
          }, {});
        }

        if (model[type]) {
          if (type === 'reducers' && Array.isArray(model[type])) {
            model[type][0] = getNamespacedReducers(model[type][0]);
          } else {
            model[type] = getNamespacedReducers(model[type]);
          }
        }
      }

      applyNamespace('reducers');
      applyNamespace('effects');

      return model;
    }

    function isHTMLElement(node) {
      return typeof node === 'object' && node !== null && node.nodeType && node.nodeName;
    }

    function getReducer(reducers, state) {
      // Support reducer enhancer
      // e.g. reducers: [realReducers, enhancer]
      if (Array.isArray(reducers)) {
        return reducers[1](handleActions(reducers[0], state));
      } else {
        return handleActions(reducers || {}, state);
      }
    }

    function getSaga(effects, model, onError) {
      return function *() {
        for (const key in effects) {
          if (Object.prototype.hasOwnProperty.call(effects, key)) {
            const watcher = getWatcher(key, effects[key], model, onError);
            const task = yield sagaEffects.fork(watcher);
            yield sagaEffects.fork(function *() {
              yield sagaEffects.take(`${model.namespace}/@@CANCEL_EFFECTS`);
              yield sagaEffects.cancel(task);
            });
          }
        }
      };
    }

    function getWatcher(key, _effect, model, onError) {
      let effect = _effect;
      let type = 'takeEvery';
      let ms;

      if (Array.isArray(_effect)) {
        effect = _effect[0];
        const opts = _effect[1];
        if (opts && opts.type) {
          type = opts.type;
          if (type === 'throttle') {
            invariant(
              opts.ms,
              'app.start: opts.ms should be defined if type is throttle',
            );
            ms = opts.ms;
          }
        }
        invariant(
          ['watcher', 'takeEvery', 'takeLatest', 'throttle'].indexOf(type) > -1,
          'app.start: effect type should be takeEvery, takeLatest, throttle or watcher',
        );
      }

      function *sagaWithCatch(...args) {
        try {
          yield effect(...args.concat(createEffects(model)));
        } catch (e) {
          onError(e);
        }
      }

      const onEffect = plugin.get('onEffect');
      const sagaWithOnEffect = applyOnEffect(onEffect, sagaWithCatch, model, key);

      switch (type) {
        case 'watcher':
          return sagaWithCatch;
        case 'takeLatest':
          return function*() {
            yield takeLatest(key, sagaWithOnEffect);
          };
        case 'throttle':
          return function*() {
            yield throttle(ms, key, sagaWithOnEffect);
          };
        default:
          return function*() {
            yield takeEvery(key, sagaWithOnEffect);
          };
      }
    }

    function runSubscriptions(subs, model, app, onError) {
      const unlisteners = [];
      const noneFunctionSubscriptions = [];
      for (const key in subs) {
        if (Object.prototype.hasOwnProperty.call(subs, key)) {
          const sub = subs[key];
          invariant(typeof sub === 'function', 'app.start: subscription should be function');
          const unlistener = sub({
            dispatch: createDispatch(app._store.dispatch, model),
            history: app._history,
          }, onError);
          if (isFunction(unlistener)) {
            unlisteners.push(unlistener);
          } else {
            noneFunctionSubscriptions.push(key);
          }
        }
      }
      return { unlisteners, noneFunctionSubscriptions };
    }

    function prefixType(type, model) {
      const prefixedType = `${model.namespace}${SEP}${type}`;
      if ((model.reducers && model.reducers[prefixedType])
        || (model.effects && model.effects[prefixedType])) {
        return prefixedType;
      }
      return type;
    }

    function createEffects(model) {
      function put(action) {
        const { type } = action;
        invariant(type, 'dispatch: action should be a plain Object with type');
        warning(
          type.indexOf(`${model.namespace}${SEP}`) !== 0,
          `effects.put: ${type} should not be prefixed with namespace ${model.namespace}`,
        );
        return sagaEffects.put({ ...action, type: prefixType(type, model) });
      }
      return { ...sagaEffects, put };
    }

    function createDispatch(dispatch, model) {
      return (action) => {
        const { type } = action;
        invariant(type, 'dispatch: action should be a plain Object with type');
        warning(
          type.indexOf(`${model.namespace}${SEP}`) !== 0,
          `dispatch: ${type} should not be prefixed with namespace ${model.namespace}`,
        );
        return dispatch({ ...action, type: prefixType(type, model) });
      };
    }

    function applyOnEffect(fns, effect, model, key) {
      for (const fn of fns) {
        effect = fn(effect, sagaEffects, model, key);
      }
      return effect;
    }
  };
}

所以从源码的角度来看,dva就是对redux、react-redux、redux-saga、react-router的一层封装,封装的代码主要是消除模版代码(比如createStore,combineReducer、store.asyncReducers等),提供一套固定的reducer写法,提高代码的可维护性,但是降低了代码的自由度

同类型的Rematch

  • 定义
    • 简化 reducers
    • 使用 Async/Await 代替 Thunks
export const count = {
    state: 0,
    reducers: {
        increment(state, payload) {
            return state + payload
        },
    },
    effects: dispatch => ({
        async incrementAsync(payload, rootState) {
            await new Promise(resolve => setTimeout(resolve, 1000))
            dispatch.count.increment(payload)
        },
    }),
}

const store = init({ models })

dispatch({ type: 'count/increment', payload: 1 }) // state = { count: 1 }
dispatch.count.increment(1)

dispatch({ type: 'count/incrementAsync', payload: 1 }) // state = { count: 3 } after delay
dispatch.count.incrementAsync(1)

Redux 与 Rematch 的对比

说得清楚点,Rematch 移除了 Redux 所需要的这些东西:

  • 声明 action 类型
  • action 创建函数
  • thunks

  • store 配置
  • mapDispatchToProps
  • sagas

thunk 通常用于在 Redux 中创建异步 action。 不是官方推荐的解决方案。

内部实现如下所示:

派发一个action(dispatch an action),它实际上是一个函数而不是预期的对象。thunk 中间件检查每个动作,看看它是否是一个函数。如果是,中间件调用该函数,并传入一些 store 的方法:dispatch 和 getState。

怎么会这样?一个简单的 action 到底是作为一个动态类型的对象、一个函数,还是一个 Promise?有没有更好的方式来做这件事

其实可以有两种 action

  1. reducer action: 触发 reducer 并改变状态。

  2. effect action:触发异步 action,这可能会调用reducer操作,但异步函数不会直接更改任何状态。

将这两种类型的 action 区分开来,将比上面的thunk用法更有帮助,也更容易理解。

可选的插件机制增强我们的使用体验, 比如@rematch/immer,增加不可变数据

init({
  models,
  plugins: [immerPlugin()],
})

export const todo = {
    state: [
        {
            todo: 'Learn typescript',
            done: true,
        },
        {
            todo: 'Try immer',
            done: false,
        },
    ],
    reducers: {
        done(state) {
            state.push({ todo: 'Tweet about it' })
            state[1].done = true
            return state
        },
        reset(state) {
            state[0].done = false
            return state
        },
    },
}

Rematch vs dva

相同点:

  • 都是为了减少模版代码
  • 都是为了提供最佳实践

不同点:

  • effect的设计不一样,一个是使用saga中间件,一个是区分了同步与异步action
  • dva包括了路由、初始化store等功能,功能更强大
  • rematch设计更简洁一些

同类型的@redux-toolkit 官方认可的redux工具

  • Simple
  • Opinionated
  • Powerful
  • Effective

定义

import { createSlice } from '@reduxjs/toolkit'

export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    increment: (state) => {
      // 集成了immer
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload
    },
  },
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions

export default counterSlice.reducer

Rematch vs @redux-toolkit

相同点:

  • 都是为了减少模版代码
  • 都是为了提供最佳实践

不同点:

  • effect的设计不一样,一个是使用thunk中间件,一个是区分了同步与异步action
  • 一个直接集成了immer库,一个通过可插拔的插件机制来实现
  • rematch设计更简洁一些
  • @redux-toolkit没有提供一步加载reducer的功能

同类型的@redux-toolkit 官方认可的redux工具

总体来说,我们可以参照dva的思路来规划我们的模版,但是我们操作redux的方式可以选择rematch或者@redux-toolkit