Dva 框架源码解析

npm 上装到的 dva 停在 2018 年,仓库里跑的是另一份代码。这篇按仓库 master 读 dva-core:model 在 start 前后是两个不同的函数,effects 的第二个参数是整个 redux-saga。

预计
8 分钟

读 dva 的源码之前得先确认读的是哪一份,因为 npm 上和仓库里差着五年多。

两条命令装到的不是同一个框架
npm i dva # 2.4.1,发布于 2018-10-26
npm i dva@next # 3.0.0-alpha.1,发布于 2024-05-10

latest 这个 tag 停在 2018 年,中间十几个 2.6.0-beta.* 谁都没被扶正,next 上挂着一个从 2024 年起就没动过的 alpha。仓库本身没有归档(GitHub API 上 archivedfalse),master 也还偶尔有文档提交,但发布这条线确实停了。反倒是两个插件包活得久一些:dva-loadingdva-immer 的最后一次发布都在 2024-05-10。

顺便澄清一个到处被抄的说法:dva-router 这个包不存在,npm 上查无此名。能 import 的是 dva/router,它是 dva 包里的一个子路径,内容是把 react-router-dom(v5)原样再导出一遍,外加 connected-react-router 换名叫 routerRedux

下面的代码都按仓库 master(也就是 3.0.0-alpha.1)读,路径写全,能直接去 dvajs/dva 里搜到。

dva 拆成两层,只有上面那层认识 React

dvadva-core 是两个包,分界线划得很干净:packages/dva-core/src/index.js 的 import 列表里没有 react,也没有 react-dom。它只管 Redux store、model、saga,拿到 Node 里跑都行。

React 相关的东西全在 packages/dva/src/index.jsreact-reduxProviderreact-router-domconnected-react-router,以及唯一一处渲染。dvapackage.json 里依赖的是 dva-corereact-reduxreact-router-domconnected-react-routerredux 这几个——dva-loadingdva-immer 都不在里面,它们是你自己装、自己 app.use() 挂上去的插件,不是框架的组成部分。

所以「dva 由 dva-core、dva-loading、dva-immer、dva-router 四块组成」是个不准确的说法:一块不存在,两块是可选插件。真实的分层只有两级——dva-core 提供 app.modelapp.startdva 在外面套一层 React 和路由。

app.model 在 start 之后换了一个实现

app.model 在应用启动前后是两个不同的函数,这是 dva 最容易看漏的一处设计。

启动前它几乎什么都不做,加个命名空间前缀就塞进数组:

dva-core/src/index.js · start 之前的 app.model
function model(m) {
if (process.env.NODE_ENV !== 'production') {
checkModel(m, app._models);
}
const prefixedModel = prefixNamespace({ ...m });
app._models.push(prefixedModel);
return prefixedModel;
}

prefixNamespace 做的事在 prefixNamespace.js 里:把 reducerseffects 的每个 key 都改写成 ${namespace}/${key}。所以 model 里写 reducers: { save },实际派发的 action type 是 user/save——命名空间不是靠运行时查表实现的,是在注册这一刻就把 key 改掉了。

app._models 也不是空数组起步。dva 自己先塞了一个 namespace: '@@dva' 的内部 model 进去,unmodel 之后靠给它派发 @@dva/UPDATE 来推动全局 state 更新。

真正干活的是 start() 跑完之后。它最后三行把三个方法重新绑了一遍:

dva-core/src/index.js · start() 的最后三行
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);

从这一刻起,app.model(m) 指向的是 injectModel

dva-core/src/index.js · injectModel
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);
}
}

三行关键。新 reducer 存进 store.asyncReducers,然后 replaceReducer 把整棵 reducer 树换掉——Redux 没有「往 store 上加一个 reducer」的 API,能做的只有整体替换,createReducer() 每次都拿 combineReducers 重新拼一份。saga 那边靠 store.runSaga,它是 start() 里挂上去的 sagaMiddleware.run

这就是路由懒加载能工作的原因:分包加载进来的 model 在启动之后才注册,走的是 injectModel 这条路。

值得说一句代价:replaceReducer 换的是整棵树,asyncReducers 这份表只增不减除非你显式 unmodel。热更新时反复注册同名 model 会走 replaceModel,它会先派发 ${namespace}/@@CANCEL_EFFECTS 把旧的 saga 取消掉——这个动作要是漏了,旧 watcher 会和新的一起响应同一个 action。

effects 的第二个参数是整个 redux-saga,不是精选的四个

写 dva 的人都记得这个形状:

model 里的 effects
*fetch({ payload }, { call, put }) {
const data = yield call(query, payload);
yield put({ type: 'save', payload: data });
}

流传很广的说法是这第二个参数里装着 putcallselecttake 四个。看一眼 getSaga.js 就知道不是:

dva-core/src/getSaga.js · createEffects 的返回值
function createEffects(model, opts) {
// 省略:assertAction —— 你自己加了命名空间前缀时会警告
function put(action) {
const { type } = action;
assertAction(type, 'sagaEffects.put');
return sagaEffects.put({ ...action, type: prefixType(type, model) });
}
// put.resolve 等 promise 落定再往下走,是 dva 自己加的,redux-saga 上没有
put.resolve = putResolve;
function take(type) {
// 省略:字符串、数组、其它三种入参,都会被补上 `${namespace}/` 前缀
}
return { ...sagaEffects, put, take };
}

{ ...sagaEffects, put, take } 里的 sagaEffectsimport { effects as sagaEffects } from 'redux-saga'——redux-saga 的全部 effects 都在里面raceallforkcancelthrottledelay 一个不少。被覆盖掉的只有 puttake 两个,覆盖的理由也单一:给 action type 补上命名空间前缀,让你在 model 里写 put({ type: 'save' }) 而不是 put({ type: 'user/save' })

多出来的那个是 put.resolve。原生 put 派发完就往下走,put.resolve 会等对应的 effect 整个跑完——跨 model 串联异步流程时要的就是它。

effect 写成数组,就能换一种 take

默认每个 effect 都用 takeEvery 包一层。想换成别的,把 effect 写成数组:

model · effect 的第二种写法
effects: {
search: [function*({ payload }, { call, put }) { /* … */ }, { type: 'takeLatest' }],
scroll: [function*() { /* … */ }, { type: 'throttle', ms: 300 }],
}

getWatcher 认五个值:takeEverytakeLatestthrottlepollwatcher。前三个对应 redux-saga 的同名 helper;poll 是 dva 自己拼的,收到 ${key}-start 开始轮询、收到 ${key}-stop 停,间隔由 delay 指定;watcher 最特殊,它把你的 generator 当成 watcher 本身直接跑,不套任何 take —— 需要自己写 while (true) { yield take(...) } 的时候用它。

throttle 不传 mspoll 不传 delay 都会被 invariant 拦下来。

真正执行你那个 generator 的地方叫 sagaWithCatch,也在这个文件里:

dva-core/src/getSaga.js · sagaWithCatch(节选)
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);
}

第 3 行就是上一节说的那个第二参数——createEffects(model, opts)concat 到你的参数后面。第 2 行和第 4 行是一对包住 effect 的 action,user/fetch/@@startuser/fetch/@@end

这对 action 很容易被认成 dva-loading 的工作原理,但不是dva-loading 走的是另一条路:插件钩子 onEffect

dva-loading/src/index.js · onEffect(节选)
function onEffect(effect, { put }, model, actionType) {
const { namespace } = model;
return function*(...args) {
yield put({ type: SHOW, payload: { namespace, actionType } });
yield effect(...args);
yield put({ type: HIDE, payload: { namespace, actionType } });
};
}

它派发的是自己的 @@DVA_LOADING/SHOWHIDE,再用一个注册进 extraReducers 的 reducer 维护 { global, models, effects } 三份状态。onEffect 钩子由 getSaga.jsapplyOnEffect 逐个套上去,套在 sagaWithCatch 外面——所以 SHOW 比 @@start 还早一步。

@@start / @@end 给谁用?它们只是被派发出去的普通 action,dva 自己没有消费者,留给需要的插件自己接。

注意 catch 里没有 rethrow:effect 抛错会被 onError 吃掉,走全局错误处理。想让某次错误不 reject 外面的 promise,得在 error 上挂 _dontReject

start() 从来没有在 store.subscribe 里重新渲染

有一个说法流传得很广,值得单独拿出来否掉:dva 的 start() 订阅 store,每次 state 变化就重新调一次 ReactDOM.render

任何版本都不是这样。packages/dva/src/index.js 里 grep subscribe 是零命中,渲染只发生一次:

dva/src/index.js · 全仓库唯一一处渲染
function render(container, store, app, router) {
const ReactDOM = require('react-dom/client'); // eslint-disable-line
ReactDOM.createRoot(container).render(React.createElement(getProvider(store, app, router)));
}

getProvider 返回的组件外面套着 react-redux<Provider store={store}>订阅 store 是 connectuseSelector 的事,它们各自订阅、各自决定要不要重渲染。框架层面再来一次全树重渲染,react-redux 那套精细的订阅就全白做了。

dva-core 里确实有 store.subscribe,但它做的是另一件事——把 onStateChange 这个插件钩子挂上去,给需要监听全局 state 的插件用,和渲染无关。

还有一处版本差异要留意:上面这段 master 的代码用的是 React 18 的 react-dom/client + createRoot。已发布的 2.6.0-beta.23(2023-04-11)还是老的 ReactDOM.render。引这段代码时得说清楚是哪个版本,否则对着 npm 装下来的包会找不到。

两个 start 也别搞混:dva-corestart() 不带参数,只负责把 store 建出来;dvastart(container)oldAppStart.call(app) 复用前者,再决定是渲染到容器上,还是在不传 container 时把组件返回给你自己渲染。

这套设计换来了什么,又赔上了什么

dva 解决的是 Redux 的文件散落问题。同一个功能的 action type、reducer、异步逻辑本来要分三个文件写,Redux、MobX、Recoil、Zustand 、Jotai 对比里那份样板代码的账,dva 的办法是按领域收进一个 model 对象里,命名空间在注册时静态展开。这个思路和 Vuex 的 namespaced modules 是同一个(见 Vue.js状态管理模式:构建可扩展的应用架构),只是 dva 把异步那一半交给了 saga 而不是 action。

代价有三样,都挺实在。

effects 是 saga 不是 thunk,起步成本高一截。 换来的是可取消、可用 take 编排先后、可以测试 generator 的 yield 序列;但团队里得有人真的懂 saga,否则写出来的还是回调套回调,只是外面包了个 function*

命名空间是字符串。 put({ type: 'save' }) 里的 'save' 没有任何类型信息,改个 reducer 名字编译器不会报错,只会在运行时静悄悄不响应。这是 dva 时代的通病,也是后来大家转向 Redux Toolkit 的直接理由之一。

版本停在那儿了。 latest 是 2018 年的包,锁着 react-router-dom v5 和 connected-react-router,两者都跟不上 React Router v6 之后的 API。新项目不该从这里起步;已经在跑的老项目也不用急着换,它没坏,只是不再往前走了——和 Nextjs 是如何实现 production ready 的 react那种还在快速迭代的框架,处境正好相反。