導讀
商業FE部門分四個業務組,每個組內迭代著多箇中後臺應用,其中有跨組協作項目。這些項目的特點是UI、UE相似,有較多的組件、邏輯複用場景,技術棧統一為VUE,且項目在不斷迭代中。實踐微前端架構能提高業務複用性,讓各團隊更高效的分治項目。
認識
1、簡述
微前端是一種方案並非技術,凡是方案都是為適應特定應用場景而生的。微前端的方案類似微服務。它是對大而複雜的應用進行分治,形成各自的單一體系並能通過一套規則聯繫在一起。
2、特點
微前端架構有以下特點:
a)每個應用都應該是一個完整的沙盒環境,具有獨立的開發、測試、部署流程;
b) 多個應用間有相同依賴,可以是模塊、可以是路由、可以是業務組件。
3、微前端的應用場景
什麼場景可以考慮引用微前端架構?
a)App大且含有多條業邏輯;
b) App是對應到不同的組織架構中;
c)多App間複用邏輯較多且有組合入口出現;
d) 不斷迭代的App。
4、範式圖
範式圖
基座式的微前端架構基本是這樣一個簡單邏輯組成,基座工程 + 子應用*n。
基於Vue的基座式微前端
1、運行時
有了對微前端的基本認識,我們梳理一下微前端項目在運行時基座與子應用的基本協作流程圖:
運行流程圖
運行時基座扮演組織者角色,因此基座應用應具備以下特點:
a)按需加載子應用靜態資源。
b) 動態註冊子應用,並註冊子應用關鍵模塊:router、store等。
2、部署時
我們強調子應用的自治,所以子應用部署應該是獨立的。
部署流程圖
在子應用部署測試和生產環境的時候,都應該將註冊信息同步反映到註冊表上。註冊表模塊是解耦基座和子應用的關鍵,它可以簡單的是一個json文件,也可以複雜的是一個子應用管理服務,具體尺度根據業務來衡量。由於我們的子應用數量及部署更新頻率還不高,所以先簡化註冊表模塊,以文件加手動更新方式來實現。
註冊表模塊應該具備以下特點:
a)信息映射:維護全量子應用註冊信息,部署時間、版本、靜態資源、scopeID等。
b) 動態性,隨著不斷部署來更新註冊表信息。
c)可讀性,暴露一個可讀的對象或返回對象的自執行函數。
我們期望降低子應用開發者的心智負擔,因此後續會將註冊表模塊服務化並接入上線系統部署鉤子,用來管理子應用的信息。
3、開發時
開發時基座與子應用的協作是相對複雜的,並且也包含了運行時的基本邏輯,下文將圍繞開發狀態下各技術點展開闡述。
注意:此處是給出的是實現思路,具體代碼實現細節可自行實現。
1、基座運行環境
開發時基座的運行環境有以下兩種方式。
a) 本地服務式 - 子應用開發者將基座拉取到本地,運行到本地服務中。
本地服務式是將基座和子應用放置於子應用開發者本地,由於同處於一個物理空間(磁盤)上,基座可以通過直接引用子應用的構建出口來解決引用熱更新問題。這種做法解耦不是很徹底,需要子應用開發者本地啟動基座服務,並且沒法提供多個子應用同週期協調開發的環境。
b) 集中服務式 - 將基座維護在一個專供開發時應用的服務器容器中。
集中服務式開發體驗較好且可以多方協調開發,但需要維護專供開發時的服務節點,並通過網絡解決基座與子應用間的資源加載及熱更新問題,設計難度較大,由於是網絡傳輸資源所以耗時需要優化。
2、選型
為快速落地框架,我們選用本地服務式方案。下面主要介紹本地服務式各點實現。不過為了後續轉集中服務式做準備,這裡也會列出一部分實現思路。為提高代碼可讀性,我們命名基座為Voo。
3、本地服務式
開發時-本地服務式關係圖
a) 子應用入口文件的包裝
<code>export default (Voo) => { Voo.Vue.prototype[appName] =vuePrototypeExtension return { router, store, App }}釋:子應用暴露一個方法並返回router、store、App模塊,供基座註冊調用和回傳基座對象(Voo);原型擴展方法需要增加namespace,方便提供給子應用本身和其他應用複用。/<code>
b) 子應用路由、數據流的包裝
<code>export default [ { 'path': '/', 'redirect': 'home' }, { 'path': 'home', 'name': 'Home', 'component': Home }]釋:暴露一個routes數組,供基座動態插入,由於基座與子應用運行在同一個router對象中,所以遵循“以 / 開頭的嵌套路徑會被當作根路徑。這可以讓你充分的使用嵌套組件而無須設置嵌套的路徑”的規則。export default {state, mutations, actions, modules }釋:暴露一個store module對象,供基座動態註冊。/<code>
c) 子應用腳手架的修改
<code>webpackConfig.output.library ='[name]'webpackConfig.output.libraryTarget= 'umd'釋:修改輸出目標為umd便於runtime、開發時基座的引用webpackConfig.output.jsonpFunction= appName釋:如果在同一網頁中使用了多個來自不同編譯過程(compilation)的webpack runtime,則需要修改此選項。/<code>
<code>webpackConfig.plugins.push(newwebpack.BannerPlugin({ 'banner': '/* eslint-disable */', 'raw': true}))釋:向bundle中追加eslint註釋,防止基座eslint校驗不通過。'devServer': { 'writeToDisk': true, before(app,server) { request.post('http://localhost:7777/__dev_subApp_register', { 'form': { 'id': [appName], 'resourcePath':path.resolve(__dirname, `dist/${[appName]}.js`) } }, (error, response, body) => { if (error) { console.error('[ error ]請先啟動基座工程Voo') } else {console.log(body)} }) }}釋:將構建目標寫入到物理磁盤中,devServer默認是寫入在虛擬內存中的,基座工程無法import。/<code>
在devServer啟動前向基座工程的本地服務發送構建目標的物理路徑,注意:此處路徑是磁盤絕對路徑,是可以通過import載入的。` 7777`是基座工程固定的端口,__dev_subApp_register是基座工程固定的子應用註冊路由。
d) 基座腳手架的修改
<code>'devServer': { before(app, server) { app.post('/__dev_subApp_register',(req, res) => { const params =Object.assign(req.query, req.body) const devSubAppRegisterInfo = `/* eslint-disable */export default (regiestSubApp, opts) => {import('${params.resourcePath}').then((res) => { const subApp =res.default(opts) regiestSubApp({ id: '${params.id}', subApp })})}` fs.writeFileSync(`${__dirname}/__dev__subApp_register_info.js`,devSubAppRegisterInfo) res.json({ 'code': 0, 'message': '開發時註冊成功' }) }) }}釋:編寫子應用註冊接口,在接收到子應用的註冊請求後,將基座引用邏輯寫入到__dev__subApp_register_info.js。/<code>
4、集中服務式
集中服務式,設計中需要注意以下幾個點:
a) 配置服務,將nginx反向代理到基座服務;
b) 基座服務提供與子應用交互的接口,此處我們選用webpack的devServer進行描述。
1、在基座根目錄創建 /subApps。
2、在before編寫註冊接口。當註冊請求進入後將子應用/dist文件寫入到/subApps中,如果/dist文件太大,可以採用壓縮解壓,如果子應用文件夾存在則更新。
c) /subApps目錄結構
├── App.vue
├── main.js
├── ...其他目錄
├── subApps
├── appA
│ ├── appA.js
│ ├── ...各chunk
│ └── appA.css
├── appB
│ ├── appB.js
│ ├── ...各chunk
│ └── appB.css
├── ...其他subApp
└── index.js
d) 動態讀取 /subApps下所有文件,暴露出去
<code>const requireSubApps =require.context('./', true, /\.js|.css$/)export defaultrequireSubApps.keys().map((fileName) => { return requireSubApps(fileName).default})/<code>
e) 熱更新
由於基座入口引用的是基座服務本地文件,所以,我們只需要在子應用代碼發生改變時觸發基座註冊接口就行。實現如下:
<code>before(app, server) {registe();app.post('/__dev_update', (req, res) => {registe()})}/<code>
由於需要監聽main.js入口以內所有模塊的變化,所以將監聽邏輯放到main.div.js,代碼實現如下:
<code>if (module.hot) { module.hot.accept('./main.js', ()=> { fetch('/__dev_update') });}同時修改dev和prod環境下的打包入口。configureWebpack(webpackConfig){ webpackConfig.entry =process.env.NODE_ENV === 'development' ? { [appName]: ['./main.dev.js'] } : { [appName]: ['./src/main.js'] }}/<code>
f) 優化
熱更新時按需上傳[hash].hot-updage.json,降低網絡耗時。
4、聯調|測試
聯調階段:需要關注的問題是mock、proxy,這兩點都可以沿用spa應用原有的開發方案。
測試階段:基座和子應用都是通過上線系統管理所以可以利用其提供的測試環境。關鍵需要注意一下注冊表的測試環境提供。
5、關鍵點
1、子應用生命週期
由於子應用會被當做一個路由組件註冊到基座中,所以子應用可以利用其root組件的vue生命週期。
2、沙盒化router、store、css、vue原型擴展
路由,將子應用註冊到其ID為根的路由上,並將其暴露的路由註冊到children上。
<code>Voo.$router.addRoutes([ {'path': `/${ subApp.id}`, 'children': subApp.router, 'component': subApp.App}])/<code>
Store module, 動態註冊的store module本來就是具有作用域的,依照vuex文檔即可。
<code>Voo.$store.registerModule(id,{ 'namespaced': true, ...subApp.store })。/<code>
Css module, 通過postcss給子應用追加作用空間。
<code>constpostcssNamespaceGlobal = postcss.plugin('postcss-namespace-global', ({namespace= ''}) => (root) => { root.walk((node) => { if(node.selector){ node.selector =(node.selector.split(',').map((selector) => { if(selector.match(/^(\s*)(html|body)(\s*)$/)) { return selector } return `${namespace}${selector}` }).join(',')) } })})module.exports ={'plugins': [postcssNamespaceGlobal({namespace: `.${appName}`})]}/<code>
Vue原型擴展,給子應用用到的原型擴展方法規定到其ID對應的對象中。
3、 複用層
複用層比較複雜。有著較多種類的使用場景。下面分析一下在微前端架構中會出現哪些複用的東西,怎麼去選型及管理。總體來說複用層的內容可以分為兩種:
a) 類性質。調用時創建實例,因此runtime時互不干擾。但要根據是否需要鎖定版本來確定複用內容的管理方案。
需要鎖定版本,採用npm scope,管理在公司內部的npm服務上。注意:要規範好子應用npm安裝重複問題。
不需要鎖定版本,採用全局註冊,例如全局註冊的業務組件。注意:子應用註冊需要scope。
b) 函數性質。 調用和執行是同一組代碼,這種複用內容性質是脆弱的。所以對設計者要求較高,且迭代應向下兼容。
4、 複用內容
a) UI組件庫等第三方依賴:類性質,在基座中規定並回傳給子應用。
b) ajax庫及統一接口處理:函數性質,在基座中規定並回傳給子應用。
c) 業務類組件:類、函數性質,採用全局註冊回傳給子應用。
d) 子應用特色業務組件:如chart、workflow等:類性質。託管npm。
5、 子應用拆分粒度
太粗、太細的子應用粒度都不利於App的維護,所以要根據業務和組織架構合理拆分。我們可以參考兩個原則:與服務對應形成前端微服務化、與團隊對應。
6、Q/A
Q: 在“開發時-本地服務式”中,既然是在本地啟動基座和子應用,為什麼基座不直接import子應用入口而是import子應用的bundle。
A: 因為框架的目的是儘量解耦應用,如果直接import子應用入口,那麼子應用就相當於是在基座環境中構建的。這樣即增加了子應用開發、生產兩個環境的構建差異,又限定了子應用的開發依賴。
總結和展望
1、總體來說這一套架構解決了以下問題:
a) 解決業務繁多的項目分治;
b) 多團隊協作開發,且團隊內項目自治;
c) 對敏捷迭代的項目構建良好的基礎;
d) 子應用開發者無需關注基座及其歷史子應用業務,直接依賴基座預覽開發效果,體驗提升;
e) 子應用開發幾乎無異於SPA,無學習門檻。
2、思考
此次是我們在微前端道路上的初探,輸出也只是基於Vue技術棧的單一形態。所以圍繞微前端概念我們還有很多事情要做。
在設計此架構前我也調研過很多應用微前端的文章,得出的結論竟然讓我自己覺得有些矛盾。微前端的理念是為了解耦,但是往往很多使用者還希望通過微前端實現業務的高度複用。那麼矛盾來了,複用就伴隨著耦合。所以說沒有銀彈,我們要做的是解耦子應用的同時,儘可能的對複用層進行分類管理,結合業務場景定製化適合的微前端架構。
3、優勢
a) 基於同技術棧的微前端,可以快速抽離複用層並無侵入性的投入使用;
b)基於vue,有效的利用了store、router動態註冊特性,貼合58商業目前技術棧及存量項目;
c) 在本地式開發流程中子應用腳手架和基座腳手架之間的合作可以提供穩定的熱更新方案。
4、規劃
將目光放的再長遠一些,那麼我們還應該做以下規劃。
d) 子應用跨技術棧,解耦更徹底,讓微前端能應用到更大的聚合App上和組織架構中,當然複用層將變為一個挑戰。
e) 為增加開發體驗,基座採用集中服務化方案,例如:有子應用需求接入時就將基座部署到沙箱節點上,或者可以將基座應用設計為服務端渲染並提供一套開發專用帶權限的接口。這樣就可以解決專門為開發提供服務的問題,同時還可以封裝註冊表相關邏輯以管理子應用。
f) 註冊表模塊服務化,此項主要是為規範工程化管理。
作者簡介:
張軍,58集團前端工程師。
參考文獻:
康威定律:http://www.melconway.com/Home/Conways_Law.html