微前端框架 之 qiankun 從入門到原始碼分析

李永寧發表於2022-02-04

封面

簡介

從 single-spa 的缺陷講起 -> qiankun 是如何從框架層面解決 single-spa 存在的問題 -> qiankun 原始碼解讀,帶你全方位刨析 qiankun 框架。

介紹

qiankun 是基於 single-spa 做了二次封裝的微前端框架,通過解決了 single-spa 的一些弊端和不足,來幫助大家實現更簡單、無痛的構建一個生產可用的微前端架構系統。

微前端框架 之 single-spa 從入門到精通 通過從 基本使用 -> 部署 -> 框架原始碼分析 -> 手寫框架,帶你全方位刨析 single-spa 框架。

因為 qiankun 是基於 single-spa 做的二次封裝,主要解決了 single-spa 的一些痛點和不足,所以最好對 single-spa 有一個全面的瞭解和認識,明白其原理、瞭解它的不足和缺陷,然後帶著問題和目的去閱讀 qiankun 原始碼,可以達到事半功倍的效果,整個閱讀過程的思路也會更加清晰明瞭。

為什麼不是 single-spa

如果你很瞭解 single-spa 或者閱讀過 微前端框架 之 single-spa 從入門到精通 ,你會發現 single-spa 就做了兩件事,載入微應用(載入方法還是使用者自己提供的)、維護微應用狀態(初始化、掛載、解除安裝)。瞭解多了會發現 single-spa 雖好,但是卻存在一些比較嚴重的問題

  1. 對微應用的侵入性太強

    single-spa 採用 JS Entry 的方式接入微應用。微應用改造一般分為三步:

    • 微應用路由改造,新增一個特定的字首
    • 微應用入口改造,掛載點變更和生命週期函式匯出
    • 打包工具配置更改

    侵入型強其實說的就是第三點,更改打包工具的配置,使用 single-spa 接入微應用需要將微應用整個打包成一個 JS 檔案,釋出到靜態資源伺服器,然後在主應用中配置該 JS 檔案的地址告訴 single-spa 去這個地址載入微應用。

    不說其它的,就現在這個改動就存在很大的問題,將整個微應用打包成一個 JS 檔案,常見的打包優化基本上都沒了,比如:按需載入、首屏資源載入優化、css 獨立打包等優化措施。

    專案釋出以後出現了 bug ,修復之後需要更新上線,為了清除瀏覽器快取帶來的影響,一般檔名會帶上 chunkcontent,微應用釋出之後檔名都會發生變化,這時候還需要更新主應用中微應用配置,然後重新編譯主應用然後釋出,這套操作簡直是不能忍受的,這也是 微前端框架 之 single-spa 從入門到精通 這篇文章中示例專案中微應用釋出時的環境配置選擇 development 的原因。

  2. 樣式隔離問題

    single-spa 沒有做這部分的工作。一個大型的系統會有很的微應用組成,怎麼保證這些微應用之間的樣式互不影響?微應用和主應用之間的樣式互不影響?這時只能通過約定命名規範來實現,比如應用樣式以自己的應用名稱開頭,以應用名構造一個獨立的名稱空間,這個方式新系統還好說,如果是一個已有的系統,這個改造工作量可不小。

  3. JS 隔離

    這部分工作 single-spa 也沒有做。 JS 全域性物件汙染是一個很常見的現象,比如:微應用 A 在全域性物件上新增了一個自己特有的屬性,window.A,這時候切換到微應用 B,這時候如何保證 window 物件是乾淨的呢?

  4. 資源預載入

    這部分的工作 single-spa 更沒做了,畢竟將微應用整個打包成一個 js 檔案。現在有個需求,比如為了提高系統的使用者體驗,在第一個微應用掛載完成後,需要讓瀏覽器在後臺悄悄的載入其它微應用的靜態資源,這個怎麼實現呢?

  5. 應用間通訊

    這部分工作 single-spa 沒做,它只在註冊微應用時給微應用注入一些狀態資訊,後續就不管了,沒有任何通訊的手段,只能使用者自己去實現

以上 5 個問題中第 2、3、5 還好說,可以通過一些方式來解決,比如採用名稱空間的方式解決樣式隔離問題, 通過備份全域性物件,每次微應用切換時初始化全域性物件的方式來解決 JS 隔離的問題,通訊問題可以通過傳遞一些通訊方法,這點依賴了 JS 物件本身的特性(傳遞的是引用)來實現;但是第一個和第四個就不好解決了,這是 JS Entry 方式帶來的問題,要解決這個問題,難度相對就會大很多,工作量也會更大。況且這些通用的髒活累活就不應該由使用者(框架使用者)來解決,而是由框架來解決。

為什麼是 qiankun

上面說到,通用的髒活累活應該在框架層面去做,qiankun 基於 single-spa 做了二次封裝,很好的解決了上面提到的幾個問題。

  1. HTML Entry

    qiankun 通過 HTML Entry 的方式來解決 JS Entry 帶來的問題,讓你接入微應用像使用 iframe 一樣簡單。

  2. 樣式隔離

    qiankun 實現了兩種樣式隔離

    • 嚴格的樣式隔離模式,為每個微應用的容器包裹上一個 shadow dom 節點,從而確保微應用的樣式不會對全域性造成影響
    • 實驗性的方式,通過動態改寫 css 選擇器來實現,可以理解為 css scoped 的方式
  3. **執行時沙箱 **

    qiankun 的執行時沙箱分為 JS 沙箱和 樣式沙箱

    JS 沙箱 為每個微應用生成單獨的 window proxy 物件,配合 HTML Entry 提供的 JS 指令碼執行器 (execScripts) 來實現 JS 隔離;

    樣式沙箱 通過重寫 DOM 操作方法,來劫持動態樣式和 JS 指令碼的新增,讓樣式和指令碼新增到正確的地方,即主應用的插入到主應用模版內,微應用的插入到微應用模版,並且為劫持的動態樣式做了 scoped css 的處理,為劫持的指令碼做了 JS 隔離的處理,更加具體的內容可繼續往下閱讀或者直接閱讀 微前端專欄 中的 qiankun 2.x 執行時沙箱 原始碼分析

  4. 資源預載入

    qiankun 實現預載入的思路有兩種,一種是當主應用執行 start 方法啟動 qiankun 以後立即去預載入微應用的靜態資源,另一種是在第一個微應用掛載以後預載入其它微應用的靜態資源,這個是利用 single-spa 提供的 single-spa:first-mount 事件來實現的

  5. 應用間通訊

    qiankun 通過釋出訂閱模式來實現應用間通訊,狀態由框架來統一維護,每個應用在初始化時由框架生成一套通訊方法,應用通過這些方法來更改全域性狀態和註冊回撥函式,全域性狀態發生改變時觸發各個應用註冊的回撥函式執行,將新舊狀態傳遞到所有應用

說明

文章基於 qiankun 2.0.26 版本做了完整的原始碼分析,目前網上好像還沒有 qiankun 2.x 版本的完整原始碼分析,簡單搜了下好像都是 1.x 版本的

由於框架程式碼比較多的,部落格有字數限制,所以將全部內容拆成了三篇文章,每一篇都可獨立閱讀:

  • 微前端框架 之 qiankun 從入門到精通

    ,文章由以下三部分組成

    • 為什麼不是 single-spa,詳細介紹了 single-spa 存在的問題
    • 為什麼是 qiankun,詳細介紹了 qiankun 是怎麼從框架層面解決 single-spa 存在的問題的
    • 原始碼解讀,完整解讀了 qiankun 2.x 版本的原始碼
  • qiankun 2.x 執行時沙箱 原始碼分析,詳細解讀了 qiankun 2.x 版本的沙箱實現

  • HTML Entry 原始碼分析,詳細解讀了 HTML Entry 的原理以及在 qiankun 中的應用

原始碼解讀

這裡沒有單獨編寫示例程式碼,因為 qiankun 原始碼中提供了完整的示例專案,這也是 qiankun 做的很好的一個地方,提供完整的示例,避免大家在使用時重複踩坑。

微前端實現和改造時面臨的第一個困難就是主應用的設定、微應用的接入,single-spa 官方沒有提供一個很好的示例專案,所以大家在使用 single-spa 接入微應用時還是需要踩不少坑的,甚至有些問題需要去閱讀原始碼才能解決

框架目錄結構

github 克隆專案以後,執行一下命令:

  • 安裝 qiankun 框架所需的包

    yarn install
    
  • 安裝示例專案的包

    yarn examples:install
    

以上命令執行結束以後:

image-20220202220056482

有料的 package.json

  • npm-run-all

    一個 CLI 工具,用於並行或順序執行多個 npm 指令碼

  • father-build

    基於 rollup 的庫構建工具,father 更加強大

  • 多專案的目錄組織以及 scripts 部分的編寫

  • main 和 module 欄位

    標識元件庫的入口,當兩者同時存在時,module 欄位的優先順序高於 main

示例專案中的主應用

這裡需要更改一下示例專案中主應用的 webpack 配置

{
  ...
  devServer: {
    // 從 package.json 中可以看出,啟動示例專案時,主應用執行了兩條命令,其實就是啟動了兩個主應用,但是卻只配置了一個埠,瀏覽器開啟 localhost:7099 和你預想的有一些出入,這時顯示的是 loadMicroApp(手動載入微應用) 方式的主應用,基於路由配置的主應用沒起來,因為埠被佔用了
    // port: '7099'
		// 這樣配置,手動載入微應用的主應用在 7099 埠,基於路由配置的主應用在 7088 埠
    port: process.env.MODE === 'multiple' ? '7099' : '7088'
  }
  ...
}

啟動示例專案

yarn examples:start

命令執行結束以後,訪問 localhost:7099localhost:7088 兩個地址,可以看到如下內容:

image-20220202220258551

image-20220202220401608

到這一步,就證明專案正式跑起來了,所有準備工作就緒

示例專案

官方為我們準備了兩種主應用的實現方式,五種微應用的接入示例,覆蓋面可以說是比較廣了,足以滿足大家的普遍需要了

主應用

主應用在 examples/main 目錄下,提供了兩種實現方式,基於路由配置的 registerMicroApps 和 手動載入微應用的 loadMicroApp。主應用很簡單,就是一個從 0 通過 webpack 配置的一個同時支援 react 和 vue 的專案,至於為什麼同時支援 react 和 vue,繼續往下看

webpack.config.js

就是一個普通的 webpack 配置,配置了一個開發伺服器 devServer、兩個 loader (babel-loader、css loader)、一個外掛 HtmlWebpackPlugin (告訴 webpack html 模版檔案是哪個)

通過 webpack 配置檔案的 entry 欄位得知入口檔案分別為 index.jsmultiple.js

基於路由配置

通用將微應用關聯到一些 url 規則的方式,實現當瀏覽器 url 發生變化時,自動載入相應的微應用的功能

index.js
// qiankun api 引入
import { registerMicroApps, runAfterFirstMounted, setDefaultMountApp, start, initGlobalState } from '../../es';
// 全域性樣式
import './index.less';

// 專門針對 angular 微應用引入的一個庫
import 'zone.js';

/**
 * 主應用可以使用任何技術棧,這裡提供了 react 和 vue 兩種,可以隨意切換
 * 最終都匯出了一個 render 函式,負責渲染主應用
 */
// import render from './render/ReactRender';
import render from './render/VueRender';

// 初始化主應用,其實就是渲染主應用
render({ loading: true });

// 定義 loader 函式,切換微應用時由 qiankun 框架負責呼叫顯示一個 loading 狀態
const loader = loading => render({ loading });

// 註冊微應用
registerMicroApps(
  // 微應用配置列表
  [
    {
      // 應用名稱
      name: 'react16',
      // 應用的入口地址
      entry: '//localhost:7100',
      // 應用的掛載點,這個掛載點在上面渲染函式中的模版裡面提供的
      container: '#subapp-viewport',
      // 微應用切換時呼叫的方法,顯示一個 loading 狀態
      loader,
      // 當路由字首為 /react16 時啟用當前應用
      activeRule: '/react16',
    },
    {
      name: 'react15',
      entry: '//localhost:7102',
      container: '#subapp-viewport',
      loader,
      activeRule: '/react15',
    },
    {
      name: 'vue',
      entry: '//localhost:7101',
      container: '#subapp-viewport',
      loader,
      activeRule: '/vue',
    },
    {
      name: 'angular9',
      entry: '//localhost:7103',
      container: '#subapp-viewport',
      loader,
      activeRule: '/angular9',
    },
    {
      name: 'purehtml',
      entry: '//localhost:7104',
      container: '#subapp-viewport',
      loader,
      activeRule: '/purehtml',
    },
  ],
  // 全域性生命週期鉤子,切換微應用時框架負責呼叫
  {
    beforeLoad: [
      app => {
        // 這個列印日誌的方法可以學習一下,第三個引數會替換掉第一個引數中的 %c%s,並且第三個引數的顏色由第二個引數決定
        console.log('[LifeCycle] before load %c%s', 'color: green;', app.name);
      },
    ],
    beforeMount: [
      app => {
        console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name);
      },
    ],
    afterUnmount: [
      app => {
        console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name);
      },
    ],
  },
);

// 定義全域性狀態,並返回兩個通訊方法
const { onGlobalStateChange, setGlobalState } = initGlobalState({
  user: 'qiankun',
});

// 監聽全域性狀態的更改,當狀態發生改變時執行回撥函式
onGlobalStateChange((value, prev) => console.log('[onGlobalStateChange - master]:', value, prev));

// 設定新的全域性狀態,只能設定一級屬性,微應用只能修改已存在的一級屬性
setGlobalState({
  ignore: 'master',
  user: {
    name: 'master',
  },
});

// 設定預設進入的子應用,當主應用啟動以後預設進入指定微應用
setDefaultMountApp('/react16');

// 啟動應用
start();

// 當第一個微應用掛載以後,執行回撥函式,在這裡可以做一些特殊的事情,比如開啟一監控或者買點指令碼
runAfterFirstMounted(() => {
  console.log('[MainApp] first app mounted');
});
VueRender.js
/**
 * 匯出一個由 vue 實現的渲染函式,渲染了一個模版,模版裡面包含一個 loading 狀態節點和微應用容器節點
 */
import Vue from 'vue/dist/vue.esm';

// 返回一個 vue 例項
function vueRender({ loading }) {
  return new Vue({
    template: `
      <div id="subapp-container">
        <h4 v-if="loading" class="subapp-loading">Loading...</h4>
        <div id="subapp-viewport"></div>
      </div>
    `,
    el: '#subapp-container',
    data() {
      return {
        loading,
      };
    },
  });
}

// vue 例項
let app = null;

// 渲染函式
export default function render({ loading }) {
  // 單例,如果 vue 例項不存在則例項化主應用,存在則說明主應用已經渲染,需要更新主營應用的 loading 狀態
  if (!app) {
    app = vueRender({ loading });
  } else {
    app.loading = loading;
  }
}
ReactRender.js
/**
 * 同 vue 實現的渲染函式,這裡通過 react 實現了一個一樣的渲染函式
 */
import React from 'react';
import ReactDOM from 'react-dom';

// 渲染主應用
function Render(props) {
  const { loading } = props;

  return (
    <>
      {loading && <h4 className="subapp-loading">Loading...</h4>}
      <div id="subapp-viewport" />
    </>
  );
}

// 將主應用渲染到指定節點下
export default function render({ loading }) {
  const container = document.getElementById('subapp-container');
  ReactDOM.render(<Render loading={loading} />, container);
}
手動載入微應用

通常這種場景下的微應用是一個不帶路由的可獨立執行的業務元件,這種使用方式的情況比較少見

multiple.js
/**
 * 呼叫 loadMicroApp 方法註冊了兩個微應用
 */
import { loadMicroApp } from '../../es';

const app1 = loadMicroApp(
  // 應用配置,名稱、入口地址、容器節點
  { name: 'react15', entry: '//localhost:7102', container: '#react15' },
  // 可以新增一些其它的配置,比如:沙箱、樣式隔離等
  {
    sandbox: {
      // strictStyleIsolation: true,
    },
  },
);

const app2 = loadMicroApp(
  { name: 'vue', entry: '//localhost:7101', container: '#vue' },
  {
    sandbox: {
      // strictStyleIsolation: true,
    },
  },
);

vue

vue 微應用在 examples/vue 目錄下,就是一個通過 vue-cli 建立的 vue demo 應用,然後對 vue.config.jsmain.js 做了一些更改

vue.config.js

一個普通的 webpack 配置,需要注意的地方就三點

{
  ...
  // publicPath 沒在這裡設定,是通過 webpack 提供的全域性變數 __webpack_public_path__ 來即時設定的,webpackjs.com/guides/public-path/
  devServer: {
    ...
    // 設定跨域,因為主應用需要通過 fetch 去獲取微應用引入的靜態資源的,所以必須要求這些靜態資源支援跨域
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  output: {
    // 把子應用打包成 umd 庫格式
    library: `${name}-[name]`,	// 庫名稱,唯一
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${name}`,
  }
  ...
}
main.js
// 動態設定 __webpack_public_path__
import './public-path';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
// 路由配置
import routes from './router';
import store from './store';

Vue.config.productionTip = false;

Vue.use(ElementUI);

let router = null;
let instance = null;

// 應用渲染函式
function render(props = {}) {
  const { container } = props;
  // 例項化 router,根據應用執行環境設定路由字首
  router = new VueRouter({
    // 作為微應用執行,則設定 /vue 為字首,否則設定 /
    base: window.__POWERED_BY_QIANKUN__ ? '/vue' : '/',
    mode: 'history',
    routes,
  });

  // 例項化 vue 例項
  instance = new Vue({
    router,
    store,
    render: h => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 支援應用獨立執行
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

/**
 * 從 props 中獲取通訊方法,監聽全域性狀態的更改和設定全域性狀態,只能操作一級屬性
 * @param {*} props 
 */
function storeTest(props) {
  props.onGlobalStateChange &&
    props.onGlobalStateChange(
      (value, prev) => console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
      true,
    );
  props.setGlobalState &&
    props.setGlobalState({
      ignore: props.name,
      user: {
        name: props.name,
      },
    });
}

/**
 * 匯出的三個生命週期函式
 */
// 初始化
export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

// 掛載微應用
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  storeTest(props);
  render(props);
}

// 解除安裝、銷燬微應用
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
public-path.js
/**
 * 在入口檔案中使用 ES6 模組匯入,則在匯入後對 __webpack_public_path__ 進行賦值。
 * 在這種情況下,必須將公共路徑(public path)賦值移至專屬模組,然後將其在最前面匯入
 */

// qiankun 設定的全域性變數,表示應用作為微應用在執行
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

jQuery

這是一個使用了 jQuery 的專案,在 examples/purehtml 目錄下,展示瞭如何接入使用 jQuery 開發的應用

package.json

為了達到演示效果,使用 http-server 在起了一個本地伺服器,並且支援跨域

{
  ...
  "scripts": {
    "start": "cross-env PORT=7104 http-server . --cors",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  ...
}
entry.js
// 渲染函式
const render = $ => {
  $('#purehtml-container').html('Hello, render with jQuery');
  return Promise.resolve();
};

// 在全域性物件上匯出三個生命週期函式
(global => {
  global['purehtml'] = {
    bootstrap: () => {
      console.log('purehtml bootstrap');
      return Promise.resolve();
    },
    mount: () => {
      console.log('purehtml mount');
      // 呼叫渲染函式
      return render($);
    },
    unmount: () => {
      console.log('purehtml unmount');
      return Promise.resolve();
    },
  };
})(window);
index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Purehtml Example</title>
  <script src="//cdn.bootcss.com/jquery/3.4.1/jquery.min.js">
  </script>
</head>
<body>
  <div style="display: flex; justify-content: center; align-items: center; height: 200px;">
    Purehtml Example
  </div>
  <div id="purehtml-container" style="text-align:center"></div>
  <!-- 引入 entry.js,相當於 vue 專案的 publicPath 配置 -->
  <script src="//localhost:7104/entry.js" entry></script>
</body>
</html>

angular 9、react 15、react 16

這三個例項專案就不一一分析了,和 vue 專案類似,都是配置打包工具將微應用打包成一個 umd 格式,然後配置應用入口檔案 和 路由字首

小結

好了,讀到這裡,系統改造(可以開始幹活了)基本上就已經可以順利進行了,從主應用的開發到微應用接入,應該是不會有什麼問題了。

當然如果你想繼續深入瞭解,比如:

  • 上面用到那些 API 的原理是什麼?
  • qiankun 是怎麼解決我們之前提到的 single-spa 未解決的問題的?
  • ...

接下來就帶著我們的疑問和目的去全面深入的瞭解 qiankun 框架的內部實現

框架原始碼

整個框架的原始碼目錄是 src,入口檔案是 src/index.ts

入口 src/index.ts

/**
 * 在示例或者官網提到的所有 API 都在這裡統一匯出
 */
// 最關鍵的三個,手動載入微應用、基於路由配置、啟動 qiankun
export { loadMicroApp, registerMicroApps, start } from './apis';
// 全域性狀態
export { initGlobalState } from './globalState';
// 全域性的未捕獲異常處理器
export * from './errorHandler';
// setDefaultMountApp 設定主應用啟動後預設進入哪個微應用、runAfterFirstMounted 設定當第一個微應用掛載以後需要呼叫的一些方法
export * from './effects';
// 型別定義
export * from './interfaces';
// prefetch
export { prefetchImmediately as prefetchApps } from './prefetch';

registerMicroApps

/**
 * 註冊微應用,基於路由配置
 * @param apps = [
 *  {
 *    name: 'react16',
 *    entry: '//localhost:7100',
 *    container: '#subapp-viewport',
 *    loader,
 *    activeRule: '/react16'
 *  },
 *  ...
 * ]
 * @param lifeCycles = { ...各個生命週期方法物件 }
 */
export function registerMicroApps<T extends object = {}>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // 防止微應用重複註冊,得到所有沒有被註冊的微應用列表
  const unregisteredApps = apps.filter(app => !microApps.some(registeredApp => registeredApp.name === app.name));

  // 所有的微應用 = 已註冊 + 未註冊的(將要被註冊的)
  microApps = [...microApps, ...unregisteredApps];

  // 註冊每一個微應用
  unregisteredApps.forEach(app => {
    // 註冊時提供的微應用基本資訊
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    // 呼叫 single-spa 的 registerApplication 方法註冊微應用
    registerApplication({
      // 微應用名稱
      name,
      // 微應用的載入方法,Promise<生命週期方法組成的物件>
      app: async () => {
        // 載入微應用時主應用顯示 loading 狀態
        loader(true);
        // 這句可以忽略,目的是在 single-spa 執行這個載入方法時讓出執行緒,讓其它微應用的載入方法都開始執行
        await frameworkStartedDefer.promise;

        // 核心、精髓、難點所在,負責載入微應用,然後一大堆處理,返回 bootstrap、mount、unmount、update 這個幾個生命週期
        const { mount, ...otherMicroAppConfigs } = await loadApp(
          // 微應用的配置資訊
          { name, props, ...appConfig },
          // start 方法執行時設定的配置物件
          frameworkConfiguration,
          // 註冊微應用時提供的全域性生命週期物件
          lifeCycles,
        );

        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      // 微應用的啟用條件
      activeWhen: activeRule,
      // 傳遞給微應用的 props
      customProps: props,
    });
  });
}

start

/**
 * 啟動 qiankun
 * @param opts start 方法的配置物件 
 */
export function start(opts: FrameworkConfiguration = {}) {
  // qiankun 框架預設開啟預載入、單例模式、樣式沙箱
  frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
  // 從這裡可以看出 start 方法支援的引數不止官網文件說的那些,比如 urlRerouteOnly,這個是 single-spa 的 start 方法支援的
  const { prefetch, sandbox, singular, urlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;

  // 預載入
  if (prefetch) {
    // 執行預載入策略,引數分別為微應用列表、預載入策略、{ fetch、getPublicPath、getTemplate }
    doPrefetchStrategy(microApps, prefetch, importEntryOpts);
  }

  // 樣式沙箱
  if (sandbox) {
    if (!window.Proxy) {
      console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
      // 快照沙箱不支援非 singular 模式
      if (!singular) {
        console.error('[qiankun] singular is forced to be true when sandbox enable but proxySandbox unavailable');
        // 如果開啟沙箱,會強制使用單例模式
        frameworkConfiguration.singular = true;
      }
    }
  }

  // 執行 single-spa 的 start 方法,啟動 single-spa
  startSingleSpa({ urlRerouteOnly });

  frameworkStartedDefer.resolve();
}

預載入 - doPrefetchStrategy

/**
 * 執行預載入策略,qiankun 支援四種
 * @param apps 所有的微應用 
 * @param prefetchStrategy 預載入策略,四種 =》 
 *  1、true,第一個微應用掛載以後載入其它微應用的靜態資源,利用的是 single-spa 提供的 single-spa:first-mount 事件來實現的
 *  2、string[],微應用名稱陣列,在第一個微應用掛載以後載入指定的微應用的靜態資源
 *  3、all,主應用執行 start 以後就直接開始預載入所有微應用的靜態資源
 *  4、自定義函式,返回兩個微應用組成的陣列,一個是關鍵微應用組成的陣列,需要馬上就執行預載入的微應用,一個是普通的微應用組成的陣列,在第一個微應用掛載以後預載入這些微應用的靜態資源
 * @param importEntryOpts = { fetch, getPublicPath, getTemplate }
 */
export function doPrefetchStrategy(
  apps: AppMetadata[],
  prefetchStrategy: PrefetchStrategy,
  importEntryOpts?: ImportEntryOpts,
) {
  // 定義函式,函式接收一個微應用名稱組成的陣列,然後從微應用列表中返回這些名稱所對應的微應用,最後得到一個陣列[{name, entry}, ...]
  const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter(app => names.includes(app.name));

  if (Array.isArray(prefetchStrategy)) {
    // 說明載入策略是一個陣列,當第一個微應用掛載之後開始載入陣列內由使用者指定的微應用資源,陣列內的每一項表示一個微應用的名稱
    prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
  } else if (isFunction(prefetchStrategy)) {
    // 載入策略是一個自定義的函式,可完全自定義應用資源的載入時機(首屏應用、次屏應用)
    (async () => {
      // critical rendering apps would be prefetch as earlier as possible,關鍵的應用程式應該儘可能早的預取
      // 執行載入策略函式,函式會返回兩個陣列,一個關鍵的應用程式陣列,會立即執行預載入動作,另一個是在第一個微應用掛載以後執行微應用靜態資源的預載入
      const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
      // 立即預載入這些關鍵微應用程式的靜態資源
      prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
      // 當第一個微應用掛載以後預載入這些微應用的靜態資源
      prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
    })();
  } else {
    // 載入策略是預設的 true 或者 all
    switch (prefetchStrategy) {
      case true:
        // 第一個微應用掛載之後開始載入其它微應用的靜態資源
        prefetchAfterFirstMounted(apps, importEntryOpts);
        break;

      case 'all':
        // 在主應用執行 start 以後就開始載入所有微應用的靜態資源
        prefetchImmediately(apps, importEntryOpts);
        break;

      default:
        break;
    }
  }
}

// 判斷是否為弱網環境
const isSlowNetwork = navigator.connection
  ? navigator.connection.saveData ||
    (navigator.connection.type !== 'wifi' &&
      navigator.connection.type !== 'ethernet' &&
      /(2|3)g/.test(navigator.connection.effectiveType))
  : false;

/**
 * prefetch assets, do nothing while in mobile network
 * 預載入靜態資源,在行動網路下什麼都不做
 * @param entry
 * @param opts
 */
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
  // 弱網環境下不執行預載入
  if (!navigator.onLine || isSlowNetwork) {
    // Don't prefetch if in a slow network or offline
    return;
  }

  // 通過時間切片的方式去載入靜態資源,在瀏覽器空閒時去執行回撥函式,避免瀏覽器卡頓
  requestIdleCallback(async () => {
    // 得到載入靜態資源的函式
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
    // 樣式
    requestIdleCallback(getExternalStyleSheets);
    // js 指令碼
    requestIdleCallback(getExternalScripts);
  });
}

/**
 * 在第一個微應用掛載之後開始載入 apps 中指定的微應用的靜態資源
 * 通過監聽 single-spa 提供的 single-spa:first-mount 事件來實現,該事件在第一個微應用掛載以後會被觸發
 * @param apps 需要被預載入靜態資源的微應用列表,[{ name, entry }, ...]
 * @param opts = { fetch , getPublicPath, getTemplate }
 */
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  // 監聽 single-spa:first-mount 事件
  window.addEventListener('single-spa:first-mount', function listener() {
    // 已掛載的微應用
    const mountedApps = getMountedApps();
    // 從預載入的微應用列表中過濾出未掛載的微應用
    const notMountedApps = apps.filter(app => mountedApps.indexOf(app.name) === -1);

    // 開發環境列印日誌,已掛載的微應用和未掛載的微應用分別有哪些
    if (process.env.NODE_ENV === 'development') {
      console.log(`[qiankun] prefetch starting after ${mountedApps} mounted...`, notMountedApps);
    }

    // 迴圈載入微應用的靜態資源
    notMountedApps.forEach(({ entry }) => prefetch(entry, opts));

    // 移除 single-spa:first-mount 事件
    window.removeEventListener('single-spa:first-mount', listener);
  });
}

/**
 * 在執行 start 啟動 qiankun 之後立即預載入所有微應用的靜態資源
 * @param apps 需要被預載入靜態資源的微應用列表,[{ name, entry }, ...]
 * @param opts = { fetch , getPublicPath, getTemplate }
 */
export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  // 開發環境列印日誌
  if (process.env.NODE_ENV === 'development') {
    console.log('[qiankun] prefetch starting for apps...', apps);
  }

  // 載入所有微應用的靜態資源
  apps.forEach(({ entry }) => prefetch(entry, opts));
}

應用間通訊 initGlobalState

// 觸發全域性監聽,執行所有應用註冊的回撥函式
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
  // 迴圈遍歷,執行所有應用註冊的回撥函式
  Object.keys(deps).forEach((id: string) => {
    if (deps[id] instanceof Function) {
      deps[id](cloneDeep(state), cloneDeep(prevState));
    }
  });
}

/**
 * 定義全域性狀態,並返回通訊方法,一般由主應用呼叫,微應用通過 props 獲取通訊方法。 
 * @param state 全域性狀態,{ key: value }
 */
export function initGlobalState(state: Record<string, any> = {}) {
  if (state === globalState) {
    console.warn('[qiankun] state has not changed!');
  } else {
    // 方法有可能被重複呼叫,將已有的全域性狀態克隆一份,為空則是第一次呼叫 initGlobalState 方法,不為空則非第一次次呼叫
    const prevGlobalState = cloneDeep(globalState);
    // 將傳遞的狀態克隆一份賦值為 globalState
    globalState = cloneDeep(state);
    // 觸發全域性監聽,當然在這個位置呼叫,正常情況下沒啥反應,因為現在還沒有應用註冊回撥函式
    emitGlobal(globalState, prevGlobalState);
  }
  // 返回通訊方法,參數列示應用 id,true 表示自己是主應用呼叫
  return getMicroAppStateActions(`global-${+new Date()}`, true);
}

/**
 * 返回通訊方法 
 * @param id 應用 id
 * @param isMaster 表明呼叫的應用是否為主應用,在主應用初始化全域性狀態時,initGlobalState 內部呼叫該方法時會傳遞 true,其它都為 false
 */
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
  return {
    /**
     * 全域性依賴監聽,為指定應用(id = 應用id)註冊回撥函式
     * 依賴資料結構為:
     * {
     *   {id}: callback
     * }
     *
     * @param callback 註冊的回撥函式
     * @param fireImmediately 是否立即執行回撥
     */
    onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
      // 回撥函式必須為 function
      if (!(callback instanceof Function)) {
        console.error('[qiankun] callback must be function!');
        return;
      }
      // 如果回撥函式已經存在,重複註冊時給出覆蓋提示資訊
      if (deps[id]) {
        console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`);
      }
      // id 為一個應用 id,一個應用對應一個回撥
      deps[id] = callback;
      // 克隆全域性狀態
      const cloneState = cloneDeep(globalState);
      // 如果需要,立即出發回撥執行
      if (fireImmediately) {
        callback(cloneState, cloneState);
      }
    },

    /**
     * setGlobalState 更新 store 資料
     *
     * 1. 對新輸入 state 的第一層屬性做校驗,如果是主應用則可以新增新的一級屬性進來,也可以更新已存在的一級屬性,
     *    如果是微應用,則只能更新已存在的一級屬性,不可以新增一級屬性
     * 2. 觸發全域性監聽,執行所有應用註冊的回撥函式,以達到應用間通訊的目的
     *
     * @param state 新的全域性狀態
     */
    setGlobalState(state: Record<string, any> = {}) {
      if (state === globalState) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }

      // 記錄舊的全域性狀態中被改變的 key
      const changeKeys: string[] = [];
      // 舊的全域性狀態
      const prevGlobalState = cloneDeep(globalState);
      globalState = cloneDeep(
        // 迴圈遍歷新狀態中的所有 key
        Object.keys(state).reduce((_globalState, changeKey) => {
          if (isMaster || _globalState.hasOwnProperty(changeKey)) {
            // 主應用 或者 舊的全域性狀態存在該 key 時才進來,說明只有主應用才可以新增屬性,微應用只可以更新已存在的屬性值,且不論主應用微應用只能更新一級屬性
            // 記錄被改變的key
            changeKeys.push(changeKey);
            // 更新舊狀態中對應的 key value
            return Object.assign(_globalState, { [changeKey]: state[changeKey] });
          }
          console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
          return _globalState;
        }, globalState),
      );
      if (changeKeys.length === 0) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }
      // 觸發全域性監聽
      emitGlobal(globalState, prevGlobalState);
      return true;
    },

    // 登出該應用下的依賴
    offGlobalStateChange() {
      delete deps[id];
      return true;
    },
  };
}

全域性未捕獲異常處理器

/**
 * 整個檔案的邏輯一眼明瞭,整個框架提供了兩種全域性異常捕獲,一個是 single-spa 提供的,另一個是 qiankun 自己的,你只需提供相應的回撥函式即可
 */

// single-spa 的異常捕獲
export { addErrorHandler, removeErrorHandler } from 'single-spa';

// qiankun 的異常捕獲
// 監聽了 error 和 unhandlerejection 事件
export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
  window.addEventListener('error', errorHandler);
  window.addEventListener('unhandledrejection', errorHandler);
}

// 移除 error 和 unhandlerejection 事件監聽
export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
  window.removeEventListener('error', errorHandler);
  window.removeEventListener('unhandledrejection', errorHandler);
}

setDefaultMountApp

/**
 * 設定主應用啟動後預設進入的微應用,其實是規定了第一個微應用掛載完成後決定預設進入哪個微應用
 * 利用的是 single-spa 的 single-spa:no-app-change 事件,該事件在所有微應用狀態改變結束後(即發生路由切換且新的微應用已經被掛載完成)觸發
 * @param defaultAppLink 微應用的連結,比如 /react16
 */
export function setDefaultMountApp(defaultAppLink: string) {
  // 當事件觸發時就說明微應用已經掛載完成,但這裡只監聽了一次,因為事件被觸發以後就移除了監聽,所以說是主應用啟動後預設進入的微應用,且只執行了一次的原因
  window.addEventListener('single-spa:no-app-change', function listener() {
    // 說明微應用已經掛載完成,獲取掛載的微應用列表,再次確認確實有微應用掛載了,其實這個確認沒啥必要
    const mountedApps = getMountedApps();
    if (!mountedApps.length) {
      // 這個是 single-spa 提供的一個 api,通過觸發 window.location.hash 或者 pushState 更改路由,切換微應用
      navigateToUrl(defaultAppLink);
    }

    // 觸發一次以後,就移除該事件的監聽函式,後續的路由切換(事件觸發)時就不再響應
    window.removeEventListener('single-spa:no-app-change', listener);
  });
}

// 這個 api 和 setDefaultMountApp 作用一致,官網也提到,相容老版本的一個 api
export function runDefaultMountEffects(defaultAppLink: string) {
  console.warn(
    '[qiankun] runDefaultMountEffects will be removed in next version, please use setDefaultMountApp instead',
  );
  setDefaultMountApp(defaultAppLink);
}

runAfterFirstMounted

/**
 * 第一個微應用 mount 後需要呼叫的方法,比如開啟一些監控或者埋點指令碼
 * 同樣利用的 single-spa 的 single-spa:first-mount 事件,當第一個微應用掛載以後會觸發
 * @param effect 回撥函式,當第一個微應用掛載以後要做的事情
 */
export function runAfterFirstMounted(effect: () => void) {
  // can not use addEventListener once option for ie support
  window.addEventListener('single-spa:first-mount', function listener() {
    if (process.env.NODE_ENV === 'development') {
      console.timeEnd(firstMountLogLabel);
    }

    effect();

    // 這裡不移除也沒事,因為這個事件後續不會再被觸發了
    window.removeEventListener('single-spa:first-mount', listener);
  });
}

手動載入微應用 loadMicroApp

/**
 * 手動載入一個微應用,是通過 single-spa 的 mountRootParcel api 實現的,返回微應用例項
 * @param app = { name, entry, container, props }
 * @param configuration 配置物件
 * @param lifeCycles 還支援一個全域性生命週期配置物件,這個引數官方文件沒提到
 */
export function loadMicroApp<T extends object = {}>(
  app: LoadableApp<T>,
  configuration?: FrameworkConfiguration,
  lifeCycles?: FrameworkLifeCycles<T>,
): MicroApp {
  const { props } = app;
  // single-spa 的 mountRootParcel api
  return mountRootParcel(() => loadApp(app, configuration ?? frameworkConfiguration, lifeCycles), {
    domElement: document.createElement('div'),
    ...props,
  });
}

qiankun 的核心 loadApp

接下來介紹 loadApp 方法,個人認為 qiankun 的核心程式碼可以說大部分都在這裡,當然這也是整個框架的精髓和難點所在

/**
 * 完成了以下幾件事:
 *  1、通過 HTML Entry 的方式遠端載入微應用,得到微應用的 html 模版(首屏內容)、JS 指令碼執行器、靜態經資源路徑
 *  2、樣式隔離,shadow DOM 或者 scoped css 兩種方式
 *  3、渲染微應用
 *  4、執行時沙箱,JS 沙箱、樣式沙箱
 *  5、合併沙箱傳遞出來的 生命週期方法、使用者傳遞的生命週期方法、框架內建的生命週期方法,將這些生命週期方法統一整理,匯出一個生命週期物件,
 * 供 single-spa 的 registerApplication 方法使用,這個物件就相當於使用 single-spa 時你的微應用匯出的那些生命週期方法,只不過 qiankun
 * 額外填了一些生命週期方法,做了一些事情
 *  6、給微應用註冊通訊方法並返回通訊方法,然後會將通訊方法通過 props 注入到微應用
 * @param app 微應用配置物件
 * @param configuration start 方法執行時設定的配置物件 
 * @param lifeCycles 註冊微應用時提供的全域性生命週期物件
 */
export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObject> {
  // 微應用的入口和名稱
  const { entry, name: appName } = app;
  // 例項 id
  const appInstanceId = `${appName}_${+new Date()}_${Math.floor(Math.random() * 1000)}`;

  // 下面這個不用管,就是生成一個標記名稱,然後使用該名稱在瀏覽器效能緩衝器中設定一個時間戳,可以用來度量程式的執行時間,performance.mark、performance.measure
  const markName = `[qiankun] App ${appInstanceId} Loading`;
  if (process.env.NODE_ENV === 'development') {
    performanceMark(markName);
  }

  // 配置資訊
  const { singular = false, sandbox = true, excludeAssetFilter, ...importEntryOpts } = configuration;

  /**
   * 獲取微應用的入口 html 內容和指令碼執行器
   * template 是 link 替換為 style 後的 template
   * execScript 是 讓 JS 程式碼(scripts)在指定 上下文 中執行
   * assetPublicPath 是靜態資源地址
   */
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

  // single-spa 的限制,載入、初始化和解除安裝不能同時進行,必須等解除安裝完成以後才可以進行載入,這個 promise 會在微應用解除安裝完成後被 resolve,在後面可以看到
  if (await validateSingularMode(singular, app)) {
    await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
  }

  // --------------- 樣式隔離 ---------------
  // 是否嚴格樣式隔離
  const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
  // 實驗性的樣式隔離,後面就叫 scoped css,和嚴格樣式隔離不能同時開啟,如果開啟了嚴格樣式隔離,則 scoped css 就為 false,強制關閉
  const enableScopedCSS = isEnableScopedCSS(configuration);

  // 用一個容器元素包裹微應用入口 html 模版, appContent = `<div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>`
  const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);
  // 將 appContent 有字串模版轉換為 html dom 元素,如果需要開啟樣式嚴格隔離,則將 appContent 的子元素即微應用入口模版用 shadow dom 包裹起來,以達到樣式嚴格隔離的目的
  let element: HTMLElement | null = createElement(appContent, strictStyleIsolation);
  // 通過 scoped css 的方式隔離樣式,從這裡也就能看出官方為什麼說:
  // 在目前的階段,該功能還不支援動態的、使用 <link />標籤來插入外聯的樣式,但考慮在未來支援這部分場景
  // 在現階段只處理 style 這種內聯標籤的情況 
  if (element && isEnableScopedCSS(configuration)) {
    const styleNodes = element.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(element!, stylesheetElement, appName);
    });
  }

  // --------------- 渲染微應用 ---------------
  // 主應用裝載微應用的容器節點
  const container = 'container' in app ? app.container : undefined;
  // 這個是 1.x 版本遺留下來的實現,如果提供了 render 函式,當微應用需要被啟用時就執行 render 函式渲染微應用,新版本用的 container,棄了 render
  // 而且 legacyRender 和 strictStyleIsolation、scoped css 不相容
  const legacyRender = 'render' in app ? app.render : undefined;

  // 返回一個 render 函式,這個 render 函式要不使用使用者傳遞的 render 函式,要不將 element 插入到 container
  const render = getRender(appName, appContent, container, legacyRender);

  // 渲染微應用到容器節點,並顯示 loading 狀態
  render({ element, loading: true }, 'loading');

  // 得到一個 getter 函式,通過該函式可以獲取 <div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>
  const containerGetter = getAppWrapperGetter(
    appName,
    appInstanceId,
    !!legacyRender,
    strictStyleIsolation,
    enableScopedCSS,
    () => element,
  );

  // --------------- 執行時沙箱 ---------------
  // 保證每一個微應用執行在一個乾淨的環境中(JS 執行上下文獨立、應用間不會發生樣式汙染)
  let global = window;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
  if (sandbox) {
    /**
     * 生成執行時沙箱,這個沙箱其實由兩部分組成 => JS 沙箱(執行上下文)、樣式沙箱
     * 
     * 沙箱返回 window 的代理物件 proxy 和 mount、unmount 兩個方法
     * unmount 方法會讓微應用失活,恢復被增強的原生方法,並記錄一堆 rebuild 函式,這個函式是微應用解除安裝時希望自己被重新掛載時要做的一些事情,比如動態樣式表重建(解除安裝時會快取)
     * mount 方法會執行一些一些 patch 動作,恢復原生方法的增強功能,並執行 rebuild 函式,將微應用恢復到解除安裝時的狀態,當然從初始化狀態進入掛載狀態就沒有恢復一說了
     */
    const sandboxInstance = createSandbox(
      appName,
      containerGetter,
      Boolean(singular),
      enableScopedCSS,
      excludeAssetFilter,
    );
    // 用沙箱的代理物件作為接下來使用的全域性物件
    global = sandboxInstance.proxy as typeof window;
    mountSandbox = sandboxInstance.mount;
    unmountSandbox = sandboxInstance.unmount;
  }

  // 合併使用者傳遞的生命週期物件和 qiankun 框架內建的生命週期物件
  const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } = mergeWith(
    {},
    // 返回內建生命週期物件,global.__POWERED_BY_QIANKUN__ 和 global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ 的設定就是在內建的生命週期物件中設定的
    getAddOns(global, assetPublicPath),
    lifeCycles,
    (v1, v2) => concat(v1 ?? [], v2 ?? []),
  );

  await execHooksChain(toArray(beforeLoad), app, global);

  // get the lifecycle hooks from module exports,獲取微應用暴露出來的生命週期函式
  const scriptExports: any = await execScripts(global, !singular);
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports, appName, global);

  // 給微應用註冊通訊方法並返回通訊方法,然後會將通訊方法通過 props 注入到微應用
  const {
    onGlobalStateChange,
    setGlobalState,
    offGlobalStateChange,
  }: Record<string, Function> = getMicroAppStateActions(appInstanceId);

  const parcelConfig: ParcelConfigObject = {
    name: appInstanceId,
    bootstrap,
    // 掛載階段需要執行的一系列方法
    mount: [
      // 效能度量,不用管
      async () => {
        if (process.env.NODE_ENV === 'development') {
          const marks = performance.getEntriesByName(markName, 'mark');
          // mark length is zero means the app is remounting
          if (!marks.length) {
            performanceMark(markName);
          }
        }
      },
      // 單例模式需要等微應用解除安裝完成以後才能執行掛載任務,promise 會在微應用解除安裝完以後 resolve
      async () => {
        if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
          return prevAppUnmountedDeferred.promise;
        }

        return undefined;
      },
      // 新增 mount hook, 確保每次應用載入前容器 dom 結構已經設定完畢
      async () => {
        // element would be destroyed after unmounted, we need to recreate it if it not exist
        // unmount 階段會置空,這裡重新生成
        element = element || createElement(appContent, strictStyleIsolation);
        // 渲染微應用到容器節點,並顯示 loading 狀態
        render({ element, loading: true }, 'mounting');
      },
      // 執行時沙箱匯出的 mount
      mountSandbox,
      // exec the chain after rendering to keep the behavior with beforeLoad
      async () => execHooksChain(toArray(beforeMount), app, global),
      // 向微應用的 mount 生命週期函式傳遞引數,比如微應用中使用的 props.onGlobalStateChange 方法
      async props => mount({ ...props, container: containerGetter(), setGlobalState, onGlobalStateChange }),
      // 應用 mount 完成後結束 loading
      async () => render({ element, loading: false }, 'mounted'),
      async () => execHooksChain(toArray(afterMount), app, global),
      // initialize the unmount defer after app mounted and resolve the defer after it unmounted
      // 微應用掛載完成以後初始化這個 promise,並且在微應用解除安裝以後 resolve 這個 promise
      async () => {
        if (await validateSingularMode(singular, app)) {
          prevAppUnmountedDeferred = new Deferred<void>();
        }
      },
      // 效能度量,不用管
      async () => {
        if (process.env.NODE_ENV === 'development') {
          const measureName = `[qiankun] App ${appInstanceId} Loading Consuming`;
          performanceMeasure(measureName, markName);
        }
      },
    ],
    // 解除安裝微應用
    unmount: [
      async () => execHooksChain(toArray(beforeUnmount), app, global),
      // 執行微應用的 unmount 生命週期函式
      async props => unmount({ ...props, container: containerGetter() }),
      // 沙箱匯出的 unmount 方法
      unmountSandbox,
      async () => execHooksChain(toArray(afterUnmount), app, global),
      // 顯示 loading 狀態、移除微應用的狀態監聽、置空 element
      async () => {
        render({ element: null, loading: false }, 'unmounted');
        offGlobalStateChange(appInstanceId);
        // for gc
        element = null;
      },
      // 微應用解除安裝以後 resolve 這個 promise,框架就可以進行後續的工作,比如載入或者掛載其它微應用
      async () => {
        if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
          prevAppUnmountedDeferred.resolve();
        }
      },
    ],
  };

  // 微應用有可能定義 update 方法
  if (typeof update === 'function') {
    parcelConfig.update = update;
  }

  return parcelConfig;
}

樣式隔離

qiankun 的樣式隔離有兩種方式,一種是嚴格樣式隔離,通過 shadow dom 來實現,另一種是實驗性的樣式隔離,就是 scoped css,兩種方式不可共存

嚴格樣式隔離

qiankun 中的嚴格樣式隔離,就是在這個 createElement 方法中做的,通過 shadow dom 來實現, shadow dom 是瀏覽器原生提供的一種能力,在過去的很長一段時間裡,瀏覽器用它來封裝一些元素的內部結構。以一個有著預設播放控制按鈕的 <video> 元素為例,實際上,在它的 Shadow DOM 中,包含來一系列的按鈕和其他控制器。Shadow DOM 標準允許你為你自己的元素(custom element)維護一組 Shadow DOM。具體內容可檢視 shadow DOM

/**
 * 做了兩件事
 *  1、將 appContent 由字串模版轉換成 html dom 元素
 *  2、如果需要開啟嚴格樣式隔離,則將 appContent 的子元素即微應用的入口模版用 shadow dom 包裹起來,達到樣式嚴格隔離的目的
 * @param appContent = `<div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>`
 * @param strictStyleIsolation 是否開啟嚴格樣式隔離
 */
function createElement(appContent: string, strictStyleIsolation: boolean): HTMLElement {
  // 建立一個 div 元素
  const containerElement = document.createElement('div');
  // 將字串模版 appContent 設定為 div 的子與阿蘇
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div,appContent 由模版字串變成了 DOM 元素
  const appElement = containerElement.firstChild as HTMLElement;
  // 如果開啟了嚴格的樣式隔離,則將 appContent 的子元素(微應用的入口模版)用 shadow dom 包裹,以達到微應用之間樣式嚴格隔離的目的
  if (strictStyleIsolation) {
    if (!supportShadowDOM) {
      console.warn(
        '[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
      );
    } else {
      const { innerHTML } = appElement;
      appElement.innerHTML = '';
      let shadow: ShadowRoot;

      if (appElement.attachShadow) {
        shadow = appElement.attachShadow({ mode: 'open' });
      } else {
        // createShadowRoot was proposed in initial spec, which has then been deprecated
        shadow = (appElement as any).createShadowRoot();
      }
      shadow.innerHTML = innerHTML;
    }
  }

  return appElement;
}
實驗性樣式隔離

實驗性樣式的隔離方式其實就是 scoped cssqiankun 會通過動態改寫一個特殊的選擇器約束來限制 css 的生效範圍,應用的樣式會按照如下模式改寫:

// 假設應用名是 react16
.app-main {
  font-size: 14px;
}
div[data-qiankun-react16] .app-main {
  font-size: 14px;
}
process
/**
 * 做了兩件事:
 *  例項化 processor = new ScopedCss(),真正處理樣式選擇器的地方
 *  生成樣式字首 `div[data-qiankun]=${appName}`
 * @param appWrapper = <div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>
 * @param stylesheetElement = <style>xx</style>
 * @param appName 微應用名稱
 */
export const process = (
  appWrapper: HTMLElement,
  stylesheetElement: HTMLStyleElement | HTMLLinkElement,
  appName: string,
) => {
  // lazy singleton pattern,單例模式
  if (!processor) {
    processor = new ScopedCSS();
  }

  // 目前支援 style 標籤
  if (stylesheetElement.tagName === 'LINK') {
    console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.');
  }

  // 微應用模版
  const mountDOM = appWrapper;
  if (!mountDOM) {
    return;
  }

  // div
  const tag = (mountDOM.tagName || '').toLowerCase();

  if (tag && stylesheetElement.tagName === 'STYLE') {
    // 生成字首 `div[data-qiankun]=${appName}`
    const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
     /**
     * 實際處理樣式的地方
     * 拿到樣式節點中的所有樣式規則,然後重寫樣式選擇器
     *  含有根元素選擇器的情況:用字首替換掉選擇器中的根元素選擇器部分,
     *  普通選擇器:將字首插到第一個選擇器的後面
     */
    processor.process(stylesheetElement, prefix);
  }
}

export const QiankunCSSRewriteAttr = 'data-qiankun';
ScopedCSS
// https://developer.mozilla.org/en-US/docs/Web/API/CSSRule
enum RuleType {
  // type: rule will be rewrote
  STYLE = 1,
  MEDIA = 4,
  SUPPORTS = 12,

  // type: value will be kept
  IMPORT = 3,
  FONT_FACE = 5,
  PAGE = 6,
  KEYFRAMES = 7,
  KEYFRAME = 8,
}

const arrayify = <T>(list: CSSRuleList | any[]) => {
  return [].slice.call(list, 0) as T[];
};

export class ScopedCSS {
  private static ModifiedTag = 'Symbol(style-modified-qiankun)';

  private sheet: StyleSheet;

  private swapNode: HTMLStyleElement;

  constructor() {
    const styleNode = document.createElement('style');
    document.body.appendChild(styleNode);

    this.swapNode = styleNode;
    this.sheet = styleNode.sheet!;
    this.sheet.disabled = true;
  }

  /**
   * 拿到樣式節點中的所有樣式規則,然後重寫樣式選擇器
   *  含有根元素選擇器的情況:用字首替換掉選擇器中的根元素選擇器部分,
   *  普通選擇器:將字首插到第一個選擇器的後面
   * 
   * 如果發現一個樣式節點為空,則該節點的樣式內容可能會被動態插入,qiankun 監控了該動態插入的樣式,並做了同樣的處理
   * 
   * @param styleNode 樣式節點
   * @param prefix 字首 `div[data-qiankun]=${appName}`
   */
  process(styleNode: HTMLStyleElement, prefix: string = '') {
    // 樣式節點不為空,即 <style>xx</style>
    if (styleNode.textContent !== '') {
      // 建立一個文字節點,內容為 style 節點內的樣式內容
      const textNode = document.createTextNode(styleNode.textContent || '');
      // swapNode 是 ScopedCss 類例項化時建立的一個空 style 節點,將樣式內容新增到這個節點下
      this.swapNode.appendChild(textNode);
      /**
       * {
       *  cssRules: CSSRuleList {0: CSSStyleRule, 1: CSSStyleRule, 2: CSSStyleRule, 3: CSSStyleRule, length: 4}
       *  disabled: false
       *  href: null
       *  media: MediaList {length: 0, mediaText: ""}
       *  ownerNode: style
       *  ownerRule: null
       *  parentStyleSheet: null
       *  rules: CSSRuleList {0: CSSStyleRule, 1: CSSStyleRule, 2: CSSStyleRule, 3: CSSStyleRule, length: 4}
       *  title: null
       *  type: "text/css"
       * }
       */
      const sheet = this.swapNode.sheet as any; // type is missing
      /**
       * 得到所有的樣式規則,比如
       * [
       *  {selectorText: "body", style: CSSStyleDeclaration, styleMap: StylePropertyMap, type: 1, cssText: "body { background: rgb(255, 255, 255); margin: 0px; }", …}
       *  {selectorText: "#oneGoogleBar", style: CSSStyleDeclaration, styleMap: StylePropertyMap, type: 1, cssText: "#oneGoogleBar { height: 56px; }", …}
       *  {selectorText: "#backgroundImage", style: CSSStyleDeclaration, styleMap: StylePropertyMap, type: 1, cssText: "#backgroundImage { border: none; height: 100%; poi…xed; top: 0px; visibility: hidden; width: 100%; }", …}
       *  {selectorText: "[show-background-image] #backgroundImage {xx}"
       * ]
       */
      const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
      /**
       * 重寫樣式選擇器
       *  含有根元素選擇器的情況:用字首替換掉選擇器中的根元素選擇器部分,
       *  普通選擇器:將字首插到第一個選擇器的後面
       */
      const css = this.rewrite(rules, prefix);
      // 用重寫後的樣式替換原來的樣式
      // eslint-disable-next-line no-param-reassign
      styleNode.textContent = css;

      // cleanup
      this.swapNode.removeChild(textNode);
      return;
    }

    /**
     * 
     * 走到這裡說明樣式節點為空
     */

    // 建立並返回一個新的 MutationObserver 它會在指定的DOM發生變化時被呼叫
    const mutator = new MutationObserver(mutations => {
      for (let i = 0; i < mutations.length; i += 1) {
        const mutation = mutations[i];

        // 表示該節點已經被 qiankun 處理過,後面就不會再被重複處理
        if (ScopedCSS.ModifiedTag in styleNode) {
          return;
        }

        // 如果是子節點列表發生變化
        if (mutation.type === 'childList') {
          // 拿到 styleNode 下的所有樣式規則,並重寫其樣式選擇器,然後用重寫後的樣式替換原有樣式
          const sheet = styleNode.sheet as any;
          const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
          const css = this.rewrite(rules, prefix);

          // eslint-disable-next-line no-param-reassign
          styleNode.textContent = css;
          // 給 styleNode 新增一個 ScopedCss.ModifiedTag 屬性,表示已經被 qiankun 處理過,後面就不會再被處理了
          // eslint-disable-next-line no-param-reassign
          (styleNode as any)[ScopedCSS.ModifiedTag] = true;
        }
      }
    });

    // since observer will be deleted when node be removed
    // we dont need create a cleanup function manually
    // see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/disconnect
    // 觀察 styleNode 節點,當其子節點發生變化時呼叫 callback 即 例項化時傳遞的函式
    mutator.observe(styleNode, { childList: true });
  }

  /**
   * 重寫樣式選擇器,都是在 ruleStyle 中處理的:
   *  含有根元素選擇器的情況:用字首替換掉選擇器中的根元素選擇器部分,
   *  普通選擇器:將字首插到第一個選擇器的後面
   * 
   * @param rules 樣式規則
   * @param prefix 字首 `div[data-qiankun]=${appName}`
   */
  private rewrite(rules: CSSRule[], prefix: string = '') {
    let css = '';

    rules.forEach(rule => {
      // 幾種型別的樣式規則,所有型別檢視 https://developer.mozilla.org/zh-CN/docs/Web/API/CSSRule#%E7%B1%BB%E5%9E%8B%E5%B8%B8%E9%87%8F
      switch (rule.type) {
        // 最常見的 selector { prop: val }
        case RuleType.STYLE:
          /**
           * 含有根元素選擇器的情況:用字首替換掉選擇器中的根元素選擇器部分,
           * 普通選擇器:將字首插到第一個選擇器的後面
           */
          css += this.ruleStyle(rule as CSSStyleRule, prefix);
          break;
        // 媒體 @media screen and (max-width: 300px) { prop: val }
        case RuleType.MEDIA:
          // 拿到其中的具體樣式規則,然後呼叫 rewrite 通過 ruleStyle 去處理
          css += this.ruleMedia(rule as CSSMediaRule, prefix);
          break;
        // @supports (display: grid) {}
        case RuleType.SUPPORTS:
          // 拿到其中的具體樣式規則,然後呼叫 rewrite 通過 ruleStyle 去處理
          css += this.ruleSupport(rule as CSSSupportsRule, prefix);
          break;
        // 其它,直接返回樣式內容
        default:
          css += `${rule.cssText}`;
          break;
      }
    });

    return css;
  }

  /**
   * 普通的根選擇器用字首代替
   * 根組合選擇器置空,忽略非標準形式的兄弟選擇器,比如 html + body {...}
   * 針對普通選擇器則是在第一個選擇器後面插入字首,比如 .xx 變成 .xxprefix
   * 
   * 總結就是:
   *  含有根元素選擇器的情況:用字首替換掉選擇器中的根元素選擇器部分,
   *  普通選擇器:將字首插到第一個選擇器的後面
   * 
   * handle case:
   * .app-main {}
   * html, body {}
   * 
   * @param rule 比如:.app-main {} 或者 html, body {}
   * @param prefix `div[data-qiankun]=${appName}`
   */
  // eslint-disable-next-line class-methods-use-this
  private ruleStyle(rule: CSSStyleRule, prefix: string) {
    // 根選擇,比如 html、body、:root
    const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
    // 根組合選擇器,比如 html body {...} 、 html > body {...}
    const rootCombinationRE = /(html[^\w{[]+)/gm;

    // 選擇器
    const selector = rule.selectorText.trim();

    // 樣式文字
    let { cssText } = rule;

    // 如果選擇器為根選擇器,則直接用字首將根選擇器替換掉
    // handle html { ... }
    // handle body { ... }
    // handle :root { ... }
    if (selector === 'html' || selector === 'body' || selector === ':root') {
      return cssText.replace(rootSelectorRE, prefix);
    }

    // 根組合選擇器
    // handle html body { ... }
    // handle html > body { ... }
    if (rootCombinationRE.test(rule.selectorText)) {
      // 兄弟選擇器 html + body,非標準選擇器,無效,轉換時忽略
      const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;

      // since html + body is a non-standard rule for html
      // transformer will ignore it
      if (!siblingSelectorRE.test(rule.selectorText)) {
        // 說明時 html + body 這種非標準形式,則將根組合器置空
        cssText = cssText.replace(rootCombinationRE, '');
      }
    }

    // 其它一般選擇器,比如 類選擇器、id 選擇器、元素選擇器、組合選擇器等
    // handle grouping selector, a,span,p,div { ... }
    cssText = cssText.replace(/^[\s\S]+{/, selectors =>
      // item 是匹配的字串,p 是第一個分組匹配的內容,s 是第二個分組匹配的內容
      selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
        // handle div,body,span { ... }
        if (rootSelectorRE.test(item)) {
          // 說明選擇器中含有根元素選擇器
          return item.replace(rootSelectorRE, m => {
            // do not discard valid previous character, such as body,html or *:not(:root)
            const whitePrevChars = [',', '('];

            // 將其中的根元素替換為字首
            if (m && whitePrevChars.includes(m[0])) {
              return `${m[0]}${prefix}`;
            }

            // replace root selector with prefix
            return prefix;
          });
        }

        // selector1 selector2 =》 selector1prefix selector2
        return `${p}${prefix} ${s.replace(/^ */, '')}`;
      }),
    );

    return cssText;
  }

  // 拿到其中的具體樣式規則,然後呼叫 rewrite 通過 ruleStyle 去處理
  // handle case:
  // @media screen and (max-width: 300px) {}
  private ruleMedia(rule: CSSMediaRule, prefix: string) {
    const css = this.rewrite(arrayify(rule.cssRules), prefix);
    return `@media ${rule.conditionText} {${css}}`;
  }

  // 拿到其中的具體樣式規則,然後呼叫 rewrite 通過 ruleStyle 去處理
  // handle case:
  // @supports (display: grid) {}
  private ruleSupport(rule: CSSSupportsRule, prefix: string) {
    const css = this.rewrite(arrayify(rule.cssRules), prefix);
    return `@supports ${rule.conditionText} {${css}}`;
  }
}

結語

以上內容就是對 qiankun 框架的完整解讀了,相信你在閱讀完這篇文章以後會有不錯的收穫,原始碼在 github

閱讀 qiankun 時的感受就是 書讀百變其義自現,qiankun 框架有些地方實現還是比較難理解的,相信大家閱讀原始碼時也會有這個感受,那就多讀幾遍吧,當然也可以來評論區交流,共同學習,共同進步!!

連結

感謝各位的:點贊收藏評論,我們下期見。


當學習成為了習慣,知識也就變成了常識,掃碼關注微信公眾號,共同學習、進步。文章已收錄到 github,歡迎 Watch 和 Star。

微信公眾號

相關文章