精讀《前端資料流哲學》

翱翔大空發表於2018-07-10

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

本篇是收官之作 《前端資料流哲學》。

1 引言

寫這篇文章時,很有壓力,如有不妥之處,歡迎指正。

同時,由於這是一篇佛系文章,所以不會得出你應該用 某某 框架的結論,你應該當作消遣來閱讀。

2 精讀

首先資料流管理模式,比較熱門的分為三種。

  • 函式式、不可變、模式化。典型實現:Redux – 簡直是正義的化身。
  • 響應式、依賴追蹤。典型實現:Mobx。
  • 響應式,和樓上區別是以流的形式實現。典型實現:Rxjs、xstream。

當然還有第四種模式,裸奔,其實有時候也挺健康的。

資料流使用通用的準則是:副作用隔離、全域性與區域性狀態的合理劃分,以上三種資料流管理模式都可以實現,唯有是否強制的區別。

2.1 從時間順序說起

一直在思考如何將這三個思維串起來,後來想通了,按照時間順序串起來就非常自然。

暫時略過 Prototype、jquery 時代,為什麼略過呢?因為當時前端還在野蠻人時代,生存問題都沒有解決,哪還有功夫思考什麼資料流,設計模式?前端也是那時候被覺得比後端水的。

好在前端發展越來越健康,大坑小坑被不斷填上,加上硬體效能的提高,同時需求又越來越複雜,是時候想想該如何組織程式碼了。

最先映入眼簾的是 angular,搬來的 mvvm 思想真是為前端開闢了新的世界,發現程式碼還可以這麼寫!雖然 angluar 用起來很重,但 mvvm 帶來的資料驅動思想已經越來越深入人心,隨後 react 就突然火起來了。

其實在 react 火起來之前,有一個框架一步到位,進入了 react + mobx 時代,對,就是 avalon。avalon 也非常火,但是一個框架要成功,必須天時、地利、人和,當時時機不對,大家處於 angular 疲憊期,大多投入了 react 的懷抱。

可能有些主觀,但我覺得 react 能火起來,主要因為大家認為它就是輕量 angular + 繼承了資料驅動思想啊,非常符合時代背景,同時一大波概念被炒得火熱,狀態驅動、單向資料流等等,基本上用過 angular 的人都跟上了這波節奏。

雖然 react 內建了分形資料流管理體系,但總是強調自己只是 View 層,於是資料層增強的框架不斷湧現,從 flux、reflux、到 redux。不得不說,react 真的推動了資料流管理的獨立,讓我們重新認識了資料流管理的重要性。

redux 概念太超前了,一步到位強制把副作用隔離掉了,但自己又沒有深入解決帶來的程式碼冗餘問題,讓我們又愛又恨,於是一部分人把目光轉向了 mobx,這個響應式資料流框架,這個沒有強制分離副作用,所以寫起來很舒服的框架。

當然 mobx 如果僅僅是 mvvm 就不會火起來了,畢竟 angular 擺在那。主要是乘上了 react 這趟車,又有很多質疑 angular 髒檢測效率的聲音,mobx 也火了起來。當然,作為前端的使命是優化人機互動,所以我們都知道,使用者習慣是最難改變的,直到現在,redux 依然是絕對主流。

mobx 還在小範圍推廣時,另一個更偏門的領域正剛處於萌芽期,就是 rxjs 為代表的框架,和 mobx 公用一個 observable 名詞,大家 mobx 都沒搞清楚,更是很少人會去了解 rxjs。

當 mobx 逐漸展露頭角時,筆者做了一個類似的庫:dob。主要動機是 mobx 手感還不夠完美,對於新賦值變數需要用一些 extendObservable 等 api 修飾,正好發現瀏覽器對 proxy 支援已經成熟,因此筆者後來幾乎所有個人專案幾乎都用 dob 替代了 mobx。

這一時期三巨頭之一的 vue 火了起來,成功利用:如果 ”react + mobx 很好用,那為什麼不用 vue?“ 的 flag 打動了我。

一直到現在,前端已經發展到可謂五花八門的地步,typescript 打敗 flow 幾乎成為了新的 js,出現了 ember、clojurescript 之後,各大語言也紛紛出了到 js 的編譯實現,陸陸續續的支援編譯到 webassembly,react 作者都棄坑 js 創造了新語言 reason。

之前寫過一篇初步認識 reason 的精讀

能接下來這一套精神洗禮的前端們,已經養出內心波瀾不驚的功夫,小眾已經不會成為跨越舒適區的門檻,再學個 rxjs 算啥呢?(開個玩笑,rxjs 社群不乏深耕多年的巨匠)所以最近 rxjs 又被炒的火熱。

所以,從時間順序來看,我們可以從 redux – mobx – rxjs 的順序解讀這三個框架。

2.2 redux 帶來了什麼

redux 是強制使用全域性 store 的框架,儘管無數人在嘗試將其做到區域性化。

當然,一方面是由於時代責任,那時需要一個全域性狀態管理工具,彌補 react 區域性資料流的不足。最重要的原因,是 redux 擁有一套幾乎潔癖般完美的定位,就是要清晰可回溯

幾乎一切都是為了這兩個詞準備的。第一步就要從分離副作用下手,因為副作用是阻礙程式碼清晰、以及無法回溯的第一道障礙,所以 action + reducer 概念閃亮登場,完美解決了副作用問題。可能是參考了 koa 中介軟體的設計思路,redux middleware 將 action 對接到 reducer 的黑盒的控制權暴露給了開發者。

由 redux middleware 原始碼閱讀引發的函式式熱,可能又拉近了開發者對 rxjs 的好感。同時高階函式概念也在中介軟體原始碼中體現,幾乎是為 react 高階元件做鋪墊。

社群出現了很多方案對 redux 非同步做支援,從 redux-thunk 到 redux-saga,redux 帶來的非同步隔離思想也逐漸深入人心。同時基於此的一套高階封裝框架也層出不窮,建議用一個就好,比如 dva

第二步就是解決阻礙回溯的“物件引用”機制,將 immutable 這套龐大思想搬到了前端。這下所有狀態都不會被修改,基於此的 redux-dev-tools “時光機” 功能讓人印象深刻。

Immutable 具體實現可以參考筆者之前寫的一篇精讀:精讀 Immutable 結構共享

當然,由於很像事件機制的 dispatch 導致了 redux 對 ts 支援比較繁瑣,所以對 redux 的專案,維護的時候需要頻繁使用全文搜尋,以及至少在兩個檔案間來回跳躍。

2.3 mobx 帶來了什麼

mobx 是一個非常靈活的 TFRP 框架,是 FRP 的一個分支,將 FRP 做到了透明化,也可以說是自動化。

從函式式(FP),到 FRP,再到 TFRP,之間只是擴充關係,並不意味著單詞越長越好。

之前說過了,由於大家對 redux 的疲勞,讓 mobx 得以迅速壯大,不過現在要從另一個角度分析。

mobx 帶來的概念從某種角度看,與 rxjs 很像,比如,都說自己的 observable 有多神奇。那麼 observable 到底是啥呢?

可以把 observable 理解為訊號源,每當訊號變化時,函式流會自動執行,並輸出結果,對前端而言,最終會使檢視重新整理。這就是資料驅動檢視。然而 mobx 是 TFRP 框架,每當變數變化時,都會自動觸發資料來源的 dispatch,而且各檢視也是自動訂閱各資料來源的,我們稱為依賴追蹤,或者叫自動依賴繫結。

筆者到現在還是認為,TFRP 是最高效的開發方式,自動訂閱 + 自動釋出,沒什麼比這個更高效了。

但是這種模式有一個隱患,它引發了副作用對純函式的汙染,就像 redux 把 action 與 reducer 合起來了一樣。同時,對 props 的直接修改,也會導致與 react 對 props 的不可變定義衝突。因此 mobx 後來給出了 action 解決方案,解決了與 react props 的衝突,但是沒有解決副作用未強制分離的問題。

筆者認為,副作用與 mutable 是兩件事,關於 mutable 與副作用的關係,後文會有說明。也就是 mobx 沒有解決副作用問題,不代表 TFRP 無法分離副作用,而且 mutable 也不一定與 可回溯 衝突,比如 mobx-state-tree,就通過 mutable 的方式,完成了與 redux 的對接。

前端對資料流的探索還在繼續,mobx 先提供了一套獨有機制,後又與 redux 找到結合點,前端探索的腳步從未停止。

2.4 rxjs 帶來了什麼

rxjs 是 FRP 的另一個分支,是基於 Event Stream 的,所以從對 view 的輔助作用來說,相比 mobx,顯得不是那麼智慧,但是對資料來源的定義,和 TFRP 有著本質的區別,似的 rxjs 這類框架幾乎可以將任何事件轉成資料來源。

同時,rxjs 其對資料流處理能力非常強大,當我們把前端的一切都轉為資料來源後,剩下的一切都由無所不能的 rxjs 做資料轉換,你會發現,副作用已經在資料來源轉換這一層完全隔離了,接下來會進入一個美妙的純函式世界,最後輸出到 dom driver 渲染,如果再加上虛擬 dom 的點綴,那豈不是。。豈不就是 cyclejs 嗎?

多提一句,rxjs 對資料流純函式的抽象能力非常強大,因此前端主要工作在於抽一個工具,將諸如事件、請求、推送等等副作用都轉化為資料來源。cyclejs 就是這樣一個框架:提供了一套上述的工具庫,與 dom 對接增加了虛擬 dom 能力。

rxjs 給前端資料流管理方案帶來了全新的視角,它的概念由 mobx 引發,但解題思路卻與 redux 相似。

rxjs 帶來了兩種新的開發方式,第一種是類似 cyclejs,將一切前端副作用轉化為資料來源,直接對接到 dom。另一種是類似 redux-observable,將 rxjs 資料流處理能力融合到已有資料流框架中,

redux-observable 將 action 與 reducer 改造為 stream 模式,對 action 中副作用行為,比如發請求,也提供了封裝好的函式轉化為資料來源,因此,將 redux middleware 中的副作用,轉移到了資料來源轉換做成中,讓 action 保持純函式,同時增強了原本就是純函式的 reducer 的資料處理能力,非常棒。

如果說 redux-saga 解決了非同步,那麼 redux-observable 就是解決了副作用,同時贈送了 rxjs 資料處理能力。

回頭看一下 mobx,發現 rxjs 與 mobx 都有對 redux 的增強方案,前端資料流的發展就是在不斷交融。

我們不但在時間線上,將 redux、mobx、rxjs 串了起來,還發現了他們內在的關聯,這三個思想像一張網,複雜的交織在一起。

2.5 可以串起來些什麼了

我們發現,redux 和 rxjs 完全隔離了副作用,是因為他們有一個共性,那就是對前端副作用的抽象

redux 通過在 action 做副作用,將副作用隔離在 reducer 之外,使 reducer 成為了純函式。

rxjs 將副作用先轉化為資料來源,將副作用隔離在管道流處理之外。

唯獨 mobx,缺少了對副作用抽象這一層,所以導致了程式碼寫的比 redux 和 rxjs 更爽,但副作用與純函式混雜在一起,因此與函式式無緣。

有人會說,mobx 直接 mutable 改變物件也是導致副作用的原因,筆者認為是,也不是,看如下程式碼:

obj.a = 1

這段程式碼在 js 中鐵定是 mutable 的?不一定,同樣在 c++ 這些可以過載運算子的語言中也不一定了,setter 語法不一定會修改原有物件,比如可以通過 Object.defineProperty 來重寫 obj 物件的 setter 事件。

由此我們可以開一個腦洞,通過運算子過載,讓 mutable 方式得到 immutable 的結果。在筆者部落格 Redux 使用可變資料結構 有說明原理和用法,而且 mobx 作者 mweststrate 是這麼反駁那些吐槽 mobx 缺少 redux 歷史回溯能力的聲音的:

autorun(() => {
  snapshots.push(Object.assign({}, obj))
})

思路很簡單,在物件有改動時,儲存一張快照,雖然效能可能有問題。這種簡單的想法開了個好頭,其實只要在框架層稍作改造,便可以實現 mutable 到 immutable 的轉換。

比如 mobx 作者的新作:immer 通過 proxy 超程式設計能力,將 setter 重寫為 Object.assign() 實現 mutable 到 immutable 的轉換。

筆者的 dob-redux 也通過 proxy,呼叫 Immutablejs.set() 實現 mutable 到 immutable 的轉換。

元件需要資料流嗎

真的是太看場景了。首先,業務場景的元件適合繫結全域性資料流,業務無關的通用元件不適合繫結全域性資料流。同時,對於複雜的通用元件,為了更好的內部通訊,可以繫結支援分形的資料流。

然而,如果資料流指的是 rxjs 對資料處理的過程,那麼任何需要資料複雜處理的場合,都適合使用 rxjs 進行資料計算。同時,如果資料流指的是對副作用的歸類,那任何副作用都可以利用 rxjs 轉成一個資料來源歸一化。當然也可以把副作用封裝成事件,或者 promise。

對於副作用歸一化,筆者認為更適合使用 rxjs 來做,首先事件機制與 rxjs 很像,另外 promise 只能返回一次,而且之後 resolve reject 兩種狀態,而 Observable 可以返回多次,而且沒有內建的狀態,所以可以更加靈活的表示狀態。

所以對於各類業務場景,可以先從人力、專案重要程度、後續維護成本等外部條件考慮,再根據具體元件在專案中使用場景,比如是否與業務繫結來確定是否使用,以及怎麼使用資料流。

可能在不遠的未來,佈局和樣式工作會被 AI 取代,但是資料驅動下資料流選型應該比較難以被 AI 取代。

再次理解 react + mobx 不如用 vue 這句話

首先這句話很有道理,也很有分量,不過筆者今天將從一個全新的角度思考。

經過前面的探討,可以發現,現在前端開發過程分為三個部分:副作用隔離 -> 資料流驅動 -> 檢視渲染。

先看檢視渲染,不論是 jsx、或 template,都是相同的,可以互相轉化的。

再看副作用隔離,一般來說框架也不解決這個問題,所以不管是 react/ag/vue + redux/mobx/rxjs 任何一種組合,最終你都不是靠前面的框架解決的,而是利用後面的 redux/mobx/rxjs 來解決。

最後看資料流驅動,不同框架內建的方式不同。react 內建的是類 redux 的方式,vue/angular 內建的是類 mobx 的方式,cyclejs 內建了 rxjs。

這麼來看,react + redux 是最自然的,react + mobx 就像 vue + redux 一樣,看上去不是很自然。也就是 react + mobx 彆扭的地方僅在於資料流驅動方式不同。對於檢視渲染、副作用隔離,這兩個因素不受任何組合的影響。

就資料流驅動問題來看,我們可以站在更高層面思考,比如將 react/vue/angular 的語法視為三種 DSL 規範,那其實可以用一種通用的 DSL 將其描述,並轉換對應的 DSL 對接不同框架(阿里內部已經有這種實現了)。而這個 DSL 對框架內建資料流處理過程也可以遮蔽,舉個例子:

<button onClick={() => {
  setState(() => {
    data: {
      name: `nick`
    }
  })
}}>
  {data.name}
</button>

如果我們將上面的通用 jsx 程式碼轉換為通用 DSL 時,會使用通用的方式描述結構以及方法,而轉化為具體 react/vue/angluar 程式碼時,就會轉化為對應內建資料流方案的實現。

所以其實內建資料流是什麼風格,在有了上層抽象後,是可以忽略的,我們甚至可以利用 proxy,將 mutable 的程式碼轉換到 react 時,改成 immutable 模式,轉到 vue 時,保持 mutable 形式。

對框架封裝的抽象度越高,框架之間差異就越小,漸漸的,我們會從框架名稱的討論中解放,演變成對框架 + 資料流哪種組合更加合適的思考。

3 總結

最近梳理了一下 gaea-editor – 筆者做的一個 web designer,重新思考了其中外掛機制,拿出來講一講。

首先大體說明一下,這個編輯器使用 dob 作為資料流,通過 react context 共享資料,寫法和 mobx 很像,不過這不是重點,重點是外掛擴充機制也深度使用了資料流。

什麼是外掛擴充機制?比如像 VScode 這些編輯器,都擁有強大的擴充能力,開發者想要新增一個功能,可以不用學習其深奧的框架內容,而是讀一下簡單明瞭的外掛文件,使用外掛完成想要功能的開發。解耦的很美好,不過重點是外掛的能力是否強大,外掛可以觸及核心哪些功能、拿到哪些資訊、擁有哪些能力?

筆者的想法比較激進,為了讓外掛擁有最大能力,這個 web designer 所有核心程式碼都是用外掛寫的,除了呼叫外掛的部分。所以外掛可以隨意訪問和修改核心中任何資料,包括 UI。

讓 UI 擁有通用能力比較容易,gaea-editor 使用了插槽方式渲染 UI,也就是任何外掛只要提供一個名字,就能嵌入到申明瞭對應名字的 UI 插槽中,而外掛自己也可以申明任意數量的插槽,核心中也有幾個內建的插槽。這樣外掛的 UI 能力極強,任何 UI 都可以被新的外掛替代掉,只要申明相同的名字即可。

剩下一半就是資料能力,筆者使用了依賴注入,將所有核心、外掛的 store、action 全量注入到每一個外掛中:

@Connect
class CustomPlugin extends React.PureComponent {
  render() {
    // this.props.Actions, this.props.Stores
  }
}

同時,每個外掛可以申明自己的 store,程式初始化時會合並所有外掛的 store 到記憶體中。因此外掛幾乎可以做任何事,重寫一套核心也沒有問題,那麼做做擴充更是輕鬆。

其實這有點像 webpack 等外掛的機制:

export default (context) => {}

每次申明外掛,都可以從函式中拿到傳來的資料,那麼通過資料流的 Connect 能力,將資料注入到元件,也是一種強大的外掛開發方式。

更多思考

通過上面外掛機制的例子會發現,資料流不僅定義了資料處理方式、副作用隔離,同時依賴注入也在資料流功能列表之中,前端資料流是個很寬泛的概念,功能很多。

redux、mobx、rxjs 都擁有獨特的資料處理、副作用隔離方式,同時對應的框架 redux-react、mobx-react、cyclejs 都補充了各種方式的依賴注入,完成了與前端框架的銜接。正是應為他們紛紛將核心能力抽象了出來,才讓 redux+rxjs mobx+rxjs 這些組合成為了可能。

未來甚至會誕生一種完全無資料管理能力的框架,只做純 view 層,核心原生對接 redux、mobx、rxjs 也不是沒有可能,因為框架自帶的資料流與這些資料流框架比起來,太弱了。

react stateless-component 就是一種嘗試,不過現在這種純 view 層元件配合資料流框架的方式還比較小眾。

純 view 層不代表沒有資料流管理功能,比如 props 的透傳,更新機制,都可以是內建的。

不過筆者認為,未來的框架可能會朝著 view 與資料流完全隔離的方式演化,這樣不但根本上解決了框架 + 資料流選擇之爭,還可以讓框架更專注於解決 view 層的問題。

從有到無

HTML5 有兩個有意思的標籤:details, summary。通過組合,可以達到 details 預設隱藏,點選 summary 可以 toggle 控制 details 下內容的效果:

<details>
  <summary>標題</summary> 
  <p>內容</p> 
</details>

更是可以通過 css 覆蓋,完全實現 collapse 元件的效果。

當然就 collapse 元件來說,因為其內部維持了狀態,所以控制摺疊皮膚的 開啟/關閉 狀態,而 HTML5 的 details 也通過瀏覽器自身內部狀態,對開發者只暴露 css。

在未來,瀏覽器甚至可能提供更多的原生上層元件,而元件內部狀態越來越不需要開發者關心,甚至,不需要開發者再引用任何一個第三方通用元件,HTML 提供足夠多的基礎元件,開發者只需要引用 css 就能實現元件庫更換,似乎回到了 bootstrap 時代。

有人會說,具有業務含義的再上層元件怎麼提供?別忘了 HTML components,這個規範配合瀏覽器實現了大量原生元件後,可能變得異常光彩奪目,DSL 再也不需要了,HTML 本身就是一套通用的 DSL,框架更不需要了,瀏覽器內建了一套框架。

插一句題外話,所有元件都通過 html components 開發,就真正意義上實現了抹平框架,未來不需要前端框架,不需要 react 到 vue 的相互轉化,元件載入速度提高一個檔次,動態元件 load 可能只需要動態載入 css,也不用擔心不同環境/框架下開發的元件無法共存。前端發展總是在進兩步退一步,不要形成思維定式,每隔一段時間,需要重新審視下舊的技術。

話題拉回來,從瀏覽器實現的 details 標籤來看,內部一定有狀態機制,假如這套狀態機制可以提供給開發者,那資料流的 資料處理、副作用隔離、依賴注入 可能都是瀏覽器幫我們做了,redux 和 mobx 會立刻失去優勢,未來潛力最大的可能是擁有強大純函式資料流處理能力的 rxjs。

當然在 2018 年,redux 和 mobx 依然會保持強大的活力,就算在未來瀏覽器內建的資料流機制,rxjs 可能也不適合大規模團隊合作,尤其在現在有許多非前端崗位兼職前端的情況下。

就像現在 facebook、google 的模式一樣,在未來的更多年內,前後端,甚至 dba 與演算法崗位職能融合,每個人都是全棧時,可能 rxjs 會在更大範圍被使用。

縱觀前端歷史,資料流框架從無到有,但在未來極有可能從有變到無,前端資料流框架消失了,但前端資料流思想永遠保留了下來,變得無處不在。

4 更多討論

討論地址是:精讀《前端資料流哲學》 · Issue #58 · dt-fe/weekly

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


相關文章