用微前端的方式搭建類單頁應用

前言

微前端由ThoughtWorks 2016年提出,將後端微服務的理念應用於瀏覽器端,即將 Web 應用由單一的單體應用轉變爲多個小型前端應用聚合爲一的應用。javascript

美團已是一家擁有幾萬人規模的大型互聯網公司,提高總體效率相當重要,這須要不少內部和外部的管理系統來支撐。因爲這些系統之間存在大量的連通和交互訴求,所以咱們但願可以按照用戶和使用場景將這些系統彙總成一個或者幾個綜合的系統。css

咱們把這種由多個微前端聚合出來的單頁應用叫作「類單頁應用」,美團HR系統就是基於這種設計實現的。美團HR系統是由30多個微前端應用聚合而成,包含1000多個頁面,300多個導航菜單項。對用戶來講,HR系統是一個單頁應用,整個交互過程很是順暢;對開發者同窗來講,各個應用都可獨立開發、獨立測試、獨立發佈,大大提升了開發效率。html

接下來,本文將爲你們介紹「微前端構建類單頁應用」在美團HR系統中的一些實踐。同時也分享一些咱們的思考和經驗,但願可以對你們有所啓發。前端

HR系統的微前端設計

由於美團的HR系統所涉及項目比較多,目前由三個團隊來負責。其中:OA團隊負責考勤、合同、流程等功能,HR團隊負責入職、轉正、調崗、離職等功能,上海團隊負責績效、招聘等功能。這種團隊和功能的劃分模式,使得每一個系統都是相對獨立的,擁有獨立的域名、獨立的UI設計、獨立的技術棧。可是,這樣會帶來開發團隊之間職責劃分不清、用戶體驗效果差等問題,因此就迫切須要把HR系統轉變成只有一個域名和一套展現風格的系統。java

爲了知足公司業務發展的要求,咱們作了一個HR的門戶頁面,把各個子系統的入口作了連接歸攏。然而咱們發現HR門戶的意義很是小,用戶跳轉兩次以後,又徹底不知道跳到哪裏去了。所以咱們經過將HR系統整合爲一個應用的方式,來解決以上問題。node

通常而言,「類單頁應用」的實現方式主要有兩種:react

  1. iframe嵌入
  2. 微前端合併類單頁應用

其中,iframe嵌入方式是比較容易實現的,但在實踐的過程當中帶來了以下問題:webpack

  • 子項目須要改造,須要提供一組不帶導航的功能
  • iframe嵌入的顯示區大小不容易控制,存在必定侷限性
  • URL的記錄徹底無效,頁面刷新不可以被記憶,刷新會返回首頁
  • iframe功能之間的跳轉是無效的
  • iframe的樣式顯示、兼容性等都具備侷限性

考慮到這些問題,iframe嵌入並不能知足咱們的業務訴求,因此咱們開始用微前端的方式來搭建HR系統。web

在這個微前端的方案裏,有幾個咱們必需要解決的問題:redux

  1. 一個前端須要對應多個後端
  2. 提供一套應用註冊機制,完成應用的無縫整合
  3. 構建時集成應用和應用獨立發佈部署

只有解決了以上問題,咱們的集成纔是有效且真正可落地的,接下來詳細講解一下這幾個問題的實現思路。

一個前端對應多個後端

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; }; } 複製代碼

其中作了這幾件事情:

  1. 把路由添加到window.app中
  2. 業務第一次功能被調用的時候執行 window.app.init(namespace,reducers),註冊項目做用域和數據流的reducers
  3. 對業務功能的掛載節點包裝一個根節點:Component掛載在classNamenamespace-kaoqindiv下面

這樣就完成了「子項目」的註冊,「子項目」的對外輸出是一個入口文件和一系列的資源文件,這些文件由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方法主要作了兩件事情:

  1. 掛載「子項目」的reducers,把「子項目」的數據流掛載了redux上
  2. 「子項目」的彈出窗所有掛載在一個全局的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目錄中的文件,提供給用戶線上訪問使用。

每次發佈,咱們主要作如下三件事情:

  1. 發佈最新的靜態資源文件
  2. 從新生成entry-xx.js和index.html(更新入口引用)
  3. 重啓前端服務

若是是純靜態服務,徹底能夠作到熱部署,動態更新一下引用關係便可,不須要重啓服務。由於咱們在Node服務層作了一些公共服務,因此選擇了重啓服務,咱們使用了公司的基礎服務和PM2來實現熱啓動。

對於歷史文件,咱們須要作版本控制,以保障以前的訪問可以正常運行。此外,爲了保證服務的高可用性,咱們上線了4臺機器,分別在兩個機房進行部署,最終來提升HR系統的容錯性。

總結

以上就是咱們使用React技術棧和微前端方式搭建的「類單頁應用」HR業務系統,回顧一下這個技術方案,整個框架流程以下圖所示:

架構流程圖

在產品層面上,「微前端類單頁應用」打破了獨立項目的概念,咱們能夠根據用戶的需求自由組裝咱們的頁面應用,例如:咱們能夠在HR門戶上把考勤、請假、OA審批、財務報銷等高頻功能放在一塊兒。甚至可讓用戶本身定製功能,讓用戶真的感覺到咱們是一個系統。

「微前端構建類單頁應用」方案是基於React技術棧開發,若是把路由管理機制和註冊機制抽離出來做爲一個公共的庫,就能夠在webpack的基礎上封裝成一個業務無關性的通用方案,並且使用起來很是的友好。

截止目前,HR系統已經穩定運行了1年多的時間,咱們總結了如下三個優勢:

  1. 單頁應用的體驗比較好,按需加載,交互流暢
  2. 項目微前端化,業務解耦,穩定性有保障,項目的粒度易控制
  3. 項目的健壯性比較好,項目註冊僅僅增長了入口文件的大小,30多個項目目前只有12K

做者簡介

賈召,2014年加入美團,前後主導了OA、HR、財務等企業項目的前端搭建,自主研發React組件庫Block,在Block的基礎上統一了整個企業平臺的前端技術棧,致力於提升研發團隊的工做效率。

相關文章
相關標籤/搜索