一文徹底搞懂 DvaJS 原理
Dva 是什么
dva 首先是一個(gè)基于redux[1]和redux-saga[2]的數(shù)據(jù)流方案,然后為了簡(jiǎn)化開發(fā)體驗(yàn),dva 還額外內(nèi)置了react-router[3]和fetch[4],所以也可以理解為一個(gè)輕量級(jí)的應(yīng)用框架。
Dva 解決的問題
經(jīng)過一段時(shí)間的自學(xué)或培訓(xùn),大家應(yīng)該都能理解 redux 的概念,并認(rèn)可這種數(shù)據(jù)流的控制可以讓應(yīng)用更可控,以及讓邏輯更清晰。但隨之而來通常會(huì)有這樣的疑問:概念太多,并且 reducer, saga, action 都是分離的(分文件)。
- 文件切換問題。redux 的項(xiàng)目通常要分 reducer, action, saga, component 等等,他們的分目錄存放造成的文件切換成本較大。
- 不便于組織業(yè)務(wù)模型 (或者叫 domain model) 。比如我們寫了一個(gè) userlist 之后,要寫一個(gè) productlist,需要復(fù)制很多文件。
- saga 創(chuàng)建麻煩,每監(jiān)聽一個(gè) action 都需要走 fork -> watcher -> worker 的流程
- entry 創(chuàng)建麻煩??梢钥聪逻@個(gè)redux entry[5]的例子,除了 redux store 的創(chuàng)建,中間件的配置,路由的初始化,Provider 的 store 的綁定,saga 的初始化,還要處理 reducer, component, saga 的 HMR 。這就是真實(shí)的項(xiàng)目應(yīng)用 redux 的例子,看起來比較復(fù)雜。
Dva 的優(yōu)勢(shì)
- 易學(xué)易用,僅有 6 個(gè) api,對(duì) redux 用戶尤其友好,配合 umi 使用[6]后更是降低為 0 API
- elm 概念,通過 reducers, effects 和 subscriptions 組織 model
- 插件機(jī)制,比如dva-loading[7]可以自動(dòng)處理 loading 狀態(tài),不用一遍遍地寫 showLoading 和 hideLoading
- 支持 HMR,基于babel-plugin-dva-hmr[8]實(shí)現(xiàn) components、routes 和 models 的 HMR
Dva 的劣勢(shì)
- 未來不確定性高。dva\@3 前年提出計(jì)劃后,官方幾乎不再維護(hù)[9]。
- 對(duì)于絕大多數(shù)不是特別復(fù)雜的場(chǎng)景來說,目前可以被 Hooks 取代
Dva 的適用場(chǎng)景
- 業(yè)務(wù)場(chǎng)景:組件間通信多,業(yè)務(wù)復(fù)雜,需要引入狀態(tài)管理的項(xiàng)目
- 技術(shù)場(chǎng)景:使用 React Class Component 寫的項(xiàng)目
Dva 核心概念
- 基于 Redux 理念的數(shù)據(jù)流向。用戶的交互或?yàn)g覽器行為通過 dispatch 發(fā)起一個(gè) action,如果是同步行為會(huì)直接通過 Reducers 改變 State,如果是異步行為(可以稱為副作用)會(huì)先觸發(fā) Effects 然后流向 Reducers 最終改變 State。
- 基于 Redux 的基本概念。包括:
- State 數(shù)據(jù),通常為一個(gè) JavaScript 對(duì)象,操作的時(shí)候每次都要當(dāng)作不可變數(shù)據(jù)(immutable data)來對(duì)待,保證每次都是全新對(duì)象,沒有引用關(guān)系,這樣才能保證 State 的獨(dú)立性,便于測(cè)試和追蹤變化。
- Action 行為,一個(gè)普通 JavaScript 對(duì)象,它是改變 State 的唯一途徑。
- dispatch,一個(gè)用于觸發(fā) action 改變 State 的函數(shù)。
- Reducer 描述如何改變數(shù)據(jù)的純函數(shù),接受兩個(gè)參數(shù):已有結(jié)果和 action 傳入的數(shù)據(jù),通過運(yùn)算得到新的 state。
- Effects(Side Effects) 副作用,常見的表現(xiàn)為異步操作。dva 為了控制副作用的操作,底層引入了redux-sagas[10]做異步流程控制,由于采用了generator 的相關(guān)概念[11],所以將異步轉(zhuǎn)成同步寫法,從而將 effects 轉(zhuǎn)為純函數(shù)。
- Connect 一個(gè)函數(shù),綁定 State 到 View
- 其他概念
- Subscription,訂閱,從源頭獲取數(shù)據(jù),然后根據(jù)條件 dispatch 需要的 action,概念來源于elm[12]。數(shù)據(jù)源可以是當(dāng)前的時(shí)間、服務(wù)器的 websocket 連接、keyboard 輸入、geolocation 變化、history 路由變化等等。
- Router,前端路由,dva 實(shí)例提供了 router 方法來控制路由,使用的是react-router[13]。
- Route Components,跟數(shù)據(jù)邏輯無關(guān)的組件。通常需要 connect Model 的組件都是 Route Components,組織在/routes/目錄下,而/components/目錄下則是純組件(Presentational Components,詳見組件設(shè)計(jì)方法[14])
Dva 應(yīng)用最簡(jiǎn)結(jié)構(gòu)
不帶 Model
- import dva from 'dva';
- const App = () => <div>Hello dva</div>;
- // 創(chuàng)建應(yīng)用
- const app = dva();
- // 注冊(cè)視圖
- app.router(() => <App />);
- // 啟動(dòng)應(yīng)用
- app.start('#root');
帶 Model
- // 創(chuàng)建應(yīng)用
- const app = dva();
- app.use(createLoading()) // 使用插件
- // 注冊(cè) Model
- app.model({
- namespace: 'count',
- state: 0,
- reducers: {
- add(state) { return state + 1 },
- },
- effects: {
- *addAfter1Second(action, { call, put }) {
- yield call(delay, 1000);
- yield put({ type: 'add' });
- },
- },
- });
- // 注冊(cè)視圖
- app.router(() => <ConnectedApp />);
- // 啟動(dòng)應(yīng)用
- app.start('#root');
Dva底層原理和部分關(guān)鍵實(shí)現(xiàn)
背景介紹
- 整個(gè) dva 項(xiàng)目使用 lerna 管理的,在每個(gè) package 的 package.json 中找到模塊對(duì)應(yīng)的入口文件,然后查看對(duì)應(yīng)源碼。
- dva 是個(gè)函數(shù),返回一了個(gè) app 的對(duì)象。
- 目前 dva 的源碼核心部分包含兩部分,dva 和 dva-core。前者用高階組件 React-redux 實(shí)現(xiàn)了 view 層,后者是用 redux-saga 解決了 model 層。
dva[15]
dva 做了三件比較重要的事情:
- 代理 router 和 start 方法,實(shí)例化 app 對(duì)象
- 調(diào)用 dva-core 的 start 方法,同時(shí)渲染視圖
- 使用 react-redux 完成了 react 到 redux 的連接。
- // dva/src/index.js
- export default function (opts = {}) {
- // 1. 使用 connect-react-router 和 history 初始化 router 和 history
- // 通過添加 redux 的中間件 react-redux-router,強(qiáng)化了 history 對(duì)象的功能
- const history = opts.history || createHashHistory();
- const createOpts = {
- initialReducer: {
- router: connectRouter(history),
- },
- setupMiddlewares(middlewares) {
- return [routerMiddleware(history), ...middlewares];
- },
- setupApp(app) {
- app._history = patchHistory(history);
- },
- };
- // 2. 調(diào)用 dva-core 里的 create 方法 ,函數(shù)內(nèi)實(shí)例化一個(gè) app 對(duì)象。
- const app = create(opts, createOpts);
- const oldAppStart = app.start;
- // 3. 用自定義的 router 和 start 方法代理
- app.router = router;
- app.start = start;
- return app;
- // 3.1 綁定用戶傳遞的 router 到 app._router
- function router(router) {
- invariant(
- isFunction(router),
- `[app.router] router should be function, but got ${typeof router}`,
- );
- app._router = router;
- }
- // 3.2 調(diào)用 dva-core 的 start 方法,并渲染視圖
- function start(container) {
- // 對(duì) container 做一系列檢查,并根據(jù) container 找到對(duì)應(yīng)的DOM節(jié)點(diǎn)
- if (!app._store) {
- oldAppStart.call(app);
- }
- const store = app._store;
- // 為HMR暴露_getProvider接口
- // ref: https://github.com/dvajs/dva/issues/469
- app._getProvider = getProvider.bind(null, store, app);
- // 渲染視圖
- 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);
- }
- }
- }
- function getProvider(store, app, router) {
- const DvaRoot = extraProps => (
- <Provider store={store}>{router({ app, history: app._history, ...extraProps })}</Provider>
- );
- return DvaRoot;
- }
- function render(container, store, app, router) {
- const ReactDOM = require('react-dom'); // eslint-disable-line
- ReactDOM.render(React.createElement(getProvider(store, app, router)), container);
- }
我們同時(shí)可以發(fā)現(xiàn) app 是通過 create(opts, createOpts)進(jìn)行初始化的,其中 opts 是暴露給使用者的配置,createOpts 是暴露給開發(fā)者的配置,真實(shí)的 create 方法在 dva-core 中實(shí)現(xiàn)
dva-core[16]
dva-core 則完成了核心功能:
1. 通過 create 方法完成 app 實(shí)例的構(gòu)造,并暴露 use、model 和 start 三個(gè)接口
2. 通過 start 方法完成
- store 的初始化
- models 和 effects 的封裝,收集并運(yùn)行 sagas
- 運(yùn)行所有的 model.subscriptions
- 暴露 app.model、app.unmodel、app.replaceModel 三個(gè)接口
dva-core create
作用: 完成 app 實(shí)例的構(gòu)造,并暴露 use、model 和 start 三個(gè)接口
- // dva-core/src/index.js
- const dvaModel = {
- namespace: '@@dva',
- state: 0,
- reducers: {
- UPDATE(state) {
- return state + 1;
- },
- },
- };
- export function create(hooksAndOpts = {}, createOpts = {}) {
- const { initialReducer, setupApp = noop } = createOpts; // 在dva/index.js中構(gòu)造了createOpts對(duì)象
- const plugin = new Plugin(); // dva-core中的插件機(jī)制,每個(gè)實(shí)例化的dva對(duì)象都包含一個(gè)plugin對(duì)象
- plugin.use(filterHooks(hooksAndOpts)); // 將dva(opts)構(gòu)造參數(shù)opts上與hooks相關(guān)的屬性轉(zhuǎn)換成一個(gè)插件
- const app = {
- _models: [prefixNamespace({ ...dvaModel })],
- _store: null,
- _plugin: plugin,
- use: plugin.use.bind(plugin), // 暴露的use方法,方便編寫自定義插件
- model, // 暴露的model方法,用于注冊(cè)model
- start, // 原本的start方法,在應(yīng)用渲染到DOM節(jié)點(diǎn)時(shí)通過oldStart調(diào)用
- };
- return app;
- }
dva-core start
作用:
- 封裝models 和 effects ,收集并運(yùn)行 sagas
- 完成store 的初始化
- 運(yùn)行所有的model.subscriptions
- 暴露app.model、app.unmodel、app.replaceModel三個(gè)接口
- function start() {
- const sagaMiddleware = createSagaMiddleware();
- const promiseMiddleware = createPromiseMiddleware(app);
- app._getSaga = getSaga.bind(null);
- const sagas = [];
- const reducers = { ...initialReducer };
- for (const m of app._models) {
- // 把每個(gè) model 合并為一個(gè)reducer,key 是 namespace 的值,value 是 reducer 函數(shù)
- reducers[m.namespace] = getReducer(m.reducers, m.state, plugin._handleActions);
- if (m.effects) {
- // 收集每個(gè) effects 到 sagas 數(shù)組
- sagas.push(app._getSaga(m.effects, m, onError, plugin.get('onEffect'), hooksAndOpts));
- }
- }
- // 初始化 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, 調(diào)用 Redux-Saga 的 createSagaMiddleware 創(chuàng)建 saga中間件,調(diào)用中間件的 run 方法所有收集起來的異步方法
- // run方法監(jiān)聽每一個(gè)副作用action,當(dāng)action發(fā)生的時(shí)候,執(zhí)行對(duì)應(yīng)的 saga
- sagas.forEach(sagaMiddleware.run);
- // Setup app
- setupApp(app);
- // 運(yùn)行 subscriptions
- const unlisteners = {};
- for (const model of this._models) {
- if (model.subscriptions) {
- unlisteners[model.namespace] = runSubscription(model.subscriptions, model, app, onError);
- }
- }
- // 暴露三個(gè) Model 相關(guān)的接口,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 : {}),
- }),
- );
- }
- }
- }
路由
在前面的 dva.start 方法中我們看到了 createOpts,并了解到在 dva-core 的 start 中的不同時(shí)機(jī)調(diào)用了對(duì)應(yīng)方法。
- import * as routerRedux from 'connected-react-router';
- const { connectRouter, routerMiddleware } = routerRedux;
- const createOpts = {
- initialReducer: {
- router: connectRouter(history),
- },
- setupMiddlewares(middlewares) {
- return [routerMiddleware(history), ...middlewares];
- },
- setupApp(app) {
- app._history = patchHistory(history);
- },
- };
其中 initialReducer 和 setupMiddlewares 在初始化 store 時(shí)調(diào)用,然后才調(diào)用 setupApp
可以看見針對(duì) router 相關(guān)的 reducer 和中間件配置,其中 connectRouter 和 routerMiddleware 均使用了 connected-react-router 這個(gè)庫,其主要思路是:把路由跳轉(zhuǎn)也當(dāng)做了一種特殊的 action。
Dva 與 React、React-Redux、Redux-Saga 之間的差異
原生 React
按照 React 官方指導(dǎo)意見, 如果多個(gè) Component 之間要發(fā)生交互, 那么狀態(tài)(即: 數(shù)據(jù))就維護(hù)在這些 Component 的最小公約父節(jié)點(diǎn)上,也即是
以及 本身不維持任何 state, 完全由父節(jié)點(diǎn) 傳入 props 以決定其展現(xiàn), 是一個(gè)純函數(shù)的存在形式, 即: Pure Component
React-Redux
與上圖相比, 幾個(gè)明顯的改進(jìn)點(diǎn):
1. 狀態(tài)及頁面邏輯從 里面抽取出來, 成為獨(dú)立的 store, 頁面邏輯就是 reducer
2. 及都是 Pure Component, 通過 connect 方法可以很方便地給它倆加一層 wrapper 從而建立起與 store 的聯(lián)系: 可以通過 dispatch 向 store 注入 action, 促使 store 的狀態(tài)進(jìn)行變化, 同時(shí)又訂閱了 store 的狀態(tài)變化, 一旦狀態(tài)變化, 被 connect 的組件也隨之刷新
3. 使用 dispatch 往 store 發(fā)送 action 的這個(gè)過程是可以被攔截的, 自然而然地就可以在這里增加各種 Middleware, 實(shí)現(xiàn)各種自定義功能, eg: logging
這樣一來, 各個(gè)部分各司其職, 耦合度更低, 復(fù)用度更高, 擴(kuò)展性更好。
Redux-Saga
因?yàn)槲覀兛梢允褂?Middleware 攔截 action, 這樣一來異步的網(wǎng)絡(luò)操作也就很方便了, 做成一個(gè) Middleware 就行了, 這里使用 redux-saga 這個(gè)類庫, 舉個(gè)栗子:
- 點(diǎn)擊創(chuàng)建 Todo 的按鈕, 發(fā)起一個(gè) type == addTodo 的 action
- saga 攔截這個(gè) action, 發(fā)起 http 請(qǐng)求, 如果請(qǐng)求成功, 則繼續(xù)向 reducer 發(fā)一個(gè) type == addTodoSucc 的 action, 提示創(chuàng)建成功, 反之則發(fā)送 type == addTodoFail 的 action 即可
Dva
有了前面三步的鋪墊, Dva 的出現(xiàn)也就水到渠成了, 正如 Dva 官網(wǎng)所言, Dva 是基于 React + Redux + Saga 的最佳實(shí)踐, 對(duì)于提升編碼體驗(yàn)有三點(diǎn)貢獻(xiàn):
- 把 store 及 saga 統(tǒng)一為一個(gè) model 的概念, 寫在一個(gè) js 文件里面
- 增加了一個(gè) Subscriptions, 用于收集其他來源的 action, 比如鍵盤操作等
- model 寫法很簡(jiǎn)約, 類似于 DSL(領(lǐng)域特定語言),可以提升編程的沉浸感,進(jìn)而提升效率
約定大于配置
- 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'}) });
- },
- },
- });
Dva 背后值得學(xué)習(xí)的思想
Dva 的 api 參考了choo[17],概念來自于 elm。
- Choo 的理念:編程應(yīng)該是有趣且輕松的,API 要看上去簡(jiǎn)單易用。
We believe programming should be fun and light, not stern and stressful. It's cool to be cute; using serious words without explaining them doesn't make for better results - if anything it scares people off. We don't want to be scary, we want to be nice and fun, and thencasually_be the best choice around._Real casually.
We believe frameworks should be disposable, and components recyclable. We don't want a web where walled gardens jealously compete with one another. By making the DOM the lowest common denominator, switching from one framework to another becomes frictionless. Choo is modest in its design; we don't believe it will be top of the class forever, so we've made it as easy to toss out as it is to pick up.
We don't believe that bigger is better. Big APIs, large complexities, long files - we see them as omens of impending userland complexity. We want everyone on a team, no matter the size, to fully understand how an application is laid out. And once an application is built, we want it to be small, performant and easy to reason about. All of which makes for easy to debug code, better results and super smiley faces.
2. 來自 Elm 的概念:
- Subscription,訂閱,從源頭獲取數(shù)據(jù),數(shù)據(jù)源可以是當(dāng)前的時(shí)間、服務(wù)器的 websocket 連接、keyboard 輸入、geolocation 變化、history 路由變化等等。