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

美團技術團隊發表於2019-03-03

前言

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

美團已經是一家擁有幾萬人規模的大型網際網路公司,提升整體效率至關重要,這需要很多內部和外部的管理系統來支撐。由於這些系統之間存在大量的連通和互動訴求,因此我們希望能夠按照使用者和使用場景將這些系統彙總成一個或者幾個綜合的系統。

我們把這種由多個微前端聚合出來的單頁應用叫做“類單頁應用”,美團HR系統就是基於這種設計實現的。美團HR系統是由30多個微前端應用聚合而成,包含1000多個頁面,300多個導航選單項。對使用者來說,HR系統是一個單頁應用,整個互動過程非常順暢;對開發者同學來說,各個應用均可獨立開發、獨立測試、獨立釋出,大大提高了開發效率。

接下來,本文將為大家介紹“微前端構建類單頁應用”在美團HR系統中的一些實踐。同時也分享一些我們的思考和經驗,希望能夠對大家有所啟發。

HR系統的微前端設計

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

為了滿足公司業務發展的要求,我們做了一個HR的門戶頁面,把各個子系統的入口做了連結歸攏。然而我們發現HR門戶的意義非常小,使用者跳轉兩次之後,又完全不知道跳到哪裡去了。因此我們通過將HR系統整合為一個應用的方式,來解決以上問題。

一般而言,“類單頁應用”的實現方式主要有兩種:

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

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

  • 子專案需要改造,需要提供一組不帶導航的功能
  • iframe嵌入的顯示區大小不容易控制,存在一定侷限性
  • URL的記錄完全無效,頁面重新整理不能夠被記憶,重新整理會返回首頁
  • iframe功能之間的跳轉是無效的
  • iframe的樣式顯示、相容性等都具有侷限性

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

在這個微前端的方案裡,有幾個我們必須要解決的問題:

  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的基礎上統一了整個企業平臺的前端技術棧,致力於提高研發團隊的工作效率。

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

相關文章