用微前端的方式搭建類單頁應用
來自專欄美團技術博客20 人贊了文章
前言
微前端由ThoughtWorks 2016年提出,將後端微服務的理念應用於瀏覽器端,即將 Web 應用由單一的單體應用轉變為多個小型前端應用聚合為一的應用。
美團已經是一家擁有幾萬人規模的大型互聯網公司,提升整體效率至關重要,這需要很多內部和外部的管理系統來支撐。由於這些系統之間存在大量的連通和交互訴求,因此我們希望能夠按照用戶和使用場景將這些系統匯總成一個或者幾個綜合的系統。
我們把這種由多個微前端聚合出來的單頁應用叫做「類單頁應用」,美團HR系統就是基於這種設計實現的。美團HR系統是由30多個微前端應用聚合而成,包含1000多個頁面,300多個導航菜單項。對用戶來說,HR系統是一個單頁應用,整個交互過程非常順暢;對開發者同學來說,各個應用均可獨立開發、獨立測試、獨立發布,大大提高了開發效率。
接下來,本文將為大家介紹「微前端構建類單頁應用」在美團HR系統中的一些實踐。同時也分享一些我們的思考和經驗,希望能夠對大家有所啟發。
HR系統的微前端設計
因為美團的HR系統所涉及項目比較多,目前由三個團隊來負責。其中:OA團隊負責考勤、合同、流程等功能,HR團隊負責入職、轉正、調崗、離職等功能,上海團隊負責績效、招聘等功能。這種團隊和功能的劃分模式,使得每個系統都是相對獨立的,擁有獨立的域名、獨立的UI設計、獨立的技術棧。但是,這樣會帶來開發團隊之間職責劃分不清、用戶體驗效果差等問題,所以就迫切需要把HR系統轉變成只有一個域名和一套展示風格的系統。
為了滿足公司業務發展的要求,我們做了一個HR的門戶頁面,把各個子系統的入口做了鏈接歸攏。然而我們發現HR門戶的意義非常小,用戶跳轉兩次之後,又完全不知道跳到哪裡去了。因此我們通過將HR系統整合為一個應用的方式,來解決以上問題。
一般而言,「類單頁應用」的實現方式主要有兩種:
- iframe嵌入
- 微前端合併類單頁應用
其中,iframe嵌入方式是比較容易實現的,但在實踐的過程中帶來了如下問題:
- 子項目需要改造,需要提供一組不帶導航的功能
- iframe嵌入的顯示區大小不容易控制,存在一定局限性
- URL的記錄完全無效,頁面刷新不能夠被記憶,刷新會返回首頁
- iframe功能之間的跳轉是無效的
- iframe的樣式顯示、兼容性等都具有局限性
考慮到這些問題,iframe嵌入並不能滿足我們的業務訴求,所以我們開始用微前端的方式來搭建HR系統。
在這個微前端的方案里,有幾個我們必須要解決的問題:
- 一個前端需要對應多個後端
- 提供一套應用註冊機制,完成應用的無縫整合
- 構建時集成應用和應用獨立發布部署
只有解決了以上問題,我們的集成才是有效且真正可落地的,接下來詳細講解一下這幾個問題的實現思路。
一個前端對應多個後端
HR系統最終線上運行的是一個單頁應用,而項目開發中要求應用獨立,因此我們新建了一個入口項目,用於整合各個應用。在我們的實踐中,把這個項目叫做「Portal項目」或「主項目」,業務應用叫做「子項目」,整個項目結構圖如下所示:
「Portal項目」是比較特殊的,在開發階段是一個容器,不包含任何業務,除了提供「子項目」註冊、合併功能外,還可以提供一些系統級公共支持,例如:
- 用戶登錄機制
- 菜單許可權獲取
- 全局異常處理
- 全局數據打點
「子項目」對外輸出不需要入口HTML頁面,只需要輸出的資源文件即可,資源文件包括js、css、fonts和imgs等。
HR系統在線上運行了一個前端服務(Node Server),這個Server用於響應用戶登錄、鑒權、資源的請求。HR系統的數據請求並沒有經過前端服務做透傳,而是被Nginx轉發到後端Server上,具體交互如下圖所示:
轉發規則上限制數據請求格式必須是 系統名+Api做前綴
這樣保障了各個系統之間的請求可以完全隔離。
其中,Nginx的配置示例如下:
server { listen 80; server_name xxx.xx.com; location /project/api/ { set $upstream_name "server.project"; proxy_pass http://$upstream_name; } ... location / { set $upstream_name "web.portal"; proxy_pass http://$upstream_name; }}
我們將用戶的統一登錄和認證問題交給了SSO,所有的項目的後端Server都要接入SSO校驗登錄狀態,從而保障業務系統間用戶安全認證的一致性。
在項目結構確定以後,應用如何進行合併呢?因此,我們開始制定了一套應用註冊機制。
應用註冊機制
「Portal項目」提供註冊的介面,「子項目」進行註冊,最終聚合成一個單頁應用。在整套機制中,比較核心的部分是路由註冊機制,「子項目」的路由應該由自己控制,而整個系統的導航是「Portal項目」提供的。
路由註冊
路由的控制由三部分組成:許可權菜單樹、導航和路由樹,「Portal項目」中封裝一個組件App,根據菜單樹和路由樹生成整個頁面。路由掛載到DOM樹上的代碼如下:
let Router = <Router fetchMenu = {fetchMenuHandle} routes = {routes} app = {App} history = {history} >ReactDOM.render(Router,document.querySelector("#app"));
Router是在react-router的基礎上做了一層封裝,通過menu和routes最後生成一個如下所示的路由樹:
<Router> <Route path="/" component={App}> <Route path="/namespace/xx" component={About} /> <Route path="inbox" component={Inbox}> <Route path="messages/:id" component={Message} /> </Route> </Route> </Router>
具體註冊使用了全局的window.app.routes
,「Portal項目」從window.app.routes
獲取路由,「子項目」把自己需要註冊的路由添加到window.app.routes
中,子項目的註冊如下:
let app = window.app = window.app || {}; app.routes = (app.routes || []).concat([{ code:attendance-record, path: /attendance-record, component: wrapper(() => async(require(./nodes/attendance-record), kaoqin)),}]);
路由合併的同時也把具體的功能做了引用關聯,再到構建時就可以把所有的功能與路由管理起來。項目的作用域要怎麼控制呢?我們要求「子項目」間是彼此隔離,要避免樣式污染,要做獨立的數據流管理,我們用項目作用域的方式來解決這些問題。
項目作用域控制
在路由控制的時候我們提到了 window.app
,我們也是通過這個全局App來做項目作用域的控制。window.app
包含了如下幾部分:
let app = window.app || {};app = { require:function(request){...}, define:function(name,context,index){...}, routes:[...], init:function(namespace,reducers){...} };
window.app主要功能:
- define 定義項目的公共庫,主要用來解決JS公共庫的管理問題
- require 引用自己的定義的基礎庫,配合define來使用
- routes 用於存放全局的路由,子項目路由添加到window.app.routes,用於完成路由的註冊
- init 註冊入口,為子項目添加上namesapce標識,註冊上子項目管理數據流的reducers
子項目完整的註冊,如下所示:
import reducers from ./redux/kaoqin-reducer;let app = window.app = window.app || {}; app.routes = (app.routes || []).concat([{ code:attendance-record, path: /attendance-record, component: wrapper(() => async(require(./nodes/attendance-record), kaoqin)), // ... 其他路由}]);function wrapper(loadComponent) { let React = null; let Component = null; let Wrapped = props => ( <div className="namespace-kaoqin"> <Component {...props} /> </div> ); return async () => { await window.app.init(namespace-kaoqin,reducers); React = require(react); Component = await loadComponent(); return Wrapped; };}
其中做了這幾件事情:
- 把路由添加到window.app中
- 業務第一次功能被調用的時候執行
window.app.init(namespace,reducers)
,註冊項目作用域和數據流的reducers - 對業務功能的掛載節點包裝一個根節點:
Component
掛載在className
為namespace-kaoqin
的div
下面
這樣就完成了「子項目」的註冊,「子項目」的對外輸出是一個入口文件和一系列的資源文件,這些文件由webpack構建生成。
CSS作用域方面,使用webpack在構建階段為業務的所有CSS都加上自己的作用域,構建配置如下:
//webpack打包部分,在postcss插件中 添加namespace的控制config.postcss.push(postcss.plugin(namespace, () => css => css.walkRules(rule => { if (rule.parent && rule.parent.type === atrule && rule.parent.name !== media) return; rule.selectors = rule.selectors.map(s => `.namespace-kaoqin ${s === body ? : s}`); })));
CSS處理用到postcss-loader,postcss-loader用到postcss,我們添加postcss的處理插件,為每一個CSS選擇器都添加名為.namespace-kaoqin
的根選擇器,最後打包出來的CSS,如下所示:
.namespace-kaoqin .attendance-record { height: 100%; position: relative}.namespace-kaoqin .attendance-record .attendance-record-content { font-size: 14px; height: 100%; overflow: auto; padding: 0 20px}...
CSS樣式問題解決之後,接下來看一下,Portal提供的init做了哪些工作。
let inited = false;let ModalContainer = null;app.init = async function (namespace,reducers) { if (!inited) { inited = true; let block = await new Promise(resolve => { require.ensure([], function (require) { app.define(block, require.context(block, true, /^./(?!dev)([^/]|/(?!demo))+.jsx?$/)); resolve(require(block)); }, common); }); ModalContainer = document.createElement(div); document.body.appendChild(mtfv3ModalContainer); let { Modal} = block; Modal.getContainer = () => ModalContainer; } ModalContainer.setAttribute(class, `${namespace}`); mountReducers(namepace,reducers)};
init方法主要做了兩件事情:
- 掛載「子項目」的reducers,把「子項目」的數據流掛載了redux上
- 「子項目」的彈出窗全部掛載在一個全局的div上,並為這個div添加對應的項目作用域,配合「子項目」構建的CSS,確保彈出框樣式正確
上述代碼中還看到了app.define
的用法,它主要是用來處理JS公共庫的控制,例如我們用到的組件庫Block,期望每個「子項目」的版本都是統一的。因此我們需要解決JS公共庫版本統一的問題。
JS公共庫版本統一
為了不侵入「子項目」,我們採用構建過程中替換的方式來做,「Portal項目」把公共庫引入進來,重新定義,然後通過window.app.require
的方式引用,在編譯「子項目」的時候,把引用公共庫的代碼從require(react)
全部替換為window.app.require(react)
,這樣就可以將JS公共庫的版本都交給「Portal項目」來控制了。
define 的代碼和示例如下:
/*** 重新定義包* @param name 引用的包名,例如 react* @param context 資源引用器 實際上是 webpackContext(是一個方法,來引用資源文件)* @param index 定義的包的入口文件*/app.define = function (name, context, index) { let keys = context.keys(); for (let key of keys) { let parts = (name + key.slice(1)).split(/); let dir = this.modules; for (let i = 0; i < parts.length - 1; i++) { let part = parts[i]; if (!dir.hasOwnProperty(part)) { dir[part] = {}; } dir = dir[part]; } dir[parts[parts.length - 1]] = context.bind(context, key); } if (index != null) { this.modules[name][index.js] = this.modules[name][index]; }};//定義app的react //定義一個react資源庫:把原來react根目錄和lib目錄下的.js全部獲取到,綁定到新定義的react中,並指定react.js作為入口文件app.define(react, require.context(react, true, /^./(lib/)?[^/]+.js$/), react.js);app.define(react-dom, require.context(react-dom, true, /^./index.js$/));
「子項目」的構建,使用webpack的externals(外部擴展)來對引用進行替換:
/** * 對一些公共包的引用做處理 通過webpack的externals(外部擴展)來解決 */const libs = [react, react-dom, "block"];module.exports = function (context, request, callback) { if (libs.indexOf(request.split(/, 1)[0]) !== -1) { //如果文件的require路徑中包含libs中的 替換為 window.app.require(${request}); //var在這兒是聲明的意思 callback(null, `var window.app.require(${request})`); } else { callback(); }};
這樣項目的註冊就完成了,還有一些需要「子項目」自己改造的地方,例如本地啟動需要把「Portal項目」的導航載入進來,需要做mock數據等等。
項目的註冊完成了,我們如何發布部署呢?
構建後集成和獨立部署
在HR系統的整合過程中,開發階段對「子項目」是「零侵入」,而在發布階段,我們也希望如此。
我們的部署過程,大概如下:
第一步:在發布機上,獲取代碼、安裝依賴、執行構建;
第二步:把構建的結果上傳到伺服器;
第三步:在伺服器執行node index.js
把服務啟動起來。「Portal項目」構建之後的文件結構如下:
「子項目」構建後的文件結構如下:
線上運行的文件結構如下:
把「子項目」的構建文件上傳到伺服器對應的「子項目」文件目錄下,然後對「子項目」的資源文件進行集成合併,生成.dist目錄中的文件,提供給用戶線上訪問使用。
每次發布,我們主要做以下三件事情:
- 發布最新的靜態資源文件
- 重新生成entry-xx.js和index.html(更新入口引用)
- 重啟前端服務
如果是純靜態服務,完全可以做到熱部署,動態更新一下引用關係即可,不需要重啟服務。因為我們在Node服務層做了一些公共服務,所以選擇了重啟服務,我們使用了公司的基礎服務和PM2來實現熱啟動。
對於歷史文件,我們需要做版本控制,以保障之前的訪問能夠正常運行。此外,為了保證服務的高可用性,我們上線了4台機器,分別在兩個機房進行部署,最終來提高HR系統的容錯性。
總結
以上就是我們使用React技術棧和微前端方式搭建的「類單頁應用」HR業務系統,回顧一下這個技術方案,整個框架流程如下圖所示:
在產品層面上,「微前端類單頁應用」打破了獨立項目的概念,我們可以根據用戶的需求自由組裝我們的頁面應用,例如:我們可以在HR門戶上把考勤、請假、OA審批、財務報銷等高頻功能放在一起。甚至可以讓用戶自己定製功能,讓用戶真的感受到我們是一個系統。
「微前端構建類單頁應用」方案是基於React技術棧開發,如果把路由管理機制和註冊機制抽離出來作為一個公共的庫,就可以在webpack的基礎上封裝成一個業務無關性的通用方案,而且使用起來非常的友好。
截止目前,HR系統已經穩定運行了1年多的時間,我們總結了以下三個優點:
- 單頁應用的體驗比較好,按需載入,交互流暢
- 項目微前端化,業務解耦,穩定性有保障,項目的粒度易控制
- 項目的健壯性比較好,項目註冊僅僅增加了入口文件的大小,30多個項目目前只有12K
作者簡介
賈召,2014年加入美團,先後主導了OA、HR、財務等企業項目的前端搭建,自主研發React組件庫Block,在Block的基礎上統一了整個企業平台的前端技術棧,致力於提高研發團隊的工作效率。
如果想跟作者零距離交流,歡迎加入美團點評前端技術俱樂部,請加美美的微信(微信號:MTDPtech01),回復:Web,美美就會自動拉你進群。
---------- END ----------
也許你還想看
WMRouter:美團外賣Android開源路由框架
Flutter的原理及美團的實踐
Jenkins的Pipeline腳本在美團餐飲SaaS中的實踐
http://weixin.qq.com/r/9HVSSg3EOFBHrUkp9yDm (二維碼自動識別)
推薦閱讀: