前言
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中注冊的子應用信息。
// 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環境。