本系列分三部曲:《框架實現》 《框架使用》 與 《跳出框架看哲學》,這三篇是我對資料流階段性的總結,正好補充之前過時的文章。
本篇是 《框架使用》。
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
注入了 UserStore
與 ReplyStore
,可以看出 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 年最後一天,祝大家元旦快樂!
更多討論
如果你想參與討論,請點選這裡,每週都有新的主題,每週五發布。