【微前端】single-spa 到底是個什麼鬼

前言

說起微前端框架,很多人第一反應就是 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, constructApplicationconstructLayoutEngine 本質上就是識別 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 了。

這個問題和上面提到的處理“公共依賴”的問題是差不多的。官方給出兩個建議:

  1. 將公共的 CSS 放到 importmap 裏,也可以理解爲在 index.html 裏直接加個 link 獲取 antd 的 CSS 完事
  2. 將所有的公共的 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 微前端框架,真正實現了微前端的所有特性,不過這又是另外一個故事了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章