微前端框架 single-spa 技術分析

  • 2021 年 12 月 3 日
  • 筆記

理解微前端技術原理中我們介紹了微前端的概念和核心技術原理。本篇我們結合目前業內主流的微前端實現 single-spa 來說明在生產實踐中是如何實現微前端的。

single-spa 的文檔略顯凌亂,概念也比較多,初次接觸它的同學容易抓不住重點。今天我們嘗試整理出一條清晰的脈絡,讓感興趣的同學能夠快速理解它。

在 single-spa 的架構設計中,有兩種主要角色,主應用和子應用,如下圖。

image

主應用力求足夠簡單,只負責子應用的調度,業務邏輯都由子應用來承擔。

核心能力

其實總結來說,single-spa 的核心就是定義了一套協議。通過這套協議,主應用可以方便的知道在什麼情況下激活哪個子應用。而這套協議主要包含兩個部分:主應用的配置資訊和子應用的生命周期函數

主應用的配置資訊

在 single-spa 中,這個配置資訊叫 Root Config

下面的樣例展示了配置資訊的結構:

{
  name: "subApp1",
  app: () => System.import("/path/to/subApp1/code"),
  activeWhen: "/subApp1",
}

name 就是子應用的名稱,app 函數告訴主應用如何載入子應用的程式碼,activeWhen 告訴主應用何時激活子應用,也可以為一個返回布爾值的函數。

通過 registerApplication 將子應用的資訊註冊到主應用中。

樣例如下:

singleSpa.registerApplication({
    name: 'appName',
    app: () => System.import('appName'),
    activeWhen: '/appName',
    customProps: {
        authToken: 'xc67f6as87f7s9d'
    }
})

子應用的生命周期函數

主應用在管理子應用的時候,通過子應用暴露的生命周期函數來實現子應用的啟動和卸載。

主要有如下幾個生命周期函數。

  • bootstrap

    這個生命周期函數會在應用第一次掛載前執行一次。就是說在子應用的程式碼載入完成以後,頁面渲染之前執行。函數形式如下:

    export function bootstrap(props) {
      return Promise
        .resolve()
        .then(() => {
          // 可以在這裡部署只執行一次的初始化程式碼
          console.log('bootstrapped!')
        });
    }
    
  • mount

    當主應用判定需要激活這個子應用時會調用這個生命周期函數。在這個函數中實現子應用的掛載、頁面渲染等邏輯。這個函數也只會執行一次。我們可以簡單的理解為 ReactDOM.render 操作。函數形式如下:

    export function mount(props) {
      return Promise.resolve().then(() => {
        // 頁面渲染邏輯
        console.log('mounted!');
      });
    }
    
  • unmount

    當主應用判定需要卸載這個子應用時會調用這個生命周期函數。在這個函數中實現組件卸載、清理事件監聽等邏輯。我們可以簡單的理解為 ReactDOM.unmountComponentAtNode 操作。函數形式如下:

    export function unmount(props) {
      return Promise.resolve().then(() => {
        // 頁面卸載邏輯
        console.log('unmounted!');
      });
    }
    

觀察每個生命周期函數的簽名我們可以發現,每個函數都有一個 props 參數,主應用可以通過這個參數向子應用傳遞一些額外資訊,後面會做說明。

為了方便各種技術棧的子應用能方便的接入,single-spa 提供了很多工具,可以在這裡查到官方維護的工具列表

其他概念

子應用的分類

single-spa 根據職能的不同,把子應用劃分成三類:

  • Application
    表示普通的子應用,需要實現上面提到的生命周期函數;
  • Parcel
    可以理解為可以跨子應用復用的業務單元,需要實現與之對應的生命周期函數
  • Utility
    表示一段可復用的邏輯,比如一個函數等,不做頁面渲染。

不難看出,Parcel 和 Utility 都是為了共享和復用,也算是 single-spa 在框架層面給出的一種復用方案。

Layout Engine

雖然 single-spa 的理念是讓主應用儘可能的簡單,但是在實踐中,主應用通常會負責通用的頂部、底部通欄的渲染。這個時候,如何確定子應用的渲染位置就成了一個問題。

single-spa 提供了 Layout Engine的方案。樣例程式碼如下,與 Vue 頗為相似,詳細的可以查看文檔,這裡不做過多敘述。

<html>
  <head>
    <template id="single-spa-layout">
      <single-spa-router>
        <nav class="topnav">
          <application name="@organization/nav"></application>
        </nav>
        <div class="main-content">
          <route path="settings">
            <application name="@organization/settings"></application>
          </route>
          <route path="clients">
            <application name="@organization/clients"></application>
          </route>
        </div>
        <footer>
          <application name="@organization/footer"></application>
        </footer>
      </single-spa-router>
    </template>
  </head>
</html>

關於 SystemJS

很多人在提到 single-spa 的時候都會提到 SystemJS,認為 SystemJS 是 single-spa 的核心之一。其實這是一個誤區, SystemJS 並不是 single-spa 所必須的。

前面說到,子應用要實現生命周期函數,然後導出給主應用使用。關鍵就是這個「導出」的實現,這就涉及到 JavaScript 的模組化問題。

在一些現代瀏覽器中,我們可以通過在 <script> 標籤上添加 type="module" 來實現導入導出。

<script type="module" src="module.js"></script>
<script type="module">
  // or an inline script
  import {helperMethod} from './providesHelperMethod.js';
  helperMethod();
</script>

// providesHelperMethod.js
export function helperMethod() {
  console.info(`I'm helping!`);
}

但是如果我們想要實現 import axios from 'axios' 還需要藉助於 importmap

<script type="importmap">
    {
       "imports": {
          "axios": "//cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js"
       }
    }
</script>
<script type="module">
  import axios from 'axios'
</script>

在低版本瀏覽器中,我們就需要藉助於一些 「Polyfill」 來實現模組化了。SystemJS 就是解決這個問題的。所以 single-spa 的樣例中大量採用了 SystemJS 來載入應用。

其實也可以不用 SystemJS,webpack 也可以實現類似的能力,但是會加深主應用與子應用間的工程耦合。

隔離

理解微前端技術原理中,我們花了很長的篇幅來說明子應用隔離的思路。那麼,single-spa 中是如何來實現隔離的呢?

樣式隔離

single-spa 中的樣式隔離可以分為兩塊來說。

首先是子應用樣式的載入和卸載。single-spa 提供了 single-spa-css 這個工具來實現。

import singleSpaCss from 'single-spa-css';

const cssLifecycles = singleSpaCss({
  // 需要載入的 css 列表
  cssUrls: ['//example.com/main.css'],

  // 是否是 webpack 導出的 css,如果是要做額外處理(webpack 導出的文件名通常會有 hash)
  webpackExtractedCss: false,

  // 當子應用 unmount 的時候,css 是否需要一併刪除
  shouldUnmount: true,
});

const reactLifecycles = singleSpaReact({...})

// 加入到子應用的 bootstrap 里
export const bootstrap = [
  cssLifecycles.bootstrap,
  reactLifecycles.bootstrap
]

export const mount = [
  // 加入到子應用的 mount 里,css 放前面,不然 mount 後會有樣式閃爍(FOUC)的問題
  cssLifecycles.mount,
  reactLifecycles.mount
]

export const unmount = [
  // 後卸載 css,防止樣式閃爍
  reactLifecycles.unmount,
  cssLifecycles.unmount
]

如果樣式是 webpack 導出的,則每次構建後都要更新樣式文件列表。single-spa 貼心的準備了一個插件來解決這個問題。只要在 webpack 的配置文件中添加如下插件即可。

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const ExposeRuntimeCssAssetsPlugin = require("single-spa-css/ExposeRuntimeCssAssetsPlugin.cjs");

module.exports = {
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].css",
    }),
    new ExposeRuntimeCssAssetsPlugin({
      // filename 必須與 MiniCssExtractPlugin 中的 filename 一一對應
      filename: "[name].css",
    }),
  ],
};

解決了子應用樣式載入和卸載問題以後,我們再來看子應用樣式隔離的問題。

single-spa 給出了一些建議,比如使用 Scoped CSS,每個子應用都有一個固定前綴,類似於下面這樣:

/*
<div class="app1__settings-67f89dd87sf89ds"></div>
*/
.app1__settings-67f89dd87sf89ds {
  color: blue;
}

/*
<div data-df65s76dfs class="settings"></div>
*/
.settings[data-df65s76dfs] {
  color: blue;
}

/*
<div id="single-spa-application:@org-name/project-name">
    <div class="settings"></div>
  </div>
*/
#single-spa-application\:\@org-name\/project-name .settings {
  color: blue;
}

有很多工具可以實現 Scoped CSS,比如 CSS Modules 等。

最後一種方式我們可以通過 webpack 自動化的實現。

const prefixer = require('postcss-prefix-selector');

module.exports = {
  plugins: [
    prefixer({
      prefix: "#single-spa-application\\:\\@org-name\\/project-name"
    })
  ]
}

single-spa 也提到了 Shadow DOM,我們在上一篇文章中已經分析過,這裡不再贅述了。

JS 隔離

single-spa 採用了類似於快照模式的隔離機制,通過 single-spa-leaked-globals 來實現。

用法如下:

import singleSpaLeakedGlobals from 'single-spa-leaked-globals';

// 其它 single-spa-xxx 提供的生命周期函數
const frameworkLifecycles = ...

// 新添加的全局變數
const leakedGlobalsLifecycles = singleSpaLeakedGlobals({
  globalVariableNames: ['$', 'jQuery', '_'],
})

export const bootstrap = [
  leakedGlobalsLifecycles.bootstrap, // 放在第一位
  frameworkLifecycles.bootstrap,
]

export const mount = [
  leakedGlobalsLifecycles.mount, // mount 時添加全局變數,如果之前有記錄在案的,直接恢復
  frameworkLifecycles.mount,
]

export const unmount = [
  leakedGlobalsLifecycles.unmount, // 刪掉新添加的全局變數
  frameworkLifecycles.unmount,
]

前面已經說過,快照模式的一個缺點是無法保證多個子應用同時運行時的有效隔離。

小結

總體來說,single-spa 算是基本實現了一個微前端框架需要具備的各種功能,但是又實現的不夠徹底,遺留了很多問題需要解決。雖然官方提供了很多樣例和最佳實踐,但是總顯得過於單薄,總給人一種「問題解決了,但是又沒有完全解決」的感覺。

qiankun 基於 single-spa 開發,一定程度上解決了很多 single-spa 沒有解決的問題。我們下篇詳細說明。

常見面試知識點、技術方案分析、教程,都可以掃碼關注公眾號「眾里千尋」獲取,或者來這裡 //everfind.github.io/posts/

眾里千尋