qiankun 找不到入口問題徹底解決

前言

嗨害嗨,好久不見,我是海怪。

有一陣子沒寫文章瞭,今天來更一期關於 qiankun 找不到生命周期的問題。

剛開始給項目接入 qiankun 的時候,時不時就會報

Application died in status LOADING_SOURCE_CODE: You need to export the functional lifecycles in xxx entry:

開發的時候一切正常,隻有在打包發佈後才會報這個 Bug,讓人非常惱火。相信有不少同學也遇到過這個問題,今天就來分享一下這個問題的思考和解決方案吧。

為什麼要找生命周期

首先,我們要知道為什麼 qiankun 加載微應用時要找生命周期鉤子。

早在 qiankun 出來前,已經有一個微前端框架 single-spa 瞭。

它的思想是:無論 React、Vue 還是 Angular,項目打包最終的產物都是 JS。如果在 合適的時機 以 某種執行方式 去執行微應用的 JS 代碼,大概就能實現 主-微 結構的微前端開發瞭。

這裡有兩個關鍵詞:合適的時機 和 執行方式。對於前者,single-spa 參考瞭單頁應用(Single Page Application)的思路,也希望用生命周期來管理微應用的 bootstrap, mount, update, unmount。而對於後者,則需要開發者自己實現執行微應用 JS 的方式。

總的來說,開發者需要在微應用的入口文件 main.js 裡寫好生命周期實現:

export async function bootstrap() {
  // 啟動微應用
}
export async function mount() {
  // 加載微應用
}
export async function unmount() {
  // 卸載微應用
}
export async function update() {
  // 更新微應用
}

single-spa 會自動劫持和監聽網頁地址 URL 的變化,在命中路由規則後,執行這些生命周期鉤子,從而實現微應用的加載、卸載和更新。

但這就有一個嚴重的問題瞭:一般我們項目的入口文件就隻有:

React.render(<App/>, document.querySelector('#root'))

這要如何和主應用交互呢?而且裡面的樣式、全局變量隔離又要怎麼實現呢?Webpack 又該如何改造呢?然而,single-spa 隻提供瞭生命周期的調度,並沒有解決這一系列問題。

既然前人解決不瞭,後人則可以基於原有框架繼續優化,這就是 qiankun。

qiankun 和 single-spa 最大的不同是:qiankun 是 HTML 入口。它的原理如圖所示:

可以看到 qiankun 自己實現瞭一套通過 HTML 地址加載微應用的機制,但對於 “要在什麼時候執行 JS” 依然用瞭 single-spa 的生命周期調度能力。

這就是為什麼微應用的入口文件 main.js 依然需要提供 single-spa 的生命周期回調。

如何找入口

現在我們來聊聊如何找入口的問題。

對於一個簡單的 SPA 項目來說,一個 <div id="app"></div> + 一個 main.js 就夠瞭,入口很好找。

但真實項目往往會做分包拆包、自動註入 <script> 腳本等操作,使得最終訪問的 HTML 會有多個 <script> 標簽:

<script>
  // 初始化 XX SDK
</script>
<body>
  ...
</body>
<script src="你真實的入口%20main.js"></script>
<script src="ant-design.js"></script>
<script>
  // 打包後自動註入的靜態資源 retry 邏輯
</script>
<script>
  // 公司代碼網關自動註入的 JS 邏輯
</script>

對於這樣復雜的情況,qiankun 提供瞭 2 種定位入口的方式:

  • 找 帶有 entry 屬性的 <script entry src="main.js"></script>
  • 如果找不到,那麼把 最後一個 <script> 作為入口

第一種方法是最穩妥的,可以使用 html-webpack-inject-attributes-plugin 這個 Webpack 插件,在打包的時候就給入口 main.js 添加 entry 屬性:

plugins = [
    new HtmlWebpackPlugin(),
    new htmlWebpackInjectAttributesPlugin({
        entry: "true",
    })
]

不推薦大傢使用最後一種方法來確定入口,這種方式很不可靠。 因為微應用 HTML 有可能在一些公司代理、網關層中被攔截,自動註入一些腳本。

這樣最終拿到 HTML 裡最後的一個 <script> 就不是原先的入口 main.js 文件瞭:

<script src="你真實的入口%20main.js"></script>
<script>
  // 自動註入的網關層的代理邏輯
</script>

兜底找入口

上面兩種找入口方式並不能 100% 覆蓋所有情況,比如我就遇到過這樣的場景:

  • 腳手架封裝得太黑盒,導致添加插件不生效,無法在打包時註入 entry 屬性
  • 測試環境中,代理工具會自動往 HTML 插入 <script>,無法將最後一個 JS 作為入口

這下 qiankun 徹底找不到我的入口瞭。你總不能說:手寫一個 JS 腳本,然後每次打包後用正則去 replace HTML,以此來添加 entry 屬性吧???

當然不行!

曾經我在 qiankun 的文檔裡看到過這段配置:

module.exports = {
  webpack: (config) => {
    config.output.library = `microApp`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    config.output.globalObject = 'window';
    return config;
  },
  ...
};

文檔裡說這是一個兜底找入口的邏輯:

但文檔沒有說這裡的細節,下面就來一起研究一下。

微應用的 Webpack 配置

libraryTarget 指定打包成 umd 格式,也即最終模塊會兼容 CommonJS 和 AMD 等多種格式來進行導出,最終 main.js 會是這樣:

(function webpackUniversalModuleDefinition(root, factory) {
  // CommonJS 導出
  if (typeof exports === 'object' && typeof module === 'object')
    module.exports = factory(require('lodash'));
  // AMD 導出
  else if (typeof define === 'function' && define.amd)
    define(['lodash'], factory);
  // 另一種導出
  else if (typeof exports === 'object')
    exports['microApp'] = factory(require('lodash'));
  // 關鍵點
  else root['microApp'] = factory(root['_']);
})(this, function (__WEBPACK_EXTERNAL_MODULE_1__) {
  // 入口文件的內容
  // ...
  return {
    bootstrap() {},
    mount() {},
    // ...
  }
});

直接看最後一種導出方式 root['microApp'] = factory(root['_'])。Webpack 配置的 globalObjectlibrary 正好對應瞭裡面的 root 以及 'microApp'

而且上面的函數 factory 則是入口文件的執行函數,理論上當執行 factory() 後會返回模塊的輸出。

最終的效果是:Webpack 會把入口文件的輸出內容掛在到 globalObject[library]/window['microApp'] 上:

window['microApp'] = {
  // main.js 所 export 的內容
  bootstrap() {},
  mount() {},
  unmount() {},
  update() {},
  // ...
}

主應用的兜底邏輯

把入口的內容掛載到 window 上有什麼好處呢?我們來稍微看點源碼:

// 發 Http 請求獲取 HTML, JS 執行器
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// 執行微應用的 JS,但這裡不一定有入口
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox);
// 獲取入口導出的生命周期
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
  scriptExports,
  appName,
  global,
  sandboxContainer?.instance?.latestSetProp,
);

上面的代碼很簡單,就是獲取微應用 HTML 和 JS,試圖從裡面獲取生命周期,所以下面我們來看看 getLifecyclesFromExports 做瞭什麼:

function getLifecyclesFromExports(
  scriptExports: LifeCycles<any>,
  appName: string,
  global: WindowProxy,
  globalLatestSetProp?: PropertyKey | null,
) {
  // 如果在獲取微應用的 JS 時可以鎖定入口文件,那麼直接返回
  if (validateExportLifecycle(scriptExports)) {
    return scriptExports;
  }
  // 不用看
  if (globalLatestSetProp) {
    const lifecycles = (<any>global)[globalLatestSetProp];
    if (validateExportLifecycle(lifecycles)) {
      return lifecycles;
    }
  }
  // 獲取 globalObject[library] 裡的內容
  const globalVariableExports = (global as any)[appName];
  // 判斷 globalObject[library] 裡的內容是否為生命周期
  // 如果是合法生命周期,那麼直接返回
  if (validateExportLifecycle(globalVariableExports)) {
    return globalVariableExports;
  }
  throw new QiankunError(`You need to export lifecycle functions in ${appName} entry`);
}

從上面可以看到,在 getLifecyclesFromExports 最後會試圖從 windowProxy[微應用名] 中拿導出的生命周期。

這也是為什麼兜底找入口操作需要微應用配置 Webpack,同時主應用指定的微應用名要和 library 名要一樣。

註意:qiankun 會使用 JS 沙箱來隔離微應用的環境,所以這裡的 globalObject 並不是 window 而是微應用對應的沙箱對象 windowProxy

在微應用裡寫 console.log(window['microApp']) 或在主應用裡輸入 console.log(window.proxy['microApp']) 即可看到微應用導出的生命周期:

因此,在主應用中註冊微應用的時候,微應用 name 最好要和 Webpack 的 output.library 一致,這樣才能命中 qiankun 的兜底邏輯。

總結

最後總結一下,qiankun 要找入口是因為要從中拿到生命周期回調,把它們給 single-spa 做調度。

qiankun 支持 2 種找入口的方式:

  • 正則匹配 帶有 entry 屬性的 <script> ,找到就把這個 JS 作為入口
  • 當找不到時,默認把 最後一個 JS 作為入口

如果這兩種方法都無法幫你正確定位入口,那麼你需要:

  • 在微應用配置 library, libraryTarget 以及 globalObject,把入口導出的內容掛載到 window
  • 加載微應用時,主應用會試著從 window[library] 找微應用的生命周期回調,找到後依然能正常加載
  • 在主應用註冊微應用時,要把微應用的 name 和 Webpack 的 output.library 設為一致,這樣才能命中第二步的邏輯

最後還要註意的是,上面說到的 window 並不是全局對象,而是 qiankun 提供的 JS 沙箱對象 windowProxy

以上就是qiankun 找不到入口問題徹底解決的詳細內容,更多關於qiankun 找不到入口的資料請關註WalkonNet其它相關文章!

推薦閱讀: