微前端框架 single-spa 技術分析
- 2021 年 12 月 3 日
- 筆記
在理解微前端技術原理中我們介紹了微前端的概念和核心技術原理。本篇我們結合目前業內主流的微前端實現 single-spa 來說明在生產實踐中是如何實現微前端的。
single-spa 的文檔略顯凌亂,概念也比較多,初次接觸它的同學容易抓不住重點。今天我們嘗試整理出一條清晰的脈絡,讓感興趣的同學能夠快速理解它。
在 single-spa 的架構設計中,有兩種主要角色,主應用和子應用,如下圖。
主應用力求足夠簡單,只負責子應用的調度,業務邏輯都由子應用來承擔。
核心能力
其實總結來說,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/ 。