【微前端】qiankun 到底是個什麼鬼

前言

在上一篇文章【微前端】single-spa 到底是個什麼鬼 聊到了 single-spa 這個框架僅僅實現了子應用的生命週期的調度以及 url 變化的監聽。微前端的一個特點都沒有實現,嚴格來說算不上微前端框架。

今天就來聊一個真正的微前端框架:qiankun。同樣地,本文不會教大家怎麼實現一個 Demo,因爲官方的 Github 已經有一個很好的 Demo 了,如果你覺得官網的 Demo 太複雜了,也可以看我自己實現的小 Demo

qiankun 到底做了什麼

首先,qiankun 並不是單一個框架,它在 single-spa 基礎上添加更多的功能。以下是 qiankun 提供的特性:

  • 實現了子應用的加載,在原有 single-spa 的 JS Entry 基礎上再提供了 HTML Entry
  • 樣式和 JS 隔離
  • 更多的生命週期:beforeMount, afterMount, beforeUnmount, afterUnmount
  • 子應用預加載
  • 全局狀態管理
  • 全局錯誤處理

接下來不會一個特性一個特性地講,因爲這樣會很無聊,講完你也只能知道這是個啥,不能深入瞭解是怎麼來的。所以我更願意聊一下這些特性是怎麼來的,它們是怎麼被想到的。

多入口

先複習一下 single-spa 是怎麼註冊子應用的:

singleSpa.registerApplication(
  'appName',
  () => System.import('appName'),
  location => location.pathname.startsWith('appName'),
);

可以看到 single-spa 採用 JS Entry 的方式接入微應用,也即:輸出一個 JS,然後 bootstrap, mount, unmount 函數。

但是事件並沒有這麼簡單:我們項目一般都會將靜態資源放到 CDN 上來加速。爲了不受緩存的影響,我們還會將 JS 文件命名成 contenthash 的亂碼文件名: jlkasjfdlkj.jalkjdsflk.js。這樣一來,每次子應用一發布,入口 JS 文件名肯定又要改了,導致主應用引入的 JS url 又得改了。麻煩!

打包成單個 JS 文件的另一個問題就是打包的優化都沒了:按需加載、首屏資源加載優化、css 獨立打包等優化措施全 🈚️。

很多時候,子應用一般都已經是線上的應用了,比如 https://abcd.com。微前端融合多個子應用本質上不就是融合多個 HTML 嘛?那爲什麼不給你子應用的 HTML,主應用就自動接入收工了呢?操作起來應該和在 <iframe/> 和插入 src 是一樣的纔對味。

這種通過提供 HTML 入口來接入子應用的方式就叫 HTML Entry。 qiankun 的一大亮點就是提供了 HTML Entry,在調用 qiankun 的註冊子應用函數時可以這麼寫:

registerMicroApps([
  {
    name: 'react app', // 子應用名
    entry: '//localhost:7100', // 子應用 html 或網址
    container: '#yourContainer', // 掛載容器選擇器
    activeRule: '/yourActiveRule', // 激活路由
  },
]);

start(); // Go

用起來毫不費力,只需要在 JS 入口加上 single-spa 的生命週期鉤子,再發布就可以直接接入了。

import-html-entry

然而,HTML Entry 並不是給個 HTML 的 url 就可以直接接入整個子應用這麼簡單了。子應用的 HTML 文件就是一堆亂七八糟的標籤文本。<link>, <style>, <script> 得處理吧?要寫正則表達式吧?頭要禿了吧?

所以 qiankun 的作者自己也寫了一個專門處理 HTML Entry 這種需求的 NPM 包:import-html-entry。用法如下:

import importHTML from 'import-html-entry';

importHTML('./subApp/index.html')
  .then(res => {
    console.log(res.template); // 拿到 HTML 模板

    res.execScripts().then(exports => { // 執行 JS 腳本
      const mobx = exports; // 獲取 JS 的輸出內容
      // 下面就是拿到 JS 入口的內容,並用來做一些事
      const { observable } = mobx;
      observable({
        name: 'kuitos'
      })    
    })
});

當然,qiankun 已經將 import-html-entry 與子應用加載函數完美地結合起來,大家只需要知道這個庫是用來獲取 HTML 模板內容,Style 樣式和 JS 腳本內容就可以了。

有了上面的瞭解後,相信大家對於如何加載子應用就有思路了,僞代碼如下:

// 解析 HTML,獲取 html,js,css 文本
const {htmlText, jsText, cssText} = importHTMLEntry('https://xxxx.com')

// 創建容器
const $= document.querySelector(container)
$container.innerHTML = htmlText

// 創建 style 和 js 標籤
const $style = createElement('style', cssText)
const $script = createElement('script', jsText)

$container.appendChild([$style, $script])

在第三步,我們不禁有個疑問:當前這個應用完美地插入了 style 和 script 標籤,那下一個應用 mount 時就會被前面的 style 和 script 污染了呀。

爲了解決這兩個問題,不得不做好應用之間的樣式和 JS 的隔離。

樣式隔離

qiankun 實現 single-spa 推薦的兩種樣式隔離方案:ShadowDOM 和 Scoped CSS。

先來說說 ShadowDOM,qiankun 的源碼實現也很簡單,只是添加一個 Shadow DOM 節點,僞代碼如下:

  if (strictStyleIsolation) {
    if (!supportShadowDOM) {
      // 報錯
      // ...
    } else {
      // 清除原有的內容
      const { innerHTML } = appElement;
      appElement.innerHTML = '';

      let shadow: ShadowRoot;

      if (appElement.attachShadow) {
        // 添加 shadow DOM 節點
        shadow = appElement.attachShadow({ mode: 'open' });
      } else {
        // deprecated 的操作
        // ...
      }
      // 在 shadow DOM 節點添加內容
      shadow.innerHTML = innerHTML;
    }
  }

通過 Shadow DOM 的天然的隔離特性來實現子應用間的樣式隔離。

另一個方案就是 Scoped CSS 了,說白了就是通過修改 CSS 選擇器來實現子應用間的樣式隔離。 比如,你有這樣的 CSS 代碼:

.container {
  background: red;
}

div {
  color: red;
}

qiankun 會掃描給定的 CSS 文本,通過正則匹配在選擇器前加上子應用的名字,如果遇到元素選擇器,就加一個爸爸類名給它,比如:

.subApp.container {
  background: red;
}

.subApp div {
  color: red;
}

JS 隔離

第一步要隔離的是對全局對象 window 上的變量進行隔離。不能 A 子應用 window.setTimeout = undefined 之後, B 子應用用 setTimeout 的時候就涼了。

所以 JS 隔離深一層本質就是記錄當前 window 對象以前的值,在 A 子應用進來時一頓亂搞之後,要將所有值都恢復過來(恢復現場)。這就是 SnapshotSandbox 的做法,僞代碼如下:

class SnapshotSandbox {
  ...

  active() {
    // 記錄當前快照
    this.windowSnapshot = {} as Window;
    getKeys(window).forEach(key => {
      this.windowSnapshot[key] = window[key];
    })

    // 恢復之前的變更
    getKeys(this.modifyPropsMap).forEach((key) => {
      window[key] = this.modifyPropsMap[key];
    });

    this.sandboxRunning = true;
  }

  inactive() {
    this.modifyPropsMap = {};

    // 記錄變更,恢復環境
    getKeys(window).forEach((key) => {
      if (window[key] !== this.windowSnapshot[key]) {
        this.modifyPropsMap[key] = window[key];
        window[key] = this.windowSnapshot[key];
      }
    });

    this.sandboxRunning = false;
  }
}

除了 SnapShotSandbox,qiankun 還提供了一種使用 ES 6 Proxy 實現的沙箱:

class SingularProxySandbox {
  /** 沙箱期間新增的全局變量 */
  private addedPropsMapInSandbox = new Map<PropertyKey, any>();

  /** 沙箱期間更新的全局變量 */
  private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();

  /** 持續記錄更新的(新增和修改的)全局變量的 map,用於在任意時刻做 snapshot */
  private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

  active() {
    if (!this.sandboxRunning) {
      // 恢復子應用修改過的值
      this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

  inactive() {
    // 恢復加載子應用前的 window 值
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
    // 刪掉子應用期間新加的 window 值 
    this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));

    this.sandboxRunning = false;
  }

  constructor(name: string) {
    this.name = name;
    this.type = SandBoxType.LegacyProxy;
    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;

    const rawWindow = window;
    const fakeWindow = Object.create(null) as Window;

    const proxy = new Proxy(fakeWindow, {
      set: (_: Window, key: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          if (!rawWindow[key]) {
            addedPropsMapInSandbox.set(key, value); // 將沙箱期間新加的值記錄下來
          } else if (!modifiedPropsOriginalValueMapInSandbox.has(key)) {
            modifiedPropsOriginalValueMapInSandbox.set(key, rawWindow[key]); // 記錄沙箱前的值
          }

          currentUpdatedPropsValueMap.set(key, value); // 記錄沙箱後的值

          // 必須重新設置 window 對象保證下次 get 時能拿到已更新的數據
          (rawWindow as any)[key] = value;
        }
      },

      get(_: Window, key: PropertyKey): any {
        return rawWindow[key]
      },
    }
  }
}

兩者差不太多,那怎麼不直接用 Proxy 高級方案呢,因爲在一些低版本的瀏覽器下是沒有 Proxy 對象的,所以 SnapshotSandbox 其實是 SingularProxySandbox 的降級方案。

然而,問題還是沒有解決完。上面這種情況僅適用於一個頁面只有一個子應用的情況,這種情況也被稱爲單例(singular mode)。 如果一個頁面有多個子應用那一個 SingluarProxySandbox 明顯不夠的。爲了解決這個問題,qiankun 提供了 ProxySandbox,僞代碼如下:

class ProxySandbox {
  ...

  active() { // +1 廢話
    if (!this.sandboxRunning) activeSandboxCount++;
    this.sandboxRunning = true;
  }

  inactive() { // -1 廢話
    if (--activeSandboxCount === 0) {
      variableWhiteList.forEach((p) => {
        if (this.proxy.hasOwnProperty(p)) {
          delete window[p]; // 刪除白名單裏子應用添加的值
        }
      });
    }

    this.sandboxRunning = false;
  }

  constructor(name: string) {
    ...
    const rawWindow = window; // 原 window 對象
    const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow); // 將真 window 上的 key-value 複製到假 window 對象上

    const proxy = new Proxy(fakeWindow, { // 代理複製出來的 window
      set: (target: FakeWindow, key: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          target[key] = value // 修改 fakeWindow 上的值

          if (variableWhiteList.indexOf(key) !== -1) {
            rawWindow[key] = value; // 白名單的話,修改真 window 上的值
          }

          updatedValueSet.add(p); // 記錄修改的值
        }
      },

      get(target: FakeWindow, key: PropertyKey): any {
        return target[key] || rawWindow[key] // 在 fakeWindow 上找,找不到從直 window 上找
      },
    }
  }
}

從上面可以看到,在 activeinactive 裏並沒有太多在恢復現場操作,因爲只要子應用 unmount,把 fakeWindow 一扔掉就完事了。

等等,說了這麼多上面還只是討論 window 對象的隔離呀,格局是不是小了點?是小了。

沙箱

現在我們再來審視一下沙箱這個玩意,其實無論沙箱也好 JS 隔離也好,最終要實現的是給子應用一個獨立的環境,這也意味着我們有成百上千的東西要做補丁來打造終極的類 <iframe> 硬隔離。

然而,qiankun 也不是萬能的,它只對某些重要的函數和監聽器進行打補丁。

其中最重要的補丁就是 insertBefore, appendChildremoveChild 的補丁了。

當我們加載子應用的時候,免不了遇到動態添加/移除 CSS 和 JS 腳本的情況。這時 <head><body> 都有可能調用 insertBefore, appendChild, removeChild 這三個函數來插入或者刪除 <style>, <link> 或者 <script> 元素。

所以,這三個函數在被 <head><body> 調用時,就要用上補丁,主要目的是別插入到主應用的 <head><body> 上,要插在子應用裏。打補丁僞代碼如下:

// patch(element)
switch (element.tagName) {
  case LINK_TAG_NAME:  // <link> 標籤
  case STYLE_TAG_NAME: { // <style> 標籤
    if (scopedCSS) { // 使用 Scoped CSS
      if (element.href;) { // 處理如 <link rel="icon" href="favicon.ico"> 的玩意
        stylesheetElement = convertLinkAsStyle( // 獲取 <link> 裏的 CSS 文本,並使用 css.process 添加前綴
          element,
          (styleElement) => css.process(mountDOM, styleElement, appName), // 添加前綴回調
          fetch,
        );
        dynamicLinkAttachedInlineStyleMap.set(element, stylesheetElement); // 緩存,下次加載沙箱時直接吐出來
      } else { // 處理如 <style>.container { background: red }</style> 的玩意
        css.process(mountDOM, stylesheetElement, appName);
      }
    }

    return rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode); // 插入到掛載容器上
  }

  case SCRIPT_TAG_NAME: {
    const { src, text } = element as HTMLScriptElement;

    if (element.src) { // 處理外鏈 JS
      execScripts(null, [src], proxy, { // 獲取並執行 JS
        fetch,
        strictGlobal,
      });

      return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode); // 插入到掛載容器上
    }

    // 處理內聯 JS
    execScripts(null, [`<script>${text}</script>`], proxy, { strictGlobal });
    return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
  }

  default:
    break;
}

當在創建沙箱時打完補丁後,在處理樣式和 JS 腳本時就可以針對當前子應用來應用樣式和 JS 了。上面我們還注意到 CSS 樣式文本是被保存的,所以當子應用 remount 的時候,這些樣式也可以作爲緩存直接一波補上,不需要再做處理了。

剩下的補丁都是給 historyListeners, setInterval, addEventListeners, removeEventListeners 做的補丁,無非就是 mount 時記錄 listeners 以及一些添加的值,在 unmount 的時候再一次性執行掉或者刪除掉,不再贅述。

更多的生命週期

如果當前項目遷移成子應用,在入口的 JS 就不得不配合 qiankun 來做一些改動,而這些改動有可能影響子應用的獨立運行。比如,接入了微前端後,可能就不得不在本地先起一個主應用,再起一個子應用,然後才能做開發和調試,那這也太蛋疼了。

爲了解決子應用也能獨立運行的問題,qiankun 注入了一些變量,來告訴子應用說:喂,你現在是兒子,要用子應用的渲染方式。而當子應用獲取不到這些注入的變量時,它就知道:哦,我現在要獨立運行了,用回原來的渲染方式就可以了,比如:

if (window. __POWERED_BY_QIANKUN__) {
  console.log('微前端場景')
  renderAsSubApp()
} else {
  console.log('單體場景')
  previousRenderApp()
}

怎麼注入就是個問題了,不能簡單的 window.__POWERED_BY_QIANKUN__ = true 就完事了,因爲子應用會在編譯時就要這個變量了。所以,qiankun 在 single-spa 提供的生命週期 load, mount, unmount 做了變量的注入,僞代碼如下:

// getAddOn
export default function getAddOn(global: Window): FrameworkLifeCycles<any> {
  return {
    async beforeLoad() {
      // eslint-disable-next-line no-param-reassign
      global.__POWERED_BY_QIANKUN__ = true;
    },

    async beforeMount() {
      // eslint-disable-next-line no-param-reassign
      global.__POWERED_BY_QIANKUN__ = true;
    },

    async beforeUnmount() {
      // eslint-disable-next-line no-param-reassign
      delete global.__POWERED_BY_QIANKUN__;
    },
  };
}

// loadApp
const addOnLifeCycles = getAddOn(window)

return {
  load: [addOnLifeCycles.beforeLoad, subApp.load],
  mount: [addOnLifeCycles.mount, subApp.mount],
  unmount: [addOnLifeCycles.unmount, subApp.unmount]
}

總結一下,新增的生命週期有:

  • beforeLoad
  • beforeMount
  • afterMount
  • beforeUnmount
  • afterUnmount

loadApp

好了,上面就是加載一個子應用的所有步驟了,這裏先做個小總結:

  • import-html-entry 解析 html,獲取 JavaScript, CSS, HTML
  • 創建容器 container,同時加上 css 樣式隔離:在 container 上添加 Shadow DOM 或者對 CSS 文本 添加前綴實現 Scoped CSS
  • 創建沙箱,監聽 window 的變化,並對一些函數打上補丁
  • 提供更多的生命週期,在 beforeXXX 裏注入一些 qiankun 提供的變量
  • 返回帶有 bootstrap, mount, unmount 屬性的對象

預加載

從上面可以看到加載一個子應用的時候需要很多的步驟,我們不禁想到:如果在 mount 第一個子應用空閒時候,可以預先加載別的子應用,那之後切換子應用就可以更快了,也即子應用預加載。

在空閒的時候幹一些事,可以使用瀏覽器提供的 requestIdleCallback。OK,那我們再來定義一下“預加載”是什麼,其實就是把 CSS 和 JS 下載下來就完事了,所以 qiankun 的源碼也是很簡單的:

requestIdleCallback(async () => {
  const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
  requestIdleCallback(getExternalStyleSheets);
  requestIdleCallback(getExternalScripts);
});

現在,我們再來腦洞大開一下:難道一下子就要所有子應用都要預加載麼?不見得吧?有可能一些子應用要預加載,一些不需要。

所以 qiankun 提供了三種預加載策略:

  • 全部子應用都立馬預加載
  • 全部子應用都在第一個子應用加載後才預加載
  • criticalAppNames 數組裏的子應用要立馬預加載,在 minorAppsName 數組裏的子應用在第一個子應用加載後才預加載

源碼實現如下:

export function doPrefetchStrategy(
  apps: AppMetadata[],
  prefetchStrategy: PrefetchStrategy,
  importEntryOpts?: ImportEntryOpts,
) {
  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 () => {
      // 一半一半
      const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
      prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
      prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
    })();
  } else {
    switch (prefetchStrategy) {
      case true: // 全部都在第一個子應用加載後才預加載
        prefetchAfterFirstMounted(apps, importEntryOpts);
        break;

      case 'all': // 全部子應用都立馬預加載
        prefetchImmediately(apps, importEntryOpts);
        break;

      default:
        break;
    }
  }
}

全局狀態管理

全局狀態很有可能出現在微前端的場景中,比如主應用提供可以一些初始化好的 SDK。剛開始先傳個未初始好的 SDK,等主應用把 SDK 初始化好了,再通過回調通知子應用:醒醒,SDK 準備好了。

這種思路和 Redux, Event Bus 一模一樣。 狀態都存在 window 的 gloablState 全局對象裏,再添加一個 onGlobalStateChange 回調就完事了,實現僞代碼如下:

let gloablState = {}
let deps = {}

// 觸發全局監聽
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));
    }
  });
}

// 添加全局狀態變化的監聽器
function onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
  deps[id] = callback;
  if (fireImmediately) {
    const cloneState = cloneDeep(globalState);
    callback(cloneState, cloneState);
  }
}

// 更新 globalState
function setGlobalState(state: Record<string, any> = {}) {
  const prevState = globalState
  globalState = {...cloneDeep(globalState), ...state}
  emitGlobal(globalState, prevState);
}

// 註銷該應用下的依賴
function offGlobalStateChange() {
  delete deps[id];
}

onGlobalStateChange 添加監聽器,當調用 setGlobalState 更新值,值改了,調用 emitGlobal,執行所有對應的監聽器。調用 offGlobalStateChange 刪掉監聽器。Easy ~

全局錯誤處理

主要監聽了 errorunhandledrejection 兩個錯誤事件:

export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
  window.addEventListener('error', errorHandler);
  window.addEventListener('unhandledrejection', errorHandler);
}

export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
  window.removeEventListener('error', errorHandler);
  window.removeEventListener('unhandledrejection', errorHandler);
}

使用的時候添加監聽器,不要的時候移除監聽器,不廢話。

總結

再次總結一下 qiankun 做了什麼事情:

  • 實現 loadApp 函數,是最關鍵、重要的一步
    • 實現 CSS 樣式隔離,主要有 Shadow DOM 和 Scoped CSS 兩種方案
    • 實現沙箱,JS 隔離,主要對 window 對象、各種 listeners 和方法進行隔離
    • 提供很多生命週期,並在一些 beforeXXX 的鉤子裏注入 qiankun 提供的變量
  • 提供預加載,提前下載 HTML、CSS、JS,並有三種策略
    • 全部立馬預加載
    • 全部在第一個加載後預加載
    • 一些立馬預加載,一些在第一個加載後預加載
  • 提供全局狀態管理,類似 Redux,Event Bus
  • 提供全局錯誤處理,主要監聽 error 和 unhandledrejection 兩個事件

最後

雖然阿里說:“可能是你見過最完善的微前端解決方案🧐”。但是從上面對源碼的解讀也可以看出來,qiankun 也有一些事情沒有做的。比如沒有對 localStorage 進行隔離,如果多個子應用都用到 localStorage 就有可能衝突了,除此之外,還有 cookie, indexedDB 的共享等。再比如如果單個頁面下多個子應用都依賴了前端路由怎麼辦呢?當然這裏的質疑也僅是我個人的猜想。

另一件事想說的是:微前端的難點並不是 single-spa 的生命週期、路由挾持。而是如何加載好一個子應用。從上面可以看到,有很多 hacky 的編碼,比如在選擇器前面加前綴,將子應用的 <link>, <script> 加載到子應用上,監聽 window 的變化,恢復現場等等,都是臺上一句話,臺下想禿頭的操作。如果不是真見過,估計想破頭都想不出來。

也正是這些 hacky 代碼,在搭建微前端的時候會遇到非常多的問題,而且微前端的目的是要將多個💩山聚合起來,所以微前端的解決方案是註定沒有銀彈的,且行且珍惜吧。

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