Dva 框架源码解析
npm 上装到的 dva 停在 2018 年,仓库里跑的是另一份代码。这篇按仓库 master 读 dva-core:model 在 start 前后是两个不同的函数,effects 的第二个参数是整个 redux-saga。
读 dva 的源码之前得先确认读的是哪一份,因为 npm 上和仓库里差着五年多。
npm i dva # 2.4.1,发布于 2018-10-26npm i dva@next # 3.0.0-alpha.1,发布于 2024-05-10latest 这个 tag 停在 2018 年,中间十几个 2.6.0-beta.* 谁都没被扶正,next 上挂着一个从 2024 年起就没动过的 alpha。仓库本身没有归档(GitHub API 上 archived 是 false),master 也还偶尔有文档提交,但发布这条线确实停了。反倒是两个插件包活得久一些:dva-loading 和 dva-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
dva 和 dva-core 是两个包,分界线划得很干净:packages/dva-core/src/index.js 的 import 列表里没有 react,也没有 react-dom。它只管 Redux store、model、saga,拿到 Node 里跑都行。
React 相关的东西全在 packages/dva/src/index.js:react-redux 的 Provider、react-router-dom、connected-react-router,以及唯一一处渲染。dva 的 package.json 里依赖的是 dva-core、react-redux、react-router-dom、connected-react-router、redux 这几个——dva-loading 和 dva-immer 都不在里面,它们是你自己装、自己 app.use() 挂上去的插件,不是框架的组成部分。
所以「dva 由 dva-core、dva-loading、dva-immer、dva-router 四块组成」是个不准确的说法:一块不存在,两块是可选插件。真实的分层只有两级——dva-core 提供 app.model 和 app.start,dva 在外面套一层 React 和路由。
app.model 在 start 之后换了一个实现
app.model 在应用启动前后是两个不同的函数,这是 dva 最容易看漏的一处设计。
启动前它几乎什么都不做,加个命名空间前缀就塞进数组:
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 里:把 reducers 和 effects 的每个 key 都改写成 ${namespace}/${key}。所以 model 里写 reducers: { save },实际派发的 action type 是 user/save——命名空间不是靠运行时查表实现的,是在注册这一刻就把 key 改掉了。
app._models 也不是空数组起步。dva 自己先塞了一个 namespace: '@@dva' 的内部 model 进去,unmodel 之后靠给它派发 @@dva/UPDATE 来推动全局 state 更新。
真正干活的是 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:
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 的人都记得这个形状:
*fetch({ payload }, { call, put }) { const data = yield call(query, payload); yield put({ type: 'save', payload: data });}流传很广的说法是这第二个参数里装着 put、call、select、take 四个。看一眼 getSaga.js 就知道不是:
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 } 里的 sagaEffects 是 import { effects as sagaEffects } from 'redux-saga'——redux-saga 的全部 effects 都在里面,race、all、fork、cancel、throttle、delay 一个不少。被覆盖掉的只有 put 和 take 两个,覆盖的理由也单一:给 action type 补上命名空间前缀,让你在 model 里写 put({ type: 'save' }) 而不是 put({ type: 'user/save' })。
多出来的那个是 put.resolve。原生 put 派发完就往下走,put.resolve 会等对应的 effect 整个跑完——跨 model 串联异步流程时要的就是它。
effect 写成数组,就能换一种 take
默认每个 effect 都用 takeEvery 包一层。想换成别的,把 effect 写成数组:
effects: { search: [function*({ payload }, { call, put }) { /* … */ }, { type: 'takeLatest' }], scroll: [function*() { /* … */ }, { type: 'throttle', ms: 300 }],}getWatcher 认五个值:takeEvery、takeLatest、throttle、poll、watcher。前三个对应 redux-saga 的同名 helper;poll 是 dva 自己拼的,收到 ${key}-start 开始轮询、收到 ${key}-stop 停,间隔由 delay 指定;watcher 最特殊,它把你的 generator 当成 watcher 本身直接跑,不套任何 take —— 需要自己写 while (true) { yield take(...) } 的时候用它。
throttle 不传 ms、poll 不传 delay 都会被 invariant 拦下来。
真正执行你那个 generator 的地方叫 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/@@start 和 user/fetch/@@end。
这对 action 很容易被认成 dva-loading 的工作原理,但不是。dva-loading 走的是另一条路:插件钩子 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/SHOW 和 HIDE,再用一个注册进 extraReducers 的 reducer 维护 { global, models, effects } 三份状态。onEffect 钩子由 getSaga.js 的 applyOnEffect 逐个套上去,套在 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 是零命中,渲染只发生一次:
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 是 connect 和 useSelector 的事,它们各自订阅、各自决定要不要重渲染。框架层面再来一次全树重渲染,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-core 的 start() 不带参数,只负责把 store 建出来;dva 的 start(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那种还在快速迭代的框架,处境正好相反。