1. 程式人生 > 實用技巧 >微前端qiankun原理學習

微前端qiankun原理學習

前言

  qiankun框架的編寫基於兩個十分重要框架,一個是single-spa,另外一個是import-html-entry。在學習qiankun的原理之前,需要知道single-spa的原理,瞭解它是如何排程子應用,可以看看我上一篇文章。https://www.cnblogs.com/synY/p/13958963.html

 這裡重新提及一下,在上一篇我對於single-spa中提及了,single-spa幫住我們解決了子應用之間的排程問題,但是它留下了一個十分大的缺口。就是載入函式,下面官方文件的載入函式寫法的截圖:

官方文件中只是給出了一個簡單的寫法,但是光光靠這麼寫,是不夠的。為什麼不夠:

1.這樣寫,無法避免全域性變數window的汙染。

2.css之間也會存在汙染。

3.如果你有若干個子應用,你就要重複的去寫這句話若干次,程式碼難看無法維護。

那麼qiankun的出現,就是提供了一種方案幫住使用者解決了這些問題,讓使用者做到開箱即用。不需要思考過多的問題。

這篇文章我們關注幾個問題:

1. qiankun是如何完善single-spa中留下的巨大缺口,載入函式的缺口

2. qiankun通過什麼策略去載入子應用資源

3. qiankun如何隔離子應用的js的全域性環境

4. 沙箱的隔離原理是什麼

5. qiankun如何隔離css環境

6. qiankun如何獲得子應用生命週期函式

7. qiankun如何該改變子應用的window環境

同理qiankun我們也從兩個函式去入手qiankun,registerMicroApps和start函式。

registerMicroApps

下面是registerMicroApps程式碼:

export function registerMicroApps<T extends object = {}>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // Each app only needs to be registered once
  
//let microApps: RegistrableApp[] = [];

 //apps是本檔案定義的一個全域性陣列,裝著你在qiankun中註冊的子應用資訊。
 //microApps.some((registeredApp) => registeredApp.name === app.name));那麼這句話返回的就是false,取反就為true,
//然後把app的元素存入unregisteredApps中。所以其實整句話的含義就是在app上找出那些沒有被註冊的應用。其實就是變數名稱unregisteredApps的含義。
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
//這裡就把未註冊的應用和已經註冊的應用進行合併 microApps
= [...microApps, ...unregisteredApps]; unregisteredApps.forEach((app) => {
//解構出子應用的名字,啟用的url匹配規規則,實際上activeRule就是用在single-spa的activeWhen,loader是一個空函式它是loadsh裡面的東西,props傳入子應用的值。
const { name, activeRule, loader = noop, props, ...appConfig } = app; //這裡呼叫的是single-spa構建應用的api //name app activeRule props都是交給single-spa用的 registerApplication({ name,
//這裡可以看出我開始說的問題,qiankun幫主我們定製了一套載入子應用的方案。整個載入函式核心的邏輯就是loadApp
//最後返回出一個經過處理的裝載著生命週期函式的物件,和我上篇分析single-spa說到的載入函式的寫法的理解是一致的 app:
async () => { loader(true); await frameworkStartedDefer.promise; const { mount, ...otherMicroAppConfigs } = ( await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles) )(); return { mount: [async () => loader(true), ...toArray(mount), async () => loader(false)], ...otherMicroAppConfigs, }; }, activeWhen: activeRule, customProps: props, }); }); }

registerMicroApps其實只做了一件事情,根據使用者傳入的引數forEach遍歷子應用註冊陣列,呼叫single-spa的registerApplication方法去註冊子應用。

start函式

qiankun的start函式在single-spa的start函式的基礎上增加了一些東西

export function start(opts: FrameworkConfiguration = {}) {
//let frameworkConfiguration: FrameworkConfiguration = {};它是本檔案開頭的全域性變數記錄著,框架的配置。
frameworkConfiguration
= { prefetch: true, singular: true, sandbox: true, ...opts }; const { prefetch, sandbox, singular, urlRerouteOnly, ...importEntryOpts } = frameworkConfiguration; if (prefetch) { //子應用預載入的策略,自行在官方文件檢視作用 doPrefetchStrategy(microApps, prefetch, importEntryOpts); }
//檢查當前環境是否支援proxy。因為後面沙箱環境中需要用到這個東西
if (sandbox) { if (!window.Proxy) { console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox'); frameworkConfiguration.sandbox = typeof sandbox === 'object' ? { ...sandbox, loose: true } : { loose: true }; if (!singular) { console.warn( '[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy', ); } } }
//startSingleSpa是single-spa的start方法的別名。這裡本質就是執行single-spa的start方法啟動應用。 startSingleSpa({ urlRerouteOnly }); frameworkStartedDefer.resolve(); }

總結:qiankun的start方法做了兩件事情:

1.根據使用者傳入start的引數,判斷預載入資源的時機。

2.執行single-spa的start方法啟動應用。

載入函式

  從我上一篇對single-spa的分析知道了。在start啟動應用之後不久,就會進入到載入函式。準備載入子應用。下面看看qiankun載入函式的原始碼。

app: async () => {
        loader(true);
        await frameworkStartedDefer.promise;

        const { mount, ...otherMicroAppConfigs } = (
//這裡loadApp就是qiankun載入子應用的應對方案 await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles) )();
return { mount: [async () => loader(true), ...toArray(mount), async () => loader(false)], ...otherMicroAppConfigs, }; },

loadApp原始碼

export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {

//從app引數中解構出子應用的入口entry,和子應用的名稱。 const { entry, name: appName } = app;
//定義了子應用例項的id const appInstanceId
= `${appName}_${+new Date()}_${Math.floor(Math.random() * 1000)}`; const markName = `[qiankun] App ${appInstanceId} Loading`; if (process.env.NODE_ENV === 'development') {
//進行效能統計 performanceMark(markName); } const { singular
= false, sandbox = true, excludeAssetFilter, ...importEntryOpts } = configuration; //importEntry是import-html-entry庫中的方法,這裡就是qiankun對於載入子應用資源的策略 const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts); ...省略 }

下面看看importEntry的原始碼,它來自import-html-entry庫。

export function importEntry(entry, opts = {}) {
    //第一個引數entry是你子應用的入口地址
    //第二個引數{prefetch: true}

//defaultFetch是預設的資源請求方法,其實就是window.fecth。在qiankun的start函式中,可以允許你傳入自定義的fetch方法去請求資源。
//defaultGetTemplate是一個函式,傳入一個字串,原封不動的返回出來 const { fetch = defaultFetch, getTemplate = defaultGetTemplate } = opts;
//getPublicPath是一個函式,用來解析使用者entry,轉變為正確的格式,因為使用者可能寫入口地址寫得奇形怪狀,框架把不同的寫法統一一下。 const getPublicPath
= opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
//沒有寫子應用載入入口直接報錯
if (!entry) { throw new SyntaxError('entry should not be empty!'); } // html entry if (typeof entry === 'string') {
//載入程式碼核心函式
return importHTML(entry, { fetch, getPublicPath, getTemplate, }); } ...省略 }

importHTML原始碼。

export default function importHTML(url, opts = {}) {
    // 傳入引數
    //  entry, {
    //     fetch,
    //     getPublicPath,
    //     getTemplate,
    // }
    let fetch = defaultFetch;
    let getPublicPath = defaultGetPublicPath;
    let getTemplate = defaultGetTemplate;

    // compatible with the legacy importHTML api
    if (typeof opts === 'function') {
        fetch = opts;
    } else {
        fetch = opts.fetch || defaultFetch;
        getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
        getTemplate = opts.getTemplate || defaultGetTemplate;
    }

    //embedHTMCache是本檔案開頭定義的全域性物件,用來快取請求的資源的結果,下一次如果想要獲取資源直接從快取獲取,不需要再次請求。
  //如果在快取中找不到的話就去通過window.fetch去請求子應用的資源。但是這裡需要注意,你從主應用中去請求子應用的資源是會存在跨域的。所以你在子應用中必須要進行跨域放行。配置下webpack的devServer的headers就可以
//從這裡可以看出來qiankun是如何獲取子應用的資源的,預設是通過window.fetch去請求子應用的資源。而不是簡單的注入srcipt標籤,通過fetch去獲得了子應用的html資源資訊,然後通過response.text把資訊轉變為字串的形式。
//然後把得到的html字串傳入processTpl裡面進行html的模板解析 return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url) //response.text()下面的data就會變成一大串html response.json()就是變成json物件 .then(response => response.text()) .then(html => { const assetPublicPath = getPublicPath(url); //processTpl這個拿到了子應用html的模板之後對微應用所有的資源引入做處理。 const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath); return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({ //getEmbedHTML通過它的處理,就把外部引用的樣式檔案轉變為了style標籤,embedHTML就是處理後的html模板字串 //embedHTML就是新生成style標籤裡面的內容 template: embedHTML, assetPublicPath, getExternalScripts: () => getExternalScripts(scripts, fetch), getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), //下面這個函式就是用來解析指令碼的。從這裡看來它並不是簡單的插入script標籤就完事了。而是 //通過在程式碼內部去請求資源,然後再去運行了別人的指令碼內容 execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { //proxy sandboxInstance.proxy if (!scripts.length) { return Promise.resolve(); } return execScripts(entry, scripts, proxy, { fetch, strictGlobal, beforeExec: execScriptsHooks.beforeExec, afterExec: execScriptsHooks.afterExec, }); }, })); })); }

HTML模板解析

processTpl原始碼:

const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|')text\/ng-template\3).)*?>.*?<\/\1>/is;
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;
const SCRIPT_TYPE_REGEX = /.*\stype=('|")?([^>'"\s]+)/;
const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/;
const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/;
const SCRIPT_NO_MODULE_REGEX = /.*\snomodule\s*.*/;
const SCRIPT_MODULE_REGEX = /.*\stype=('|")?module('|")?\s*.*/;
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/;
const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const LINK_AS_FONT = /.*\sas=('|")?font\1.*/;
const STYLE_TAG_REGEX = /<style[^>]*>[\s\S]*?<\/style>/gi;
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
const LINK_IGNORE_REGEX = /<link(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
const STYLE_IGNORE_REGEX = /<style(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
const SCRIPT_IGNORE_REGEX = /<script(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;

//在函式所定義的檔案開頭有很多的正則表示式,它們主要是用來匹配得到的html的一些標籤,分別有style標籤,link標籤,script標籤。總之就是和樣式,js有關的標籤。同時它會特別的關注那些有外部引用的標籤。
export default function processTpl(tpl, baseURI) { //tpl就是我們的html模板, baseURI == http://localhost:8080
//這個script陣列是用來存放在html上解析得到含有外部資源引用的script標籤的資源引用地址
   let scripts = [];
//這個是用來存放外部引用的css標籤引用路徑地址 const styles
= []; let entry = null; // Detect whether browser supports `<script type=module>` or not const moduleSupport = isModuleScriptSupported();
//下面有若干個replace函式,開始對html字串模板進行匹配修改 const template
= tpl /* remove html comment first */
//匹配所有的註釋直接移除
.replace(HTML_COMMENT_REGEX, '') //匹配link .replace(LINK_TAG_REGEX, match => { /* change the css link */
       //檢查link裡面的type是不是寫著stylesheet,就是找樣式表
const styleType = !!match.match(STYLE_TYPE_REGEX); if (styleType) { //匹配href,找你引用的外部css的路徑 const styleHref = match.match(STYLE_HREF_REGEX); const styleIgnore = match.match(LINK_IGNORE_REGEX);           //進入if語句說明你的link css含有外部引用 if (styleHref) { //這裡就是提取出了css的路徑 const href = styleHref && styleHref[2]; let newHref = href;
           //我們在單個專案的時候,我們的css或者js的引用路徑很有可能是個相對路徑,但是相對路徑放在微前端的是不適用的,因為你的主專案中根本不存在你子專案的資原始檔,相對路徑無法獲取得到你的子應用的資源,只有通過絕對路徑去引用資源
            //所以這裡需要把你所有的相對路徑都提取出來,然後根據你最開始註冊子應用時候傳入的entry,資源訪問的入口去把你的相對路徑和絕對路徑進行拼接,最後得到子應用資源的路徑
    
//hasProtocol這裡是用來檢驗你寫的href是不是一個絕對路徑 //如果不是的話,他就幫你拼接上變為絕對路徑+相對路徑的形式。 if (href && !hasProtocol(href)) { newHref = getEntirePath(href, baseURI); } if (styleIgnore) { return genIgnoreAssetReplaceSymbol(newHref); }             //把css外部資源的引用路徑存入styles陣列。供後面正式訪問css資源提供入口 styles.push(newHref); //這個genLinkReplaceSymbol函式就把你的link註釋掉,並且寫明你的css已經被import-html-entry工具註釋掉了 //並且直接去掉你你自己的css。因為接入微前端。裡面原本存在的一些資源引入是不需要的,因為它們的路徑都是錯誤的。後面會有統一的資源引入的入口 return genLinkReplaceSymbol(newHref); } } const preloadOrPrefetchType = match.match(LINK_PRELOAD_OR_PREFETCH_REGEX) && match.match(LINK_HREF_REGEX) && !match.match(LINK_AS_FONT); if (preloadOrPrefetchType) { const [, , linkHref] = match.match(LINK_HREF_REGEX); return genLinkReplaceSymbol(linkHref, true); } return match; }) //這裡匹配style標籤 .replace(STYLE_TAG_REGEX, match => { if (STYLE_IGNORE_REGEX.test(match)) { return genIgnoreAssetReplaceSymbol('style file'); } return match; }) //這裡匹配script標籤,處理和css標籤類似,也是存放外部js引用的路徑到scripts陣列,然後把你的script標籤註釋掉 //const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi; .replace(ALL_SCRIPT_REGEX, (match, scriptTag) => { const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX); const moduleScriptIgnore = (moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) || (!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX)); // in order to keep the exec order of all javascripts const matchedScriptTypeMatch = scriptTag.match(SCRIPT_TYPE_REGEX); //獲取type裡面的值,如果裡面的值是無效的就不需要處理,原封不動的返回 const matchedScriptType = matchedScriptTypeMatch && matchedScriptTypeMatch[2]; if (!isValidJavaScriptType(matchedScriptType)) { return match; } // if it is a external script if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) { /* collect scripts and replace the ref */ //獲得entry欄位 const matchedScriptEntry = scriptTag.match(SCRIPT_ENTRY_REGEX); //獲得src裡面的內容 //const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/; const matchedScriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX); let matchedScriptSrc = matchedScriptSrcMatch && matchedScriptSrcMatch[2]; if (entry && matchedScriptEntry) { throw new SyntaxError('You should not set multiply entry script!'); } else { // append the domain while the script not have an protocol prefix //這裡把src改為絕對路徑 if (matchedScriptSrc && !hasProtocol(matchedScriptSrc)) { matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI); } entry = entry || matchedScriptEntry && matchedScriptSrc; } if (scriptIgnore) { return genIgnoreAssetReplaceSymbol(matchedScriptSrc || 'js file'); } if (moduleScriptIgnore) { return genModuleScriptReplaceSymbol(matchedScriptSrc || 'js file', moduleSupport); } //把這些script存入陣列中,然後註釋掉他們 if (matchedScriptSrc) { //const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/; const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX); scripts.push(asyncScript ? { async: true, src: matchedScriptSrc } : matchedScriptSrc); return genScriptReplaceSymbol(matchedScriptSrc, asyncScript); } return match; } else { if (scriptIgnore) { return genIgnoreAssetReplaceSymbol('js file'); } if (moduleScriptIgnore) { return genModuleScriptReplaceSymbol('js file', moduleSupport); } // if it is an inline script const code = getInlineCode(match); // remove script blocks when all of these lines are comments. const isPureCommentBlock = code.split(/[\r\n]+/).every(line => !line.trim() || line.trim().startsWith('//')); if (!isPureCommentBlock) { scripts.push(match); } return inlineScriptReplaceSymbol; } }); //過濾掉一些空標籤 scripts = scripts.filter(function (script) { // filter empty script return !!script; });    return { template, scripts, styles, // set the last script as entry if have not set entry: entry || scripts[scripts.length - 1], }; }

模板解析的過程稍微長一些,總結一下它做的核心事情:

1. 刪除html上的註釋。

2. 找到link標籤中有效的外部css引用的路徑,並且把他變為絕對路徑存入styles陣列,提供給後面資源統一引入作為入口

3. 找到script標籤處理和link css類似。

4. 最後把處理過後的模板,css引用的入口陣列,js引用的入口陣列進行返回

現在回到importHTML函式中看看處理完模板後面做了什麼事情。

export default function importHTML(url, opts = {}) {
   。。。省略

    return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
        //response.text()下面的data就會變成一大串html response.json()就是變成json物件,自行了解window.fetch用法
        .then(response => response.text())
        .then(html => {
            const assetPublicPath = getPublicPath(url);
            //processTpl這個拿到了html的模板之後對微應用所有的資源引入做處理
            const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath);
       //這裡執行getEmbedHTML的作用就是根據剛剛模板解析得到的styles路徑陣列,正式通過fetch去請求獲得css資源。
            return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
               ...省略
            }));
        }));
}

getEmbedHTML原始碼:function getEmbedHTML(template, styles, opts = {}) {

//template, styles, { fetch }
    const { fetch = defaultFetch } = opts;
    let embedHTML = template;
    //getExternalStyleSheets這個函式的作用是什麼?就是如果在快取中有了style的樣式的話。就直接從快取獲取,沒有的話就正式去請求獲取資源
    return getExternalStyleSheets(styles, fetch)
        //getExternalStyleSheets返回了一個處理樣式檔案的promise
        .then(styleSheets => {
            //styleSheets就是整個樣式檔案的字串 這裡就是開始注入style標籤,生成子應用的樣式
            embedHTML = styles.reduce((html, styleSrc, i) => {
          //這裡genLinkReplaceSymbol的作用就是根據上面在處理html模板的時候把link css註釋掉了,然後現在匹配回這個註釋,就是找到這個註釋的位置,然後替換成為style標籤
          //說明對於外部的樣式引用最後通過拿到它的css字串,然後把全部的外部引用都變成style標籤的引用形式。 html
= html.replace(genLinkReplaceSymbol(styleSrc), `<style>/* ${styleSrc} */${styleSheets[i]}</style>`); return html; }, embedHTML); return embedHTML; }); } // for prefetch export function getExternalStyleSheets(styles, fetch = defaultFetch) {
  //第一個引數就是存放著有css路徑的陣列,第二個是fetch請求方法
return Promise.all(styles.map(styleLink => { if (isInlineCode(styleLink)) { // if it is inline style return getInlineCode(styleLink); } else { // external styles
          //先從快取中尋找,有的話直接從快取中獲取使用,沒有的話就通過fetch去請求,最後把請求的到的css資源裝變為字串的形式返回
      return styleCache[styleLink] || (styleCache[styleLink] = fetch(styleLink).then(response => response.text())); } }, )); }

繼續回到importHTML中。

export default function importHTML(url, opts = {}) {
    ...
    return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
        .then(response => response.text())
        .then(html => {
            const assetPublicPath = getPublicPath(url);
            const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath);
       //最後通過getEmbedHTML請求到了css資源並且把這些css資源通過style標籤的形式注入到了html,重新把新的html返回回來
       //最後then中return了一個物件,但是注意現在並沒有真正的去引用js的資源,js資源在loadApp後面進行引入
return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
          //經過註釋處理和樣式注入的html模板
template: embedHTML, assetPublicPath,
          //獲取js資源的方法 getExternalScripts: ()
=> getExternalScripts(scripts, fetch), getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), //下面這個函式就是用來解析指令碼的。後面分析這段程式碼的作用 execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { //proxy sandboxInstance.proxy if (!scripts.length) { return Promise.resolve(); } return execScripts(entry, scripts, proxy, { fetch, strictGlobal, beforeExec: execScriptsHooks.beforeExec, afterExec: execScriptsHooks.afterExec, }); }, })); })); }

這裡總結一下整個importEntry做了什麼:

1. 請求html模板,進行修改處理

2. 請求css資源注入到html中

3. 返回一個物件,物件的內容含有處理過後的html模板,通過提供獲取js資源的方法getExternalScripts,和執行獲取到的js指令碼的方法execScripts。

回到loadApp方法繼續解析後面的內容

export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  ...省略
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

  // as single-spa load and bootstrap new app parallel with other apps unmounting
  // (see https://github.com/CanopyTax/single-spa/blob/master/src/navigation/reroute.js#L74)
  // we need wait to load the app until all apps are finishing unmount in singular mode
  if (await validateSingularMode(singular, app)) {
    await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
  }
  //getDefaultTplWrapper這個函式的作用就是為上面解析得到的HTML新增一個div,包裹子應用所有的html程式碼,然後把包裹之後的新的html模板進行返回
  const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);

  //預設情況下sandbox框架會幫我們配置為true, 在官方文件上你可以為它配置為一個物件{strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean}
 //預設情況下沙箱可以保證子應用之間的樣式隔離,但是無法保證主應用和子應用之間的樣式隔離。 當strictStyleIsolation: true,框架會幫住每一個子應用包裹上一個shadowDOM。
//從而保證微應用的樣式不會對全域性造成汙染。當 experimentalStyleIsolation 被設定為 true 時, qiankun會改寫子應用的樣式,在它上面增加特殊的選擇器,從而實現隔離。這個後面詳細講
//所以這個typeof判斷就是你專案所需要的隔離程度。
const strictStyleIsolation
= typeof sandbox === 'object' && !!sandbox.strictStyleIsolation; //如果你沒有配置sandbox那麼這裡就返回false。如果你寫成了物件配置成了物件,另外判斷。 const scopedCSS = isEnableScopedCSS(sandbox); //這個東西就是根據對沙箱環境的不同配置進入css的樣式隔離處理 let initialAppWrapperElement: HTMLElement | null = createElement( appContent, strictStyleIsolation, scopedCSS, appName, ); ...省略 }

樣式隔離處理

createElement原始碼:

function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appName: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div
 
const appElement = containerElement.firstChild as HTMLElement; //這裡就說明了嚴格樣式隔離採用shadow dom隔離,如果不知道shadowDOM的需要去自行了解一下 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) { //在appElement下建立shadowDom shadow = appElement.attachShadow({ mode: 'open' }); } else { // createShadowRoot was proposed in initial spec, which has then been deprecated shadow = (appElement as any).createShadowRoot(); }
//把子應用的東西放在shadowDOM下 shadow.innerHTML
= innerHTML; } } //這裡當experimentalStyleIsolation為true的時候,scopedCSS才會為true
//todo 這裡我還沒有搞懂,不解析了 if (scopedCSS) {
   //css.QiankunCSSRewriteAttr是一個字串'data-qiankun',在之前的分析中,執行這個函式執行,就給子應用 const attr
= appElement.getAttribute(css.QiankunCSSRewriteAttr); if (!attr) { appElement.setAttribute(css.QiankunCSSRewriteAttr, appName); } //這裡獲取所有style標籤的內容, 為什麼要獲得style標籤的內容?因為之前在解析css的時候說過,qiankun在獲取外部的css樣式的時候,最終都是通過fetch獲得樣式檔案字串之後,然後再轉為style標籤。 const styleNodes = appElement.querySelectorAll('style') || [];
//遍歷所有的樣式。 forEach(styleNodes, (stylesheetElement: HTMLStyleElement)
=> { css.process(appElement!, stylesheetElement, appName); }); } return appElement; }

看完了css處理,我們重新回到loadApp中

export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  ...省略

  //這個東西就是根據對沙箱環境的不同配置進入css的樣式隔離處理,最後返回經過處理過後的子應用的根節點資訊。注意返回的不是一個字串,而是根節點的資訊json物件。
  let initialAppWrapperElement: HTMLElement | null = createElement(
    appContent,
    strictStyleIsolation,
    scopedCSS,
    appName,
  );

//判斷使用者在開始呼叫registerMicroApps的時候有沒有傳入container選項,它是微應用容器的節點選擇器,或者是Element例項。 const initialContainer
= 'container' in app ? app.container : undefined; //獲取引數中使用者自己寫的render,這裡有點奇怪,不知道為什麼官方文件上沒有看到對這個欄位的使用說明,但是你確實可以使用它 const legacyRender = 'render' in app ? app.render : undefined; //建立一個render函式並且返回,這個render的作用下面解析 const render = getRender(appName, appContent, legacyRender); //這句話執行render函式就是開始真正渲染我們主應用的地方,因為我們有可能在自定義render中去new Vue,建立我們的主應用的vue例項 render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading'); //這個getAppWrapperGetter方法返回一個函式,貌似是一個提供給你訪問dom的一個方法 const initialAppWrapperGetter = getAppWrapperGetter( appName, appInstanceId, !!legacyRender, strictStyleIsolation, scopedCSS, () => initialAppWrapperElement, );    ...省略 }

getRender函式原始碼:

function getRender(appName: string, appContent: string, legacyRender?: HTMLContentRender) {
  //第一個引數是子應用的名稱,第二個是子應用的html字串,第三個是使用者在registerMicroApps時候傳入的render函式
  const render: ElementRender = ({ element, loading, container }, phase) => {
    //如果我們在registerMicroApps中傳入的render函式。那麼這裡就是執行我們的render函式
    if (legacyRender) {
//如果真的傳入的render函式就給你發一個小小的警告,不明白既然開放給你了,為什麼要給你警告。 if (process.env.NODE_ENV === 'development') { console.warn( '[qiankun] Custom rendering function is deprecated, you can use the container element setting instead!', ); }     //最後執行你自己的自定義render函式,傳入的引數是loading,appContent,appContent是子應用的html模板,但是這個時候,子應用沒有渲染出來,因為子應用要渲染出來的話,需要js的配合
//但是這個時候子應用的js並沒有載入到主應用中,更加沒有執行,這裡就是給子應用準備好了一個html的容器而已
return legacyRender({ loading, appContent: element ? appContent : '' }); } // export function getContainer(container: string | HTMLElement): HTMLElement | null { // return typeof container === 'string' ? document.querySelector(container) : container; // }
//如果沒有寫render函式的話那麼就會去校驗在registerMicroApps中有沒有傳入container引數
   const containerElement = getContainer(container!); // The container might have be removed after micro app unmounted. // Such as the micro app unmount lifecycle called by a react componentWillUnmount lifecycle, after micro app unmounted, the react component might also be removed if (phase !== 'unmounted') { const errorMsg = (() => { switch (phase) { case 'loading': case 'mounting': return `[qiankun] Target container with ${container} not existed while ${appName} ${phase}!`; case 'mounted': return `[qiankun] Target container with ${container} not existed after ${appName} ${phase}!`; default: return `[qiankun] Target container with ${container} not existed while ${appName} rendering!`; } })(); assertElementExist(containerElement, errorMsg); } if (containerElement && !containerElement.contains(element)) { // clear the container while (containerElement!.firstChild) { rawRemoveChild.call(containerElement, containerElement!.firstChild); } // append the element to container if it exist if (element) { rawAppendChild.call(containerElement, element); } } return undefined; }; return render; }

從上面的render函式的情況,我們看到我們是可以傳入render函式的,為了幫住大家理解,給出一個render函式使用的樣例。

registerMicroApps(
    [
      {
        name: "sub-app-1",
        entry: "//localhost:8091/",
        render,
        activeRule: genActiveRule("/app1"),
        props: ""
      },
    ],
    {
      beforeLoad: [
        app => {
          console.log("before load", app);
        }
      ], // 掛載前回調
      beforeMount: [
        app => {
          console.log("before mount", app);
        }
      ], // 掛載後回撥
      afterUnmount: [
        app => {
          console.log("after unload", app);
        }
      ] // 解除安裝後回撥
    }
);
let app = null
function render({ appContent, loading } = {}) {
  if (!app) {
    app = new Vue({
      el: "#container",
      router,
      data() {
        return {
          content: appContent,
          loading
        };
      },
      render(h) {
        return h(App, {
          props: {
            content: this.content,
            loading: this.loading
          }
        });
      }
    });
  } else {
    app.content = appContent;
    app.loading = loading;
  }
}

沙箱環境

上面講完了css隔離,我們繼續看看loadApp後面的程式碼,後面接下來進入沙箱環境。在開篇的時候我們講過,如果我們傳入single-spa的載入函式編寫隨意的話,那麼會有一個全域性環境的汙染問題所在。下面來看看qiankun是如何解決這個問題

export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  ...省略
  //預設全域性環境是window,下面建立完沙箱環境之後就會被替換
  let global = window;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
//校驗使用者在start中傳入的sandbox,不傳的話預設為true。如果你寫成了物件,則校驗有沒有loose這個屬性。這個loose屬性我好像沒有在官方文件上看到對於它的使用說明 const useLooseSandbox
= typeof sandbox === 'object' && !!sandbox.loose; //這段程式碼和沙箱環境有關係 if (sandbox) {
//建立沙箱環境例項 const sandboxInstance
= createSandbox( appName, // FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518 initialAppWrapperGetter, scopedCSS, useLooseSandbox, excludeAssetFilter, ); // 用沙箱的代理物件作為接下來使用的全域性物件 global = sandboxInstance.proxy as typeof window; //這個mountSandbox將會被當作子應用生命週期之一,返回到single-spa中,說明當執行子應用掛載的時候,沙箱就會啟動 mountSandbox = sandboxInstance.mount; unmountSandbox = sandboxInstance.unmount; } //這裡就是合併鉤子函式 const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } = mergeWith( {}, //getAddOns注入一些內建地生命鉤子。主要是在子應用的全域性變數上加一些變數,讓你的子應用識別出來 //你目前的環境是在微應用下,讓使用者能夠正確處理publicPath或者其他東西 getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []), ); //這裡執行beforeLoad生命鉤子 await execHooksChain(toArray(beforeLoad), app, global); ...省略 }

createSandbox函式:

export function createSandbox(
  appName: string,
  elementGetter: () => HTMLElement | ShadowRoot,
  scopedCSS: boolean,
  useLooseSandbox?: boolean,
  excludeAssetFilter?: (url: string) => boolean,
) {
  let sandbox: SandBox;
//這裡根據瀏覽器的相容和使用者傳入引數的情況分別有三個建立沙箱的例項。
if (window.Proxy) { sandbox = useLooseSandbox ? new LegacySandbox(appName) : new ProxySandbox(appName); } else { //在不支援ES6 Proxy的沙箱中sandbox.proxy = window sandbox = new SnapshotSandbox(appName); } // some side effect could be be invoked while bootstrapping, such as dynamic stylesheet injection with style-loader, especially during the development phase const bootstrappingFreers = patchAtBootstrapping(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter); // mounting freers are one-off and should be re-init at every mounting time let mountingFreers: Freer[] = []; let sideEffectsRebuilders: Rebuilder[] = []; return { proxy: sandbox.proxy, /** * 沙箱被 mount * 可能是從 bootstrap 狀態進入的 mount * 也可能是從 unmount 之後再次喚醒進入 mount */ async mount() { ... }, /** * 恢復 global 狀態,使其能回到應用載入之前的狀態 */ async unmount() { ... }, }; }

先從簡單分析,先看看在瀏覽器不支援Proxy建立的沙箱。

SnapShotSandbox

export default class SnapshotSandbox implements SandBox {
  proxy: WindowProxy;

  name: string;

  type: SandBoxType;

  sandboxRunning = true;

  private windowSnapshot!: Window;

  private modifyPropsMap: Record<any, any> = {};

  constructor(name: string) {
//繫結沙箱名字為子應用的名字
this.name = name;
//沙箱proxy指向window
this.proxy = window; //'Snapshot' this.type = SandBoxType.Snapshot; } active() { ... } inactive() { ... } }

SnapshotSandbox的沙箱環境主要是通過啟用時記錄window狀態快照,在關閉時通過快照還原window物件來實現的。

active() {
    // 記錄當前快照
//假如我們在子應用使用了一些window【xxx】那麼就會改變了全域性環境的window,造成了全域性環境的汙染。那麼我們可以在啟動我們沙箱環境的時候,預先記錄下來,我們在沒有執行子應用程式碼,即window沒有被改變
//前的現狀。最後在執行完子應用程式碼的時候,我們再去根據我們記錄的狀態去還原回window。那麼就巧妙地避開了window汙染的問題。 this.windowSnapshot = {} as Window;
//逐個遍歷window的屬性。把window不在原型鏈上的屬性和對應的值都存放進入windowSnapshot中記錄下來。 iter(window, (prop)
=> { this.windowSnapshot[prop] = window[prop]; }); // 恢復之前的變更 Object.keys(this.modifyPropsMap).forEach((p: any) => { window[p] = this.modifyPropsMap[p]; }); this.sandboxRunning = true; }

inactive() {
//定義一個物件,記錄修改過的屬性
this.modifyPropsMap = {};

//遍歷window
iter(window, (prop) => {
//this.windowSnapshot記錄了修改前,window[prop]是被修改後,如果兩個值不相等的話就說明window這個屬性的值被人修改了。
if (window[prop] !== this.windowSnapshot[prop]) {
// 發現了被人修改過,記錄變更,恢復環境,這裡相當於把子應用期間造成的window汙染全部清除。
this.modifyPropsMap[prop] = window[prop];
//這裡就是還原回去
window[prop] = this.windowSnapshot[prop];
}
});

if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
}

this.sandboxRunning = false;
}
function iter(obj: object, callbackFn: (prop: any) => void) {
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
callbackFn(prop);
}
 }
}

總結下這個snapshotSandbox原理就是,啟動之前記錄環境。並且還原回你inactive之前的window環境。在inactive時記錄你修改過的記錄,當在active的時候還原你在inactive時候環境。

上面這個snapshotSandbox的修改來,修改去。可能會讓讀者覺得十分混亂,我們我可以假想一下一個順序。在最開始的時候,我們需要啟動我們的沙箱環境。此時先對window的值做一次記錄備份。

但是我們還沒有進行過inactive。所以此時this.modifyPropsMap是沒有記錄的。

當我們這次沙箱環境結束了,執行了inactive。我們把active-到inactive期間修改過的window的值記錄下來,此時this.modifyPropsMap有了記錄。並且還原回acitve之前的值。

當我們下次再次想啟動我們沙箱的時候就是acitve,此時再次記錄下來在inactive之後window的值,因為這個時候this.modifyPropsMap有記錄了,那麼通過記錄我們就可以還原了我們在inactive之前window狀態,那麼子應用沙箱環境的window的值就會被還原回到了inactive前,完美復原環境。從而不會造成window值的混亂。

ProxySandbox

  在支援proxy環境下,並且你的sanbox沒有配置loose,就會啟用ProxySandbox。

export default class ProxySandbox implements SandBox {
  /** window 值變更記錄,記錄的是變更過的屬性值 */
  private updatedValueSet = new Set<PropertyKey>();

  name: string;

  type: SandBoxType;

  proxy: WindowProxy;

  sandboxRunning = true;

  active() {
   ...
  }

  inactive() {
    ...
  }

  constructor(name: string) {
    //我們在沙箱中傳入的是appName
    this.name = name;
    //'Proxy'
    this.type = SandBoxType.Proxy;
    const { updatedValueSet } = this;

    const self = this;
    const rawWindow = window;
//最後我們要proxy代理的就是fakeWindow。這個fakeWindow是一個{}物件。 const { fakeWindow, propertiesWithGetter }
= createFakeWindow(rawWindow); const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>(); const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);
//這裡的Proxy就是整個代理的關鍵,這個proxy最終就是會被作為子應用的window,後面在載入和執行js程式碼的時候就知道是怎麼把這個環境進行繫結。現在我們從get和set就能夠知道它是如何應對全域性的環境和子應用的環境 const proxy
= new Proxy(fakeWindow, { set(target: FakeWindow, p: PropertyKey, value: any): boolean { //當對它進行賦值操作的時候。首先改變在target上對應的屬性值,然後在updatedValueSet新增這個屬性 //最後返回一個true if (self.sandboxRunning) { // target指的就是fakeWindow,如果你在子應用環境中有修改window的值,那麼就會落入這個set陷阱中,那麼其實你本質就是在修改fakeWindow的值
//然後在updateValueSet中增加你在這個修改的屬性。
target[p] = value; updatedValueSet.add(p);
//這裡是對於某些屬性的特殊處理,修改子應用的值,如果命中了同時也會修改的主應用的,rawWindow就是window
if (variableWhiteList.indexOf(p) !== -1) { // @ts-ignore rawWindow[p] = value; } return true; } if (process.env.NODE_ENV === 'development') { console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`); } // 在 strict-mode 下,Proxy 的 handler.set 返回 false 會丟擲 TypeError,在沙箱解除安裝的情況下應該忽略錯誤 return true; }, get(target: FakeWindow, p: PropertyKey): any { if (p === Symbol.unscopables) return unscopables; // avoid who using window.window or window.self to escape the sandbox environment to touch the really window // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13 if (p === 'window' || p === 'self') { return proxy; } if ( p === 'top' || p === 'parent' || (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop')) ) { ... } // proxy.hasOwnProperty would invoke getter firstly, then its value represented as rawWindow.hasOwnProperty if (p === 'hasOwnProperty') { ... } // mark the symbol to document while accessing as document.createElement could know is invoked by which sandbox for dynamic append patcher if (p === 'document') { ... } // 這裡就可以看出,如果我們嘗試在子應用中去讀取window上的值。如果滿足了某些條件,就會直接從window上返回給你,但是對於大多數情況下,框架先從fakeWindow上找一找有沒有這個東西,有的話就直接返回給你,如果fakeWindow沒有的話再從window上找給你。 const value = propertiesWithGetter.has(p) ? (rawWindow as any)[p] : (target as any)[p] || (rawWindow as any)[p]; return getTargetValue(rawWindow, value); }, ...省略 }); this.proxy = proxy; } }

大致的簡單分析了下這個proxySandbox的原理:

就是就是定義一個物件fakeWindow,把它繫結在了子應用的window上,然後如果你取值,那麼就先從fakeWindow這裡拿,沒有的話再從window上找給你。

如果你要修改值,那麼大多數情況下,其實你都是在修改fakeWindow,不會直接修改到window。這裡就是通過這種方式去避免子應用汙染全域性環境。

但是這裡就有問題,就是它是如何把這個proxy繫結進入子應用環境中,這個在解析執行js指令碼時分析。

分析完了如何建立沙箱,我們重新回到loadApp程式碼中。

export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  ...省略
  //預設全域性環境是window,下面建立完沙箱環境之後就會被替換
  let global = window;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
  //校驗使用者在start中傳入的sandbox,不傳的話預設為true。如果你寫成了物件,則校驗有沒有loose這個屬性。這個loose屬性我好像沒有在官方文件上看到對於它的使用說明
  const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;

  //這段程式碼和沙箱環境有關係
  if (sandbox) {
    //建立沙箱環境例項
    const sandboxInstance = createSandbox(
      appName,
      // FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
      initialAppWrapperGetter,
      scopedCSS,
      useLooseSandbox,
      excludeAssetFilter,
    );
    // 用沙箱的代理物件作為接下來使用的全域性物件
    global = sandboxInstance.proxy as typeof window;
    mountSandbox = sandboxInstance.mount;
    unmountSandbox = sandboxInstance.unmount;
  }

  //我們在qiankun registerMicroApps方法中,它允許我們傳入一些生命鉤子函式。這裡就是合併生命鉤子函式的地方。
  const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } = mergeWith(
    {},
    //getAddOns注入一些內建地生命鉤子。主要是在子應用的全域性變數上加一些變數,讓你的子應用識別出來你現在處於微前端環境,從這裡也說明了window某些屬性我們直接能夠從子應用中獲得,子應用並不是一個完全封閉,無法去讀主應用window屬性的環境
    getAddOns(global, assetPublicPath),
    lifeCycles,
    (v1, v2) => concat(v1 ?? [], v2 ?? []),
  );
  //這裡執行beforeLoad生命鉤子,就不分析了。
  await execHooksChain(toArray(beforeLoad), app, global);

  ...省略
}

生命鉤子,官方文件:

getAddOns函式

export default function getAddOns<T extends object>(global: Window, publicPath: string): FrameworkLifeCycles<T> {
  return mergeWith({}, getEngineFlagAddon(global), getRuntimePublicPathAddOn(global, publicPath), (v1, v2) =>
    concat(v1 ?? [], v2 ?? []),
  );
}
//這個getEnginFlagAddon就是下面這個函式,檔案中給他取了別名,其實就是增加除了使用者的自定義生命鉤子以外的內建生命鉤子,框架還需要新增一些內建的生命鉤子
export default function getAddOn(global: Window): FrameworkLifeCycles<any> {
  return {
    async beforeLoad() {
      // eslint-disable-next-line no-param-reassign
//在子應用環境中新增__POWERED_BT_QIANKUN_。屬性,這讓使用者知道,你子應用目前的環境是在微前端中,而不是單獨啟動
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__; }, }; } //getRuntimePublicPathAddOn就是下面的方法 export default function getAddOn(global: Window, publicPath = '/'): FrameworkLifeCycles<any> { let hasMountedOnce = false; return { async beforeLoad() { // eslint-disable-next-line no-param-reassign
//新增專案的共有路徑
global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = publicPath; }, async beforeMount() { if (hasMountedOnce) { // eslint-disable-next-line no-param-reassign global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = publicPath; } }, async beforeUnmount() { if (rawPublicPath === undefined) { // eslint-disable-next-line no-param-reassign delete global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } else { // eslint-disable-next-line no-param-reassign global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = rawPublicPath; } hasMountedOnce = true; }, }; }

通過上面的分析我們可以得出一個結論,我們可以在子應用中獲取該環境變數,將其設定為__webpack_public_path__的值,從而使子應用在主應用中執行時,可以匹配正確的資源路徑。

if (window.__POWERED_BY_QIANKUN__) {
    // eslint-disable-next-line no-undef
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}

繼續loadApp下面的程式碼,進入了最重要的地方,載入並且執行js,繫結js的執行window環境。

子應用js的載入和執行,子應用生命週期函式的獲取。

接下來這段程式碼做了幾件事情:

1. 獲取子應用js,並且執行。

2. 幫住子應用繫結js window環境。

3. 得到子應用的生命週期函式。

export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  ...省略
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

  ...省略// get the lifecycle hooks from module exports
  const scriptExports: any = await execScripts(global, !useLooseSandbox);
  //子應用的生命鉤子都在這裡
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports, appName, global);

  ...省略
}

在loadApp開頭處呼叫了import-html-entry庫中importEntry函式。這個函式最終返回了一個解析之後的子應用的html模板。

還有一個載入子應用和執行子應用js的方法execScripts,下面看看這個方法,這個方法在庫import-html-entry中。

execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => {
     //proxy sandboxInstance.proxy
   //這裡的第一個引數就是proxy,這個porxy就是在沙箱程式碼中建立的子應用的沙箱環境。
if (!scripts.length) { return Promise.resolve(); } return execScripts(entry, scripts, proxy, { fetch, strictGlobal, beforeExec: execScriptsHooks.beforeExec, afterExec: execScriptsHooks.afterExec, }); },

execScripts原始碼:

export function execScripts(entry, scripts, proxy = window, opts = {}) {
    // 第一個引數子應用入口,第二個引數就是在子應用html模板解析的時候收集到的從外部引用的js資源的路徑。,第三個引數就是沙箱環境
    const {
//定義fetch準備通過這個方法去獲取js資源 fetch
= defaultFetch, strictGlobal = false, success, error = () => { }, beforeExec = () => { }, afterExec = () => { }, } = opts; //這裡就是根據絕對地址去讀取script的檔案資源 return getExternalScripts(scripts, fetch, error) .then(scriptsText => {
//scripts就是解析得到的js字串。 ...

     } }); }

getExternalScripts原始碼:

export function getExternalScripts(scripts, fetch = defaultFetch, errorCallback = () => {
}) {
    //這裡和後去css資源的手段是類似的
    //這裡也是先從快取獲取,如果沒有的話就通過fetch去請求資源
    //這裡就是正式的去獲取js資源
    const fetchScript = scriptUrl => scriptCache[scriptUrl] ||
        (scriptCache[scriptUrl] = fetch(scriptUrl).then(response => {
            // usually browser treats 4xx and 5xx response of script loading as an error and will fire a script error event
            // https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603
            if (response.status >= 400) {
                errorCallback();
                throw new Error(`${scriptUrl} load failed with status ${response.status}`);
            }

            return response.text();
        }));

    return Promise.all(scripts.map(script => {

            if (typeof script === 'string') {
                if (isInlineCode(script)) {
                    // if it is inline script
                    return getInlineCode(script);
                } else {
                    // external script
                    return fetchScript(script);
                }
            } else {
                // use idle time to load async script
                const { src, async } = script;
                if (async) {
                    return {
                        src,
                        async: true,
                        content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src).then(resolve, reject))),
                    };
                }

                return fetchScript(src);
            }
        },
    ));
}

回到execScripts函式中,當解析完js程式碼,得到了js程式碼字串,看看then後面做了什麼。

function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
 const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
 const globalWindow = (0, eval)('window');
 //這裡直接把window.proxy物件改為了沙箱環境proxy,然後下面就傳入的程式碼中,當作是別人的window環境。
 globalWindow.proxy = proxy;
 //這句話就是繫結作用域 然後同時也是立即執行函式順便把js指令碼也運行了
//這裡返回一個立即執行函式的字串,可以看到傳入的引數就是window.proxy,就是沙箱環境,然後把整個子應用的js程式碼包裹在這個立即執行函式的環境中,把window,當前引數。所以就是通過這中引數傳入的
//window.proxy環境的手段修改了子應用js程式碼的window環境,讓它變成了沙箱環境。
return strictGlobal ? `;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);` : `;(function(window, self){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy);`; } export function execScripts(entry, scripts, proxy = window, opts = {}) { ...省略//這裡就是根據絕對地址去讀取script的檔案資源 return getExternalScripts(scripts, fetch, error) .then(scriptsText => { //scriptsText就是解析的到的js資源的字串 const geval = (scriptSrc, inlineScript) => { //第一個引數是解析js指令碼的絕對路徑 第二引數是解析js指令碼的js字串程式碼 const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript; //這裡這個code存放著執行指令碼js程式碼的字串 const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal); //這裡就是正式執行js指令碼,這裡含有我們的子應用的js程式碼,但是被包裹在了一個立即執行函式的環境中。 (0, eval)(code); afterExec(inlineScript, scriptSrc); }; function exec(scriptSrc, inlineScript, resolve) { //第一個引數是解析js指令碼的路徑 第二引數是解析js指令碼的js字串程式碼 const markName = `Evaluating script ${scriptSrc}`; const measureName = `Evaluating Time Consuming: ${scriptSrc}`; if (process.env.NODE_ENV === 'development' && supportsUserTiming) { performance.mark(markName); }            if (scriptSrc === entry) { noteGlobalProps(strictGlobal ? proxy : window); try { // bind window.proxy to change `this` reference in script //這個geval會對的得到的js字串程式碼做一下包裝,這個包裝就是改變它的window環境。 geval(scriptSrc, inlineScript); const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {}; //這裡的resolve是從上層函式通過引數傳遞過來的,這裡resolve相當於上層函式resolve返回給qiankun的呼叫await resolve(exports); } catch (e) { // entry error must be thrown to make the promise settled console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`); throw e; } } else { if (typeof inlineScript === 'string') { try { // bind window.proxy to change `this` reference in script geval(scriptSrc, inlineScript); } catch (e) { // consistent with browser behavior, any independent script evaluation error should not block the others throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`); } } else { // external script marked with async inlineScript.async && inlineScript?.content .then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText)) .catch(e => { throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`); }); } } if (process.env.NODE_ENV === 'development' && supportsUserTiming) { performance.measure(measureName, markName); performance.clearMarks(markName); performance.clearMeasures(measureName); } } function schedule(i, resolvePromise) { if (i < scripts.length) { //遞迴去進行js的指令碼解析。             //得到指令碼獲取的路徑 const scriptSrc = scripts[i]; //得到對應的js指令碼程式碼字串 const inlineScript = scriptsText[i]; //這個是執行js指令碼的入口 exec(scriptSrc, inlineScript, resolvePromise); // resolve the promise while the last script executed and entry not provided if (!entry && i === scripts.length - 1) { resolvePromise(); } else { schedule(i + 1, resolvePromise); } } } //這個schedule的作用就是開始解析script指令碼 return new Promise(resolve => schedule(0, success || resolve)); }); }

總結一下:

1. 框架其實是通過window.fetch去獲取子應用的js程式碼。

2. 拿到了子應用的js程式碼字串之後,把它進行包裝處理。把程式碼包裹在了一個立即執行函式中,通過引數的形式改變了它的window環境,變成了沙箱環境。

function(window, self) {
    子應用js程式碼
}(window,proxy, window.proxy)

3. 最後通過eval()去執行立即執行函式,正式去執行我們的子應用的js程式碼,去渲染出整個子應用。

到這裡,js程式碼的講解就說完了。

來看看最後一個問題:如何獲取子應用的生命鉤子函式。

重新回到loadApp中。

我們先來看看,假如我們使用了qiankun框架,並且子應用使用vue,我們子應用main.js是怎麼定義這些生命鉤子函式。其實就是通過簡單的export匯出就好了

export async function bootstrap() {
  console.log('vue app bootstraped');
}

export async function mount(props) {
  console.log('props from main app', props);
  render();
}

export async function unmount() {
  instance.$destroy();
  instance = null;
  // router = null;
}

function getLifecyclesFromExports(scriptExports: LifeCycles<any>, appName: string, global: WindowProxy) {
//校驗你的子應用的匯出的生命鉤子函式是否合法,合法的話直接返回
if (validateExportLifecycle(scriptExports)) { return scriptExports; }   ... } export async function loadApp<T extends object>( app: LoadableApp<T>, configuration: FrameworkConfiguration = {}, lifeCycles?: FrameworkLifeCycles<T>, ): Promise<ParcelConfigObjectGetter> { ...省略 const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts); ...省略 // get the lifecycle hooks from module exports const scriptExports: any = await execScripts(global, !useLooseSandbox); //子應用的生命鉤子都在這裡 //在上面執行完了子應用的js程式碼,假設我們的子應用使用vue寫的。那麼vue應用的入口的地方是main.js。我們在main,js通過export匯出宣告周期函式。這些export的東西其實本質上都是被存放在一個物件中。
//最後通過解構出來就好了
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports, appName, global); ...省略 }

到了這裡。qiankun整個核心的部分應該算是講解完了。

看看最後的程式碼,最後在loadApp中返回了一系列的生命週期函式到載入函式中,在載入函式中把它們返回。這個和我們在single-spa中分析的沒有出入,載入函式需要以物件的形式返回出生命周期函式

export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {

...省略
const parcelConfig: ParcelConfigObject = {
  name: appInstanceId,
  bootstrap,
  mount: [
   ...
  ],
  unmount: [
    ...
  ],
};

if (typeof update === 'function') {
  parcelConfig.update = update;
}

return parcelConfig;
}

export function registerMicroApps<T extends object = {}>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
 ...省略
  unregisteredApps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    registerApplication({
      name,
      app: async () => {
        ...

        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();

        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

總結

整個qiankun框架中我們知道了什麼東西:

1. qiankun是如何完善single-spa中留下的巨大缺口-————載入函式。

2. qiankun通過什麼策略去載入子應用資源————window.fetch。

3. qiankun如何隔離子應用的js的全域性環境————通過沙箱。

4. 沙箱的隔離原理是什麼————在支援proxy中有一個代理物件,子應用優先訪問到了代理物件,如果代理物件沒有的值再從window中獲取。如果不支援proxy,那麼通過快照,快取,復原的形式解決汙染問題。

5. qiankun如何隔離css環境————shadowDOM隔離;加上選擇器隔離。

6. qiankun如何獲得子應用生命週期函式————export 儲存在物件中,然後解構出來。

7. qiankun如何該改變子應用的window環境————通過立即執行函式,傳入window.proxy為引數,改變window環境。