一文徹底搞懂 DvaJS 原理
Dva 是什么
dva 首先是一個基于redux[1]和redux-saga[2]的數據流方案,然后為了簡化開發體驗,dva 還額外內置了react-router[3]和fetch[4],所以也可以理解為一個輕量級的應用框架。
Dva 解決的問題
經過一段時間的自學或培訓,大家應該都能理解 redux 的概念,并認可這種數據流的控制可以讓應用更可控,以及讓邏輯更清晰。但隨之而來通常會有這樣的疑問:概念太多,并且 reducer, saga, action 都是分離的(分文件)。
- 文件切換問題。redux 的項目通常要分 reducer, action, saga, component 等等,他們的分目錄存放造成的文件切換成本較大。
- 不便于組織業務模型 (或者叫 domain model) 。比如我們寫了一個 userlist 之后,要寫一個 productlist,需要復制很多文件。
- saga 創建麻煩,每監聽一個 action 都需要走 fork -> watcher -> worker 的流程
- entry 創建麻煩。可以看下這個redux entry[5]的例子,除了 redux store 的創建,中間件的配置,路由的初始化,Provider 的 store 的綁定,saga 的初始化,還要處理 reducer, component, saga 的 HMR 。這就是真實的項目應用 redux 的例子,看起來比較復雜。
Dva 的優勢
- 易學易用,僅有 6 個 api,對 redux 用戶尤其友好,配合 umi 使用[6]后更是降低為 0 API
- elm 概念,通過 reducers, effects 和 subscriptions 組織 model
- 插件機制,比如dva-loading[7]可以自動處理 loading 狀態,不用一遍遍地寫 showLoading 和 hideLoading
- 支持 HMR,基于babel-plugin-dva-hmr[8]實現 components、routes 和 models 的 HMR
Dva 的劣勢
- 未來不確定性高。dva\@3 前年提出計劃后,官方幾乎不再維護[9]。
- 對于絕大多數不是特別復雜的場景來說,目前可以被 Hooks 取代
Dva 的適用場景
- 業務場景:組件間通信多,業務復雜,需要引入狀態管理的項目
- 技術場景:使用 React Class Component 寫的項目
Dva 核心概念
- 基于 Redux 理念的數據流向。用戶的交互或瀏覽器行為通過 dispatch 發起一個 action,如果是同步行為會直接通過 Reducers 改變 State,如果是異步行為(可以稱為副作用)會先觸發 Effects 然后流向 Reducers 最終改變 State。
- 基于 Redux 的基本概念。包括:
- State 數據,通常為一個 JavaScript 對象,操作的時候每次都要當作不可變數據(immutable data)來對待,保證每次都是全新對象,沒有引用關系,這樣才能保證 State 的獨立性,便于測試和追蹤變化。
- Action 行為,一個普通 JavaScript 對象,它是改變 State 的唯一途徑。
- dispatch,一個用于觸發 action 改變 State 的函數。
- Reducer 描述如何改變數據的純函數,接受兩個參數:已有結果和 action 傳入的數據,通過運算得到新的 state。
- Effects(Side Effects) 副作用,常見的表現為異步操作。dva 為了控制副作用的操作,底層引入了redux-sagas[10]做異步流程控制,由于采用了generator 的相關概念[11],所以將異步轉成同步寫法,從而將 effects 轉為純函數。
- Connect 一個函數,綁定 State 到 View
- 其他概念
- Subscription,訂閱,從源頭獲取數據,然后根據條件 dispatch 需要的 action,概念來源于elm[12]。數據源可以是當前的時間、服務器的 websocket 連接、keyboard 輸入、geolocation 變化、history 路由變化等等。
- Router,前端路由,dva 實例提供了 router 方法來控制路由,使用的是react-router[13]。
- Route Components,跟數據邏輯無關的組件。通常需要 connect Model 的組件都是 Route Components,組織在/routes/目錄下,而/components/目錄下則是純組件(Presentational Components,詳見組件設計方法[14])
Dva 應用最簡結構
不帶 Model
- import dva from 'dva';
- const App = () => <div>Hello dva</div>;
- // 創建應用
- const app = dva();
- // 注冊視圖
- app.router(() => <App />);
- // 啟動應用
- app.start('#root');
帶 Model
- // 創建應用
- const app = dva();
- app.use(createLoading()) // 使用插件
- // 注冊 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' });
- },
- },
- });
- // 注冊視圖
- app.router(() => <ConnectedApp />);
- // 啟動應用
- app.start('#root');
Dva底層原理和部分關鍵實現
背景介紹
- 整個 dva 項目使用 lerna 管理的,在每個 package 的 package.json 中找到模塊對應的入口文件,然后查看對應源碼。
- dva 是個函數,返回一了個 app 的對象。
- 目前 dva 的源碼核心部分包含兩部分,dva 和 dva-core。前者用高階組件 React-redux 實現了 view 層,后者是用 redux-saga 解決了 model 層。
dva[15]
dva 做了三件比較重要的事情:
- 代理 router 和 start 方法,實例化 app 對象
- 調用 dva-core 的 start 方法,同時渲染視圖
- 使用 react-redux 完成了 react 到 redux 的連接。
- // dva/src/index.js
- export default function (opts = {}) {
- // 1. 使用 connect-react-router 和 history 初始化 router 和 history
- // 通過添加 redux 的中間件 react-redux-router,強化了 history 對象的功能
- const history = opts.history || createHashHistory();
- const createOpts = {
- initialReducer: {
- router: connectRouter(history),
- },
- setupMiddlewares(middlewares) {
- return [routerMiddleware(history), ...middlewares];
- },
- setupApp(app) {
- app._history = patchHistory(history);
- },
- };
- // 2. 調用 dva-core 里的 create 方法 ,函數內實例化一個 app 對象。
- 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 調用 dva-core 的 start 方法,并渲染視圖
- function start(container) {
- // 對 container 做一系列檢查,并根據 container 找到對應的DOM節點
- 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);
- }
我們同時可以發現 app 是通過 create(opts, createOpts)進行初始化的,其中 opts 是暴露給使用者的配置,createOpts 是暴露給開發者的配置,真實的 create 方法在 dva-core 中實現
dva-core[16]
dva-core 則完成了核心功能:
1. 通過 create 方法完成 app 實例的構造,并暴露 use、model 和 start 三個接口
2. 通過 start 方法完成
- store 的初始化
- models 和 effects 的封裝,收集并運行 sagas
- 運行所有的 model.subscriptions
- 暴露 app.model、app.unmodel、app.replaceModel 三個接口
dva-core create
作用: 完成 app 實例的構造,并暴露 use、model 和 start 三個接口
- // 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中構造了createOpts對象
- const plugin = new Plugin(); // dva-core中的插件機制,每個實例化的dva對象都包含一個plugin對象
- plugin.use(filterHooks(hooksAndOpts)); // 將dva(opts)構造參數opts上與hooks相關的屬性轉換成一個插件
- const app = {
- _models: [prefixNamespace({ ...dvaModel })],
- _store: null,
- _plugin: plugin,
- use: plugin.use.bind(plugin), // 暴露的use方法,方便編寫自定義插件
- model, // 暴露的model方法,用于注冊model
- start, // 原本的start方法,在應用渲染到DOM節點時通過oldStart調用
- };
- return app;
- }
dva-core start
作用:
- 封裝models 和 effects ,收集并運行 sagas
- 完成store 的初始化
- 運行所有的model.subscriptions
- 暴露app.model、app.unmodel、app.replaceModel三個接口
- 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) {
- // 把每個 model 合并為一個reducer,key 是 namespace 的值,value 是 reducer 函數
- reducers[m.namespace] = getReducer(m.reducers, m.state, plugin._handleActions);
- if (m.effects) {
- // 收集每個 effects 到 sagas 數組
- 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, 調用 Redux-Saga 的 createSagaMiddleware 創建 saga中間件,調用中間件的 run 方法所有收集起來的異步方法
- // run方法監聽每一個副作用action,當action發生的時候,執行對應的 saga
- sagas.forEach(sagaMiddleware.run);
- // Setup app
- setupApp(app);
- // 運行 subscriptions
- const unlisteners = {};
- for (const model of this._models) {
- if (model.subscriptions) {
- unlisteners[model.namespace] = runSubscription(model.subscriptions, model, app, onError);
- }
- }
- // 暴露三個 Model 相關的接口,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 中的不同時機調用了對應方法。
- 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 時調用,然后才調用 setupApp
可以看見針對 router 相關的 reducer 和中間件配置,其中 connectRouter 和 routerMiddleware 均使用了 connected-react-router 這個庫,其主要思路是:把路由跳轉也當做了一種特殊的 action。
Dva 與 React、React-Redux、Redux-Saga 之間的差異
原生 React
按照 React 官方指導意見, 如果多個 Component 之間要發生交互, 那么狀態(即: 數據)就維護在這些 Component 的最小公約父節點上,也即是
以及 本身不維持任何 state, 完全由父節點 傳入 props 以決定其展現, 是一個純函數的存在形式, 即: Pure Component
React-Redux
與上圖相比, 幾個明顯的改進點:
1. 狀態及頁面邏輯從 里面抽取出來, 成為獨立的 store, 頁面邏輯就是 reducer
2. 及都是 Pure Component, 通過 connect 方法可以很方便地給它倆加一層 wrapper 從而建立起與 store 的聯系: 可以通過 dispatch 向 store 注入 action, 促使 store 的狀態進行變化, 同時又訂閱了 store 的狀態變化, 一旦狀態變化, 被 connect 的組件也隨之刷新
3. 使用 dispatch 往 store 發送 action 的這個過程是可以被攔截的, 自然而然地就可以在這里增加各種 Middleware, 實現各種自定義功能, eg: logging
這樣一來, 各個部分各司其職, 耦合度更低, 復用度更高, 擴展性更好。
Redux-Saga
因為我們可以使用 Middleware 攔截 action, 這樣一來異步的網絡操作也就很方便了, 做成一個 Middleware 就行了, 這里使用 redux-saga 這個類庫, 舉個栗子:
- 點擊創建 Todo 的按鈕, 發起一個 type == addTodo 的 action
- saga 攔截這個 action, 發起 http 請求, 如果請求成功, 則繼續向 reducer 發一個 type == addTodoSucc 的 action, 提示創建成功, 反之則發送 type == addTodoFail 的 action 即可
Dva
有了前面三步的鋪墊, Dva 的出現也就水到渠成了, 正如 Dva 官網所言, Dva 是基于 React + Redux + Saga 的最佳實踐, 對于提升編碼體驗有三點貢獻:
- 把 store 及 saga 統一為一個 model 的概念, 寫在一個 js 文件里面
- 增加了一個 Subscriptions, 用于收集其他來源的 action, 比如鍵盤操作等
- model 寫法很簡約, 類似于 DSL(領域特定語言),可以提升編程的沉浸感,進而提升效率
約定大于配置
- 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 背后值得學習的思想
Dva 的 api 參考了choo[17],概念來自于 elm。
- Choo 的理念:編程應該是有趣且輕松的,API 要看上去簡單易用。
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,訂閱,從源頭獲取數據,數據源可以是當前的時間、服務器的 websocket 連接、keyboard 輸入、geolocation 變化、history 路由變化等等。