精讀《dob - 框架使用》

hzy666666發表於2017-12-31

本系列分三部曲:《框架實現》 《框架使用》 與 《跳出框架看哲學》,這三篇是我對資料流階段性的總結,正好補充之前過時的文章。

本篇是 《框架使用》。

1 引言

現在我們團隊也在重新思考資料流的價值,在業務不斷髮展,業務場景增多時,一個固定的資料流方案可能難以覆蓋所有場景,在所有業務裡都用得爽。特別在前端資料層很薄的場景下,在資料流治理上花功夫反倒是本末倒置。

業務場景通常很複雜,但是對技術的探索往往只追求理想情況下的效果,所以很多人草草閱讀完別人的經驗,給自己業務操刀時,會聽到一些反對的聲音,而實際效果也差強人意。

所以在閱讀文章之前,應該先認識到資料流只是專案中非常微小的一環,而且每個具體方案都很看場景,就算用對了路子,帶來的提效也不一定很明顯。

2017 年 Redux 依然是主流,可能到 18 年還是。大家吐槽歸吐槽,最終活還是得幹,Redux 還是得用,就算分析出 js 天生不適合函式式,也依然一條路走到黑,因為誰也不知道未來會如何發展,redux 生態雖然用得繁瑣,但普適性強,忍一忍,生活也能繼續過。

Dob 和 Mobx 類似,也只是資料流中響應式方案的一個分支,思考也是比較理想化的,因此可能也擺脫不了中看不中用的命運,誰叫業務場景那麼多呢。

不過相對而言,應該算是接地氣一些,它既沒有要求純函式式和分離副作用,也沒有 cyclejs 那麼抽象,只要入門的物件導向,就可以用好。

2 精讀 dob 框架使用

使用 redux 時,很多時候是傻傻分不清要不要將結構化資料拍平,再分別訂閱,或者分不清訂閱後資料處理應該放在元件上還是全域性。這是因為 redux 破壞了 react 分形設計,在 最近的一次討論記錄 有說到。而許多基於 redux 的分形方案都是 “偽” 分形的,偷偷利用 replaceReducer 做一些動態 reducer 註冊,再繫結到全域性。

討論理想資料流方案比較痛苦,而且引言裡說到,很多業務場景下收益也不大,所以可以考慮結合工程化思維解決,將元件型別區分開,分為普通元件與業務元件,普通元件不使用資料流,業務元件繫結全域性資料流,可以避免糾結。

Store 如何管理

使用 Mobx 時,文件告訴我們它具有依賴追蹤、監聽等許多能力,但沒有好的實踐例子做指導,看完了 todoMvc 覺得學完了 90%,在專案中實踐後發現無從下手。

所謂最佳實踐,是基於某種約定或約束,讓程式碼可讀性、可維護性更好的方案。約定是活的,不遵守也沒事,約束是死的,不遵守就無法執行。約束大部分由框架提供,比如開啟嚴格模式後,禁止在 Action 外修改變數。然而糾結最多的地方還是在約定上,我在寫 dob 框架前後,總結出了一套使用約定,可能僅對這種響應式資料流管用。

使用資料流,第一要做的事情就是管理資料,要解決 Store 放在哪,怎麼放的問題。其實還有個前置條件:要不要用 Store 的問題。

要不要用 store

首先,最簡單的元件肯定不需要用資料流。那麼元件複雜時,如果資料流本身具有分形功能,那麼可用可不用。所謂具有分形功能的資料流,是貼著 react 分形功能,將其包裝成任具有分形能力的元件:

import { combineStores, observable, inject, observe } from 'dob'
import { Connect } from 'dob-react'

@observable
class Store { name = 123 }

class Action {
  @inject(Store) store: Store

  changeName = () => { this.store.name = 456 }
}

const stores = combineStores({ Store, Action })

@Connect(stores)
class App extends React.Component<typeof stores, any> {
  render() {
    return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div>
  }
}

ReactDOM.render(<App /> , document.getElementById('react-dom'))

dob 就是這樣的框架,上面例子中,點選文字可以觸發重新整理,即便根 dom 節點沒有 Provider。這意味著這個元件不論放到任何環境,都可以獨立執行,成為任何專案中的一部分。這種元件雖然用了資料流,但是和普通 React 元件完全無區別,可以放心使用。

如果是偽分形的資料流,可能在 ReactDOM.render 需要特定的 Provider 配合才可使用,那麼這個元件就不具備可遷移能力。如果別人不幸安裝了這種元件,就需要在專案根目錄安裝一個全家桶。

問:雖然資料流+元件具備完全分形能力,但若此元件對 props 有響應式要求,那還是有對該資料流框架的隱形依賴。

答:是的,如果元件要求接收的 props 是 observable 化的,以便在其變化時自動 rerender,那當某個環境傳遞了普通 props,這個元件的部分功能將失效。其實 props 屬於 react 的通用連線橋樑,因此元件只應該依賴普通物件的 props,內部可以再對其 observable 化,以具備完備的可遷移能力。

怎麼用 store

React 雖然可以完全模組化,但實際專案中模組一定分為通用元件與業務元件,頁面模組也可以當作業務元件。複雜的網站由資料驅動比較好,既然是資料驅動,那麼可以將業務元件與資料的連線移到頂層管理,一般通過頁面頂層包裹 Provider 實現:

import { combineStores, observable, inject, observe } from 'dob'
import { Connect } from 'dob-react'

@observable
class Store { name = 123 }

class Action {
  @inject(Store) store: Store

  changeName = () => { this.store.name = 456 }
}

const stores = combineStores({ Store, Action })

ReactDOM.render(
  <Provider {...store}>
    <App />
  </Provider>  
, document.getElementById('react-dom'))

本質上只是改變了 Store 定義的位置,而元件使用方式依然不變:

@Connect
class App extends React.Component<typeof stores, any> {
  render() {
    return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div>
  }
}

有一個區別是 @Connect 不需要帶引數了,因為如果全域性註冊了 Provider,會預設透傳到 Connect 中。與分形相反,這種設計會導致元件無法遷移到其他專案單獨執行,但好處是可以在本專案中任意移動。

分形的元件對結構強依賴,只要給定需要的 props 就可以完成功能,而全域性資料流的元件幾乎可以完全不依賴結構,所有 props 都從全域性 store 獲取。

其實說到這裡,可以發現這兩點是難以合二為一的,我們可以預先將元件分為業務耦合與非業務耦合兩種,讓業務耦合的元件依賴全域性資料流,讓非業務耦合元件保持分形能力。

如果有更好的 Store 管理方式,可以在我的 github知乎 深入聊聊。

每個元件都要 Connect 嗎

對於 Mvvm 思想的庫,Connect 概念不僅僅在於注入資料(與 redux 不同),還會監聽資料的變化觸發 rerender。那麼每個元件需要 Connect 嗎?

從資料流功能來說,沒有用到資料流的元件當然不需要 Connect,但業務元件保持著未來不確定性(業務不確定),所以保持每個業務元件的 Connect 便於後期維護。

而且 Connect 可能還會做其他優化工作,比如 dob 的 Connect 不僅會注入資料,完成元件自動 render,還會保證元件的 PureRender,如果對 dob 原理感興趣,可以閱讀 精讀《dob - 框架實現》

其實個議題只是非常微小的點,不過現實就是諷刺的,很多時候多會糾結在這種小點子上,所以單獨花費篇幅說幾句。

資料流是否要扁平化

Store 扁平化有很大原因是 js 對 immutable 支援力度不夠,導致對深層資料修改非常麻煩導致的,雖然 immutable.js 這類庫可以通過字串快速操作,但這種使用方式必然會被不斷髮展的前端浪潮所淹沒,我們不可能看到 js 標準推薦我們使用字串訪問物件屬性。

通過字串訪問物件屬性,和 lodash 的 _.get 類似,不過對於安全訪問屬性,也已經有 proposal-optional-chaining 的提案在語法層面解決,同樣 immutable 的便捷操作也需要一種標準方式完成。實際上不用等待另一個提案,利用 js 現有能力就可以模擬原生 immutable 支援的效果。

dob-redux 可以通過類似 this.store.articles.push(article) 的 mutable 寫法,實現與 react-redux 的對接,內部自然做掉了類似 immutable.set 的事情,感興趣可以讀讀我的這篇文章:Redux 使用可變資料結構,介紹了這個黑魔法的實現原理。

有點扯遠了,那麼資料流扁平化本質解決的是資料格式規範問題。比如 normalizr 就是一種標準資料規範的推進,很多時候我們都將冗餘、或者錯誤歸類的資料存入 Store,那維護性自然比較差,Redux 推崇的應當是正確的資料格式化,而不是一昧追求扁平化。

對於前端資料流很薄的場景,也不是隨便處理資料就完事了。還有許多事可做,比如使用 node 微服務對後端資料標準化、封裝一些標準格式處理元件,把很薄的資料做成零厚度,業務程式碼可以對簡單的資料流完全無感知等等。

非同步與副作用

Redux 自然而然用 action 隔離了副作用與非同步,那在只有 action 的 Mvvm 開發模式中,非同步需要如何隔離?Mvvm 真的完美解決了 Redux 避而遠之的非同步問題嗎?

在使用 dob 框架時,非同步後賦值需要非常小心:

@Action async getUserInfo() {
  const userInfo = await fetchUser()
  this.store.user.data = userInfo // 嚴格模式下將會報錯,因為脫離了 Action 作用域。
}

原因是 await 只是假裝用同步寫非同步,當一個 await 開始時,當前函式的棧已經退出,因此後續程式碼都不在一個 Action 中,所以一般的解法是顯示申明 Action 的顯示申明大法:

@Action async getUserInfo() {
  const userInfo = await fetchUser()
  Action(() => {
    this.store.user.data = userInfo
  })
}

這說明了非同步需要當心!Redux 將非同步隔離到 Reducer 之外很正確,只要涉及到資料流變化的操作是同步的,外面 Action 怎麼千奇百怪,Reducer 都可以高枕無憂。

其實 redux 的做法與下面程式碼類似:

@Action async getUserInfo() { // 類 redux action
  const userInfo = await fetchUser()
  this.setUserInfo(userInfo)
}

@Action async setUserInfo(userInfo) { // 類 redux reduer
  this.store.user.data = userInfo
}

所以這是 dob 中對非同步的另一種處理方法,稱作隔離大法吧。所以在響應式框架中,顯示申明大法與隔離大法都可以解決非同步問題,程式碼也顯得更加靈活。

請求自動重發

響應式框架的另一個好處在於可以自動觸發,比如自動觸發請求、自動觸發操作等等。

比如我們希望當請求引數改變時,可以自動重發,一般的,在 react 中需要這麼申明:

componentWillMount() {
  this.fetch({ url: this.props.url, userName: this.props.userName })
}

componentWillReceiveProps(nextProps) {
  if (
    nextProps.url !== this.props.url ||
    nextProps.userName !== this.props.userName
  ) {
    this.fetch({ url: nextProps.url, userName: nextProps.userName })
  }
}

在 dob 這類框架中,以下程式碼的功能是等價的:

import { observe } from 'dob'

componentWillMount() {
  this.signal = observe(() => {
    this.fetch({ url: this.props.url, userName: this.props.userName })
  })
}

其神奇地方在於,observe 回撥函式內用到的變數(observable 後的變數)改變時,會重新執行此回撥函式。而 componentWillReceiveProps 內做的判斷,其實是利用 react 的生命週期手工監聽變數是否改變,如果改變了就觸發請求函式,然而這一系列操作都可以讓 observe 函式代勞。

observe 有點像更自動化的 addEventListener

document.addEventListener('someThingChanged', this.fetch)

所以元件銷燬時不要忘了取消監聽:

this.signal.unobserve()

最近我們團隊也在探索如何更方便的利用這一特性,正在考慮實現一個自動請求庫,如果有好的建議,也非常歡迎一起交流。

型別推導

如果你在使用 redux,可以參考 你所不知道的 Typescript 與 Redux 型別優化 優化 typescript 下 redux 型別的推導,如果使用 dob 或 mobx 之類的框架,型別推導就更簡單了:

import { combineStores, Connect } from 'dob'

const stores = combineStores({ Store, Action })

@Connect
class Component extends React.PureComponent<typeof stores, any> {
  render() {
    this.props.Store // 幾行程式碼便獲得了完整型別支援
  }
}

這都得益於響應式資料流是基於物件導向方式操作,可以自然的推匯出型別。

Store 之間如何引用

複雜的資料流必然存在 Store 與 Action 之間相互引用,比較推薦依賴注入的方式解決,這也是 dob 推崇的良好實踐之一。

當然依賴注入不能濫用,比如不要存在迴圈依賴,雖然手握靈活的語法,但在下手寫程式碼之前,需要對資料流有一套較為完整的規劃,比如簡單的使用者、文章、評論場景,我們可以這麼設計資料流:

分別建立 UserStore ArticleStore ReplyStore

import { inject } from 'dob'

class UserStore {
  users
}

class ReplyStore {
  @inject(UserStore) userStore: UserStore

  replys // each.user
}

class ArticleStore {
  @inject(UserStore) userStore: UserStore
  @inject(ReplyStore) replyStore: ReplyStore

  articles // each.replys each.user
}

每個評論都涉及到使用者資訊,所以 ReplyStore 注入了 UserStore,每個文章都包含作者與評論資訊,所以 ArticleStore 注入了 UserStoreReplyStore,可以看出 Store 之間依賴關係應當是樹形,而不是環形。

最終 Action 對 Store 的操作也是通過注入來完成,而由於 Store 之間已經注入完了,Action 可以只操作對應的 Store,必要的時候再注入額外 Store,而且也不會存在迴圈依賴:

class UserAction {
  @inject(UserStore) userStore: UserStore
}

class ReplyAction {
  @inject(ReplyStore) replyStore: ReplyStore
}

class ArticleAction {
  @inject(ArticleStore) articleStore: ArticleStore
}

最後,不建議在區域性 Store 注入全域性 Store,或者區域性 Action 注入全域性 Store,因為這會破壞區域性資料流的分形特點,切記保證非業務元件的獨立性,把全域性繫結交給業務元件處理。

Action 的錯誤處理

比較優雅的方式,是編寫類級別的裝飾器,統一捕獲 Action 的異常並丟擲:

const errorCatch = (errorHandler?: (error?: Error) => void) => (target: any) => {
    Object.getOwnPropertyNames(target.prototype).forEach(key => {
        const func = target.prototype[key]
        target.prototype[key] = async (...args: any[]) => {
            try {
                await func.apply(this, args)
            } catch (error) {
                errorHandler && errorHandler(error)
            }
        }
    })
    return target
}

const myErrorCatch = errorCatch(error => {
    // 上報異常資訊 error
})

@myErrorCatch
class ArticleAction {
  @inject(ArticleStore) articleStore: ArticleStore
}

當任意步驟觸發異常,await 之後的程式碼將停止執行,並將異常上報到前端監控平臺,比如我們內部的 clue 系統。關於異常處理更多資訊,可以訪問我較早的一篇文章:Callback Promise Generator Async-Await 和異常處理的演進

3 總結

準確區分出業務與非業務元件、寫程式碼前先設計資料流的依賴關係、非同步時注意分離,就可以解決絕大部分業務場景的問題,實在遇到特殊情況可以使用 observe 監聽資料變化,由此可以擴充出比如請求自動重發的功能,運用得當可以解決餘下比較棘手的特殊需求。

雖然資料流只是專案中非常微小的一環,但如果想讓整個專案保持良好的可維護性,需要把各個環節做精緻。

這篇文章寫於 2017 年最後一天,祝大家元旦快樂!

更多討論

討論地址是:精讀《dob - 框架使用》 · Issue #53 · dt-fe/weekly

如果你想參與討論,請點選這裡,每週都有新的主題,每週五發布。

相關文章