dva
简介:dva 首先是一个基于 redux 和 redux-saga 的数据流方案,然后为了简化开发体验,dva 还额外内置了 react-router 和 fetch,所以也可以理解为一个轻量级的应用框架
- 易学易用,仅有 6 个 api,对 redux 用户尤其友好,配合 umi 使用后更是降低为 0 API
- elm 概念,通过 reducers, effects 和 subscriptions 组织 model
- 插件机制
- 支持 HMR
背景:
- duck方式组织的redux 概念太多,并且 reducer, action 都是分离的
- 编辑成本高,需要在 reducer, action 之间来回切换
- 不便于组织业务模型 (或者叫 domain model) 。比如我们写了一个 userlist 之后,要写一个 productlist,需要复制很多文件
- saga 书写太复杂,每监听一个 action 都需要走 fork -> watcher -> worker 的流程
- 模版代码太多
解决问题:
- dva 是 framework,不是 library,类似 emberjs,会很明确地告诉你每个部件应该怎么写,这对于团队而言,会更可控。
- dva 实现上尽量不创建新语法,而是用依赖库本身的语法
快速上手:
npm install dva-cli -g
dva new dva-quickstart
cd dva-quickstart
npm start
数据流向:
数据的改变发生通常是通过用户交互行为或者浏览器行为(如路由跳转等)触发的,当此类行为会改变数据的时候可以通过 dispatch 发起一个 action,如果是同步行为会直接通过 Reducers 改变 State ,如果是异步行为(副作用)会先触发 Effects 然后流向 Reducers 最终改变 State,所以在 dva 中,数据流向非常清晰简明,并且思路基本跟开源社区保持一致(也是来自于开源社区)
const app = dva();
app.model({
namespace: 'count',
state: { // state
record: 0,
current: 0,
},
reducers: { // 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: { // saga 中的effect
*add(action, { call, put }) {
yield call(delay, 1000);
yield put({ type: 'minus' });
},
},
subscriptions: {
keyboardWatcher({ dispatch }) {
key('⌘+up, ctrl+up', () => { dispatch({type:'add'}) });
},
},
});
app.use(require('dva-immer').default());
源码分析:基于1.2.1,后面的版本代码做了拆分,拆分成了dva、dva-core、dva-immer等库,但是核心功能还是基本没变
export default function createDva(createOpts) {
const {
mobile,
initialReducer,
defaultHistory,
routerMiddleware,
setupHistory,
} = createOpts;
/**
- Create a dva instance.
*/
return function dva(hooks = {}) {
// history and initialState does not pass to plugin
const history = hooks.history || defaultHistory;
const initialState = hooks.initialState || {};
delete hooks.history;
delete hooks.initialState;
const plugin = new Plugin();
plugin.use(hooks);
const app = {
// properties
_models: [],
_router: null,
_store: null,
_history: null,
_plugin: plugin,
// methods
use,
model,
router,
start,
};
return app;
/**
* Register a model.
*
* @param model
*/
function model(model) {
this._models.push(checkModel(model, mobile));
}
// 动态注册reducer
function injectModel(createReducer, onError, unlisteners, m) {
m = checkModel(m, mobile);
this._models.push(m);
const store = this._store;
// reducers
store.asyncReducers[m.namespace] = getReducer(m.reducers, m.state);
store.replaceReducer(createReducer(store.asyncReducers));
// effects
if (m.effects) {
store.runSaga(getSaga(m.effects, m, onError));
}
// subscriptions
if (m.subscriptions) {
unlisteners[m.namespace] = runSubscriptions(m.subscriptions, m, this, onError);
}
}
// 动态删除某个reducer
function unmodel(createReducer, reducers, _unlisteners, namespace) {
const store = this._store;
// Delete reducers
delete store.asyncReducers[namespace];
delete reducers[namespace];
store.replaceReducer(createReducer(store.asyncReducers));
store.dispatch({ type: '@@dva/UPDATE' });
// Cancel effects
store.dispatch({ type: `${namespace}/@@CANCEL_EFFECTS` });
// unlisten subscrioptions
if (_unlisteners[namespace]) {
const { unlisteners, noneFunctionSubscriptions } = _unlisteners[namespace];
warning(
noneFunctionSubscriptions.length === 0,
`app.unmodel: subscription should return unlistener function, check these subscriptions ${noneFunctionSubscriptions.join(', ')}`,
);
for (const unlistener of unlisteners) {
unlistener();
}
delete _unlisteners[namespace];
}
// delete model from this._models
this._models = this._models.filter(model => model.namespace !== namespace);
}
// 存储传入的router数组
function router(router) {
invariant(typeof router === 'function', 'app.router: router should be function');
this._router = router;
}
// 启动方法
function start(container) {
// 主动调用一次model方法,注册一个reducer
model.call(this, {
namespace: '@@dva',
state: 0,
reducers: {
UPDATE(state) { return state + 1; },
},
});
// get reducers and sagas from model
const sagas = [];
const reducers = { ...initialReducer };
for (const m of this._models) {
reducers[m.namespace] = getReducer(m.reducers, m.state);
if (m.effects) sagas.push(getSaga(m.effects, m, onErrorWrapper));
}
// extra reducers
const extraReducers = plugin.get('extraReducers');
// extra enhancers
const extraEnhancers = plugin.get('extraEnhancers');
// create store
const extraMiddlewares = plugin.get('onAction');
const reducerEnhancer = plugin.get('onReducer');
const sagaMiddleware = createSagaMiddleware();
let middlewares = [
sagaMiddleware,
...flatten(extraMiddlewares),
];
if (routerMiddleware) {
middlewares = [routerMiddleware(history), ...middlewares];
}
let devtools = () => noop => noop;
if (process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION__) {
devtools = window.__REDUX_DEVTOOLS_EXTENSION__;
}
const enhancers = [
applyMiddleware(...middlewares),
devtools(),
...extraEnhancers,
];
const store = this._store = createStore(
createReducer(),
initialState,
compose(...enhancers),
);
function createReducer(asyncReducers) {
return reducerEnhancer(combineReducers({
...reducers,
...extraReducers,
...asyncReducers,
}));
}
// extend store
store.runSaga = sagaMiddleware.run;
store.asyncReducers = {};
// store change
const listeners = plugin.get('onStateChange');
for (const listener of listeners) {
store.subscribe(listener);
}
// start saga
sagas.forEach(sagaMiddleware.run);
// setup history
if (setupHistory) setupHistory.call(this, history);
// run subscriptions
const unlisteners = {};
for (const model of this._models) {
if (model.subscriptions) {
unlisteners[model.namespace] = runSubscriptions(model.subscriptions, model, this,
onErrorWrapper);
}
}
// inject model after start
this.model = injectModel.bind(this, createReducer, onErrorWrapper, unlisteners);
this.unmodel = unmodel.bind(this, createReducer, reducers, unlisteners);
// If has container, render; else, return react component
if (container) {
render(container, store, this, this._router);
plugin.apply('onHmr')(render.bind(this, container, store, this));
} else {
return getProvider(store, this, this._router);
}
}
// //////////////////////////////////
// Helpers
function getProvider(store, app, router) {
return extraProps => (
<Provider store={store}>
{ router({ app, history: app._history, ...extraProps }) }
</Provider>
);
}
function render(container, store, app, router) {
const ReactDOM = require('react-dom');
ReactDOM.render(React.createElement(getProvider(store, app, router)), container);
}
function checkModel(m, mobile) {
// Clone model to avoid prefixing namespace multiple times
const model = { ...m };
const { namespace, reducers, effects } = model;
invariant(
namespace,
'app.model: namespace should be defined',
);
invariant(
!app._models.some(model => model.namespace === namespace),
'app.model: namespace should be unique',
);
invariant(
mobile || namespace !== 'routing',
'app.model: namespace should not be routing, it\'s used by react-redux-router',
);
invariant(
!model.subscriptions || isPlainObject(model.subscriptions),
'app.model: subscriptions should be Object',
);
invariant(
!reducers || isPlainObject(reducers) || Array.isArray(reducers),
'app.model: reducers should be Object or array',
);
invariant(
!Array.isArray(reducers) || (isPlainObject(reducers[0]) && typeof reducers[1] === 'function'),
'app.model: reducers with array should be app.model({ reducers: [object, function] })',
);
invariant(
!effects || isPlainObject(effects),
'app.model: effects should be Object',
);
function applyNamespace(type) {
function getNamespacedReducers(reducers) {
return Object.keys(reducers).reduce((memo, key) => {
warning(
key.indexOf(`${namespace}${SEP}`) !== 0,
`app.model: ${type.slice(0, -1)} ${key} should not be prefixed with namespace ${namespace}`,
);
memo[`${namespace}${SEP}${key}`] = reducers[key];
return memo;
}, {});
}
if (model[type]) {
if (type === 'reducers' && Array.isArray(model[type])) {
model[type][0] = getNamespacedReducers(model[type][0]);
} else {
model[type] = getNamespacedReducers(model[type]);
}
}
}
applyNamespace('reducers');
applyNamespace('effects');
return model;
}
function isHTMLElement(node) {
return typeof node === 'object' && node !== null && node.nodeType && node.nodeName;
}
function getReducer(reducers, state) {
// Support reducer enhancer
// e.g. reducers: [realReducers, enhancer]
if (Array.isArray(reducers)) {
return reducers[1](handleActions(reducers[0], state));
} else {
return handleActions(reducers || {}, state);
}
}
function getSaga(effects, model, onError) {
return function *() {
for (const key in effects) {
if (Object.prototype.hasOwnProperty.call(effects, key)) {
const watcher = getWatcher(key, effects[key], model, onError);
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) {
let effect = _effect;
let type = 'takeEvery';
let ms;
if (Array.isArray(_effect)) {
effect = _effect[0];
const opts = _effect[1];
if (opts && opts.type) {
type = opts.type;
if (type === 'throttle') {
invariant(
opts.ms,
'app.start: opts.ms should be defined if type is throttle',
);
ms = opts.ms;
}
}
invariant(
['watcher', 'takeEvery', 'takeLatest', 'throttle'].indexOf(type) > -1,
'app.start: effect type should be takeEvery, takeLatest, throttle or watcher',
);
}
function *sagaWithCatch(...args) {
try {
yield effect(...args.concat(createEffects(model)));
} catch (e) {
onError(e);
}
}
const onEffect = plugin.get('onEffect');
const sagaWithOnEffect = applyOnEffect(onEffect, sagaWithCatch, model, key);
switch (type) {
case 'watcher':
return sagaWithCatch;
case 'takeLatest':
return function*() {
yield takeLatest(key, sagaWithOnEffect);
};
case 'throttle':
return function*() {
yield throttle(ms, key, sagaWithOnEffect);
};
default:
return function*() {
yield takeEvery(key, sagaWithOnEffect);
};
}
}
function runSubscriptions(subs, model, app, onError) {
const unlisteners = [];
const noneFunctionSubscriptions = [];
for (const key in subs) {
if (Object.prototype.hasOwnProperty.call(subs, key)) {
const sub = subs[key];
invariant(typeof sub === 'function', 'app.start: subscription should be function');
const unlistener = sub({
dispatch: createDispatch(app._store.dispatch, model),
history: app._history,
}, onError);
if (isFunction(unlistener)) {
unlisteners.push(unlistener);
} else {
noneFunctionSubscriptions.push(key);
}
}
}
return { unlisteners, noneFunctionSubscriptions };
}
function prefixType(type, model) {
const prefixedType = `${model.namespace}${SEP}${type}`;
if ((model.reducers && model.reducers[prefixedType])
|| (model.effects && model.effects[prefixedType])) {
return prefixedType;
}
return type;
}
function createEffects(model) {
function put(action) {
const { type } = action;
invariant(type, 'dispatch: action should be a plain Object with type');
warning(
type.indexOf(`${model.namespace}${SEP}`) !== 0,
`effects.put: ${type} should not be prefixed with namespace ${model.namespace}`,
);
return sagaEffects.put({ ...action, type: prefixType(type, model) });
}
return { ...sagaEffects, put };
}
function createDispatch(dispatch, model) {
return (action) => {
const { type } = action;
invariant(type, 'dispatch: action should be a plain Object with type');
warning(
type.indexOf(`${model.namespace}${SEP}`) !== 0,
`dispatch: ${type} should not be prefixed with namespace ${model.namespace}`,
);
return dispatch({ ...action, type: prefixType(type, model) });
};
}
function applyOnEffect(fns, effect, model, key) {
for (const fn of fns) {
effect = fn(effect, sagaEffects, model, key);
}
return effect;
}
};
}
所以从源码的角度来看,dva就是对redux、react-redux、redux-saga、react-router的一层封装,封装的代码主要是消除模版代码(比如createStore,combineReducer、store.asyncReducers等),提供一套固定的reducer写法,提高代码的可维护性,但是降低了代码的自由度
同类型的Rematch
- 定义
- 简化 reducers
- 使用 Async/Await 代替 Thunks
export const count = {
state: 0,
reducers: {
increment(state, payload) {
return state + payload
},
},
effects: dispatch => ({
async incrementAsync(payload, rootState) {
await new Promise(resolve => setTimeout(resolve, 1000))
dispatch.count.increment(payload)
},
}),
}
const store = init({ models })
dispatch({ type: 'count/increment', payload: 1 }) // state = { count: 1 }
dispatch.count.increment(1)
dispatch({ type: 'count/incrementAsync', payload: 1 }) // state = { count: 3 } after delay
dispatch.count.incrementAsync(1)
Redux 与 Rematch 的对比
说得清楚点,Rematch 移除了 Redux 所需要的这些东西:
- 声明 action 类型
- action 创建函数
-
thunks
- store 配置
- mapDispatchToProps
- sagas
thunk 通常用于在 Redux 中创建异步 action。 不是官方推荐的解决方案。
内部实现如下所示:
派发一个action(dispatch an action),它实际上是一个函数而不是预期的对象。thunk 中间件检查每个动作,看看它是否是一个函数。如果是,中间件调用该函数,并传入一些 store 的方法:dispatch 和 getState。
怎么会这样?一个简单的 action 到底是作为一个动态类型的对象、一个函数,还是一个 Promise?有没有更好的方式来做这件事
其实可以有两种 action
-
reducer action: 触发 reducer 并改变状态。
-
effect action:触发异步 action,这可能会调用reducer操作,但异步函数不会直接更改任何状态。
将这两种类型的 action 区分开来,将比上面的thunk用法更有帮助,也更容易理解。
可选的插件机制增强我们的使用体验, 比如@rematch/immer,增加不可变数据
init({
models,
plugins: [immerPlugin()],
})
export const todo = {
state: [
{
todo: 'Learn typescript',
done: true,
},
{
todo: 'Try immer',
done: false,
},
],
reducers: {
done(state) {
state.push({ todo: 'Tweet about it' })
state[1].done = true
return state
},
reset(state) {
state[0].done = false
return state
},
},
}
Rematch vs dva
相同点:
- 都是为了减少模版代码
- 都是为了提供最佳实践
不同点:
- effect的设计不一样,一个是使用saga中间件,一个是区分了同步与异步action
- dva包括了路由、初始化store等功能,功能更强大
- rematch设计更简洁一些
同类型的@redux-toolkit 官方认可的redux工具
- Simple
- Opinionated
- Powerful
- Effective
定义
import { createSlice } from '@reduxjs/toolkit'
export const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
// 集成了immer
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
},
},
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
Rematch vs @redux-toolkit
相同点:
- 都是为了减少模版代码
- 都是为了提供最佳实践
不同点:
- effect的设计不一样,一个是使用thunk中间件,一个是区分了同步与异步action
- 一个直接集成了immer库,一个通过可插拔的插件机制来实现
- rematch设计更简洁一些
- @redux-toolkit没有提供一步加载reducer的功能
同类型的@redux-toolkit 官方认可的redux工具
总体来说,我们可以参照dva的思路来规划我们的模版,但是我们操作redux的方式可以选择rematch或者@redux-toolkit