Dva 是如何进行数据流管理的?

2,994 阅读10分钟

1 概述

目前流行的数据流方案有:

  • Flux,单向数据流方案,以 Redux 为代表;
  • Reactive,响应式数据流方案,以 Mobx 为代表;
  • 其他,比如 rxjs 等。

2 图解 Dva

以一个 TodoList 作为示例,包含两部分:Todo list + Add todo button。

2.1 原始农耕——React

在不引入状态管理工具,仅使用 React 自身提供的状态管理能力时,涉及到不同组件之间的数据通信时,通常会使用以下做法。 将需要共享的数据提取到最近的公共祖先 中,由公共祖先统一维护一个 state,和相应的修改 state 的方法,将 state 和方法按需以 props 的形式分发到各个子组件中。子组件监听事件,调用 props 传递下来的方法,来更改 state 中的数据。

image.png

2.2 渐入佳境——Redux

当业务比较复杂时,以上方式就显得捉襟见肘了,这个时候就需要借助专业的状态管理工具了。 Redux 的做法是:

  1. 将状态及页面逻辑(reducer)从 中抽取出来,成为独立的 store。
  2. 通过 connect 方法给 和 添加一层 wrapper,与 store 连接起来。
  3. store 中的数据可以以 props 的方式注入组件内。
  4. 组件内通过 dispatch 来向 store 发起一个 action,store 根据这个 action 调用相应的 reducer 来改变 state。
  5. 当 state 更新时,connect 过的组件也会随之刷新。 这样一来,状态与视图的耦合度降低,方便拓展,数据统一管理,数据流向清晰,遇到问题时也方便及时定位。

image.png

2.3 如虎添翼——Saga

实际的项目开发中,少不了异步的网络请求,而上面的数据更新都是同步的。这时候可以在组件 dispatch 一个 action 的时候,使用 Middleware 对其进行拦截,以 redux-saga 为例:

  1. 点击创建 Todo 的按钮,发起一个 type == addTodo 的 action;
  2. saga 拦截这个 action,发起 http 请求,如果请求成功,则继续向 reducer 发一个 type == addTodoSucc 的 action,通过相应的 reducer 把成功返回的数据写入 state;反之则发送 type == addTodoFail 的 action,通过相应的 reducer 把失败的数据写入 state。

image.png

2.4 千呼万唤始出来——Dva

看起来万事俱备了,而 Dva 则是“基于 React + Redux + Saga 的最佳实践沉淀”。Dva 主要做了以下两件事来提升编码体验:

  1. 把 store 及 saga 统一为一个 model 的概念,写在一个 js 文件里面;
  2. 增加了一个 Subscriptions,用于收集其他来源的 action,如键盘操作等;

image.png

以下是一个典型 model 的示例:

app.model({
  namespace: "count",
  state: {
    record: 0,
    current: 0,
  },
  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: {
    *add(action, { call, put }) {
      yield call(delay, 1000);
      yield put({ type: "minus" });
    },
  },
  subscriptions: {
    keyboardWatcher({ dispatch }) {
      key("⌘+up, ctrl+up", () => {
        dispatch({ type: "add" });
      });
    },
  },
});

一些关键字段的解释:

  • namespace:model 的命名空间。
  • state:保存了整个 model 的状态数据,可以是任何值,通常是一个对象。每次操作都要将 state 当作不可变数据(immutable data)来对待,保证每次都是全新对象,没有引用关系,这样才能保证 State 的独立性,便于测试和追踪变化。
  • reducers:定义了一些如何改变 state 的函数,每次会接收老的 state,返回新的 state。reducer 必须是纯函数,所以同样的输入必然得到同样的输出,它们不应该产生任何副作用。并且,每一次的计算都应该使用 immutable data,这种特性简单理解就是每次操作都是返回一个全新的数据(独立,纯净),所以热重载和时间旅行这些功能才能够使用。
  • effects:在这里定义副作用相关的逻辑,在我们的应用中,最常见的就是异步操作。它来自于函数编程的概念,之所以叫副作用是因为它使得我们的函数变得不纯,同样的输入不一定获得同样的输出。dva 为了控制副作用的操作,底层引入了 redux-saga 做异步流程控制,由于采用了generator的相关概念,所以将异步转成同步写法,从而将effects转为纯函数。
  • subscriptions:用于订阅一个数据源,然后根据条件 dispatch 需要的 action。数据源可以是当前的时间、服务器的 websocket 连接、keyboard 输入、geolocation 变化、history 路由变化等等。

其他重要的概念包括:

  • action:一个对象,描述了如何改变 state,使用 dispatch 来发起一次改变。action 必须带有 type 属性指明具体的行为,其它字段可以自定义。
  • dispatch: connect 过的组件的 props 都会包含 dispatch 函数,以 action 作为参数,发起一次对 state 的更改。
dispatch({
  type: 'user/add', // 如果在 model 外调用,需要添加 namespace
  payload: {}, // 需要传递的信息
});

Dva 数据流向图:

image.png

3 源码分析

版本:

  • dva: 2.6.0-beta.22
  • redux: 4.0.4
  • react-redux: 7.2.3

为了简化开发体验,dva 还额外内置了 react-router 和 fetch(这两者不是本次分析的重点),所以 dva 也可以理解为一个轻量级的应用框架。通过 dva-cli 创建了一个最小化的 dva 项目来进行本次分析(官方现在更推荐使用 umi)。

如下图所示,页面包括一个获取远程数据的按钮(用来试用 effects),和一个展示数据的列表。点击“获取远程数据”按钮时,会执行 effect,并将返回的数据展示在列表中。目前展示的是一些初始值。

image.png

3.1 创建 app

项目生成的 index.js 文件里,刚开始调用了 dva 方法创建了一个 app 对象,传入了初始的 state。这个 app 将作为一条主线,贯穿始终(如果读过 vue 的源码,会觉得似曾相识)。

const app = dva({
  initialState: {
    products: [
      { name: "dva", id: 1 },
      { name: "antd", id: 2 },
    ],
  },
});

值得一提的是,dva 将自身拆成了两部分,dva 和 dva-core。前者用高阶组件 react-redux 实现了 view 层,后者是用 redux-saga 解决了 model 层。 来看看生成的 app 都包含哪些内容:

{
  _models:[namespace: '@@dva', state, reducers: {'namespace/key': f}]
  _store: null,
  _plugin: {_handleActions:null, hooks:{onError:[],onStateChange:[],...}},
  use: plugin.use
  model: core 的 model 函数
  router: router 函数
  start: start 函数
}

这里的 _model 后面会用来保存我们定义的所有 model,刚开始里面会包含一条 dva 内部使用的 model,用来在执行 unmodel 的时候更新全局 state。

3.2 添加插件

第二步添加了一些插件。上一步创建了一个 plugin 实例,把实例的 use 方法添加到 app 上,用来加入一些插件。

app.use({});

3.3 载入 model

在这一步,我们在 model 目录中定义的所有 model 都加入进来:

app.model(require("./models/products").default);

// ./models/products.js
export default {
  namespace: "products",
  state: [],
  reducers: {
    delete(state, { payload }) {
      return state.filter((item) => item.id !== payload);
    },
    setList(state, { payload }) {
      return [...state, ...payload];
    },
  },
  effects: {
    *fetchList({ payload }, { put }) {
      const res = yield fetch(
        "https://cloudapi.bytedance.net/faas/services/ttt9zd/invoke/dva"
      );
      const jsonRes = yield res.json();

      yield put({
        type: "setList",
        payload: (jsonRes && jsonRes.data) || [],
      });
    },
  },
};

这一步非常简单,就是把 model 添加到 app._model 数组中:

app._models = [
  {namespace: '', state, reducers:{'@@dva/key': f, ...},}
  {namespace: '', state, reducers:{'products/key': f, ...}, effects: {'products/key': f}}
]

需要注意的是,dva 对我们的 model 做了一些简单的处理,使用 prefixNamespace 方法将 reducers 和 effects 里所有的 key 都加上了 ${namespace}/ 前缀(还记得在 model 外 diapatch 时 type 要加上 namespace 吗?)。

3.4 处理路由组件

app.router(require("./router").default);

// router.js
import React from "react";
import { Router } from "dva/router";
import IndexPage from "./routes/IndexPage";

export default function RouterConfig({ history }) {
  return (
    <Router history={history}>
      <Switch>
        <Route path="/" exact component={IndexPage} />
      </Switch>
    </Router>
  );
}

这一步也比较简单,只是简单地把 router 组件挂到 app 上:

app._router = router

值得注意的是,这个过程中会 connect 组件。例如这里 router 组件中需要渲染的 indexPage 组件:

import React from "react";
import { connect } from "dva";
import { Button, Popconfirm, Table } from "antd";

const Products = (props) => {
  console.log("props", props);
  const { dispatch, products } = props;
  const columns = [
    {
      title: "Name",
      dataIndex: "name",
    },
    {
      title: "Actions",
      render: (text, record) => {
        return (
          <Popconfirm title="Delete?" onConfirm={() => handleDelete(record.id)}>
            <Button>Delete</Button>
          </Popconfirm>
        );
      },
    },
  ];

  function handleDelete(id) {
    dispatch({
      type: "products/delete",
      payload: id,
    });
  }

  function getRemote() {
    dispatch({
      type: "products/fetchList",
    });
  }

  return (
    <div style={{ padding: "24px" }}>
      <Button
        type="primary"
        style={{ marginBottom: "24px" }}
        onClick={getRemote}
      >
        获取远程数据
      </Button>
      <Table dataSource={products} columns={columns} />
    </div>
  );
};

export default connect(({ products }) => ({ products }))(Products);

执行 connect(({ products }) => ({ products }))(Products) 这部分时,会订阅 store,把 dispatch 和 store 中的部分 state(这里是 products)注入组件的 props中。connect(...)(Products) 内部基于 Products 重新创建了一个组件 ConnectFunction,其 WrappedComponent 属性指向 Products。等到 ConnectFunction 组件执行的时候做一些 state 注入(映射为props),dispatch注入,以及一些props 合并、subscriptions 相关的事情。

return useMemo(() => {
  if (shouldHandleStateChanges) {
    // If this component is subscribed to store updates, we need to pass its own
    // subscription instance down to our descendants. That means rendering the same
    // Context instance, and putting a different value into the context.
    return (
      <ContextToUse.Provider value={overriddenContextValue}>
        {renderedWrappedComponent}
      </ContextToUse.Provider>
    )
  }

  return renderedWrappedComponent
}, [ContextToUse, renderedWrappedComponent, overriddenContextValue])

3.5 启动

app.start("#root");

这一步是关键,dva 主要做了以下几件事:

  1. 调用 dva-core 的 start 方法
  2. 创建了两个中间件,一个是 saga 中间件用来处理异步网络请求,一个是 promise 中间件,用来处理 effects
const sagaMiddleware = createSagaMiddleware();
const promiseMiddleware = createPromiseMiddleware(app);
  1. 把所有 app._models 里所有 model 的 reducers 和 effects 收集起来,并做一些处理:
app._getSaga = getSaga.bind(null);

const sagas = [];
const reducers = { ...initialReducer }; // {router: connectRouter(history)}
for (const m of app._models) {
  reducers[m.namespace] = getReducer(m.reducers, m.state, plugin._handleActions);
  if (m.effects) {
    sagas.push(app._getSaga(m.effects, m, onError, plugin.get('onEffect'), hooksAndOpts));
  }
}

处理 reducers 其实就是为了把所有同一个 namespace 下的 reducer 压缩成一个 reducer。按照 dva 中对 model 的约定,我们对业务划分模块后,通常会把同一模块的状态定义在一个 model 文件,每个 model 用 namespace 来区分,一个 namespace 下可能会有多个 reducer。getReducer 整个过程如下(这段代码写的非常简洁,但是信息量却不小):

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

function handleActions(handlers, defaultState) {
  const reducers = Object.keys(handlers).map(type => handleAction(type, handlers[type]));
  const reducer = reduceReducers(...reducers);
  return (state = defaultState, action) => reducer(state, action);
}

function handleAction(actionType, reducer = identify) {
  return (state, action) => {
    const { type } = action;
    if (actionType === type) {
      return reducer(state, action);
    }
    return state;
  };
}

function reduceReducers(...reducers) {
  return (previous, current) => reducers.reduce((p, r) => r(p, current), previous);
}

最终 reducers[m.namespace] 会得到一个函数,这个函数就是这个 namespace 下所有的 reducers 组合的产物。用伪代码表示如下:

 (state = defaultState, action) => {
  let handleActions = [
    (state,action)=> {if (action.type === 'namespace/key1') return reducer(state,action)},
    (state,action)=> {if (action.type === 'namespace/key2') return reducer(state,action)},
  ]
  handleActions[0]()
  handleActions[1]()
}

假如这个 namespace 来了一个 action,这个超级 reducer 里面的每个 reducer 小兵都会依次执行,每个小兵都会先判断一下 action 里的 type 跟自己的 type 是不是一致,如果是的话就调用 reducer 返回新的 state;新的 state 又成为下一个小兵的参数,继续重复上述过程。

  1. 调用 createStore 方法创建 store。上一步每个 namespace 都创建了一个超级 reducer,先将所有的超级 reducer 通过 redux 提供的 combineReducers 方法进行组合。在 createStore 中对所有中间件、enhancers 通过 redux 的 compose 组合成一个 enhancer,这个 enhancer 能够在创建 store 的时候对其进行拓展,来让 store 具备更多第三方的能力,例如 middleware, time travel, persistence 等。最后调用 redux 的 createStore 方法完成 store 的创建。
app._store = createStore({
  reducers: createReducer(), // redux的combination函数
  initialState: hooksAndOpts.initialState || {},
  plugin,
  createOpts,
  sagaMiddleware,
  promiseMiddleware,
});

// createStore
export default function({
  reducers,
  initialState,
  plugin,
  sagaMiddleware,
  promiseMiddleware,
  createOpts: { setupMiddlewares = returnSelf },
}) {
  // extra enhancers
  const extraEnhancers = plugin.get('extraEnhancers');
  const extraMiddlewares = plugin.get('onAction');
  const middlewares = setupMiddlewares([
    promiseMiddleware,
    sagaMiddleware,
    ...flatten(extraMiddlewares),
  ]); // 加了一个 routerMiddleware

  const enhancers = [applyMiddleware(...middlewares), ...extraEnhancers];
  return createStore(reducers, initialState, compose(...enhancers));
}

创建出来的 store 会包含以下几个方法,然后对其进行了一些拓展

app._store = {
  dispatch: ƒ (action);
  getState: ƒ getState();
  replaceReducer: ƒ replaceReducer(nextReducer);
  subscribe: ƒ subscribe(listener);
  Symbol(observable): ƒ observable();
}

const store = app._store;
store.runSaga = sagaMiddleware.run;
store.asyncReducers = {};
  1. 执行 saga
sagas.forEach(sagaMiddleware.run);
  1. setupApp:其实就是对不同类型的 history.listen 方法做一个对齐,(dva 内部依赖的 history 库提供了三种创建 history 的方式:createBrowserHistory, createMemoryHistory, createHashHistory ,这也是 react-router 内部的一个核心依赖)。dva 借助 history.listen 来订阅 url 的变化,从而进行一些组件的渲染工作。
setupApp(app);

app._history = patchHistory(history);
  1. 最后给 app 添加 model 和 unmodel
app.model = injectModel.bind(app, createReducer, onError, unlisteners);
app.unmodel = unmodel.bind(app, createReducer, reducers, unlisteners);
app.replaceModel = replaceModel.bind(app, createReducer, reducers, unlisteners, onError);
  1. 最后一步执行 render 方法
// 前面获取的 container
if (isString(container)) {
  container = document.querySelector(container);
}

if (container) {
  render(container, store, app, app._router);
  app._plugin.apply('onHmr')(render.bind(null, container, store, app));
}

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

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

4 总结

整体看下来,dva 实际上还是依托于 redux、saga 进行数据流管理,只是做了一层很薄的封装,但是在开发体验上降低了状态的管理门槛,很像 vuex。整个代码的组织也是非常清晰的,跟 vue 很像,也是使用了单例模式,然后不断往 app 对象中添加全局方法和变量,来一步步扩展能力,非常值得学习借鉴。

5 参考资料

  1. dvajs.com/guide/intro…
  2. www.yuque.com/flying.ni/t…