1 概述
目前流行的数据流方案有:
- Flux,单向数据流方案,以 Redux 为代表;
- Reactive,响应式数据流方案,以 Mobx 为代表;
- 其他,比如 rxjs 等。
2 图解 Dva
以一个 TodoList 作为示例,包含两部分:Todo list + Add todo button。
2.1 原始农耕——React
在不引入状态管理工具,仅使用 React 自身提供的状态管理能力时,涉及到不同组件之间的数据通信时,通常会使用以下做法。 将需要共享的数据提取到最近的公共祖先 中,由公共祖先统一维护一个 state,和相应的修改 state 的方法,将 state 和方法按需以 props 的形式分发到各个子组件中。子组件监听事件,调用 props 传递下来的方法,来更改 state 中的数据。
2.2 渐入佳境——Redux
当业务比较复杂时,以上方式就显得捉襟见肘了,这个时候就需要借助专业的状态管理工具了。 Redux 的做法是:
- 将状态及页面逻辑(reducer)从 中抽取出来,成为独立的 store。
- 通过 connect 方法给 和 添加一层 wrapper,与 store 连接起来。
- store 中的数据可以以 props 的方式注入组件内。
- 组件内通过 dispatch 来向 store 发起一个 action,store 根据这个 action 调用相应的 reducer 来改变 state。
- 当 state 更新时,connect 过的组件也会随之刷新。 这样一来,状态与视图的耦合度降低,方便拓展,数据统一管理,数据流向清晰,遇到问题时也方便及时定位。
2.3 如虎添翼——Saga
实际的项目开发中,少不了异步的网络请求,而上面的数据更新都是同步的。这时候可以在组件 dispatch 一个 action 的时候,使用 Middleware 对其进行拦截,以 redux-saga 为例:
- 点击创建 Todo 的按钮,发起一个 type == addTodo 的 action;
- saga 拦截这个 action,发起 http 请求,如果请求成功,则继续向 reducer 发一个 type == addTodoSucc 的 action,通过相应的 reducer 把成功返回的数据写入 state;反之则发送 type == addTodoFail 的 action,通过相应的 reducer 把失败的数据写入 state。
2.4 千呼万唤始出来——Dva
看起来万事俱备了,而 Dva 则是“基于 React + Redux + Saga 的最佳实践沉淀”。Dva 主要做了以下两件事来提升编码体验:
- 把 store 及 saga 统一为一个 model 的概念,写在一个 js 文件里面;
- 增加了一个 Subscriptions,用于收集其他来源的 action,如键盘操作等;
以下是一个典型 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 数据流向图:
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,并将返回的数据展示在列表中。目前展示的是一些初始值。
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 主要做了以下几件事:
- 调用 dva-core 的 start 方法
- 创建了两个中间件,一个是 saga 中间件用来处理异步网络请求,一个是 promise 中间件,用来处理 effects
const sagaMiddleware = createSagaMiddleware();
const promiseMiddleware = createPromiseMiddleware(app);
- 把所有 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 又成为下一个小兵的参数,继续重复上述过程。
- 调用 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 = {};
- 执行 saga
sagas.forEach(sagaMiddleware.run);
- setupApp:其实就是对不同类型的 history.listen 方法做一个对齐,(dva 内部依赖的 history 库提供了三种创建 history 的方式:createBrowserHistory, createMemoryHistory, createHashHistory ,这也是 react-router 内部的一个核心依赖)。dva 借助 history.listen 来订阅 url 的变化,从而进行一些组件的渲染工作。
setupApp(app);
app._history = patchHistory(history);
- 最后给 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);
- 最后一步执行 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 对象中添加全局方法和变量,来一步步扩展能力,非常值得学习借鉴。