我们在 github 上查看 dva 的源码,地址为:github.com/dvajs/dva/b...。
其中包括 dva、dva-core、dva-immer、dva-loading 四个目录:
- dva 是出口,redux、saga、router 等接口都通过这个文件统一导出。
- dva-core 是 dva 的核心代码,对主要依赖和插件的封装在这里面实现。
- dva-immer 是 dva 的一个插件,引用了 immer 库,实现类 vue3 的 Proxy 封装。
- dva-loading 也是 dva 的一个插件,用途是在所有 effect 之外添加一个 loading 状态。
本文的内容主要集中与 redux-saga 的封装。
dva 的官网打不开了,用梯子也不行,不知道是我的问题还是网站的问题。所以我是直接在 github 里看 markdown。
跟读源码
1. 项目入口
首先从 dva 式的 react 项目入口演示开始:github.com/dvajs/dva/b...。
这段代码中:
- 7 行,通过 dva 方法创建了一个 app 对象。
- 9-39 行,通过 app.model 添加一个 count model。
- 58-62 行,通过 app.router 添加了路由配置。
- 64 行,通过 app.start 初始化 react。
javascript
import dva, { connect } from 'dva';
import { Router, Route } from 'dva/router';
import React from 'react';
import styles from './index.less';
import key from 'keymaster';
const app = dva();
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: {
*addThenMinus(action, { call, put }) {
yield put({ type: 'add' });
yield call(delay, 1000);
yield put({ type: 'minus' });
},
},
subscriptions: {
keyboardWatcher({ dispatch }) {
key('⌘+up, ctrl+up', () => { dispatch({type:'addThenMinus'}) });
},
},
});
const CountApp = ({count, dispatch}) => {
return (
<div className={styles.normal}>
<div className={styles.record}>Highest Record: {count.record}</div>
<div className={styles.current}>{count.current}</div>
<div className={styles.button}>
<button onClick={() => { dispatch({type: 'count/addThenMinus'}); }}>+</button>
</div>
</div>
);
};
function mapStateToProps(state) {
return { count: state.count };
}
const HomePage = connect(mapStateToProps)(CountApp);
app.router(({history}) =>
<Router history={history}>
<Route path="/" component={HomePage} />
</Router>
);
app.start('#root');
// ---------
// Helpers
function delay(timeout){
return new Promise(resolve => {
setTimeout(resolve, timeout);
});
}
2. dva 方法
文件地址:github.com/dvajs/dva/b...
在下面的代码中:
- 20 行,通过 dva-core 的 create 方法创建了 app 对象。
- 22、23 行为 app 对象添加 router、start 方法,26 行开始是方法的定义。
- 50-53 行,通过 app 的原 start 方法为 app 添加 store。
ini
/**
其他引用
**/
import { utils, create, saga } from 'dva-core';
export default function(opts = {}) {
const history = opts.history || createHashHistory();
const createOpts = {
initialReducer: {
router: connectRouter(history),
},
setupMiddlewares(middlewares) {
return [routerMiddleware(history), ...middlewares];
},
setupApp(app) {
app._history = patchHistory(history);
},
};
const app = create(opts, createOpts);
const oldAppStart = app.start;
app.router = router;
app.start = start;
return app;
function router(router) {
invariant(
isFunction(router),
`[app.router] router should be function, but got ${typeof router}`,
);
app._router = router;
}
function start(container) {
// 允许 container 是字符串,然后用 querySelector 找元素
if (isString(container)) {
container = document.querySelector(container);
invariant(container, `[app.start] container ${container} not found`);
}
// 并且是 HTMLElement
invariant(
!container || isHTMLElement(container),
`[app.start] container should be HTMLElement`,
);
// 路由必须提前注册
invariant(app._router, `[app.start] router must be registered before app.start()`);
if (!app._store) {
oldAppStart.call(app);
}
const store = app._store;
// export _getProvider for HMR
// ref: https://github.com/dvajs/dva/issues/469
app._getProvider = getProvider.bind(null, store, app);
// If has container, render; else, return react component
if (container) {
render(container, store, app, app._router);
app._plugin.apply('onHmr')(render.bind(null, container, store, app));
} else {
return getProvider(store, this, this._router);
}
}
}
/**
其他代码
**/
3. dva-core 的 create 方法
上面代码的关键是 dva-core 的 create,所以我们将视线转向 dva-core:github.com/dvajs/dva/b...。
在下面的代码中:
- 39-47 行,创建了初始的 app 对象,并返回。
- 158 行开始,是前文调用的 start 方法的定义,关键是配置了 app 的 store,其中:
-
- 176-183 行,收集所有 models 文件中的 reduers 和 effects。
- 218 行,对所有 saga 调用 sagaMiddleware.run,启动所有 saga。
- 181 行,在 sagas 的收集中,调用了 getSaga 方法,这是 dva 对 redux-saga 的封装。
ini
import { combineReducers } from 'redux';
import createSagaMiddleware, * as saga from 'redux-saga';
import invariant from 'invariant';
import checkModel from './checkModel';
import prefixNamespace from './prefixNamespace';
import Plugin, { filterHooks } from './Plugin';
import createStore from './createStore';
import getSaga from './getSaga';
import getReducer from './getReducer';
import createPromiseMiddleware from './createPromiseMiddleware';
import { run as runSubscription, unlisten as unlistenSubscription } from './subscription';
import * as utils from './utils';
const { noop, findIndex } = utils;
// Internal model to update global state when do unmodel
const dvaModel = {
namespace: '@@dva',
state: 0,
reducers: {
UPDATE(state) {
return state + 1;
},
},
};
/**
* Create dva-core instance.
*
* @param hooksAndOpts
* @param createOpts
*/
export function create(hooksAndOpts = {}, createOpts = {}) {
const { initialReducer, setupApp = noop } = createOpts;
const plugin = new Plugin();
plugin.use(filterHooks(hooksAndOpts));
const app = {
_models: [prefixNamespace({ ...dvaModel })],
_store: null,
_plugin: plugin,
use: plugin.use.bind(plugin),
model,
start,
};
return app;
/**
* Register model before app is started.
*
* @param m {Object} model to register
*/
function model(m) {
if (process.env.NODE_ENV !== 'production') {
checkModel(m, app._models);
}
const prefixedModel = prefixNamespace({ ...m });
app._models.push(prefixedModel);
return prefixedModel;
}
/**
* Inject model after app is started.
*
* @param createReducer
* @param onError
* @param unlisteners
* @param m
*/
function injectModel(createReducer, onError, unlisteners, m) {
m = model(m);
const store = app._store;
store.asyncReducers[m.namespace] = getReducer(m.reducers, m.state, plugin._handleActions);
store.replaceReducer(createReducer());
if (m.effects) {
store.runSaga(app._getSaga(m.effects, m, onError, plugin.get('onEffect'), hooksAndOpts));
}
if (m.subscriptions) {
unlisteners[m.namespace] = runSubscription(m.subscriptions, m, app, onError);
}
}
/**
* Unregister model.
*
* @param createReducer
* @param reducers
* @param unlisteners
* @param namespace
*
* Unexpected key warn problem:
* https://github.com/reactjs/redux/issues/1636
*/
function unmodel(createReducer, reducers, unlisteners, namespace) {
const store = app._store;
// Delete reducers
delete store.asyncReducers[namespace];
delete reducers[namespace];
store.replaceReducer(createReducer());
store.dispatch({ type: '@@dva/UPDATE' });
// Cancel effects
store.dispatch({ type: `${namespace}/@@CANCEL_EFFECTS` });
// Unlisten subscrioptions
unlistenSubscription(unlisteners, namespace);
// Delete model from app._models
app._models = app._models.filter(model => model.namespace !== namespace);
}
/**
* Replace a model if it exsits, if not, add it to app
* Attention:
* - Only available after dva.start gets called
* - Will not check origin m is strict equal to the new one
* Useful for HMR
* @param createReducer
* @param reducers
* @param unlisteners
* @param onError
* @param m
*/
function replaceModel(createReducer, reducers, unlisteners, onError, m) {
const store = app._store;
const { namespace } = m;
const oldModelIdx = findIndex(app._models, model => model.namespace === namespace);
if (~oldModelIdx) {
// Cancel effects
store.dispatch({ type: `${namespace}/@@CANCEL_EFFECTS` });
// Delete reducers
delete store.asyncReducers[namespace];
delete reducers[namespace];
// Unlisten subscrioptions
unlistenSubscription(unlisteners, namespace);
// Delete model from app._models
app._models.splice(oldModelIdx, 1);
}
// add new version model to store
app.model(m);
store.dispatch({ type: '@@dva/UPDATE' });
}
/**
* Start the app.
*
* @returns void
*/
function start() {
// Global error handler
const onError = (err, extension) => {
if (err) {
if (typeof err === 'string') err = new Error(err);
err.preventDefault = () => {
err._dontReject = true;
};
plugin.apply('onError', err => {
throw new Error(err.stack || err);
})(err, app._store.dispatch, extension);
}
};
const sagaMiddleware = createSagaMiddleware();
const promiseMiddleware = createPromiseMiddleware(app);
app._getSaga = getSaga.bind(null);
const sagas = [];
const reducers = { ...initialReducer };
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));
}
}
const reducerEnhancer = plugin.get('onReducer');
const extraReducers = plugin.get('extraReducers');
invariant(
Object.keys(extraReducers).every(key => !(key in reducers)),
`[app.start] extraReducers is conflict with other reducers, reducers list: ${Object.keys(
reducers,
).join(', ')}`,
);
// Create store
app._store = createStore({
reducers: createReducer(),
initialState: hooksAndOpts.initialState || {},
plugin,
createOpts,
sagaMiddleware,
promiseMiddleware,
});
const store = app._store;
// Extend store
store.runSaga = sagaMiddleware.run;
store.asyncReducers = {};
// Execute listeners when state is changed
const listeners = plugin.get('onStateChange');
for (const listener of listeners) {
store.subscribe(() => {
listener(store.getState());
});
}
// Run sagas
sagas.forEach(sagaMiddleware.run);
// Setup app
setupApp(app);
// Run subscriptions
const unlisteners = {};
for (const model of this._models) {
if (model.subscriptions) {
unlisteners[model.namespace] = runSubscription(model.subscriptions, model, app, onError);
}
}
// Setup app.model and app.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);
/**
* Create global reducer for redux.
*
* @returns {Object}
*/
function createReducer() {
return reducerEnhancer(
combineReducers({
...reducers,
...extraReducers,
...(app._store ? app._store.asyncReducers : {}),
}),
);
}
}
}
export { saga };
export { utils };
4. 从 getSaga 看 dva 对 redux-saga 的封装
从上文的引用中可以看到,getSaga 来自单独的 getSaga 文件:github.com/dvajs/dva/b...。
getSata 方法是对单个 model 对象中的 effects 做处理,在下面的代码中:
- 7-20 行,是 getSaga 方法的定义主体:遍历所有 effects,用 Object.prototype.hasOwnProperty 过滤非自有属性
- 11 行,调用了 getWatcher 方法。22-105 行,是 getWatcher 方法的定义:
-
- 50-67 行,定义了 sagaWithCatch 生成器函数:函数将 effect 封装了一层 try...catch,并且如果有参数的话会通过 resolve、reject 返回一个 Promise。
- 69 行,调用了 applyOnEffect 方法。162-167 行是 applyOnEffect 方法的定义:其作用是将引入的插件一层层封装到 sagaWithCatch 上,返回新的 effect。
- 71-104 行,是为所有 effect 添加 action 的监听,action 的格式是 modelName/effectName,默认使用 takeEvery。
- 再回到 12 行,通过 fork 来执行 getWatcher 返回的封装后的 effect
- 13-16 行,为所有的 task 添加了 cancel 监听
javascript
import invariant from 'invariant';
import warning from 'warning';
import { effects as sagaEffects } from 'redux-saga';
import { NAMESPACE_SEP } from './constants';
import prefixType from './prefixType';
export default function getSaga(effects, model, onError, onEffect, opts = {}) {
return function*() {
for (const key in effects) {
if (Object.prototype.hasOwnProperty.call(effects, key)) {
const watcher = getWatcher(key, effects[key], model, onError, onEffect, opts);
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, onEffect, opts) {
let effect = _effect;
let type = 'takeEvery';
let ms;
let delayMs;
if (Array.isArray(_effect)) {
[effect] = _effect;
const opts = _effect[1];
if (opts && opts.type) {
({ type } = opts);
if (type === 'throttle') {
invariant(opts.ms, 'app.start: opts.ms should be defined if type is throttle');
({ ms } = opts);
}
if (type === 'poll') {
invariant(opts.delay, 'app.start: opts.delay should be defined if type is poll');
({ delay: delayMs } = opts);
}
}
invariant(
['watcher', 'takeEvery', 'takeLatest', 'throttle', 'poll'].indexOf(type) > -1,
'app.start: effect type should be takeEvery, takeLatest, throttle, poll or watcher',
);
}
function noop() {}
function* sagaWithCatch(...args) {
const { __dva_resolve: resolve = noop, __dva_reject: reject = noop } =
args.length > 0 ? args[0] : {};
try {
yield sagaEffects.put({ type: `${key}${NAMESPACE_SEP}@@start` });
const ret = yield effect(...args.concat(createEffects(model, opts)));
yield sagaEffects.put({ type: `${key}${NAMESPACE_SEP}@@end` });
resolve(ret);
} catch (e) {
onError(e, {
key,
effectArgs: args,
});
if (!e._dontReject) {
reject(e);
}
}
}
const sagaWithOnEffect = applyOnEffect(onEffect, sagaWithCatch, model, key);
switch (type) {
case 'watcher':
return sagaWithCatch;
case 'takeLatest':
return function*() {
yield sagaEffects.takeLatest(key, sagaWithOnEffect);
};
case 'throttle':
return function*() {
yield sagaEffects.throttle(ms, key, sagaWithOnEffect);
};
case 'poll':
return function*() {
function delay(timeout) {
return new Promise(resolve => setTimeout(resolve, timeout));
}
function* pollSagaWorker(sagaEffects, action) {
const { call } = sagaEffects;
while (true) {
yield call(sagaWithOnEffect, action);
yield call(delay, delayMs);
}
}
const { call, take, race } = sagaEffects;
while (true) {
const action = yield take(`${key}-start`);
yield race([call(pollSagaWorker, sagaEffects, action), take(`${key}-stop`)]);
}
};
default:
return function*() {
yield sagaEffects.takeEvery(key, sagaWithOnEffect);
};
}
}
function createEffects(model, opts) {
function assertAction(type, name) {
invariant(type, 'dispatch: action should be a plain Object with type');
const { namespacePrefixWarning = true } = opts;
if (namespacePrefixWarning) {
warning(
type.indexOf(`${model.namespace}${NAMESPACE_SEP}`) !== 0,
`[${name}] ${type} should not be prefixed with namespace ${model.namespace}`,
);
}
}
function put(action) {
const { type } = action;
assertAction(type, 'sagaEffects.put');
return sagaEffects.put({ ...action, type: prefixType(type, model) });
}
// The operator `put` doesn't block waiting the returned promise to resolve.
// Using `put.resolve` will wait until the promsie resolve/reject before resuming.
// It will be helpful to organize multi-effects in order,
// and increase the reusability by seperate the effect in stand-alone pieces.
// https://github.com/redux-saga/redux-saga/issues/336
function putResolve(action) {
const { type } = action;
assertAction(type, 'sagaEffects.put.resolve');
return sagaEffects.put.resolve({
...action,
type: prefixType(type, model),
});
}
put.resolve = putResolve;
function take(type) {
if (typeof type === 'string') {
assertAction(type, 'sagaEffects.take');
return sagaEffects.take(prefixType(type, model));
} else if (Array.isArray(type)) {
return sagaEffects.take(
type.map(t => {
if (typeof t === 'string') {
assertAction(t, 'sagaEffects.take');
return prefixType(t, model);
}
return t;
}),
);
} else {
return sagaEffects.take(type);
}
}
return { ...sagaEffects, put, take };
}
function applyOnEffect(fns, effect, model, key) {
for (const fn of fns) {
effect = fn(effect, sagaEffects, model, key);
}
return effect;
}
5. dva 添加插件,以 dva-loading 为例
5.1. dva-loading
dva-loading 的源代码在此处:github.com/dvajs/dva/b...。
其中定义了 createLoading 方法,返回了 extraReducers 和 onEffect 两个对象。其中 onEffect 的定义如下,用处是在 effect 前后添加 loading 状态变化:
php
function onEffect(effect, { put }, model, actionType) {
const { namespace } = model;
if (
(only.length === 0 && except.length === 0) ||
(only.length > 0 && only.indexOf(actionType) !== -1) ||
(except.length > 0 && except.indexOf(actionType) === -1)
) {
return function*(...args) {
yield put({ type: SHOW, payload: { namespace, actionType } });
yield effect(...args);
yield put({ type: HIDE, payload: { namespace, actionType } });
};
} else {
return effect;
}
}
5.2. dva-core/Plugin
dva 添加插件依赖的是 dva-core 中的 Plugin:github.com/dvajs/dva/b...。
在上文第 3 段"dva-core 的 create 方法",其中 36-47 行代码中:
- 创建了 plugin 对象,并调用 use 方法处理了 create 的默认参数 hooksAndOpts。
- 将 use 方法添加到了 app 对象上。
在 Plugin 文件中:
- 34-49 行,是 Plugin 类的 use 方法的定义。该方法将插件中的 onEffect 或其他属性添加到 plugin 对象的特定 hooks 中。
- 68-79 行,是 get 方法的定义,其作用是读取特定 hooks,例如在第 3 段代码中收集 sagas 时:sagas.push(app._getSaga(m.effects, m, onError, plugin.get('onEffect'), hooksAndOpts))。
- onEffect 如何使用?可以回看第 4 段------从 getSaga 看 dva 对 redux-saga 的封装。
ini
import invariant from 'invariant';
import { isPlainObject } from './utils';
const hooks = [
'onError',
'onStateChange',
'onAction',
'onHmr',
'onReducer',
'onEffect',
'extraReducers',
'extraEnhancers',
'_handleActions',
];
export function filterHooks(obj) {
return Object.keys(obj).reduce((memo, key) => {
if (hooks.indexOf(key) > -1) {
memo[key] = obj[key];
}
return memo;
}, {});
}
export default class Plugin {
constructor() {
this._handleActions = null;
this.hooks = hooks.reduce((memo, key) => {
memo[key] = [];
return memo;
}, {});
}
use(plugin) {
invariant(isPlainObject(plugin), 'plugin.use: plugin should be plain object');
const { hooks } = this;
for (const key in plugin) {
if (Object.prototype.hasOwnProperty.call(plugin, key)) {
invariant(hooks[key], `plugin.use: unknown plugin property: ${key}`);
if (key === '_handleActions') {
this._handleActions = plugin[key];
} else if (key === 'extraEnhancers') {
hooks[key] = plugin[key];
} else {
hooks[key].push(plugin[key]);
}
}
}
}
apply(key, defaultHandler) {
const { hooks } = this;
const validApplyHooks = ['onError', 'onHmr'];
invariant(validApplyHooks.indexOf(key) > -1, `plugin.apply: hook ${key} cannot be applied`);
const fns = hooks[key];
return (...args) => {
if (fns.length) {
for (const fn of fns) {
fn(...args);
}
} else if (defaultHandler) {
defaultHandler(...args);
}
};
}
get(key) {
const { hooks } = this;
invariant(key in hooks, `plugin.get: hook ${key} cannot be got`);
if (key === 'extraReducers') {
return getExtraReducers(hooks[key]);
} else if (key === 'onReducer') {
return getOnReducer(hooks[key]);
} else {
return hooks[key];
}
}
}
function getExtraReducers(hook) {
let ret = {};
for (const reducerObj of hook) {
ret = { ...ret, ...reducerObj };
}
return ret;
}
function getOnReducer(hook) {
return function(reducer) {
for (const reducerEnhancer of hook) {
reducer = reducerEnhancer(reducer);
}
return reducer;
};
}
6. dva 中的 Promise 中间件
在第 4 段"从 getSaga 看 dva 对 redux-saga 的封装"中,有对 sagaWithCatch 函数(50-67 行)的简单介绍,其中 resolve 和 reject 涉及到 dva 对 effect 的 Promise 封装,单列一个小结讲解。
sagaWithCatch 函数中的 resolve、reject 来自 __dva_resolve、__dva_reject 两个属性,这两个属性来自第 3 段"dva-core 的 create 方法"代码 200 行的 promiseMiddleware。这个中间件为所有 action 添加了这两个属性,使得我们在调用 model 中定义的 effect 时,可以使用 then 接收返回值,catch 捕捉错误:
scss
dispatch('modelName/effectName').then(() => {}).catch(() => {})
promiseMiddleware 的实现在:github.com/dvajs/dva/b...。
typescript
import { NAMESPACE_SEP } from './constants';
export default function createPromiseMiddleware(app) {
return () => next => action => {
const { type } = action;
if (isEffect(type)) {
return new Promise((resolve, reject) => {
next({
__dva_resolve: resolve,
__dva_reject: reject,
...action,
});
});
} else {
return next(action);
}
};
function isEffect(type) {
if (!type || typeof type !== 'string') return false;
const [namespace] = type.split(NAMESPACE_SEP);
const model = app._models.filter(m => m.namespace === namespace)[0];
if (model) {
if (model.effects && model.effects[type]) {
return true;
}
}
return false;
}
}