前言
說起微前端框架,很多人第一反應就是 single-spa。但是再問深入一點:它是幹嘛的,它有什麼用,可能就回答不出來了。
一方面沒多少人研究和使用微前端。可能還沒來得及用微前端擴展項目,公司就已經倒閉了。
另一方面是中文博客對微前端的研究少之又少,很多文章只是簡單翻譯一下官方文檔,讀幾個API,放個官方的 Demo 就完事了。很少有深入研究到底 single-spa 是怎麼一回事的。
還有一方面是 single-spa 的文檔非常難看懂,和 Redux 文檔一樣喜歡造概念。講一個東西的時候,總是把別的庫拉進來一起講,把一個簡單的東西變得非常複雜。最令人吐槽的一點就是官方的 sample code 都是隻言片語,完全拼湊不出來一個 Demo,而 Github 的 Demo 還賊複雜,沒解釋,光看完都要 clone 好幾個 repo。
最後,求人不如求己,剛完源碼再剛一下文檔。
這篇文章將不會聊怎麼搭建一個 Demo,而是會從 “Why” 和 “How” 的角度來聊一下官方文檔的都講了哪些內容,相信看完這篇文章就能看懂 官方的 Demo 了。
一個需求
讓我們從一個最小的需求開始說起。有一天產品經理突然說:我們要做一個 A 頁面,我看到隔壁組已經做過這個 A 頁面了,你把它放到我們項目裏吧,應該不是很難吧?明天上線吧。
此時,產品經理想的是:應該就填一個 URL 就好吧?再不行,複製粘貼也很快吧。而程序員想的卻是:又要看屎山了。又要重構了。又要聯調了。測試數據有沒有啊?等一下,聯調的後端是誰啊?
估計這是做大項目時經常遇到的需求了:搬運一個現有的頁面。我想大多數人都會選擇在自己項目裏複製粘貼別人的代碼,然後稍微重構一下,再測試環境聯調,最後上線。
但是,這樣就又多了一份代碼了,如果別人的頁面改了,那麼自己項目又要跟着同步修改,再聯調,再上線,非常麻煩。
所以程序員就想能不能我填一個 url,然後這個頁面就到項目裏來了呢?所以,<iframe/>
就出場了。
iframe 的弊端
iframe 就相當於頁面裏再開個窗口加載別的頁面,但是它有很多弊端:
- 每次進來都要加載,狀態不能保留
- DOM 結構不共享。比如子應用裏有一個 Modal,顯示的時候只能在那一小塊地方展示,不能全屏展示
- 無法跟隨瀏覽器前進後退
- 天生的硬隔離,無法與主應用進行資源共享,交流也很困難
而 SPA 正好可以解決上面的問題:
- 切換路由就是切換頁面組件,組件的掛載和卸載非常快
- 單頁應用肯定共享 DOM
- 前端控制路由,想前就前,想後就後
- React 通信有 Redux,Vue 通信有 Vuex,可與 App 組件進行資源共享,交流很爽
這就給我們一個啓發:能不能有這麼一個巨型 SPA 框架,把現有的 SPA 當成 Page Component 來組裝成一個新的 SPA 呢?這就是微前端的由來。
微前端是什麼
微前端應該有如下特點:
- 技術棧無關,主框架不限制接入應用的技術棧,微應用具備完全自主權
- 獨立開發、獨立部署,微應用倉庫獨立,前後端可獨立開發,部署完成後主框架自動完成同步更新
- 增量升級,在面對各種複雜場景時,我們通常很難對一個已經存在的系統做全量的技術棧升級或重構,而微前端是一種非常好的實施漸進式重構的手段和策略
- 獨立運行時,每個微應用之間狀態隔離,運行時狀態不共享
等一下等一下,說了一堆,到底啥是 single-spa 啊。
嘿嘿,single-spa 框架並沒有實現上面任何特點,對的,一個都沒有,Just Zero。
single-spa 到底是幹嘛的
single-spa 僅僅是一個子應用生命週期的調度者。single-spa 爲應用定義了 boostrap, load, mount, unmount 四個生命週期回調:
只要寫過 SPA 的人都能理解,無非就是生、老、病、死。不過有幾個點需要注意一下:
- Register 不是生命週期,指的是調用
registerApplication
函數這一步 - Load 是開始加載子應用,怎麼加載由開發者自己實現(等會會說到)
- Unload 鉤子只能通過調用
unloadApplication
函數纔會被調用
OK,上面 4 個生命週期的回調順序是 single-spa 可以控制的,我能理解,那什麼時候應該開始這一套生命週期呢?應該是有一個契機來開始整套流程的,或者某幾個流程的。
契機就是當 window.location.href
匹配到 url 時,開始走對應子 App 的這一套生命週期嘛。所以,single-spa 還要監聽 url 的變化,然後執行子 app 的生命週期流程。
到此,我們就有了 single-spa 的大致框架了,無非就兩件事:
- 實現一套生命週期,在 load 時加載子 app,由開發者自己玩,別的生命週期裏要幹嘛的,還是由開發者造的子應用自己玩
- 監聽 url 的變化,url 變化時,會使得某個子 app 變成 active 狀態,然後走整套生命週期
畫個草圖如下:
是不是感覺 single-spa 很雞賊?雖然 single-spa 說自己是微前端框架,但是一個微前端的特性都沒有實現,都是需要開發者在加載自己子 App 的時候實現的,要不就是通過一些第三方工具實現。
註冊子應用
有了上面的瞭解之後,我們再來看 single-spa 裏最重要的 API:registerApplication
,表示註冊一個子應用。使用如下:
singleSpa.registerApplication({
name: 'taobao', // 子應用名
app: () => System.import('taobao'), // 如何加載你的子應用
activeWhen: '/appName', // url 匹配規則,表示啥時候開始走這個子應用的生命週期
customProps: { // 自定義 props,從子應用的 bootstrap, mount, unmount 回調可以拿到
authToken: 'xc67f6as87f7s9d'
}
})
singleSpa.start() // 啓動主應用
上面註冊了一個子應用 'taobao'。我們自己實現了加載子應用的方法,通過 activeWhen
告訴 single-spa 什麼時候要掛載子應用,好像就可以上手開擼代碼嘍。
可以個鬼!請告訴我 System.import
是個什麼鬼。哦,是 SystemJS,誒,SystemJS 聽說過,它是個啥?爲啥要用 SystemJS?憑啥要用 SystemJS?
SystemJS
相信很多人看過一些微前端的博客,它們都會說 single-spa 是基於 SystemJS 的。錯!single-spa 和 SystemJS 一點關係都沒有!這裏先放個主應用和子應用的關係圖:
single-spa 的理念是希望主應用可以做到非常非常簡單的和輕量,簡單到只要一個 index.html + 一個 main.js 就可以完成微前端工程,連 Webpack 都不需要,直接在瀏覽器裏執行 singleSpa.registerApplication
就收工了,這種執行方式也就是 in-browser 執行。
但是,瀏覽器裏執行 JS,別說實現 import xxx from 'https://taobao.com'
了,我要是在瀏覽器裏實現 ES6 的 import/export 都不行啊: import axios from 'axios'
。
其實,也不是不行,只需要在 <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": {
"vue": "https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.esm.browser.js"
}
}
</script>
<div id="container">我是:{{name}}</div>
<script type="module">
import Vue from 'vue'
new Vue({
el: '#container',
data: {
name: 'Jack'
}
})
</script>
importmap 的功能就是告訴 'vue' 這個玩意要從 "https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.esm.browser.js" 這裏來的。不過,importmap 現在只有 Chrome 是支持的。
所以,SystemJS 就將這一塊補齊了。當然,除了 importmap,它還有很多的功能,比如獲取當前加載的所有模塊、當前模塊的 URL、可以 import html, import css,import wasm。
等等,這在 Webpack 不也可以做到麼?Webpack 還能 import less, import scss 呢?這不比 SystemJS 牛逼?對的,如果不是因爲要在瀏覽器使用 import/export,沒人會用 SystemJS。SystemJS 的好處和優勢有且僅有一點:那就是在瀏覽器裏使用 ES6 的 import/export。
而正因爲 SystemJS 可以在瀏覽器裏可以使用 ES6 的 import/export 並支持動態引入,正好符合 single-spa 所提倡的 in-browser 執行思路,所以 single-spa 文檔裏才反覆出現 SystemJS 的身影,而且 Github Demo 裏依然是使用 SystemJS 的 importmap 機制來引入不同模塊:
<script type="systemjs-importmap">
{
"imports": {
"@react-mf/root-config": "//localhost:9000/react-mf-root-config.js"
}
}
</script>
<script>
singleSpa.registerApplication({
name: 'taobao', // 子應用名
app: () => System.import('@react-mf/root-config'), // 如何加載你的子應用
activeWhen: '/appName', // url 匹配規則,表示啥時候開始走這個子應用的生命週期
customProps: { // 自定義 props,從子應用的 bootstrap, mount, unmount 回調可以拿到
authToken: 'xc67f6as87f7s9d'
}
})
</script>
公共依賴
SystemJS 另一個好處就是可以通過 importmap 引入公共依賴。
假如,我們有三個子應用,它們都有公共依賴項 antd,那每個子應用打包出來都會有一份 antd 的代碼,就顯示很冗餘。
一個解決方法就是在主應用裏,通過 importmap 直接把 antd 代碼引入進來,子應用在 Webpack 設置 external 把 antd 打包時排除掉。子應用打包就不會把 antd 打包進去了,體積也變小了。
有人會說了:我用 CDN 引入不行嘛?不行啊,因爲子應用的代碼都是 import {Button} from 'antd'
的,瀏覽器要怎麼直接識別 ES6 的 import/export 呢?那還不得 SystemJS 嘛。
難道 Webpack 就沒有辦法可以實現 importmap 的效果了麼?Webpack 5 提出的 Module Federation 模塊聯邦就可以很好地做的 importmap 的效果。這是 Webpack 5 的新特性,使用的效果和 importmap 差不多。關於模塊聯邦是個啥,可以參考 這篇文章。
至於用 importmap 還是 Webpack 的 Module Federation,singles-spa 是推薦使用 importmap 的,但是,文檔也沒有反對使用 Webpack 的 Module Federation 的理由。能用就OK。
SystemJS vs Webpack ES
有人可能會想:都 1202 年了,怎麼還要在瀏覽器環境寫 JS 呢?不上個 Webpack 都不好意思說自己是前端開發了。
沒錯,Webpack 是非常強大的,而且可以利用 Webpack 很多能力,讓主應用變得更加靈活。比如,寫 less,scss,Webpack 的 prefetch 等等等等。然後在註冊子應用時,完全可以利用 Webpack 的動態引入:
singleSpa.registerApplication({
name: 'taobao', // 子應用名
app: () => import('taobao'), // 如何加載你的子應用
activeWhen: '/appName', // url 匹配規則,表示啥時候開始走這個子應用的生命週期
customProps: { // 自定義 props,從子應用的 bootstrap, mount, unmount 回調可以拿到
authToken: 'xc67f6as87f7s9d'
}
})
那爲什麼 single-spa 還要推薦 SystemJS 呢?個人猜測是因爲 single-spa 希望主應用應該就一個空殼子,只需要管內容要放在哪個地方,所有的功能、交互都應該交由 index.html 來統一管理。
當然,這僅僅是一種理念,可以完全不遵循它。像我個人還是喜歡用 Webpack 多一點,SystemJS 還是有點多餘,而且覺得有點奧特曼了。不過,爲了跟着文檔的節奏來,這裏假設就用 SystemJS 來實現主應用。
Root Config
由於 single-spa 非常強調 in-browser 的方式來實現主應用,所以 index.html 就充當了靜態資源、子應用的路徑聲明的角色。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Polyglot Microfrontends</title>
<meta name="importmap-type" content="systemjs-importmap" />
<script type="systemjs-importmap" src="https://storage.googleapis.com/polyglot.microfrontends.app/importmap.json"></script>
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@polyglot-mf/root-config": "//localhost:9000/polyglot-mf-root-config.js"
}
}
</script>
<% } %>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/import-map-overrides.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/system.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/extras/amd.min.js"></script>
</head>
<body>
<script>
System.import('@polyglot-mf/root-config');
System.import('@polyglot-mf/styleguide');
</script>
<import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>
</html>
而 main.js 則實現子應用註冊、主應用啓動。
import { registerApplication, start } from "single-spa";
registerApplication({
name: "@polyglot-mf/navbar",
app: () => System.import("@polyglot-mf/navbar"),
activeWhen: "/",
});
registerApplication({
name: "@polyglot-mf/clients",
app: () => System.import("@polyglot-mf/clients"),
activeWhen: "/clients",
});
registerApplication({
name: "@polyglot-mf/account-settings",
app: () => loadWithoutAmd("@polyglot-mf/account-settings"),
activeWhen: "/settings",
});
start();
// A lot of angularjs libs are compiled to UMD, and if you don't process them with webpack
// the UMD calls to window.define() can be problematic.
function loadWithoutAmd(name) {
return Promise.resolve().then(() => {
let globalDefine = window.define;
delete window.define;
return System.import(name).then((module) => {
window.define = globalDefine;
return module;
});
});
}
像這樣的資源聲明 + 主子應用加載的組件,single-spa 稱之爲 Root Config。 它不是什麼新概念,就只有兩個東西:
- 一個主應用的 index.html
- 一個執行
registerApplication
函數的 JS 文件
single-spa-layout
雖然一個 index.html 是完美的輕量微前端主應用,但是就算再壓縮主應用的交互,那總得告訴子應用放置的位置吧,那不還得 DOM API 一把梭?一樣麻煩?
爲了解決這個問題,single-spa 說:沒事,我幫你搞,然後造了 single-spa-layout。具體使用請看代碼:
<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>
不能說和 Vue Router 很像,只能說一模一樣吧。當然上面這麼寫很直觀,但是瀏覽器並不認識這些元素,所以 single-spa-layout 把識別這些元素的邏輯都封裝成了函數,並暴露給開發者,開發者只要調用一下就能識別出 appName 等信息了:
import { registerApplication, start } from 'single-spa';
import {
constructApplications,
constructRoutes,
constructLayoutEngine,
} from 'single-spa-layout';
// 獲取 routes
const routes = constructRoutes(document.querySelector('#single-spa-layout'));
// 獲取所有的子應用
const applications = constructApplications({
routes,
loadApp({ name }) {
return System.import(name); // SystemJS 引入入口 JS
},
});
// 生成 layoutEngine
const layoutEngine = constructLayoutEngine({ routes, applications });
// 批量註冊子應用
applications.forEach(registerApplication);
// 啓動主應用
start();
沒什麼好說的,constrcutRoutes
, constructApplication
和 constructLayoutEngine
本質上就是識別 single-spa-layout 定義的元素標籤,然後獲取裏面的屬性,再通過 registerApplication
函數一個個註冊就完事了。
改造子應用
上面說的都是主應用的事情,現在我們來關心一下子應用。
子應用最關鍵的一步就是導出 bootstrap, mount, unmount 三個生命週期鉤子。
import SubApp from './index.tsx'
export const bootstrap = () => {}
export const mount = () => {
// 使用 React 來渲染子應用的根組件
ReactDOM.render(<SubApp/>, document.getElementById('root'));
}
export const unmount = () => {}
single-spa-react, single-spa-vue, single-spa-angular, single-spa-xxx, ...
emmmm,怎麼說的呢,上面三個 export 不太好看,能不能有一種更直接的方法就實現 3 個生命週期的導出呢?
single-spa 說:可以啊,搞!所以有了 single-spa-react:
import React from 'react';
import ReactDOM from 'react-dom';
import SubApp from './index.tsx';
import singleSpaReact, {SingleSpaContext} from 'single-spa-react';
const reactLifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: SubApp,
errorBoundary(err, info, props) {
return (
<div>出錯啦!</div>
);
},
});
export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;
single-spa 說:我不能單給 react 搞啊,別的框架也要給它們整上一個,一碗水端平,所以有這了這些牛鬼蛇神:
不禁感慨:這些小輪子是真能造啊。
導入子應用的 CSS
不知道你有沒有注意到,在剛剛的子應用註冊裏我們僅僅用 System.import
導入了一個 JS 文件,那 CSS 樣式文件怎麼搞呢?可能可以 System.import('xxx.css')
來導入。
但是,這又有問題了:在切換了應用時,unmount 的時候要怎麼把已有的 CSS 給刪掉呢?官方說可以這樣:
const style = document.createElement('style');
style.textContent = `.settings {color: blue;}`;
export const mount = [
async () => {
document.head.appendChild(styleElement);
},
reactLifecycles.mount,
]
export const unmount = [
reactLifecycles.unmount,
async () => {
styleElement.remove();
}
]
我:single-spa,求求你做個人吧,搭個 Demo,還要我來處理 CSS?single-spa 說:好,等我再去造一個輪子。於是,就有了 single-spa-css。用法如下:
import singleSpaCss from 'single-spa-css';
const cssLifecycles = singleSpaCss({
// 這裏放你導出的 CSS,如果 webpackExtractedCss 爲 true,可以不指定
cssUrls: ['https://example.com/main.css'],
// 是否要使用從 Webpack 導出的 CSS,默認爲 false
webpackExtractedCss: false,
// 是否 unmount 後被移除,默認爲 true
shouldUnmount: true,
// 超時,不廢話了,都懂的
timeout: 5000
})
const reactLifecycles = singleSpaReact({...})
// 加入到子應用的 bootstrap 裏
export const bootstrap = [
cssLifecycles.bootstrap,
reactLifecycles.bootstrap
]
export const mount = [
// 加入到子應用的 mount 裏,一定要在前面,不然 mount 後會有樣式閃一下的問題
cssLifecycles.mount,
reactLifecycles.mount
]
export const unmount = [
// 和 mount 同理
reactLifecycles.unmount,
cssLifecycles.unmount
]
這裏要注意一下,上面的 https://example.com/main.css 並沒有看起來那麼簡單易用。
假如你用了 Webpack 來打包,很有可能會用分包或者 content hash 來給 CSS 文件命名,比如 filename: "[name].[contenthash].css"
。那請問 cssUrls
要怎麼寫呀,每次都要改 cssUrls
參數麼?太麻煩了吧。
single-spa-css 說:我可以通過 Webpack 導出的 __webpack_require__.cssAssetFileName
獲取導出之後的真實 CSS 文件名。ExposeRuntimeCssAssetsPlugin 這個插件正好可以解決這個問題。這麼一來 cssUrls
就可以不用指定了,直接把 Webpack 導出的真實 CSS 名放到 cssUrls
裏了。
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({
// The filename here must match the filename for the MiniCssExtractPlugin
filename: "[name].css",
}),
],
};
子應用 CSS 樣式隔離
雖然 single-spa-css 解決了子應用的 CSS 引入和移除問題,但是又帶來了另一個問題:怎麼保證各個子應用的樣式不互相干擾呢?官方給出的建議是:
第一種方法:使用 Scoped CSS,也即在子應用的 CSS 選擇器上加前綴就好了嘛,像這樣:
.app1__settings-67f89dd87sf89ds {
color: blue;
}
要是嫌麻煩,可以在 Webpack 使用 PostCSS Prefix Selector 給樣式自動加前綴:
const prefixer = require('postcss-prefix-selector');
module.exports = {
plugins: [
prefixer({
prefix: "#single-spa-application\\:\\@org-name\\/project-name"
})
]
}
另一種方法是在加載子應用的函數裏,將子應用掛載到 Shadow DOM 上,可以實現完美的樣式隔離。Shadow DOM 是什麼,怎麼玩可見 MDN這裏。
公共 CSS 樣式怎麼處理
上面說的都是子應用自己的 CSS 樣式,那如果子應用之間要共享 CSS 怎麼辦呢?比如有兩個子應用都用了 antd,那都要 import 兩次 antd.min.css 了。
這個問題和上面提到的處理“公共依賴”的問題是差不多的。官方給出兩個建議:
- 將公共的 CSS 放到 importmap 裏,也可以理解爲在 index.html 裏直接加個 link 獲取 antd 的 CSS 完事
- 將所有的公共的 UI 庫都 import 到 utility 裏,將 antd 所有內容都 export,再把 utility 包放到 importmap 裏,然後
import { Button } from '@your-org-name/utility';
去引入裏面的組件
其實上面兩個方法都大同小異,思路都是在主應用一波引入,只是一個統一引入CSS,另一個統一引入 UI 庫。
子應用的 JS 隔離
我們來想想應用的 JS 隔離本質是什麼,本質其實就是在 B 子應用裏使用 window 全局對象裏的變量時,不要被 A 子應用給污染了。
一個簡單的解決思路就是:在 mount A 子應用時,正常添加全局變量,比如 jQuery 的 $
, lodash 的 _
。在 unmount A 子應用時,用一個對象記錄之前給 window 添加的全局變量,並把 A 應用裏添加 window 的變量都刪掉。下一次再 mount A 應用時,把記錄的全局變量重新加回來就好了。
single-spa 再次站出來:這個不用你自己手動記錄 window 的變更了。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,
]
但是,這個庫的侷限性在於:每個 url 只能加一個子 app,如果多個子 app 之間還是會訪問同一個 window 對象,也因此會互相干擾,並不能做到完美的 JS 沙箱。
比如:一個頁面裏,導航欄用 3.0 的 jQuery,而頁面主體用 5.0 的 jQuery,那就會有衝突了。
所以這個庫的場景也僅限於:首頁用 3.0 的 jQuery,訂單詳情頁使用 5.0 的 jQuery 這樣的場景。
子應用的分類
上面我們說到了,當 url 匹配 activeWhen 參數時,就會執行對應子應用的生命週期。那這樣就相當於子應用和 url 綁定在了一起了。
我們再來看 single-spa-leaked-globals,single-spa-css 這些庫,雖然它們也導出了生命週期,但這些生命週期與頁面渲染、url 變化沒有多大關係。
它們與普通的 application 唯一不同的地方就是:普通 application 的生命週期是通過 single-spa 來自動調度的,而這些庫是要通過手動調度的。只不過我們一般選擇在子應用裏的生命週期裏手動調用它們而已。
這種與 url 無關的 “app” 在微前端也有着非常重要的作用,一般是在子應用的生命週期裏提供一些功能,像 single-spa-css 就是在 mount 時添加 <link/>
標籤。single-spa 將這樣的 “類子 app” 稱爲 Parcel。
同時,single-spa 還分出另一個類:Utility Modules。很多子應用都用 antd, dayjs, axios 的,那麼就可以搞一個 utility 集合這些公共庫,然後統一做 export,然後在 importmap 裏統一導入。子應用就可以不需要在自己的 package.json 裏添加 antd, dayjs, axios 的依賴了。
總結一下,single-spa 將微前端分爲三大類:
分類 | 功能 | 導出 | 是否與 url 有關 |
---|---|---|---|
Application | 子應用 | bootstrap, mount, unmount | 是 |
Parcel | 功能組件,比如子應用的生命週期打一些補丁 | bootstrap, mount, unmount, update | 否 |
Utility Module | 公共資源 | 所有公共資源 | 否 |
create-single-spa
上面介紹了一堆的與子應用相關的庫,如果自己要從 0 開始慢慢地配置子應用就比較麻煩。所以,single-spa 說:不麻煩,有腳手架工具,一行命令生成子應用,都給您配好了。
npm install --global create-single-spa
# 或者
yarn global add create-single-spa
然後
create-single-spa
注意!這裏的 create-single-spa 指的是創建子應用!
總結
以上就是 singles-spa 文檔裏的所有內容了(除了 SSR 和 Dev Tools,前者用的不多,後者自己看一下就會了,不多廢話)。由於本文是通過發現問題到解決問題來講述文檔內容的,所以從頭看到尾還是有點亂,這裏就做一下總結:
微前端概念
特點:
- 技術棧無關
- 獨立開發、獨立部署
- 增量升級
- 獨立運行時
single-spa
只做兩件事:
- 提供生命週期概念,並負責調度子應用的生命週期
- 挾持 url 變化事件和函數,url 變化時匹配對應子應用,並執行生命週期流程
三大分類:
- Application:子應用,和 url 強相關,交由 single-spa 調用生命週期
- Parcel:組件,和 url 無關,手動調用生命週期
- Utility Module:統一將公共資源導出的模塊
“重要”概念
- Root Config:指主應用的 index.html + main.js。HTML 負責聲明資源路徑,JS 負責註冊子應用和啓動主應用
- Application:要暴露 bootstrap, mount, umount 三個生命週期,一般在 mount 開始渲染子 SPA 應用
- Parcel:也要暴露 bootstrap, mount, unmount 三個生命週期,可以再暴露 update 生命週期。Parcel 可大到一個 Application,也可以小到一個功能組件。與 Application 不同的是 Parcel 需要開發都手動調用生命週期
SystemJS
可以在瀏覽器使用 ES6 的 import/export 語法,通過 importmap 指定依賴庫的地址。
和 single-spa 沒有關係,只是 in-browser import/export 和 single-spa 倡導的 in-browser run time 相符合,所以 single-spa 將其作爲主要的導入導出工具。
用 Webpack 動態引入可不可以,可以,甚至可能比 SystemJS 好用,並無好壞之分。
single-spa-layout
和 Vue Router 差不多,主要功能是可以在 index.html 指定在哪裏渲染哪個子應用。
single-spa-react, single-spa-xxx....
給子應用快速生成 bootstrap, mount, unmount 的生命週期函數的工具庫。
single-spa-css
隔離前後兩個子應用的 CSS 樣式。
在子應用 mount 時添加子應用的 CSS,在 unmount 時刪除子應用的 CSS。子應用使用 Webpack 導出 CSS 文件時,要配合 ExposeRuntimeCssAssetsPlugin
插件來獲取最終導出的 CSS 文件名。
算實現了一半的 CSS 沙箱。
如果要在多個子應用進行樣式隔離,可以有兩種方法:
- Shadow DOM,樣式隔離比較好的方法,但是穿透比較麻煩
- Scoped CSS,在子應用的 CSS 選擇器上添加前綴做區分,可以使用
postcss-prefix-selector
這個包來快速添加前綴
single-spa-leaked-globals
在子應用 mount 時給 window 對象恢復/添加一些全局變量,如 jQuery 的 $
或者 lodash 的 _
,在 unmount 時把 window 對象的變量刪掉。
實現了“如果主應用一個url只有一個頁面”情況下的 JS 沙箱。
公共依賴
有兩種方法處理:
- 造一個 Utility Module 包,在這個包導出所有公共資源內容,並用 SystemJS 的 importmap 在主應用的 index.html 裏聲明
- 使用 Webpack 5 Module Federation 特性實現公共依賴的導入
哪個更推薦?都可以。
最後
single-spa 文檔就這些了嘛?沒錯,就這些了。文檔好像給了很多“最佳實踐”,但真正將所有“最佳實踐”結合起來並落地的又沒多少。
比如文檔說用 Shadow CSS 來做子應用之間的樣式隔離,但是 single-spa-leaked-globals 又不讓別人在一個 url 上掛載多個子應用。感覺很不靠譜:這裏行了,那裏又不行了。
再說回 Shadow CSS 來做樣式隔離,但是也沒有詳細說明要具體要怎麼做。像這樣的例子還有很多:文檔往往只告訴了一條路,怎麼走還要看開發者自己。這就你給人一種 “把問題只解決一半” 的感覺。
如果真的想用 single-spa 來玩小 Demo 的,用上面提到的小庫來搭建微前端是可以的,但是要用到生產環境真的沒那麼容易。
所以,爲了填平 single-spa 遺留下來的坑,阿里基於 single-spa 造出了 qiankun 微前端框架,真正實現了微前端的所有特性,不過這又是另外一個故事了。