與DvaJS風雲對話,是DvaJS挑戰者?還是又一輪子?

wooline發表於2019-03-04

有請主角登場:

Dva:我目前 Github 上 12432 個星,你呢?
React-coat:我目前 74 個。
Dva:那你還敢吐槽我?
React-coat:我星少我怕誰?
Dva:...
複製程式碼
Dva:我來自阿里,系出名門,你呢?
React-coat:個人專案。
Dva:那你還敢挑剔我?
React-coat:我野蠻生長我怕誰?
Dva:...
複製程式碼

DvaJS 和 React-coat 都是 React+Redux+Redux-router 生態的框架,都是把傳統MVC的呼叫風格引入MVVM,兩者諸多地方頗為相似。

DvaJS已經廣為人知,上線已經好幾年了,從文件、穩定性、測試充分度、輔助工具等方面都自然比 react-coat 強。React-coat 只不過是我的個人專案,之前一直在公司內部使用,今年 1 月升級到 4.0 後感覺較穩定了才開始向外界釋出。

本文撇開其它因素,僅從設計思路和使用者使用 API 對兩者進行深度對比。網際網路是一個神奇的世界,人人都有機會發表自已的觀點,正所謂初生螞蟻不畏象,希望 Dva 不要介意,畢竟兩者不是一個量級,沒有吐槽哪有進步嘛。另外如果存在對 DvaJS 理解錯誤的地方,請網友們批評指正。

>-<,好吧,我承認有點標題黨了。客官別急,請上坐,飲杯茶歇息一下。。。


開發語言

  • Dva 基於 JS,支援 Typescript
  • React-coat 基於 Typescript 支援 JS

雖然 Dva 號稱支援 Typescript,可是看了一下官方給出的:使用 TypeScript 的例子,完全感覺不到誠意,action、model、view 之間的資料型別都是孤立的,沒有相互約束?路由配置裡的檔案路徑也是無法反射,全是字串,看得我一頭霧水...

舉個 Model 例子,在 Dva 中定義一個 Model:

export default {
  effects: {
    // Call、Put、State型別都需自已手動引入
    *fetch(action: {payload: {page: number}}, {call: Call, put: Put}) {
      //使用 yield 後,data 將反射不到 usersService.fetch
      const data = yield call(usersService.fetch, {page: action.payload.page});
      // 這裡將觸發下面 save reducer,可是它們之間沒有建立強關聯
      // 如何讓這裡的 playload 型別與下面 save reducer中的 playload 型別自動約束?
      // 如果下面 save reducer 改名為 save2,如何讓這裡的 type 自動感應報錯?
      yield put({type: "save", payload: data});
    },
  },
  reducers: {
    save(state: State, action: {payload: {list: []}}) {
      return {...state, ...action.payload};
    },
  },
};
複製程式碼

反過來看看在 React-coat 中定義一個同樣的 Model:

class ModuleHandlers extends BaseModuleHandlers {
  // this.state、this.actions、this.dispatch都整合在Model中,直接呼叫即可
  @effect("loading") // 注入loading狀態
  public async fetch(payload: {page: number}) {
    // 使用 await 更直觀,而且 data 能自動反射型別
    const data = await usersService.fetch({page: action.payload.page});
    // 使用方法呼叫,更直觀,而且引數型別和方法名都有自動約束
    this.dispatch(this.actions.save(data));
  }

  @reducer
  public save(payload: {list: []}): State {
    return {...this.state, ...payload};
  }
}
複製程式碼

另外,在 react-coat 的 demo 中用了大量的 TS 泛型運算來保證 module、model、action、view、router 之間相互檢查與約束,具體可看一下react-coat-helloworld

結論:

  • react-coat 將 Typescript 轉換為生產力,而 dva 只是讓你玩玩 Typescript。
  • react-coat 有著更直觀和自然的 API 呼叫。

整合框架

兩者整合框架都差不多,都屬於 Redux 生態圈,最大差別:

  • Dva 整合 Redux-Saga,使用 yield 處理非同步
  • React-Coat 使用原生 async + await

Redux-Saga 有很多優點,比如方便測試、方便 Fork 多工、 多個 Effects 之間 race 等。但缺點也很明顯:

  • 概念太多、容易把問題複雜化
  • 使用 yield 時,不能返回 typescript 型別

結論:

你喜不喜歡 Saga,這是個人選擇的問題了,沒有絕對的標準。


Page vs Module

umi 和 dva 都喜歡用 Page 為主線來組織站點結構,並和 Router 繫結,官方文件中這樣說:

在元件設計方法中,我們提到過 Container Components,在 dva 中我們通常將其約束為 Route Components,因為在 dva 中我們通常以頁面維度來設計 Container Components。

所以,dva 的工程多為這種目錄結構:

src
├── components
├── layouts
├── models
│       └── globalModel.js
├── pages
│       ├── photos
│       │     ├── page.js
│       │     └── model.js
│       ├── videos
│       │     ├── page.js
│       │     └── model.js

複製程式碼

幾個質疑:

  • 單頁 SPA,什麼是 Page? 它的邊界在哪裡?它和其它 Component 有什麼區別?目前看起來是個 Page,說不一定有一天它被巢狀在別的 Component 裡,也說不定有一天它被 Modal 彈窗彈出。
  • 某些 Component 可能被多個 Page 引用,那應當放在哪個 Page 下面呢?
  • 為什麼路由要和 Page 強關聯?Page 切換必須要用路由載入嗎?不用路由行不行?
  • model 跟著 Page 走?model 是抽象的資料,它與 UI 可能是一對多的關係。

來看看 React-coat

在 React-coat 中沒有 Page 的概念,只有 View,因為一個 View 有可能被路由載入成為一個所謂的 Page,也可能被一個 modal 彈出成為一個彈窗,也可能被其它 View 直接巢狀。

假如有一個 PhotosView:

// 以路由方式載入,所謂的 Page
render() {
  return (
    <Switch>
      <Route exact={true} path="/photos/:id" component={DetailsView} />
      <Route component={ListView} />
    </Switch>
  );
}
複製程式碼
// 也可以直接用 props 引數來控制載入
render() {
  const {showDetails} = this.props;
  return showDetails ? <DetailsView /> : <ListView />;
}
複製程式碼
  • 用哪種方式來載入,這屬於 PhotosView 的內部事務,對外界來說,你只管載入 PhotosView 本身就好了。
  • 對於 DetailsView 和 ListView 來說,它並不知道自已將來被外界如何載入。

在 React-coat 中的組織結構的主線是 Module,它以業務功能的**高內聚,低耦合**的原則劃分:一個 Module = 一個model(維護資料)一組view(展現互動)。典型的目錄結構如下:

src
├── components
├── modules
│       ├── app
│       │     ├── views
│       │     │     ├── View1.tsx
│       │     │     ├── View2.tsx
│       │     │     └── index.ts
│       │     ├── model.ts
│       │     └── index.ts
│       ├── photos
│       │     ├── views
│       │     │     ├── View1.tsx
│       │     │     ├── View2.tsx
│       │     │     └── index.ts
│       │     ├── model.ts
│       │     └── index.ts
複製程式碼

結論:

  • Dva 中以 UI Page 為主線來主織業務功能,並將其與路由繫結,比較死板,在簡單應用中還好,對於互動性複雜的專案,Model 和 UI 的重用將變得很麻煩。
  • React-coat 以業務功能的高內聚、低偶合來劃分 Moduel,更自由靈活,也符合程式設計理念。

路由設計

在 Dva 中的路由是集中配置式的,需要用 app.router()方法來註冊。比較複雜,涉及到 Page、Layout、ContainerComponents、RealouteComponents、loadComponent、loadMode 等概念。複雜一點的應用會有動態路由、許可權判斷等,所以 Router.js 寫起來又臭又長,可讀性很差。而且使用一些相對路徑和字串名稱,沒辦法用引起 TS 的檢查。

後面在 umi+dva 中,路由以 Pages 目錄結構自動生成,對於簡單應用尚可,對於複雜一點的又引發出新問題。比如某個 Page 可能被多個 Page 巢狀,某個 model 被多個 page 共用等。所以,umi 又想出來一些潛規則:

model 分兩類,一是全域性 model,二是頁面 model。全域性 model 存於 /src/models/ 目錄,所有頁面都可引用;頁面 model 不能被其他頁面所引用。

規則如下:

src/models/**/*.js 為 global model
src/pages/**/models/**/*.js 為 page model
global model 全量載入,page model 在 production 時按需載入,在 development 時全量載入
page model 為 page js 所在路徑下 models/**/*.js 的檔案
page model 會向上查詢,比如 page js 為 pages/a/b.js,他的 page model 為 pages/a/b/models/**/*.js + pages/a/models/**/*.js,依次類推
約定 model.js 為單檔案 model,解決只有一個 model 時不需要建 models 目錄的問題,有 model.js 則不去找 models/**/*.js
複製程式碼

看看在 React-coat 中:

不使用路由集中配置,路由邏輯分散在各個元件中,沒那麼多強制的概念和潛規則。

一句話:一切皆 Component

結論:

React-coat 的路由無限制,更簡單明瞭。


程式碼分割與按需載入

在 Dva 中,因為 Page 是和路由繫結的,所以按需載入只能使用在路由中,需要配置路由:

{
  path: '/user',
  models: () => [import(/* webpackChunkName: 'userModel' */'./pages/users/model.js')],
  component: () => import(/* webpackChunkName: 'userPage' */'./pages/users/page.js'),
}
複製程式碼

幾個問題:

  • models 和 component 分開配置,如何保證 models 中載入了 component 中所需要的 所有 model?
  • 每個 model 和 component 都作為一個 split code,會不會太碎了?
  • 路由和程式碼分割繫結在一起,不夠靈活。
  • 集中配置載入邏輯導致配置檔案可讀性差。

在 React-coat 中,View 可以用路由載入,也可以直接載入:

// 定義程式碼分割
export const moduleGetter = {
  app: () => {
    return import(/* webpackChunkName: "app" */ "modules/app");
  },
  photos: () => {
    return import(/* webpackChunkName: "photos" */ "modules/photos");
  },
}
複製程式碼
// 使用路由載入:
const PhotosView = loadView(moduleGetter, ModuleNames.photos, "Main");
...
<Route exact={false} path="/photos" component={PhotosView} />
複製程式碼
// 直接載入:
const PhotosView = loadView(moduleGetter, ModuleNames.photos, "Main");
...
render() {
  const {showDetails} = this.props;
  return showDetails ? <DetailsView /> : <ListView />;
}
複製程式碼

React-coat 這樣做的好處:

  • 程式碼分割只做程式碼分割,不參和路由的事,因為模組也不一定是非得用路由的方式來載入。
  • 路由只做路由的事情,不參和程式碼分割的事,因為模組也不一定非得做程式碼分割。
  • 一個 Module 整體打包成一個 bundle,包括 model 和 views,不至於太碎片。
  • 載入 View 會自動 載入與該 View 相關的所有 Model,無需手工配置。
  • 將路由邏輯分散在各 View 內部並對外隱藏細節,更符合一切皆元件的理念。

結論:

  • 使用 React-coat 做程式碼分割和按需載入更簡單也更靈活。

動態載入 model 時對 Redux 的破壞

在使用 Dva 時發現一個嚴重的問題,讓我一度懷疑是自已哪裡弄錯了:

1.首先進入一個頁面:localhost:8000/pages,此時檢視 Redux-DevTools 如下:

第一步

2.然後點選一個 link 進入 localhost:8000/photos,此時檢視 Redux-DevTools 如下:

第二步

眼尖的夥伴們看出什麼毛病來沒有?

載入 photos model 時,第一個 action @@INIT 時的 State 快照竟然變了,把 photos 強行塞進去了。Redux 奉行的不是不可變資料麼???

結論:

Dva 動態載入 model 時,破壞了 Redux 的基本原則,而 React-coat 不會。


Model 定義

  • Dva 中的 Model 跟著 Page 走,而 Page 又跟著路由走。
  • Dva 中的 Model 比較散,可以隨意定義多個,也可以隨意 load,於是 umi 又出了某些限制,如:
model 分兩類,一是全域性 model,二是頁面 model。全域性 model 存於 /src/models/ 目錄,所有頁面都可引用;頁面 model 不能被其他頁面所引用。
global model 全量載入,page model 在 production 時按需載入,在 development 時全量載入。

複製程式碼

一個字:

React-coat 中 model 跟著業務功能走,一個 module 只能有一個 model:

在 Module 內部,我們可進一步劃分為`一個model(維護資料)`和`一組view(展現互動)`
集中在一個名為model.js的檔案中編寫 Model,並將此檔案放在本模組根目錄下
model狀態可以被所有Module讀取,但只能被自已Module修改,(切合combineReducers理念)
複製程式碼

結論:

  • React-coat 中的 model 更簡單和純粹,不與 UI 和路由掛勾。
  • Dva 中路由按需載入 Page 時還需要手工配置載入 Model。
  • React-coat 中按需載入 View 時會自動載入相應的 Model。

Model 結構

Dva 中定義 model 使用一個 Object 物件,有五個約定的 key,例如:

{
  namespace: 'count',
  state: 0,
  reducers: {
    aaa(payload) {...},
    bbb(payload) {...},
  },
  effects: {
    *ccc(action, { call, put }) {...},
    *ddd(action, { call, put }) {...},
  },
  subscriptions: {
    setup({ dispatch, history }) {...},
  },
}
複製程式碼

這樣有幾個問題:

  • 如何保證 reducers 和 effects 之間命名不重複?簡單的一目瞭然還好,如果是複雜的長業務流程,可能涉及到重用和提取,用到 Mixin 和 Extend,這時候怎麼保證?

  • 如何重用和擴充套件?官方文件中這樣寫道:

    從這個角度看,我們要新增或者覆蓋一些東西,都會是比較容易的,比如說,使用 Object.assign 來進行物件屬性複製,就可以把新的內容新增或者覆蓋到原有物件上。注意這裡有兩級,model 結構中的 state,reducers,effects,subscriptions 都是物件結構,需要分別在這一級去做 assign。可以藉助 dva 社群的 dva-model-extend 庫來做這件事。換個角度,也可以通過工廠函式來生成 model。

    還是一個字:

現在反過來看看 React-coat 怎麼解決這兩個問題:

class ModuleHandlers extends BaseModuleHandlers<State, RootState, ModuleNames> {
  @reducer
  public aaa(payload): State {...}
  @reducer
  protected bbb(payload): State {...}
  @effect("loading")
  protected async ccc(payload) {...}
}
複製程式碼
  • 相當於 reducer、effect、subscriptions 都作為方法寫在一個 Class 中,天然不會重名。
  • 因為基於 Class,所以重用和擴充套件就可以充分利用類的繼承、覆蓋、過載。
  • 因為基於 TS,還可以利用 public 或 private 許可權來減少對外暴露。

結論:

react-coat 的 model 利用 Class 和裝飾器來實現,更簡單,更適合 TS 型別檢查,也更利於重用與提取。


Action 派發

在 Dva 中,派發 action 裡要手動寫 type 和 payload,缺少型別驗證和靜態檢查

dispatch({ type: 'moduleA/query', payload:{username:"jimmy"}} })
複製程式碼

在 React-coat 中直接利用 TS 的型別反射:

dispatch(moduleA.actions.query({username:"jimmy"}))
複製程式碼

結論:

react-coat 的 Action 派發方式更優雅


React-coat 獨有的 ActionHandler 機制

我們可以簡單的認為:在 Redux 中 store.dispatch(action),可以觸發一個註冊過的 reducer,看起來似乎是一種觀察者模式。推廣到以上的 effect 概念,effect 同樣是一個觀察者。一個 action 被 dispatch,可能觸發多個觀察者被執行,它們可能是 reducer,也可能是 effect。所以 reducer 和 effect 統稱為:ActionHandler

ActionHandler 機制對於複雜業務流程、跨 model 之間的協作有著強大的作用,舉例說明:

  • 在 React-coat 中,有一些框架級的特別 Action 在適當的時機被觸發,比如:

    **module/INIT**:模組初次載入時觸發
    **@@router/LOCATION_CHANGE**: 路由變化時觸發
    **@@framework/ERROR**:發生錯誤時觸發
    **module/LOADING**:loading狀態變化時觸發
    **@@framework/VIEW_INVALID**:UI介面失效時觸發
    複製程式碼

    有了 ActionHandler 機制,它們全部變成了可注入的 hooks,你可以監聽它們,例如:

    // 兼聽自已的INIT Action
    @effect()
    protected async [ModuleNames.app + "/INIT"]() {
      const [projectConfig, curUser] = await Promise.all([settingsService.api.getSettings(), sessionService.api.getCurUser()]);
      this.updateState({
        projectConfig,
        curUser,
        startupStep: StartupStep.configLoaded,
      });
    }
    複製程式碼
  • 在 Dva 中,要同步處理 effect 必須使用 put.resolve,有點抽象,在 React-coat 中直接 await 更直觀和容易理解。

// 在 Dva 中處理同步 effect
effects: {
    * query (){
        yield put.resolve({type: 'otherModule/query',payload:1});
        yield put({type: 'updateState',  payload: 2});
    }
}

// 在React-coat中,可使用 awiat
class ModuleHandlers {
    async query (){
        await this.dispatch(otherModule.actions.query(1));
        this.dispatch(thisModule.actions.updateState(2));
    }
}
複製程式碼
  • 如果 ModuleA 進行某項操作成功之後,ModuleB 或 ModuleC 都需要 update 自已的 State,由於缺少 action 的觀察者模式,所以只能將 ModuleB 或 ModuleC 的重新整理動作寫死在 ModuleA 中:
// 在Dva中需要主動Put呼叫ModuleB或ModuleC的Action
effects: {
    * update (){
        ...
        if(callbackModuleName==="ModuleB"){
          yield put({type: 'ModuleB/update',payload:1});
        }else if(callbackModuleName==="ModuleC"){
          yield put({type: 'ModuleC/update',payload:1});
        }
    }
}

// 在React-coat中,可使用ActionHandler觀察者模式:
class ModuleB {
    //在ModuleB中兼聽"ModuleA/update" action
    async ["ModuleA/update"] (){
        ....
    }
}

class ModuleC {
    //在ModuleC中兼聽"ModuleA/update" action
    async ["ModuleA/update"] (){
        ....
    }
}
複製程式碼

結論

React-coat 中因為引入了 ActionHandler 機制,對於複雜流程和跨 model 協作比 Dva 簡單清晰得多。


結語

好了,先對比這些點,其它想起來再補充吧!百聞不如一試,只有切身用過這兩個框架才能感受它們之間的差別。所以還是請君一試吧:

git clone https://github.com/wooline/react-coat-helloworld.git
npm install
npm start
複製程式碼

當然,Dva 也有很多優秀的地方,因為它已經廣為人知,所以就不在此複述了。重申一下,以上觀點僅代表個人,如果文中對 Dva 理解有誤,歡迎批評指正。

相關文章