這篇文章將簡略地介紹我們當前的無線前端架構設計及其演進之路。主要內容包含以下幾個部分,希望我們的經驗能帶給大家一些啟發。
1)當前的前端方案及其解決的問題
2)現在面對的新挑戰
3)我們的前端方案設計和選擇。
一、當前的前端方案及其解決的問題
1.1 當前方案的技術背景
將時間調回到 2016 年。我們已經將幾個核心的前端應用,從 C# ASP.NET 遷移到了 Node.js。并且在基于 Backbone.js 的前端框架上,添加了 React 去管理 View 層,取代了 Underscore.js 的 template 模板引擎,實現了徹底的前后端分離。
在舊框架中引入 React,這個過程并不像上面描述得那樣輕松。我們需要解決 2 個問題。
1)React 體積過大
2)React 開發需要 ES2015 和 JSX 的編譯工具的支持
彼時,現有框架體積已然龐大,引入 React 會再增加 140+Kb 的 JS Size,將進一步拖慢我們的 SPA 首次渲染時間。這是不可接受的,也是阻礙當時絕大多數公司的在原有前端項目中使用 React 的重要因素。
React 體積太大了,除非是新項目或者重構,有機會重更新分配 JS Size 預算。否則,想要使用新技術解決現有項目的問題,首先要能解決引入新技術的成本問題。
為了能使用 React 的組件化技術,解決大塊大塊的渲染模板難以維護的問題。我們自研了兼容 React API 的輕量版實現 react-lite。將 140+Kb 的 Size 降低到了 20+Kb 的可接受水平。
當時我們的項目的模塊管理工具是 require.js。我們編寫 ES5 語法的代碼,然后它們直接運行在瀏覽器上。沒有目前 Webpack/Babel 的編譯和打包環節。
盡管用 react-lite 降低了引入 React 的體積,但我們的目的,是用組件化的方式,將巨大的渲染模板代碼,分解為多個小塊的組件,方便維護和增加可復用性。不能使用 JSX 語法,需要手寫 React.createElement 的函數調用,React 組件可能比 Underscore.js 的模板還難以維護。
我們曾經嘗試用 Webpack 來取代 require.js,運行整個項目,因為 Webpack 支持編譯 require.js 的 AMD 模塊。但很快我們發現了巨大的麻煩,現有框架對 require.js 的動態模塊和遠程模塊有強依賴。
動態模塊是指,它會判斷不同的環境,拼接不同的 url 地址,如 :
require('/path/to/' + isInApp ? 'hybrid' : 'h5')
遠程模塊是指,有很多模塊,是通過 http 請求下發的 js 腳本,它們不在項目本地目錄中。
這讓基于本地模塊的依賴分析的 Webpack 很難用起來。還有其它各種瑣碎問題,雖然不如上面兩個致命,但也阻礙了我們將前端基礎設施從 require.js 遷移到 Webpack + Babel。
最后,我們設計了一個降級方案。既保留 require.js 的運行機制,又能使用 JSX/ES2015 的新語法,開發 React 組件。
我們設置了 ES6 和 ES5 兩類目錄,基于 Gulp + Babel 創建了一個實時根據文件改動,編譯 ES6 模塊到 ES5 模塊的腳本任務。在開發時,運行 gulp 命令即可。
通過上述取巧的方式,我們在團隊中成功推廣了 ES6 和 React 開發模式。為我們后續基于 React + Node.js + Webpack + Babel 打造新的前端開發方式,建立了良好的基礎。
1.2 當前方案:同構框架 React-IMVC 的誕生
在現有項目中引入 Node.js + React + ES2015 的開發方式,對我們的前端開發確實帶來了幫助。我們可以編寫更簡潔和優雅的 ES2015 代碼,也不再需要維護 .cshtml 模板、配置 IIS 服務器,才能運行我們的 SPA 應用。
前端項目里沒有了其它語言的代碼和配置,只用 JAVAScript 做到自洽和自理。
然而,我們仍然在一個沉重的歷史技術負擔下迭代我們的前端應用。這不是長久之計。
我們需要一個站在 2016 年,而不是 2012 年的視角下,一個全新的、更大程度上發揮 Node.js + React 模式的前端新架構。
它需要實現以下目標:
1)一條命令啟動完整的開發環境
2)一條命令編譯和構建源代碼
3)一份代碼,既可以在 node.js 做服務端渲染(SSR),也可以在瀏覽器端復用后繼續渲染(CSR & SPA)
4)既是多頁應用,也是單頁應用,還可以通過配置自由切換兩種模式,用「同構應用」打破「單頁 VS 多頁」的兩難抉擇
5)構建時可以生成一份 hash history 模式的靜態文件,當做普通單頁應用的入口文件(SPA)
6)構建時可以根據路由切割代碼,按需加載 js 文件
7)支持在 IE9 及更高版本瀏覽器里,使用包括 async/await 在內的 ES2015+ 語言新特性
8)豐富的生命周期,讓業務代碼有更清晰的功能劃分
9)內部自動解決在瀏覽器端復用服務端渲染的 html 和數據,無縫過渡
10)好用的同構方法 fetch、redirect 和 cookie 等,貫通前后端的請求、重定向和 cookie 等操作
眼尖的同學可能發現,直接用 Next.js 不就可以滿足上述目標了嗎?
確實如此。
不過 Next.js 要等到 2016 年 10 月份才誕生,接近 2018 年才逐漸廣為人知。我們沒有時間等待未來的框架來解決當下的難題。
因此在 2016 年 7 月份,我開發了 create-app 庫,實現了同構的最小核心功能,并且在 create-app 基礎上,添加了 store, fetch, cookie, redirect, webpack, babel, ssr/csr, config 等多個功能,組成了我們自研的同構框架 React-IMVC,實現了上述 10 大目標。
1.3 React-IMVC 的設計思路
我們將每個頁面,分解成 3 個部分:Model,View 和 Controller。回歸到 GUI 開發最樸素的 MVC 心智模型。這從 React-IMVC 的框架命名中,可以看出來。
IMVC 的 I 是 Isomorphic 的縮寫,意思是同構,在這里是指,一份 JavaScript 代碼,既可以在 Node.js 里運行,也可以在 Browser 里運行。
IMVC 的 M 是 Model 的縮寫,意思是模型,在這里是指,狀態及其狀態變化函數的集合,由 initialState 狀態和 actions 函數組成。
IMVC 的 V 是 View 的縮寫,意思是視圖,在這里是指,React 組件。
IMVC 的 C 是指 Controller 的縮寫,意思是控制器,在這里是指,包含生命周期方法、事件處理器、同構工具方法以及負責同步 View 和 Model 的中間媒介。
React-IMVC 里的 MVC 三個部分都是 Isomorphic 的,所以它可以做到:只編寫一份代碼,在 Node.js 里做 Server-Side-Rendering 服務端渲染,在 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 副作用是跟外界交互的必然產物,只可能被隔離,不可能被消滅。所以,我們需要一個承擔 Side-Effects 的對象,它就是 Controller。
Life-Cycle methods 是副作用來源,Ajax/Fetch 也是副作用來源,Event Handler 事件處理器也是副作用來源,localStorage 也是副作用來源,它們都應該在 Controller 這個 ES2015 Classes 里,用面向對象的方式來處理。
一個 Web App 包含多個 Page 頁面,每個 page 都由 MVC 三個部分組成。
上圖的代碼實現了一個支持 SSR/CSR 的計數器頁面。我們可以清晰地看到 React-IMVC 的設計理念。
Controller 類的 Model 屬性描述了 Model 的初始狀態 initialState,以及定義了狀態變化方式 actions。
Controller 類的 View 屬性通過 React 組件描述了視圖的呈現方式,它根據 Model 提供的 state/actions 進行數據綁定和事件綁定。
當 View 層的點擊事件觸發 actions 時,將引起 Model 內部的 state 變化,而 Model 的變化,將通知 Controller 去觸發 View 層的更新。如此構成了 Model, View 和 Controller 經典的渲染循環模型。
那么,我們是如何支持 SSR 的呢?
如上圖所示,很簡單,Controller 包含了很多生命周期,其中 getInitialState 會在創建 Model/Store 實例之前調用,支持異步,可以使用 Controller 提供的 fetch api 進行 http 接口請求。
React-IMVC 會在內部 hold 住異步的數據獲取,在 SSR 數據準備好之后,才進行后續的渲染流程。這些復雜的操作,都隱藏到了框架內部。對于頁面開發者來說,它們只是生命周期、異步接口調用而已。
除了 getInitialState 以外,React-IMVC 還提供了其它實用的生命周期,比如:
1)shouldComponentCreate: 頁面應該被渲染嗎?在這里可以鑒權和 this.redirect 重定向。
2)pageWillLeave:頁面即將跳轉到其它頁面
3)pageDidBack:頁面從其它頁面跳轉回來
4)windowWillUnload:窗口即將被關閉
5)……
通過配置豐富的生命周期,我們可以將業務代碼進行更清晰地分塊。
再配合一個 index.js 作為路由模塊,將多個 Page 的 Controller.js 按照跟 Express.js 一樣的 path/router 路徑配置規則設置,可以按需加載和響應不同的頁面請求。
React-IMVC 框架會在 Node.js 里接管 Request,根據 Request.pathname 請求路徑,匹配出對應的 Controller 控制器模塊,并進行實例化和 SSR 等工作。在瀏覽器端,框架內部會自動根據 SSR 內容,對 html 結構和 initialState 數據進行復用。這個過程 React 稱之為 Hydration。
對于頁面的開發者來說,他們在大部分場景下,不需要考慮對 SSR 的適配。controller 里的 { fetch, get, post, cookie, redirect } 等方法內部,會自動根據運行環境切換對應的代碼實現,對使用者保持透明。
通過同構框架 React-IMVC,我們對前端項目的開發方式進行了一次革新和標準化。在幾年內,大量的舊項目遷移到新框架,以及幾乎所有新項目都基于新框架研發,引領我們團隊步入 Modern Web Development 現代前端開發技術棧的時代。
二、當前的新挑戰和問題
在開發 React-IMVC 框架時,我們預期 5 年內這套方案依然適用,不至于過時。如今 3 年多過去了,前端里也發生了一些有趣的變化。比如,2018 年 10 月份 React-Hooks 的出現,比如 TypeScript 的流行。
這些漸進增強的事物,并不會讓一個 SSR 框架過時。React-IMVC 對 React-Hooks 和 TypeScript 支持也做了適時的跟進。
讓我們再次停下來,重新審視新的前端架構設計的,不是現有方案再次過時。而是我們面對了新的問題,現有方案不足以充分解決它們。
React-IMVC 框架設計之初,主要考慮的是 Node.js + Browser 兩個平臺的統一。讓一份代碼,可以同時運行在 Node.js 和 Browser 里,并能自動協調 Server/Browser 之間的 Hydration 過程。只涉及 Web 開發的前后端分離應用,React-IMVC 仍然是合理的選型。
當遇到多端 + 國際化的場景時,情況超出了當初的考量。一條產品線可能有多個應用:
1)國內 PC 站點;
2)國際 PC 站點
3)國內 H5 站點
4)國際 H5 站點
5)國內 APP 內的 React-Native 應用
6)國際 APP 內的 React-Native 應用
7)國內小程序應用
8)其它分銷或渠道里的應用等……
這么多應用形態,每個都投入全職的前端開發小組,其成本和效率都難以讓人滿意。React-IMVC 適用于做 PC/H5 的同構前端應用,但對 App/React-Native 和小程序的支持不足。如何節省多端開發成本,成了一個需要嚴肅考量的議題。
看到這里,對新興技術比較敏感的同學,或許覺得用 Flutter 就能解決問題。Flutter 不失為一種選擇,但未必適合所有場景和團隊。
2.1 跨端方案考察
某種程度上,跨端對前端開發來說,是一個已經解決的問題。JavaScript 在 PC/Mobile 里,在 IOS/Android 里,在 APP/Browser 都能運行,網頁無處不在。
當我們討論跨端方案時,其實不是能不能的問題,而是成熟度/滿意度的問題。
通過 WebView/Browser 在所有地方都用 HTML/css/JavaScript 開發界面,固然是跨端了。但在 App 里的加載速度、流暢度等核心指標上,并不能滿足要求。因此才有 React-Native 這類強化方案:使用 JavaScript 編寫業務邏輯,用 React 組件去表達抽象的界面,但通過 Native UI 去加速渲染:Written in JavaScript—rendered with native code。
React-Native 提供了不錯的 IOS/Android 跨端能力,但它有兩個問題:
1)官方甚至沒有承諾過 IOS/Android 的跨端,只是說“Learn once, write anywhere.”。官方沒有支持的跨端兼容問題,需要自行封裝和處理。
2)React-Native for Web 是一個社區方案(react-native-web),不是官方迭代的項目,在 web 端的性能表現和體驗,得不到充分的保障,一旦出現問題,代碼難以調試和修改??煽爻潭炔蛔?。
我們實際使用下來,React-Native 用在 IOS/Android 的 App 里面是不錯的選擇,但編譯到 Web 平臺運行有一定風險。
Flutter 聲稱自己可以用一套代碼,運行在 mobile, web, 和 desktop 等平臺上,背后又是 google 的團隊在開發。確實非常有吸引力。出于以下考量,目前可能不適合我們的場景:
1)Flutter 使用 Google 自己的 Dart 語言,而非 JavaScript。所有業務代碼都要重寫,學習和重構成本較高。
2)Flutter 對 Web 的支持目前還在 beta channel,處于 preview releases 階段,仍有一定的生產使用風險。
3)Flutter 的功能主要覆蓋的是渲染引擎,在實際業務開發時,IOS/Android/Web 各個平臺特定的 API 還需要去額外適配,并非 100% 使用 Flutter 自身功能就能解決一切問題,需要付出大量時間和成本去做圍繞 Flutter 的基礎建設等工作。
因此,從現階段看,Flutter 可能比較適合創業公司、中小型公司或者大公司里從零開始的非核心項目。
對幾個主流跨端方案的總結如下:
1)Web/Page:在 Browser 里體驗還行,但在 App 里的體驗不佳;
2)React-Native:在 App 里的體驗很好,但在 Broser 里的體驗沒有保障;
3)Flutter:在 App/Browser 里的體驗都有一定保障,但學習、重構和基建成本大;
Flutter 是一個徹底革新的方案,所使用的語言和基礎設施,對公司里的開發者來說都是新的。我們更想要的,其實是不推翻現有積累,而是在當前方案上做一個漸進的提升。
不排除未來 Flutter 可能成為統一大前端的最佳方案,但在它成為事實之前,我們還得面對和解決現在的問題,不能只是等待未來的完美方案出現。并且,多端是我們面對的問題的其中一個,國際化是另一個。
出于國內用戶跟國際用戶之間巨大的文化差異等因素,我們起碼要準備兩套界面風格和交互形態顯著不同的產品。一種是面向國內用戶,另一種是面向國外用戶(通過 I18N 實現多語言的支持)。
即便用 Flutter 等技術解決了多端問題,我們還需要思考國內/國際兩組多端應用,是不是也有可以統一/歸并起來的空間?
三、從 VOP 到 MOP 的躍遷
我們將目光放到了 Model 層,它承擔了應用的狀態管理和業務邏輯的職能,是更普適和純粹的部分。
我們可以將多端項目的 Model 層統一起來,但保持 View 層的獨立,不同的 View 層再去對接它相對應的 Platform/Renderer。
問題轉變成,如何最大化 Model 層,讓 Model 層承擔盡可能多的職能,在 Model 層寫盡可能多的代碼?
通過這個新視角,我們審視過去 5 年前端開發領域蓬勃發展,發現了一個有趣現象。
可以將過去 5 年的發展歸類為 View-Oriented Programming 路線,簡稱 VOP(這是我們自造的說辭,在此只是分享見解,不作為權威定義,權當參考)。
不管是 React/React-Native,Vue/Weex,Angular,Flutter 還是 SwiftUI,它們都是 component-based 的視圖增強模式。它們以視圖組件為中心,不斷增強視圖組件的表達能力,從最基本的父子嵌套的組合能力,到狀態管理能力,再到副作用和交互管理的能力等。
我們來看一下它們的組件寫法。
上圖是 React 組件代碼,在 function component 內,同時包含了 State 和 View 的部分,并且它們不可分割,State 是局部變量,和 View 是綁定關系。雖然我們可以抽取成 custom hooks,使之可以復用到 React-Native,但當我們在 useEffect 里使用 DOM/BOM 或 RN 特有 API 去觸發 setState 時,它們又跟特定平臺耦合。
上面是 Vue SFC 代碼,template 是 View 部分,data/compted 是 State 部分,它們是一一對應的。
上面是 Angular 的組件代碼,View 和 State 管理的部分,也是一一對應的。
上圖是 Flutter 的 Stateful Widget 代碼,View 在 build 方法里,State 管理則是通過 class 的 members 和 methods 實現。members 和 methods 在 class 里是不可分割的。
上圖是 SwfitUI 的代碼,組件也是通過 class 去表達,相對 Flutter,SwiftUI 組件的 View 在 body 方法里。
不管它們將 State/View 放到一個函數里,還是 class 里,State/View 之間都構成了一一對應的綁定關系。State 是圍繞 View 的消費和交互需求而產生的,View 是組件真正核心的部分。
這并不是說 React、Vue 以及 Flutter/SwiftUI 都做錯了,增強組件表達能力是正確的。只是說,當 State 和 View 綁定起來時,難以達到最大化 Model 層代碼復用的目標。
我們需要讓狀態管理變成 view agnostic,在獨立的 Model 層去管理 state 及其變化,不假定下游是哪種 View Framework。
也就是說,我們要從 View-Oriented Programming 轉向 Model-Oriented Programming,簡稱 MOP。
從面向 View 編程,變成面向 Model 編程。
四、MOP 選型
在當前 JavaScript 生態圈里,可以脫離具體 View 框架獨立使用的流行方案,主要有:
1)Redux
2)Mobx
3)Vue 3.0 reactivity api
4)Rxjs
5)……
Redux 曾經是 React 狀態管理的首選方案,它有自己的 devtools 支持便利地通過 action 追溯狀態變更歷史。但鑒于它在使用上有太多模板代碼,實現一個功能需要橫跨多個文件夾,不是很便利。社區里對 Redux 不乏抱怨的聲音,每當 React 添加一個新功能,社區就想用這個新功能替代 Redux。將 Redux 封裝成使用上更簡便的形態的嘗試也層出不窮,甚至 Redux 官方也提供了一個封裝方案,叫做 redux/toolkit。
Mobx 可以說是 React 社區僅次于 Redux 的另一個流行方案,參考了 Vue 的 Reactive 狀態管理風格。它也可以不跟 React 綁定,獨立使用或者跟其它視圖框架搭配使用。
Vue 3.0 將內部的 reactivity api 提取成 standalone library,也可以獨立使用或搭配其它視圖框架。
Rxjs 是一個響應式的數據流模式,基于 Rxjs 可以實現一套 State-Management 方案,用在任意地方。
總的來說,這 4 個庫選擇任意一個都是可以的,就看你所在的團隊的風格和喜好。同時,不做任何增強,只用它們現有功能,也很難實現 Model 層最大化。
我們的選擇是 Redux。
原因比較簡單,我們團隊使用的 React-IMVC 框架的 Model 層,是基于我們自己實現的 Relite 庫,它本身就是 Redux 模式的簡化版,跟 Redux 官方的 redux/toolkit 編寫風格相近。選擇 Redux 可以延續我們現有的經驗和部分代碼。
此外,我們認為,Redux 的 action/reducer 包含了可預測的狀態管理的必要核心部分,不管用不用 Redux,狀態管理最終都會暴露出一組更新函數 actions。
比如,不管使用的是 Mobx、Vue-Reactivity-API 還是 Rxjs,去編寫 Todo APP 的狀態管理代碼,還是會得到 addTodo/removeTodo/updateTodo 等更新函數。而 Redux Devtools 是現成的追蹤這些 action 的成熟工具,選擇其它方案都有額外的適配成本。
五、我們的 MOP 框架:Pure-Model
我們基于 Redux 實現了一個支持最大化 Model 層的 MOP 框架,叫做 Pure-Model。
相比 VOP 階段對 Redux 進行簡化,讓 Model 層承擔更少的職能,讓 View 承擔更多的職能。MOP 階段的 Pure-Model 是對 Redux 進行強化,讓 Model 層承擔更多的職能,讓 View 承擔更少的職能。
Redux 本身要求 state 是 immutable 的,reducer 是 pure function,IO/Side-Effects 通過 redux-middlewares 去實現。可是 redux-middleware 極其難用和難以理解,它割裂了一個功能的代碼分布,強制放到兩個地方去處理,不便于閱讀和維護。
那是 2015 年的設計局限。當時整個前端社區都還不知道如何在 pure function 里管理副作用。直到 2018 年 10 月份 React-Hooks 的發布,我們看到了在 function-component 里添加 state 狀態和 effect 交互的有效途徑。
React-Hooks 是對 View 層的增強,讓 View 組件可以表達 state 和 effect,可以通過 custom hooks 模式做邏輯復用。但它背后的理念是通用的,不局限于 View 層,我們可以在 Model 層重新實現 Hooks,得到一樣的能力增強。
上圖是跟前文演示的 React-IMVC Counter 功能等價的 Pure-Model 代碼,Model 不再跟 View 一塊綁定到 Controller 的屬性中。Model 是單獨定義的,通過暴露的 React-Hooks API,在 React-DOM 組件里使用,同時它也可以在 React-Native 組件中使用。
我們的演示代碼將 Model 和 View 寫在同一個 JS 模塊里,是為了能在一張圖里呈現代碼。實際開發,Model 層是獨立的模塊,然后用在 View.H5.tsx 和 View.RN.tsx 等組件模塊里。
需要注意的是,其中有兩個 Hooks,一個是 View Hooks,一個是 Model Hooks。
Pure Model 的 setupStore 是一個 Model Hooks,用來定義 store。createReactModel 將它轉換成 React-Hooks 的 Model.useState。
那么,Pure-Model 如何支持 SSR ?沒有了 Controller 提供的 getInitialState 方法,也沒有 fetch/post 等接口,如何請求數據和更新到 store 里?
如上所示,我們提供了內置的 Model-Hooks API 和 setupPreloadCallback 等生命周期函數,覆蓋了 Http 請求和 preload, start, finish 等事件。
在 setupPreloadCallback 里注冊一個預加載函數,支持異步,可以通過 Http 接口獲取數據,并調用 action 更新狀態。該生命周期提供的能力是,在外部訂閱者消費 state 之前,先進行數據的預加載和更新。如此,外部第一次消費數據時,拿到的是一個豐滿的結構。
而 setupStartCallback/setupFinishCallback 則是在 Model 被訂閱和解除訂閱的兩個回調。當 Pure-Model 被用在 React 組件中時,它們對應的是 componentDidMount 和 componentWillUnmount 的生命周期。
Model-Hooks 跟 React-Hooks 或者 Vue-Composition-API 一樣,支持編寫 Custom Hooks 實現可復用的邏輯,如上面的 setupInitialCount,可以在任意支持 Model-Hooks 的地方調用/復用。
我們還內置了 setupCancel 等 Model-Hooks,可以方便的構造可取消的異步任務,并且不局限于 Http 請求。通過這些 Model Hooks API 的封裝,Model 層的代碼會變得很清晰和優雅,開發者可以根據不同的場景,使用不同的 Model-Hooks 去注冊不同的 onXXX 生命周期,觸發不同的 actions。
并且這些生命周期不是 class 里扁平的 methods 形式,它可以分組,切片、封裝和樹形嵌套,是一個更加靈活和自由的模式。
在 Pure-Model 中,reducer 是 pure function,但 setupXXX 等其它額外的部分,支持 IO/Side-Effects。相當于把原本需要寫在外部的 redux-middleware 代碼,放到了一個 createReactModel 中,上面是 setupStore 構造 immutable/pure 的 store/actions,下面則基于 store/actions,構造支持異步的 actions。
所有功能實現,其實都包裹在 setupStore/setupXXX 等函數中,它們只是定義,并未執行,因此 createReactModel 是 pure 的,它只是返回了一組函數。
在不同平臺,我們可以注入不同的 setupFetch 等實現,比如在瀏覽器里,我們注入 window.fetch 的封裝,在 Node.js 里我們注入 node-fetch 的封裝,在 React-Native 里我們注入 global.fetch 的封裝。
Pure-Model 采用的是構建上層抽象的路線,所有 Hooks,都是描述要做什么,但沒有限定底層實現怎么去做。當 Pure-Model 在具體平臺運行時,這部分代碼實現由一個適配和銜接層給出。
有了 Pure-Model 這層 Redux + Model-Hooks 的抽象,我們不僅能把 State-Management 代碼放到 Model 層,還可以把 Effect-Management 副作用管理代碼放到 Model 層。而 View 層里,只需要 Model.useState 獲取到當前狀態,Model.useActions 獲取到狀態更新函數,將它們綁定到視圖和事件訂閱中去即可。
換句話說,Model 層包含了函數實現,而 View 層只剩下必要的函數調用。函數實現的代碼是更長的,而函數調用的代碼是更短的。我們不斷地將函數實現提取到 Model 層,那么 View 層和 Controller 層代碼就會越來越薄。
在實踐中我們發現,最后我們得到的 Model 層,里面包含的就是應用的核心業務邏輯代碼,它們可以獨立運行和測試,可以用在任意視圖框架中。不僅是跨平臺,甚至具備跨時代的生命力。當 React 被下一代視圖框架所淘汰,我們不必拋棄所有代碼;實現一個 Model 層到新視圖框架的適配即可。
基于 MOP 框架 Pure-Model 編寫的代碼,如此成為了應用的核心資產。
我們回過頭去看,其實在 React/Vue 等視圖框架強盛之前,大家對 Model 和 View 層的耦合,本來就是否定的。View 是薄薄的一層,甚至只是一行 render(template, data) 的模板渲染。核心代碼都在 Model 層和 Controller 層去管理數據和事件。
等到 React/Vue 崛起成為前端開發的主旋律后,因為視圖組件的表達能力更強,在視圖組件里編寫一切代碼,成了一個流行趨勢。
然而,Model 層和 View 層的職能,在某種程度上是互斥的。我們需要 Model 獨立、穩定以及具備長期迭代的生命力,而 View 層是多變的、依賴數據的、存在的生命周期隨著 UI 風格潮流的變化而變化。
當我們在 View 層實現 Model 層的代碼,某種意義上我們就放棄了 Model 層的核心價值。
那么,為什么大家用了 5 年 VOP 模式,也沒遇到什么真正的問題?
這是因為,Model 層自身也分成好幾層,前端 Model 層和后端 Model 層,前端 Model 層是對后端 Model 層的銜接,把前端 Model 層跟 View 層綁定起來,只影響了前端 Model 層的穩定性,而應用依賴的后端 Model 層還是保持了獨立、穩定和長期迭代的生命力。
在前端框架高速發展的階段,整個前端項目重構和框架升級,也算是常態。因此 Model 層和 View 層的耦合,很少帶來實質影響。這跟網頁內存泄露不是什么致命問題類似,刷新一下就好了。
當前端框架競爭趨于穩定,重構前端項目的頻次變少,再加上多端和國際化的需求,跟 View 層耦合的前端 Model 層,開始變得尷尬起來。
同一個后端 Model 層,可以對接多個不同 UI 界面風格的應用,它是一個收斂的模型。而前端 Model 層,竟然隨著 UI 界面的增加而增加,這是一個不收斂的模型。
MOP 框架 Pure-Model 是一個收斂前端 Model 層的嘗試。它其實沒有對 React-IMVC 等 SSR 框架進行徹底的推翻,它在 Browser/Node.js 里仍然是由 React-IMVC 去驅動,在 App 里仍然是 React-Native 去驅動。從本質上說,它只是改變了代碼的模塊化方式,將堆積在 View 層和 Controller 層的部分代碼實現,放到了 Model 層維護,在 View 層和 Controller 層只留下函數調用的少量代碼。
再配合我們使用 GraphQL-BFF 模式構造的后端 Model 整合能力,為多端服務的 Pure-Model 可以按需查詢 GraphQL-BFF 以適配在不同端的前后端數據交互。詳情請見《GraphQL-BFF:微服務背景下的前后端數據交互方案》
六、Monorepo
只有 Pure-Mode 也是不夠的,它只是抽象層,真正驅動代碼的還是 React-Native/React-DOM 等視圖框架。
也就是說,我們會有多個項目,分別是不同的腳手架搭建的,只是共用了通過一個 Model 層的代碼。那么,如何在多個項目里共享代碼,就成了一個需要解決的工程問題。
通過 npm 等包管理服務去分發 Model 層代碼,是一個低效方案,任意改動,都需要發布版本,并在每個項目里重新 npm install 或者 npm upgrade,難以使用快速開發的效率要求。
把多個項目放到多個 git 倉庫,也會產生類似問題,Model 層代碼放到哪個項目的 git 倉庫里?還是再增加一個 Model 層的獨立 git 倉庫。N + 1 個倉庫的代碼同步和版本管理將陷入混亂。
通過 Monorepo 單倉庫多項目的模式,可以實現更高效和一致的的代碼共享。
比如,我們將項目按照下面的目錄結構放置:
projects/isomorphic
projects/graphql-bff
projects/react-native-01
projects/react-native-02
projects/react-dom-01
project/react-dom-02
isomorphics 項目是 Model 層所在的項目,它有自己獨立的 package.json 去管理開發、測試等任務。projects 目錄的其它項目,可以使用任意腳手架搭建,支持多個由同個腳手架搭建的項目并存。它們也有自己獨立的開發、構建和測試套件。
通過軟鏈接的方式,將 isomorphic 的 src 目錄映射到其它 projects 的 src/isomorphic 目錄里。如此,代碼源是唯一的,但出現在多個項目中,每個項目都可以 import 引入共享的代碼。當一個項目,不再需要跟其它項目共享代碼,它可以整個文件夾遷移到另一個獨立 git 倉庫中做自己的獨立迭代。
再將 projects/graphql-bff 這類 GraphQL-BFF 的后端 Model 項目也引入進來,通過 GraphQL Schema 生成接口數據類型的 TypeScript 文件,在所有前端項目中共享。我們可以得到更權威的接口數據類型提示,減少絕大部分因為前后端數據結構和類型不匹配,導致的空/非空、類型不一致、字段名大小寫拼錯等的問題。
通過 Monorepo 我們得到了多項目共享代碼的便捷方式;通過 Pure-Model 我們最大化前端 Model 層代碼復用的能力;通過 GraphQL-BFF 我們將后端 Model 統籌起來,并提供權威的接口數據類型來源;通過 React-IMVC 我們得到在 Node.js 和 Browser 里所 SSR 和 CSR 渲染的能力;通過 React-Native 我們得到在 IOS 和 Android 平臺構建接近 Native 的 APP 體驗。它們配合起來,構成了我們的跨端代碼復用方案。
我們原本以為,要解決多端和國際化帶來的多應用冗余開發問題,需要動用 Flutter 等技術進行翻天覆地的變革。但探索和思考到后面,發現原有基礎上做出調整,也能帶來可觀的收益,成本更低且更加安全。
在新的設計中,需要落實的代碼量并不是特別多,它本身就是建立在現有框架的基礎上的新抽象。現有框架 React-IMVC 和 React-Native 繼續發揮作用,只是改善了Model 層以及將 git 倉庫管理變成 Monorepo 模式。
實際使用這個模式的過程中,還有很多需要克服的細節問題,
比如 Webpack/Babel/TypeScript/Node.js/NPM 等工具對軟鏈接的支持和處理方式不盡相同,協調軟鏈接讓它在各個框架中表現正常需要處理很多兼容問題。
比如多個項目在一個 Git 倉庫里的構建、發布和分支管理問題等,都是需要面對的新挑戰。
七、展望
目前我們處于第一階段,將 Model 層獨立出來并最大化它的職能。
第二階段,我們將對 View 層進行分層:
1)Container-Component;
2)Atom-Component/Atom-Element;
React-Native、React-DOM 乃至 React-? 等其它渲染目標,它們會提供一些 Atom-Component 或者 Atom-Element。比如 React-DOM 里的 div/span/h1 等,React-Native 里的 View/Text/Image 等。在 Atom 層面將它們統一起來的問題,前面已經做過論述,在此不再贅述。
我們可以保留 Atom 層面的差異以發揮各個渲染目標最大的能力,但在 Container 這種抽象層面做一些統一。
如上圖所示,我們通過 React 的 useContext 封裝 useComponents,在不同平臺,注入不同的 Banner/Calendar 組件實現,然后將它們和 Model 里的 state/actions 關聯起來。
那么,View 層里存在的相當一部分代碼,比如組件結構堆疊、狀態綁定、事件綁定等,都可以提取出來,在多端復用。在每個端啟動時,注入不同的組件實現即可。如此,既保留了底層實現的靈活性和自由度,又得到了上層抽象的穩定性和一致性。
當我們不斷自上而下的推進這個過程,提取所有可復用的抽象,一直到抹平所有底層差異,此時等價于實現了一個類似 Flutter 一樣跨平臺框架。但我們不必像 Flutter 那樣,必須先從底層開始搭建,到一定完成度后,才開始發揮實用價值。我們是在現有基礎上,每一步都帶來收益。并且,當 Flutter 變得更加成熟時,我們可以保留上層抽象的同時,將底層替換成 Flutter 渲染。
因此,這是一條既處理了當下的困境,又兼顧了將來的發展的做法。
八、總結
經過這次跨端方案的歷練,我們對代碼如何組織有了更清晰的認識。
比之前更加了解哪些代碼應該放到 Model 層,哪些代碼應該放到 View 層,哪些代碼是可復用的,哪些需要保持差異,哪些問題通過運行時框架去解決,而哪些問題其實是工程問題,通過目錄和 git 倉庫的調整和團隊協作來解決等等。
當我們強行拉平底層差異,發現能用的能力變得越來越少。
當我們把應該放到 Model 層的,放到了 View 層,則丟失了 Model 層應有的長期價值。
當我們把工程問題,放到運行時框架去解決,我們的框架將變得越來越臃腫,運行越來越慢。
我們選擇保留底層差異,用多個更輕量的運行時框架,去代替一個大而全的運行時框架。
我們通過構造上層抽象,將 Model 層和 View 層具有長期價值的、更穩固的部分,統一起來,在多個項目中共享。
如此,在每個層次上,我們都有機會去榨取最大價值,而不必遷就兼容性。
以上,我們粗略地描述了我們的前端架構設計如何從 Backbone.js 走到 Pure-Model + Monorepo + GraphQL-BFF + React-Native/React-IMVC 的模式,并呈現了在每個階段我們所面對的問題、所作的思考和最終的選擇。
它們未必適合所有項目和團隊,不過希望能帶給大家一點啟發或思考。
【作者簡介】Jade Gu,攜程高級前端開發專家,負責度假前端框架設計和 Node.js 基礎設施建設等工作。
更多攜程技術人一手干貨文章,請關注“攜程技術”微信公眾號。