前端數據流哲學

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

本篇是收官之作 《前端數據流哲學》。

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 全量注入到每一個插件中:

@Connectclass 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

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


推薦閱讀:

TAG:前端工程師 | 數據流 | Redux |