這篇文章將簡略地介紹我們當(dāng)前的無線前端架構(gòu)設(shè)計(jì)及其演進(jìn)之路。主要內(nèi)容包含以下幾個(gè)部分,希望我們的經(jīng)驗(yàn)?zāi)軒Ыo大家一些啟發(fā)。
1)當(dāng)前的前端方案及其解決的問題
2)現(xiàn)在面對(duì)的新挑戰(zhàn)
3)我們的前端方案設(shè)計(jì)和選擇。
一、當(dāng)前的前端方案及其解決的問題
1.1 當(dāng)前方案的技術(shù)背景
將時(shí)間調(diào)回到 2016 年。我們已經(jīng)將幾個(gè)核心的前端應(yīng)用,從 C# ASP.NET 遷移到了 Node.js。并且在基于 Backbone.js 的前端框架上,添加了 React 去管理 View 層,取代了 Underscore.js 的 template 模板引擎,實(shí)現(xiàn)了徹底的前后端分離。
在舊框架中引入 React,這個(gè)過程并不像上面描述得那樣輕松。我們需要解決 2 個(gè)問題。
1)React 體積過大
2)React 開發(fā)需要 ES2015 和 JSX 的編譯工具的支持
彼時(shí),現(xiàn)有框架體積已然龐大,引入 React 會(huì)再增加 140+Kb 的 JS Size,將進(jìn)一步拖慢我們的 SPA 首次渲染時(shí)間。這是不可接受的,也是阻礙當(dāng)時(shí)絕大多數(shù)公司的在原有前端項(xiàng)目中使用 React 的重要因素。
React 體積太大了,除非是新項(xiàng)目或者重構(gòu),有機(jī)會(huì)重更新分配 JS Size 預(yù)算。否則,想要使用新技術(shù)解決現(xiàn)有項(xiàng)目的問題,首先要能解決引入新技術(shù)的成本問題。
為了能使用 React 的組件化技術(shù),解決大塊大塊的渲染模板難以維護(hù)的問題。我們自研了兼容 React API 的輕量版實(shí)現(xiàn) react-lite。將 140+Kb 的 Size 降低到了 20+Kb 的可接受水平。
當(dāng)時(shí)我們的項(xiàng)目的模塊管理工具是 require.js。我們編寫 ES5 語法的代碼,然后它們直接運(yùn)行在瀏覽器上。沒有目前 Webpack/Babel 的編譯和打包環(huán)節(jié)。
盡管用 react-lite 降低了引入 React 的體積,但我們的目的,是用組件化的方式,將巨大的渲染模板代碼,分解為多個(gè)小塊的組件,方便維護(hù)和增加可復(fù)用性。不能使用 JSX 語法,需要手寫 React.createElement 的函數(shù)調(diào)用,React 組件可能比 Underscore.js 的模板還難以維護(hù)。
我們曾經(jīng)嘗試用 Webpack 來取代 require.js,運(yùn)行整個(gè)項(xiàng)目,因?yàn)?Webpack 支持編譯 require.js 的 AMD 模塊。但很快我們發(fā)現(xiàn)了巨大的麻煩,現(xiàn)有框架對(duì) require.js 的動(dòng)態(tài)模塊和遠(yuǎn)程模塊有強(qiáng)依賴。
動(dòng)態(tài)模塊是指,它會(huì)判斷不同的環(huán)境,拼接不同的 url 地址,如 :
require('/path/to/' + isInApp ? 'hybrid' : 'h5')
遠(yuǎn)程模塊是指,有很多模塊,是通過 http 請求下發(fā)的 js 腳本,它們不在項(xiàng)目本地目錄中。
這讓基于本地模塊的依賴分析的 Webpack 很難用起來。還有其它各種瑣碎問題,雖然不如上面兩個(gè)致命,但也阻礙了我們將前端基礎(chǔ)設(shè)施從 require.js 遷移到 Webpack + Babel。
最后,我們設(shè)計(jì)了一個(gè)降級(jí)方案。既保留 require.js 的運(yùn)行機(jī)制,又能使用 JSX/ES2015 的新語法,開發(fā) React 組件。
我們設(shè)置了 ES6 和 ES5 兩類目錄,基于 Gulp + Babel 創(chuàng)建了一個(gè)實(shí)時(shí)根據(jù)文件改動(dòng),編譯 ES6 模塊到 ES5 模塊的腳本任務(wù)。在開發(fā)時(shí),運(yùn)行 gulp 命令即可。
通過上述取巧的方式,我們在團(tuán)隊(duì)中成功推廣了 ES6 和 React 開發(fā)模式。為我們后續(xù)基于 React + Node.js + Webpack + Babel 打造新的前端開發(fā)方式,建立了良好的基礎(chǔ)。
1.2 當(dāng)前方案:同構(gòu)框架 React-IMVC 的誕生
在現(xiàn)有項(xiàng)目中引入 Node.js + React + ES2015 的開發(fā)方式,對(duì)我們的前端開發(fā)確實(shí)帶來了幫助。我們可以編寫更簡潔和優(yōu)雅的 ES2015 代碼,也不再需要維護(hù) .cshtml 模板、配置 IIS 服務(wù)器,才能運(yùn)行我們的 SPA 應(yīng)用。
前端項(xiàng)目里沒有了其它語言的代碼和配置,只用 JAVAScript 做到自洽和自理。
然而,我們?nèi)匀辉谝粋€(gè)沉重的歷史技術(shù)負(fù)擔(dān)下迭代我們的前端應(yīng)用。這不是長久之計(jì)。
我們需要一個(gè)站在 2016 年,而不是 2012 年的視角下,一個(gè)全新的、更大程度上發(fā)揮 Node.js + React 模式的前端新架構(gòu)。
它需要實(shí)現(xiàn)以下目標(biāo):
1)一條命令啟動(dòng)完整的開發(fā)環(huán)境
2)一條命令編譯和構(gòu)建源代碼
3)一份代碼,既可以在 node.js 做服務(wù)端渲染(SSR),也可以在瀏覽器端復(fù)用后繼續(xù)渲染(CSR & SPA)
4)既是多頁應(yīng)用,也是單頁應(yīng)用,還可以通過配置自由切換兩種模式,用「同構(gòu)應(yīng)用」打破「單頁 VS 多頁」的兩難抉擇
5)構(gòu)建時(shí)可以生成一份 hash history 模式的靜態(tài)文件,當(dāng)做普通單頁應(yīng)用的入口文件(SPA)
6)構(gòu)建時(shí)可以根據(jù)路由切割代碼,按需加載 js 文件
7)支持在 IE9 及更高版本瀏覽器里,使用包括 async/await 在內(nèi)的 ES2015+ 語言新特性
8)豐富的生命周期,讓業(yè)務(wù)代碼有更清晰的功能劃分
9)內(nèi)部自動(dòng)解決在瀏覽器端復(fù)用服務(wù)端渲染的 html 和數(shù)據(jù),無縫過渡
10)好用的同構(gòu)方法 fetch、redirect 和 cookie 等,貫通前后端的請求、重定向和 cookie 等操作
眼尖的同學(xué)可能發(fā)現(xiàn),直接用 Next.js 不就可以滿足上述目標(biāo)了嗎?
確實(shí)如此。
不過 Next.js 要等到 2016 年 10 月份才誕生,接近 2018 年才逐漸廣為人知。我們沒有時(shí)間等待未來的框架來解決當(dāng)下的難題。
因此在 2016 年 7 月份,我開發(fā)了 create-app 庫,實(shí)現(xiàn)了同構(gòu)的最小核心功能,并且在 create-app 基礎(chǔ)上,添加了 store, fetch, cookie, redirect, webpack, babel, ssr/csr, config 等多個(gè)功能,組成了我們自研的同構(gòu)框架 React-IMVC,實(shí)現(xiàn)了上述 10 大目標(biāo)。
1.3 React-IMVC 的設(shè)計(jì)思路
我們將每個(gè)頁面,分解成 3 個(gè)部分:Model,View 和 Controller。回歸到 GUI 開發(fā)最樸素的 MVC 心智模型。這從 React-IMVC 的框架命名中,可以看出來。
IMVC 的 I 是 Isomorphic 的縮寫,意思是同構(gòu),在這里是指,一份 JavaScript 代碼,既可以在 Node.js 里運(yùn)行,也可以在 Browser 里運(yùn)行。
IMVC 的 M 是 Model 的縮寫,意思是模型,在這里是指,狀態(tài)及其狀態(tài)變化函數(shù)的集合,由 initialState 狀態(tài)和 actions 函數(shù)組成。
IMVC 的 V 是 View 的縮寫,意思是視圖,在這里是指,React 組件。
IMVC 的 C 是指 Controller 的縮寫,意思是控制器,在這里是指,包含生命周期方法、事件處理器、同構(gòu)工具方法以及負(fù)責(zé)同步 View 和 Model 的中間媒介。
React-IMVC 里的 MVC 三個(gè)部分都是 Isomorphic 的,所以它可以做到:只編寫一份代碼,在 Node.js 里做 Server-Side-Rendering 服務(wù)端渲染,在 Browser 里做 Client-Side-Rendering 客戶端渲染。
在 React-IMVC 的 Model 里, 采用的是 Redux 模式,但做了一定的簡化,減少樣板代碼的編寫。其中,state 是 immutable data,action 是 pure function,不包含 side effect 副作用。
React-IMVC 的 View 是 React,建議盡可能使用 functional component 寫法,不建議包含 side effect 副作用。
然而,Side-Effects 副作用是跟外界交互的必然產(chǎn)物,只可能被隔離,不可能被消滅。所以,我們需要一個(gè)承擔(dān) Side-Effects 的對(duì)象,它就是 Controller。
Life-Cycle methods 是副作用來源,Ajax/Fetch 也是副作用來源,Event Handler 事件處理器也是副作用來源,localStorage 也是副作用來源,它們都應(yīng)該在 Controller 這個(gè) ES2015 Classes 里,用面向?qū)ο蟮姆绞絹硖幚怼?/p>
一個(gè) Web App 包含多個(gè) Page 頁面,每個(gè) page 都由 MVC 三個(gè)部分組成。

上圖的代碼實(shí)現(xiàn)了一個(gè)支持 SSR/CSR 的計(jì)數(shù)器頁面。我們可以清晰地看到 React-IMVC 的設(shè)計(jì)理念。
Controller 類的 Model 屬性描述了 Model 的初始狀態(tài) initialState,以及定義了狀態(tài)變化方式 actions。
Controller 類的 View 屬性通過 React 組件描述了視圖的呈現(xiàn)方式,它根據(jù) Model 提供的 state/actions 進(jìn)行數(shù)據(jù)綁定和事件綁定。
當(dāng) View 層的點(diǎn)擊事件觸發(fā) actions 時(shí),將引起 Model 內(nèi)部的 state 變化,而 Model 的變化,將通知 Controller 去觸發(fā) View 層的更新。如此構(gòu)成了 Model, View 和 Controller 經(jīng)典的渲染循環(huán)模型。
那么,我們是如何支持 SSR 的呢?

如上圖所示,很簡單,Controller 包含了很多生命周期,其中 getInitialState 會(huì)在創(chuàng)建 Model/Store 實(shí)例之前調(diào)用,支持異步,可以使用 Controller 提供的 fetch api 進(jìn)行 http 接口請求。
React-IMVC 會(huì)在內(nèi)部 hold 住異步的數(shù)據(jù)獲取,在 SSR 數(shù)據(jù)準(zhǔn)備好之后,才進(jìn)行后續(xù)的渲染流程。這些復(fù)雜的操作,都隱藏到了框架內(nèi)部。對(duì)于頁面開發(fā)者來說,它們只是生命周期、異步接口調(diào)用而已。
除了 getInitialState 以外,React-IMVC 還提供了其它實(shí)用的生命周期,比如:
1)shouldComponentCreate: 頁面應(yīng)該被渲染嗎?在這里可以鑒權(quán)和 this.redirect 重定向。
2)pageWillLeave:頁面即將跳轉(zhuǎn)到其它頁面
3)pageDidBack:頁面從其它頁面跳轉(zhuǎn)回來
4)windowWillUnload:窗口即將被關(guān)閉
5)……
通過配置豐富的生命周期,我們可以將業(yè)務(wù)代碼進(jìn)行更清晰地分塊。

再配合一個(gè) index.js 作為路由模塊,將多個(gè) Page 的 Controller.js 按照跟 Express.js 一樣的 path/router 路徑配置規(guī)則設(shè)置,可以按需加載和響應(yīng)不同的頁面請求。
React-IMVC 框架會(huì)在 Node.js 里接管 Request,根據(jù) Request.pathname 請求路徑,匹配出對(duì)應(yīng)的 Controller 控制器模塊,并進(jìn)行實(shí)例化和 SSR 等工作。在瀏覽器端,框架內(nèi)部會(huì)自動(dòng)根據(jù) SSR 內(nèi)容,對(duì) html 結(jié)構(gòu)和 initialState 數(shù)據(jù)進(jìn)行復(fù)用。這個(gè)過程 React 稱之為 Hydration。
對(duì)于頁面的開發(fā)者來說,他們在大部分場景下,不需要考慮對(duì) SSR 的適配。controller 里的 { fetch, get, post, cookie, redirect } 等方法內(nèi)部,會(huì)自動(dòng)根據(jù)運(yùn)行環(huán)境切換對(duì)應(yīng)的代碼實(shí)現(xiàn),對(duì)使用者保持透明。
通過同構(gòu)框架 React-IMVC,我們對(duì)前端項(xiàng)目的開發(fā)方式進(jìn)行了一次革新和標(biāo)準(zhǔn)化。在幾年內(nèi),大量的舊項(xiàng)目遷移到新框架,以及幾乎所有新項(xiàng)目都基于新框架研發(fā),引領(lǐng)我們團(tuán)隊(duì)步入 Modern Web Development 現(xiàn)代前端開發(fā)技術(shù)棧的時(shí)代。
二、當(dāng)前的新挑戰(zhàn)和問題
在開發(fā) React-IMVC 框架時(shí),我們預(yù)期 5 年內(nèi)這套方案依然適用,不至于過時(shí)。如今 3 年多過去了,前端里也發(fā)生了一些有趣的變化。比如,2018 年 10 月份 React-Hooks 的出現(xiàn),比如 TypeScript 的流行。
這些漸進(jìn)增強(qiáng)的事物,并不會(huì)讓一個(gè) SSR 框架過時(shí)。React-IMVC 對(duì) React-Hooks 和 TypeScript 支持也做了適時(shí)的跟進(jìn)。
讓我們再次停下來,重新審視新的前端架構(gòu)設(shè)計(jì)的,不是現(xiàn)有方案再次過時(shí)。而是我們面對(duì)了新的問題,現(xiàn)有方案不足以充分解決它們。
React-IMVC 框架設(shè)計(jì)之初,主要考慮的是 Node.js + Browser 兩個(gè)平臺(tái)的統(tǒng)一。讓一份代碼,可以同時(shí)運(yùn)行在 Node.js 和 Browser 里,并能自動(dòng)協(xié)調(diào) Server/Browser 之間的 Hydration 過程。只涉及 Web 開發(fā)的前后端分離應(yīng)用,React-IMVC 仍然是合理的選型。
當(dāng)遇到多端 + 國際化的場景時(shí),情況超出了當(dāng)初的考量。一條產(chǎn)品線可能有多個(gè)應(yīng)用:
1)國內(nèi) PC 站點(diǎn);
2)國際 PC 站點(diǎn)
3)國內(nèi) H5 站點(diǎn)
4)國際 H5 站點(diǎn)
5)國內(nèi) APP 內(nèi)的 React-Native 應(yīng)用
6)國際 APP 內(nèi)的 React-Native 應(yīng)用
7)國內(nèi)小程序應(yīng)用
8)其它分銷或渠道里的應(yīng)用等……
這么多應(yīng)用形態(tài),每個(gè)都投入全職的前端開發(fā)小組,其成本和效率都難以讓人滿意。React-IMVC 適用于做 PC/H5 的同構(gòu)前端應(yīng)用,但對(duì) App/React-Native 和小程序的支持不足。如何節(jié)省多端開發(fā)成本,成了一個(gè)需要嚴(yán)肅考量的議題。
看到這里,對(duì)新興技術(shù)比較敏感的同學(xué),或許覺得用 Flutter 就能解決問題。Flutter 不失為一種選擇,但未必適合所有場景和團(tuán)隊(duì)。
2.1 跨端方案考察
某種程度上,跨端對(duì)前端開發(fā)來說,是一個(gè)已經(jīng)解決的問題。JavaScript 在 PC/Mobile 里,在 IOS/Android 里,在 APP/Browser 都能運(yùn)行,網(wǎng)頁無處不在。
當(dāng)我們討論跨端方案時(shí),其實(shí)不是能不能的問題,而是成熟度/滿意度的問題。
通過 WebView/Browser 在所有地方都用 HTML/css/JavaScript 開發(fā)界面,固然是跨端了。但在 App 里的加載速度、流暢度等核心指標(biāo)上,并不能滿足要求。因此才有 React-Native 這類強(qiáng)化方案:使用 JavaScript 編寫業(yè)務(wù)邏輯,用 React 組件去表達(dá)抽象的界面,但通過 Native UI 去加速渲染:Written in JavaScript—rendered with native code。
React-Native 提供了不錯(cuò)的 IOS/Android 跨端能力,但它有兩個(gè)問題:
1)官方甚至沒有承諾過 IOS/Android 的跨端,只是說“Learn once, write anywhere.”。官方?jīng)]有支持的跨端兼容問題,需要自行封裝和處理。
2)React-Native for Web 是一個(gè)社區(qū)方案(react-native-web),不是官方迭代的項(xiàng)目,在 web 端的性能表現(xiàn)和體驗(yàn),得不到充分的保障,一旦出現(xiàn)問題,代碼難以調(diào)試和修改。可控程度不足。
我們實(shí)際使用下來,React-Native 用在 IOS/Android 的 App 里面是不錯(cuò)的選擇,但編譯到 Web 平臺(tái)運(yùn)行有一定風(fēng)險(xiǎn)。
Flutter 聲稱自己可以用一套代碼,運(yùn)行在 mobile, web, 和 desktop 等平臺(tái)上,背后又是 google 的團(tuán)隊(duì)在開發(fā)。確實(shí)非常有吸引力。出于以下考量,目前可能不適合我們的場景:
1)Flutter 使用 Google 自己的 Dart 語言,而非 JavaScript。所有業(yè)務(wù)代碼都要重寫,學(xué)習(xí)和重構(gòu)成本較高。
2)Flutter 對(duì) Web 的支持目前還在 beta channel,處于 preview releases 階段,仍有一定的生產(chǎn)使用風(fēng)險(xiǎn)。
3)Flutter 的功能主要覆蓋的是渲染引擎,在實(shí)際業(yè)務(wù)開發(fā)時(shí),IOS/Android/Web 各個(gè)平臺(tái)特定的 API 還需要去額外適配,并非 100% 使用 Flutter 自身功能就能解決一切問題,需要付出大量時(shí)間和成本去做圍繞 Flutter 的基礎(chǔ)建設(shè)等工作。
因此,從現(xiàn)階段看,F(xiàn)lutter 可能比較適合創(chuàng)業(yè)公司、中小型公司或者大公司里從零開始的非核心項(xiàng)目。
對(duì)幾個(gè)主流跨端方案的總結(jié)如下:
1)Web/Page:在 Browser 里體驗(yàn)還行,但在 App 里的體驗(yàn)不佳;
2)React-Native:在 App 里的體驗(yàn)很好,但在 Broser 里的體驗(yàn)沒有保障;
3)Flutter:在 App/Browser 里的體驗(yàn)都有一定保障,但學(xué)習(xí)、重構(gòu)和基建成本大;
Flutter 是一個(gè)徹底革新的方案,所使用的語言和基礎(chǔ)設(shè)施,對(duì)公司里的開發(fā)者來說都是新的。我們更想要的,其實(shí)是不推翻現(xiàn)有積累,而是在當(dāng)前方案上做一個(gè)漸進(jìn)的提升。
不排除未來 Flutter 可能成為統(tǒng)一大前端的最佳方案,但在它成為事實(shí)之前,我們還得面對(duì)和解決現(xiàn)在的問題,不能只是等待未來的完美方案出現(xiàn)。并且,多端是我們面對(duì)的問題的其中一個(gè),國際化是另一個(gè)。
出于國內(nèi)用戶跟國際用戶之間巨大的文化差異等因素,我們起碼要準(zhǔn)備兩套界面風(fēng)格和交互形態(tài)顯著不同的產(chǎn)品。一種是面向國內(nèi)用戶,另一種是面向國外用戶(通過 I18N 實(shí)現(xiàn)多語言的支持)。
即便用 Flutter 等技術(shù)解決了多端問題,我們還需要思考國內(nèi)/國際兩組多端應(yīng)用,是不是也有可以統(tǒng)一/歸并起來的空間?
三、從 VOP 到 MOP 的躍遷
我們將目光放到了 Model 層,它承擔(dān)了應(yīng)用的狀態(tài)管理和業(yè)務(wù)邏輯的職能,是更普適和純粹的部分。
我們可以將多端項(xiàng)目的 Model 層統(tǒng)一起來,但保持 View 層的獨(dú)立,不同的 View 層再去對(duì)接它相對(duì)應(yīng)的 Platform/Renderer。

問題轉(zhuǎn)變成,如何最大化 Model 層,讓 Model 層承擔(dān)盡可能多的職能,在 Model 層寫盡可能多的代碼?
通過這個(gè)新視角,我們審視過去 5 年前端開發(fā)領(lǐng)域蓬勃發(fā)展,發(fā)現(xiàn)了一個(gè)有趣現(xiàn)象。
可以將過去 5 年的發(fā)展歸類為 View-Oriented Programming 路線,簡稱 VOP(這是我們自造的說辭,在此只是分享見解,不作為權(quán)威定義,權(quán)當(dāng)參考)。
不管是 React/React-Native,Vue/Weex,Angular,F(xiàn)lutter 還是 SwiftUI,它們都是 component-based 的視圖增強(qiáng)模式。它們以視圖組件為中心,不斷增強(qiáng)視圖組件的表達(dá)能力,從最基本的父子嵌套的組合能力,到狀態(tài)管理能力,再到副作用和交互管理的能力等。
我們來看一下它們的組件寫法。

上圖是 React 組件代碼,在 function component 內(nèi),同時(shí)包含了 State 和 View 的部分,并且它們不可分割,State 是局部變量,和 View 是綁定關(guān)系。雖然我們可以抽取成 custom hooks,使之可以復(fù)用到 React-Native,但當(dāng)我們在 useEffect 里使用 DOM/BOM 或 RN 特有 API 去觸發(fā) setState 時(shí),它們又跟特定平臺(tái)耦合。

上面是 Vue SFC 代碼,template 是 View 部分,data/compted 是 State 部分,它們是一一對(duì)應(yīng)的。

上面是 Angular 的組件代碼,View 和 State 管理的部分,也是一一對(duì)應(yīng)的。

上圖是 Flutter 的 Stateful Widget 代碼,View 在 build 方法里,State 管理則是通過 class 的 members 和 methods 實(shí)現(xiàn)。members 和 methods 在 class 里是不可分割的。

上圖是 SwfitUI 的代碼,組件也是通過 class 去表達(dá),相對(duì) Flutter,SwiftUI 組件的 View 在 body 方法里。
不管它們將 State/View 放到一個(gè)函數(shù)里,還是 class 里,State/View 之間都構(gòu)成了一一對(duì)應(yīng)的綁定關(guān)系。State 是圍繞 View 的消費(fèi)和交互需求而產(chǎn)生的,View 是組件真正核心的部分。
這并不是說 React、Vue 以及 Flutter/SwiftUI 都做錯(cuò)了,增強(qiáng)組件表達(dá)能力是正確的。只是說,當(dāng) State 和 View 綁定起來時(shí),難以達(dá)到最大化 Model 層代碼復(fù)用的目標(biāo)。
我們需要讓狀態(tài)管理變成 view agnostic,在獨(dú)立的 Model 層去管理 state 及其變化,不假定下游是哪種 View Framework。
也就是說,我們要從 View-Oriented Programming 轉(zhuǎn)向 Model-Oriented Programming,簡稱 MOP。
從面向 View 編程,變成面向 Model 編程。
四、MOP 選型
在當(dāng)前 JavaScript 生態(tài)圈里,可以脫離具體 View 框架獨(dú)立使用的流行方案,主要有:
1)Redux
2)Mobx
3)Vue 3.0 reactivity api
4)Rxjs
5)……
Redux 曾經(jīng)是 React 狀態(tài)管理的首選方案,它有自己的 devtools 支持便利地通過 action 追溯狀態(tài)變更歷史。但鑒于它在使用上有太多模板代碼,實(shí)現(xiàn)一個(gè)功能需要橫跨多個(gè)文件夾,不是很便利。社區(qū)里對(duì) Redux 不乏抱怨的聲音,每當(dāng) React 添加一個(gè)新功能,社區(qū)就想用這個(gè)新功能替代 Redux。將 Redux 封裝成使用上更簡便的形態(tài)的嘗試也層出不窮,甚至 Redux 官方也提供了一個(gè)封裝方案,叫做 redux/toolkit。
Mobx 可以說是 React 社區(qū)僅次于 Redux 的另一個(gè)流行方案,參考了 Vue 的 Reactive 狀態(tài)管理風(fēng)格。它也可以不跟 React 綁定,獨(dú)立使用或者跟其它視圖框架搭配使用。
Vue 3.0 將內(nèi)部的 reactivity api 提取成 standalone library,也可以獨(dú)立使用或搭配其它視圖框架。
Rxjs 是一個(gè)響應(yīng)式的數(shù)據(jù)流模式,基于 Rxjs 可以實(shí)現(xiàn)一套 State-Management 方案,用在任意地方。
總的來說,這 4 個(gè)庫選擇任意一個(gè)都是可以的,就看你所在的團(tuán)隊(duì)的風(fēng)格和喜好。同時(shí),不做任何增強(qiáng),只用它們現(xiàn)有功能,也很難實(shí)現(xiàn) Model 層最大化。
我們的選擇是 Redux。
原因比較簡單,我們團(tuán)隊(duì)使用的 React-IMVC 框架的 Model 層,是基于我們自己實(shí)現(xiàn)的 Relite 庫,它本身就是 Redux 模式的簡化版,跟 Redux 官方的 redux/toolkit 編寫風(fēng)格相近。選擇 Redux 可以延續(xù)我們現(xiàn)有的經(jīng)驗(yàn)和部分代碼。
此外,我們認(rèn)為,Redux 的 action/reducer 包含了可預(yù)測的狀態(tài)管理的必要核心部分,不管用不用 Redux,狀態(tài)管理最終都會(huì)暴露出一組更新函數(shù) actions。
比如,不管使用的是 Mobx、Vue-Reactivity-API 還是 Rxjs,去編寫 Todo APP 的狀態(tài)管理代碼,還是會(huì)得到 addTodo/removeTodo/updateTodo 等更新函數(shù)。而 Redux Devtools 是現(xiàn)成的追蹤這些 action 的成熟工具,選擇其它方案都有額外的適配成本。
五、我們的 MOP 框架:Pure-Model
我們基于 Redux 實(shí)現(xiàn)了一個(gè)支持最大化 Model 層的 MOP 框架,叫做 Pure-Model。
相比 VOP 階段對(duì) Redux 進(jìn)行簡化,讓 Model 層承擔(dān)更少的職能,讓 View 承擔(dān)更多的職能。MOP 階段的 Pure-Model 是對(duì) Redux 進(jìn)行強(qiáng)化,讓 Model 層承擔(dān)更多的職能,讓 View 承擔(dān)更少的職能。
Redux 本身要求 state 是 immutable 的,reducer 是 pure function,IO/Side-Effects 通過 redux-middlewares 去實(shí)現(xiàn)。可是 redux-middleware 極其難用和難以理解,它割裂了一個(gè)功能的代碼分布,強(qiáng)制放到兩個(gè)地方去處理,不便于閱讀和維護(hù)。
那是 2015 年的設(shè)計(jì)局限。當(dāng)時(shí)整個(gè)前端社區(qū)都還不知道如何在 pure function 里管理副作用。直到 2018 年 10 月份 React-Hooks 的發(fā)布,我們看到了在 function-component 里添加 state 狀態(tài)和 effect 交互的有效途徑。
React-Hooks 是對(duì) View 層的增強(qiáng),讓 View 組件可以表達(dá) state 和 effect,可以通過 custom hooks 模式做邏輯復(fù)用。但它背后的理念是通用的,不局限于 View 層,我們可以在 Model 層重新實(shí)現(xiàn) Hooks,得到一樣的能力增強(qiáng)。

上圖是跟前文演示的 React-IMVC Counter 功能等價(jià)的 Pure-Model 代碼,Model 不再跟 View 一塊綁定到 Controller 的屬性中。Model 是單獨(dú)定義的,通過暴露的 React-Hooks API,在 React-DOM 組件里使用,同時(shí)它也可以在 React-Native 組件中使用。
我們的演示代碼將 Model 和 View 寫在同一個(gè) JS 模塊里,是為了能在一張圖里呈現(xiàn)代碼。實(shí)際開發(fā),Model 層是獨(dú)立的模塊,然后用在 View.H5.tsx 和 View.RN.tsx 等組件模塊里。
需要注意的是,其中有兩個(gè) Hooks,一個(gè)是 View Hooks,一個(gè)是 Model Hooks。
Pure Model 的 setupStore 是一個(gè) Model Hooks,用來定義 store。createReactModel 將它轉(zhuǎn)換成 React-Hooks 的 Model.useState。
那么,Pure-Model 如何支持 SSR ?沒有了 Controller 提供的 getInitialState 方法,也沒有 fetch/post 等接口,如何請求數(shù)據(jù)和更新到 store 里?

如上所示,我們提供了內(nèi)置的 Model-Hooks API 和 setupPreloadCallback 等生命周期函數(shù),覆蓋了 Http 請求和 preload, start, finish 等事件。
在 setupPreloadCallback 里注冊一個(gè)預(yù)加載函數(shù),支持異步,可以通過 Http 接口獲取數(shù)據(jù),并調(diào)用 action 更新狀態(tài)。該生命周期提供的能力是,在外部訂閱者消費(fèi) state 之前,先進(jìn)行數(shù)據(jù)的預(yù)加載和更新。如此,外部第一次消費(fèi)數(shù)據(jù)時(shí),拿到的是一個(gè)豐滿的結(jié)構(gòu)。
而 setupStartCallback/setupFinishCallback 則是在 Model 被訂閱和解除訂閱的兩個(gè)回調(diào)。當(dāng) Pure-Model 被用在 React 組件中時(shí),它們對(duì)應(yīng)的是 componentDidMount 和 componentWillUnmount 的生命周期。

Model-Hooks 跟 React-Hooks 或者 Vue-Composition-API 一樣,支持編寫 Custom Hooks 實(shí)現(xiàn)可復(fù)用的邏輯,如上面的 setupInitialCount,可以在任意支持 Model-Hooks 的地方調(diào)用/復(fù)用。

我們還內(nèi)置了 setupCancel 等 Model-Hooks,可以方便的構(gòu)造可取消的異步任務(wù),并且不局限于 Http 請求。通過這些 Model Hooks API 的封裝,Model 層的代碼會(huì)變得很清晰和優(yōu)雅,開發(fā)者可以根據(jù)不同的場景,使用不同的 Model-Hooks 去注冊不同的 onXXX 生命周期,觸發(fā)不同的 actions。
并且這些生命周期不是 class 里扁平的 methods 形式,它可以分組,切片、封裝和樹形嵌套,是一個(gè)更加靈活和自由的模式。
在 Pure-Model 中,reducer 是 pure function,但 setupXXX 等其它額外的部分,支持 IO/Side-Effects。相當(dāng)于把原本需要寫在外部的 redux-middleware 代碼,放到了一個(gè) createReactModel 中,上面是 setupStore 構(gòu)造 immutable/pure 的 store/actions,下面則基于 store/actions,構(gòu)造支持異步的 actions。
所有功能實(shí)現(xiàn),其實(shí)都包裹在 setupStore/setupXXX 等函數(shù)中,它們只是定義,并未執(zhí)行,因此 createReactModel 是 pure 的,它只是返回了一組函數(shù)。
在不同平臺(tái),我們可以注入不同的 setupFetch 等實(shí)現(xiàn),比如在瀏覽器里,我們注入 window.fetch 的封裝,在 Node.js 里我們注入 node-fetch 的封裝,在 React-Native 里我們注入 global.fetch 的封裝。
Pure-Model 采用的是構(gòu)建上層抽象的路線,所有 Hooks,都是描述要做什么,但沒有限定底層實(shí)現(xiàn)怎么去做。當(dāng) Pure-Model 在具體平臺(tái)運(yùn)行時(shí),這部分代碼實(shí)現(xiàn)由一個(gè)適配和銜接層給出。
有了 Pure-Model 這層 Redux + Model-Hooks 的抽象,我們不僅能把 State-Management 代碼放到 Model 層,還可以把 Effect-Management 副作用管理代碼放到 Model 層。而 View 層里,只需要 Model.useState 獲取到當(dāng)前狀態(tài),Model.useActions 獲取到狀態(tài)更新函數(shù),將它們綁定到視圖和事件訂閱中去即可。
換句話說,Model 層包含了函數(shù)實(shí)現(xiàn),而 View 層只剩下必要的函數(shù)調(diào)用。函數(shù)實(shí)現(xiàn)的代碼是更長的,而函數(shù)調(diào)用的代碼是更短的。我們不斷地將函數(shù)實(shí)現(xiàn)提取到 Model 層,那么 View 層和 Controller 層代碼就會(huì)越來越薄。
在實(shí)踐中我們發(fā)現(xiàn),最后我們得到的 Model 層,里面包含的就是應(yīng)用的核心業(yè)務(wù)邏輯代碼,它們可以獨(dú)立運(yùn)行和測試,可以用在任意視圖框架中。不僅是跨平臺(tái),甚至具備跨時(shí)代的生命力。當(dāng) React 被下一代視圖框架所淘汰,我們不必拋棄所有代碼;實(shí)現(xiàn)一個(gè) Model 層到新視圖框架的適配即可。
基于 MOP 框架 Pure-Model 編寫的代碼,如此成為了應(yīng)用的核心資產(chǎn)。
我們回過頭去看,其實(shí)在 React/Vue 等視圖框架強(qiáng)盛之前,大家對(duì) Model 和 View 層的耦合,本來就是否定的。View 是薄薄的一層,甚至只是一行 render(template, data) 的模板渲染。核心代碼都在 Model 層和 Controller 層去管理數(shù)據(jù)和事件。
等到 React/Vue 崛起成為前端開發(fā)的主旋律后,因?yàn)橐晥D組件的表達(dá)能力更強(qiáng),在視圖組件里編寫一切代碼,成了一個(gè)流行趨勢。
然而,Model 層和 View 層的職能,在某種程度上是互斥的。我們需要 Model 獨(dú)立、穩(wěn)定以及具備長期迭代的生命力,而 View 層是多變的、依賴數(shù)據(jù)的、存在的生命周期隨著 UI 風(fēng)格潮流的變化而變化。
當(dāng)我們在 View 層實(shí)現(xiàn) Model 層的代碼,某種意義上我們就放棄了 Model 層的核心價(jià)值。
那么,為什么大家用了 5 年 VOP 模式,也沒遇到什么真正的問題?
這是因?yàn)椋琈odel 層自身也分成好幾層,前端 Model 層和后端 Model 層,前端 Model 層是對(duì)后端 Model 層的銜接,把前端 Model 層跟 View 層綁定起來,只影響了前端 Model 層的穩(wěn)定性,而應(yīng)用依賴的后端 Model 層還是保持了獨(dú)立、穩(wěn)定和長期迭代的生命力。
在前端框架高速發(fā)展的階段,整個(gè)前端項(xiàng)目重構(gòu)和框架升級(jí),也算是常態(tài)。因此 Model 層和 View 層的耦合,很少帶來實(shí)質(zhì)影響。這跟網(wǎng)頁內(nèi)存泄露不是什么致命問題類似,刷新一下就好了。
當(dāng)前端框架競爭趨于穩(wěn)定,重構(gòu)前端項(xiàng)目的頻次變少,再加上多端和國際化的需求,跟 View 層耦合的前端 Model 層,開始變得尷尬起來。
同一個(gè)后端 Model 層,可以對(duì)接多個(gè)不同 UI 界面風(fēng)格的應(yīng)用,它是一個(gè)收斂的模型。而前端 Model 層,竟然隨著 UI 界面的增加而增加,這是一個(gè)不收斂的模型。
MOP 框架 Pure-Model 是一個(gè)收斂前端 Model 層的嘗試。它其實(shí)沒有對(duì) React-IMVC 等 SSR 框架進(jìn)行徹底的推翻,它在 Browser/Node.js 里仍然是由 React-IMVC 去驅(qū)動(dòng),在 App 里仍然是 React-Native 去驅(qū)動(dòng)。從本質(zhì)上說,它只是改變了代碼的模塊化方式,將堆積在 View 層和 Controller 層的部分代碼實(shí)現(xiàn),放到了 Model 層維護(hù),在 View 層和 Controller 層只留下函數(shù)調(diào)用的少量代碼。
再配合我們使用 GraphQL-BFF 模式構(gòu)造的后端 Model 整合能力,為多端服務(wù)的 Pure-Model 可以按需查詢 GraphQL-BFF 以適配在不同端的前后端數(shù)據(jù)交互。詳情請見《GraphQL-BFF:微服務(wù)背景下的前后端數(shù)據(jù)交互方案》
六、Monorepo
只有 Pure-Mode 也是不夠的,它只是抽象層,真正驅(qū)動(dòng)代碼的還是 React-Native/React-DOM 等視圖框架。
也就是說,我們會(huì)有多個(gè)項(xiàng)目,分別是不同的腳手架搭建的,只是共用了通過一個(gè) Model 層的代碼。那么,如何在多個(gè)項(xiàng)目里共享代碼,就成了一個(gè)需要解決的工程問題。
通過 npm 等包管理服務(wù)去分發(fā) Model 層代碼,是一個(gè)低效方案,任意改動(dòng),都需要發(fā)布版本,并在每個(gè)項(xiàng)目里重新 npm install 或者 npm upgrade,難以使用快速開發(fā)的效率要求。
把多個(gè)項(xiàng)目放到多個(gè) git 倉庫,也會(huì)產(chǎn)生類似問題,Model 層代碼放到哪個(gè)項(xiàng)目的 git 倉庫里?還是再增加一個(gè) Model 層的獨(dú)立 git 倉庫。N + 1 個(gè)倉庫的代碼同步和版本管理將陷入混亂。
通過 Monorepo 單倉庫多項(xiàng)目的模式,可以實(shí)現(xiàn)更高效和一致的的代碼共享。
比如,我們將項(xiàng)目按照下面的目錄結(jié)構(gòu)放置:
projects/isomorphic
projects/graphql-bff
projects/react-native-01
projects/react-native-02
projects/react-dom-01
project/react-dom-02
isomorphics 項(xiàng)目是 Model 層所在的項(xiàng)目,它有自己獨(dú)立的 package.json 去管理開發(fā)、測試等任務(wù)。projects 目錄的其它項(xiàng)目,可以使用任意腳手架搭建,支持多個(gè)由同個(gè)腳手架搭建的項(xiàng)目并存。它們也有自己獨(dú)立的開發(fā)、構(gòu)建和測試套件。
通過軟鏈接的方式,將 isomorphic 的 src 目錄映射到其它 projects 的 src/isomorphic 目錄里。如此,代碼源是唯一的,但出現(xiàn)在多個(gè)項(xiàng)目中,每個(gè)項(xiàng)目都可以 import 引入共享的代碼。當(dāng)一個(gè)項(xiàng)目,不再需要跟其它項(xiàng)目共享代碼,它可以整個(gè)文件夾遷移到另一個(gè)獨(dú)立 git 倉庫中做自己的獨(dú)立迭代。
再將 projects/graphql-bff 這類 GraphQL-BFF 的后端 Model 項(xiàng)目也引入進(jìn)來,通過 GraphQL Schema 生成接口數(shù)據(jù)類型的 TypeScript 文件,在所有前端項(xiàng)目中共享。我們可以得到更權(quán)威的接口數(shù)據(jù)類型提示,減少絕大部分因?yàn)榍昂蠖藬?shù)據(jù)結(jié)構(gòu)和類型不匹配,導(dǎo)致的空/非空、類型不一致、字段名大小寫拼錯(cuò)等的問題。

通過 Monorepo 我們得到了多項(xiàng)目共享代碼的便捷方式;通過 Pure-Model 我們最大化前端 Model 層代碼復(fù)用的能力;通過 GraphQL-BFF 我們將后端 Model 統(tǒng)籌起來,并提供權(quán)威的接口數(shù)據(jù)類型來源;通過 React-IMVC 我們得到在 Node.js 和 Browser 里所 SSR 和 CSR 渲染的能力;通過 React-Native 我們得到在 IOS 和 Android 平臺(tái)構(gòu)建接近 Native 的 APP 體驗(yàn)。它們配合起來,構(gòu)成了我們的跨端代碼復(fù)用方案。
我們原本以為,要解決多端和國際化帶來的多應(yīng)用冗余開發(fā)問題,需要?jiǎng)佑?Flutter 等技術(shù)進(jìn)行翻天覆地的變革。但探索和思考到后面,發(fā)現(xiàn)原有基礎(chǔ)上做出調(diào)整,也能帶來可觀的收益,成本更低且更加安全。
在新的設(shè)計(jì)中,需要落實(shí)的代碼量并不是特別多,它本身就是建立在現(xiàn)有框架的基礎(chǔ)上的新抽象。現(xiàn)有框架 React-IMVC 和 React-Native 繼續(xù)發(fā)揮作用,只是改善了Model 層以及將 git 倉庫管理變成 Monorepo 模式。
實(shí)際使用這個(gè)模式的過程中,還有很多需要克服的細(xì)節(jié)問題,
比如 Webpack/Babel/TypeScript/Node.js/NPM 等工具對(duì)軟鏈接的支持和處理方式不盡相同,協(xié)調(diào)軟鏈接讓它在各個(gè)框架中表現(xiàn)正常需要處理很多兼容問題。
比如多個(gè)項(xiàng)目在一個(gè) Git 倉庫里的構(gòu)建、發(fā)布和分支管理問題等,都是需要面對(duì)的新挑戰(zhàn)。
七、展望
目前我們處于第一階段,將 Model 層獨(dú)立出來并最大化它的職能。
第二階段,我們將對(duì) View 層進(jìn)行分層:
1)Container-Component;
2)Atom-Component/Atom-Element;
React-Native、React-DOM 乃至 React-? 等其它渲染目標(biāo),它們會(huì)提供一些 Atom-Component 或者 Atom-Element。比如 React-DOM 里的 div/span/h1 等,React-Native 里的 View/Text/Image 等。在 Atom 層面將它們統(tǒng)一起來的問題,前面已經(jīng)做過論述,在此不再贅述。
我們可以保留 Atom 層面的差異以發(fā)揮各個(gè)渲染目標(biāo)最大的能力,但在 Container 這種抽象層面做一些統(tǒng)一。

如上圖所示,我們通過 React 的 useContext 封裝 useComponents,在不同平臺(tái),注入不同的 Banner/Calendar 組件實(shí)現(xiàn),然后將它們和 Model 里的 state/actions 關(guān)聯(lián)起來。
那么,View 層里存在的相當(dāng)一部分代碼,比如組件結(jié)構(gòu)堆疊、狀態(tài)綁定、事件綁定等,都可以提取出來,在多端復(fù)用。在每個(gè)端啟動(dòng)時(shí),注入不同的組件實(shí)現(xiàn)即可。如此,既保留了底層實(shí)現(xiàn)的靈活性和自由度,又得到了上層抽象的穩(wěn)定性和一致性。
當(dāng)我們不斷自上而下的推進(jìn)這個(gè)過程,提取所有可復(fù)用的抽象,一直到抹平所有底層差異,此時(shí)等價(jià)于實(shí)現(xiàn)了一個(gè)類似 Flutter 一樣跨平臺(tái)框架。但我們不必像 Flutter 那樣,必須先從底層開始搭建,到一定完成度后,才開始發(fā)揮實(shí)用價(jià)值。我們是在現(xiàn)有基礎(chǔ)上,每一步都帶來收益。并且,當(dāng) Flutter 變得更加成熟時(shí),我們可以保留上層抽象的同時(shí),將底層替換成 Flutter 渲染。
因此,這是一條既處理了當(dāng)下的困境,又兼顧了將來的發(fā)展的做法。
八、總結(jié)
經(jīng)過這次跨端方案的歷練,我們對(duì)代碼如何組織有了更清晰的認(rèn)識(shí)。
比之前更加了解哪些代碼應(yīng)該放到 Model 層,哪些代碼應(yīng)該放到 View 層,哪些代碼是可復(fù)用的,哪些需要保持差異,哪些問題通過運(yùn)行時(shí)框架去解決,而哪些問題其實(shí)是工程問題,通過目錄和 git 倉庫的調(diào)整和團(tuán)隊(duì)協(xié)作來解決等等。
當(dāng)我們強(qiáng)行拉平底層差異,發(fā)現(xiàn)能用的能力變得越來越少。
當(dāng)我們把應(yīng)該放到 Model 層的,放到了 View 層,則丟失了 Model 層應(yīng)有的長期價(jià)值。
當(dāng)我們把工程問題,放到運(yùn)行時(shí)框架去解決,我們的框架將變得越來越臃腫,運(yùn)行越來越慢。
我們選擇保留底層差異,用多個(gè)更輕量的運(yùn)行時(shí)框架,去代替一個(gè)大而全的運(yùn)行時(shí)框架。
我們通過構(gòu)造上層抽象,將 Model 層和 View 層具有長期價(jià)值的、更穩(wěn)固的部分,統(tǒng)一起來,在多個(gè)項(xiàng)目中共享。
如此,在每個(gè)層次上,我們都有機(jī)會(huì)去榨取最大價(jià)值,而不必遷就兼容性。
以上,我們粗略地描述了我們的前端架構(gòu)設(shè)計(jì)如何從 Backbone.js 走到 Pure-Model + Monorepo + GraphQL-BFF + React-Native/React-IMVC 的模式,并呈現(xiàn)了在每個(gè)階段我們所面對(duì)的問題、所作的思考和最終的選擇。
它們未必適合所有項(xiàng)目和團(tuán)隊(duì),不過希望能帶給大家一點(diǎn)啟發(fā)或思考。
【作者簡介】Jade Gu,攜程高級(jí)前端開發(fā)專家,負(fù)責(zé)度假前端框架設(shè)計(jì)和 Node.js 基礎(chǔ)設(shè)施建設(shè)等工作。
更多攜程技術(shù)人一手干貨文章,請關(guān)注“攜程技術(shù)”微信公眾號(hào)。