整體核心流程
源碼分析
single-spa 存在以下主要的缺點
- 路由狀態管理不足:無法保持路由狀態,頁面刷新後路由狀態丟失
- 父子應用間的路由交互以來
postMessage等方式,開發體驗差 - 未提供原生的 CSS 和 JS 沙箱隔離,可能導致樣式污染或者全局變量衝突
- 默認以來 webpack 的構建配置,其他構建工具需要改造後才能兼容
- 版本兼容性差,如果使用不同的 Vue 版本,可能引發衝突
- 僅提供路由核心能力,缺乏多實例並行等微前端所需要的完整功能
- 子應用需要遵循特定的生命週期函數,對於一些非標準化的頁面支持較弱,改造成本較高
qiankun 基於 single-spa 進行二次封裝修正了一些缺點,主要包括:
- 降低侵入性:single-spa 對主應用和子應用的改造要求較高,而 qiankun 通過封裝減少了代碼侵入性,提供了更簡潔的 API 和基於 HTML Entry 的接入方式,降低了接入複雜度
- 隔離機制:single-spa 未內置完善的隔離方案,可能導致子應用的樣式、全局變量衝突。qiankun 通過沙箱機制(如 CSS Modules、Proxy 代理等)實現了子應用的樣式和作用域隔離,提升安全性
- 優化開發體驗:qiankun 提供了更貼近實際開發需求的功能,例如子應用的動態加載、預加載策略,以及基於發佈-訂閲模式的通信機制,彌補了 single-spa 在工程化實踐中的不足
1. registerMicroApps() 和 start()
1.1 registerMicroApps()
registerMicroApps() 的邏輯非常簡單:
- 防止微應用重複註冊
- 遍歷
unregisteredApps調用 single-spa 的registerApplication()進行微應用的註冊
function registerMicroApps(apps, lifeCycles) {
const unregisteredApps = apps.filter(
(app) => !microApps.some((registeredApp) => registeredApp.name === app.name)
);
microApps = [...microApps, ...unregisteredApps];
unregisteredApps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
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,
});
});
}
1.2 start()
start() 的邏輯也非常簡單:
prefetch:預加載觸發doPrefetchStrategy()- 兼容舊的瀏覽器版本
autoDowngradeForLowVersionBrowser()改變配置參數frameworkConfiguration - 觸發 single-spa 的
start()
function start(opts: FrameworkConfiguration = {}) {
frameworkConfiguration = {
prefetch: true,
singular: true,
sandbox: true,
...opts,
};
const {
prefetch,
urlRerouteOnly = defaultUrlRerouteOnly,
...importEntryOpts
} = frameworkConfiguration;
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
frameworkConfiguration = autoDowngradeForLowVersionBrowser(
frameworkConfiguration
);
startSingleSpa({ urlRerouteOnly });
started = true;
frameworkStartedDefer.resolve(); // frameworkStartedDefer本質就是一個promise
}
2. 預加載
支持傳入預加載的策略,如果不傳則默認為 true,即默認會觸發 prefetchAfterFirstMounted()
function doPrefetchStrategy(apps, prefetchStrategy, importEntryOpts) {
const appsName2Apps = (names) =>
apps.filter((app) => names.includes(app.name));
if (Array.isArray(prefetchStrategy)) {
prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy), importEntryOpts);
} else if (isFunction(prefetchStrategy)) {
(async () => {
const { criticalAppNames = [], minorAppsName = [] } =
await prefetchStrategy(apps);
prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
})();
} else {
switch (prefetchStrategy) {
case true:
prefetchAfterFirstMounted(apps, importEntryOpts);
break;
case "all":
prefetchImmediately(apps, importEntryOpts);
break;
}
}
}
通過 requestIdleCallback() 控制瀏覽器空閒時進行
importEntry()獲取所有微應用的 entry 資源- 然後再觸發對應
getExternalStyleSheets()獲取外部的 styles 數據 +getExternalScripts()獲取外部的 js 數據
function prefetchAfterFirstMounted(apps, opts) {
window.addEventListener("single-spa:first-mount", function listener() {
const notLoadedApps = apps.filter(
(app) => getAppStatus(app.name) === NOT_LOADED
);
notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));
window.removeEventListener("single-spa:first-mount", listener);
});
}
function prefetch(entry, opts) {
if (!navigator.onLine || isSlowNetwork) {
return;
}
requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(
entry,
opts
);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
3. start()後觸發微應用 mount() 和 unmount()
當用户觸發 start() 後,我們從上面流程圖可以知道,會觸發多個生命週期,比如 app.unmount()、app.bootstrap()、app.mount()
app.unmount()、app.bootstrap()、app.mount()這三個方法的獲取是從微應用註冊時聲明的,從 single-spa 的源碼分析可以知道,是registerApplication()傳入的 app
從下面的代碼可以知道, qiankun 封裝了傳入的 app() 方法,從 loadApp()中獲取 bootstrap、mount、unmount三個方法然後再傳入 registerApplication()
function registerMicroApps(apps, lifeCycles) {
const unregisteredApps = apps.filter(
(app) => !microApps.some((registeredApp) => registeredApp.name === app.name)
);
microApps = [...microApps, ...unregisteredApps];
unregisteredApps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
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,
});
});
}
3.1 核心方法 loadApp()
代碼較為冗長,下面將針對每一個小點進行分析
3.1.1 初始化階段
根據註冊的 name 生成唯一的 appInstanceId
const { entry, name: appName } = app;
const appInstanceId = genAppInstanceIdByName(appName);
const markName = `[qiankun] App ${appInstanceId} Loading`;
3.1.2 初始化配置 & importEntry
初始化配置項
- singular:單例模式
- sandbox:沙箱模式
- excludeAssetFilter:資源過濾
然後使用第三方庫 importEntry 加載微應用的各種數據,包括
template:link 替換為 style 後的 HTML 數據getExternalScripts:需要另外加載的 JS 代碼execScripts:執行 getExternalScripts() 下載 scripts,然後調用 geval() 生成沙箱代碼並執行,確保 JS 在代理的上下文中運行,避免全局污染assetPublicPath:靜態資源地址
const {
singular = false,
sandbox = true,
excludeAssetFilter,
globalContext = window,
...importEntryOpts
} = configuration;
const {
template,
execScripts: execScripts2,
assetPublicPath,
getExternalScripts,
} = await importEntry(entry, importEntryOpts);
await getExternalScripts();
然後執行 getExternalScripts() 下載 scripts
通過上面的importEntry()內部已經觸發了外部styles的下載並且替換到template中
3.1.3 校驗單例模式
如果開啓了單例模式,需要等待前一個應用卸載完成後再加載當前的新應用
if (await validateSingularMode(singular, app)) {
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}
3.1.4 DOM 根容器的創建 & 處理 style 標籤樣式隔離
用一個 <div id=xxx></div> 包裹 importEntry 拿到的微應用的 HTML 模板數據,同時處理模板中的 <style>數據,保證樣式作用域隔離
在接下來的小點中再着重分析樣式隔離的相關邏輯
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
const strictStyleIsolation =
typeof sandbox === "object" && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox);
let initialAppWrapperElement = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId
);
function getDefaultTplWrapper(name, sandboxOpts) {
return (tpl) => {
let tplWithSimulatedHead;
if (tpl.indexOf("<head>") !== -1) {
tplWithSimulatedHead = tpl
.replace("<head>", `<${qiankunHeadTagName}>`)
.replace("</head>", `</${qiankunHeadTagName}>`);
} else {
tplWithSimulatedHead = `<${qiankunHeadTagName}></${qiankunHeadTagName}>${tpl}`;
}
return `<div id="${getWrapperId(
name
)}" data-name="${name}" data-version="${version}" data-sandbox-cfg=${JSON.stringify(
sandboxOpts
)}>${tplWithSimulatedHead}</div>`;
};
}
function createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId
) {
const containerElement = document.createElement("div");
containerElement.innerHTML = appContent;
const appElement = containerElement.firstChild;
if (strictStyleIsolation) {
if (!supportShadowDOM) {
} else {
const { innerHTML } = appElement;
appElement.innerHTML = "";
let shadow;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: "open" });
} else {
shadow = appElement.createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
if (scopedCSS) {
const attr = appElement.getAttribute(QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(QiankunCSSRewriteAttr, appInstanceId);
}
const styleNodes = appElement.querySelectorAll("style") || [];
forEach(styleNodes, (stylesheetElement) => {
process$1(appElement, stylesheetElement, appInstanceId);
});
}
return appElement;
}
3.1.5 渲染函數 render
定義 DOM 的掛載方法,本質就是 dom.appendChild() 這一套邏輯
const render = getRender(appInstanceId, appContent, legacyRender);
render(
{
element: initialAppWrapperElement,
loading: true,
container: initialContainer,
},
"loading"
);
觸發 render() 進行 DOM 的掛載,如下圖所示
3.1.6 沙箱容器的創建
創建對應的 sandbox 容器,構建出對應的
- sandboxContainer.mount
- sandboxContainer.unmount
在接下來的小點中再着重分析沙箱的相關邏輯
if (sandbox) {
sandboxContainer = createSandboxContainer(
appInstanceId,
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
speedySandbox
);
global = sandboxContainer.instance.proxy;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
3.1.7 生命週期鈎子方法的處理
執行 beforeLoad 生命週期的方法
執行 importEntry 拿到的微應用的 execScripts 代碼,注入全局變量global並執行微應用的腳本
global = sandboxContainer.instance.proxy
最終通過微應用的 execScripts 代碼執行拿到對應的聲明週期方法:
bootstarpmountunmount
await execHooksChain(toArray(beforeLoad), app, global);
const scriptExports = await execScripts2(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
(_a = sandboxContainer == null ? void 0 : sandboxContainer.instance) == null
? void 0
: _a.latestSetProp
);
3.1.8 返回 mount、unmount 的對象數據
其中 mount 依次執行
- 初始化容器 DOM
- 檢查容器 DOM ,如果還沒有設置則觸發
createElement()確保容器 DOM 構建完成,進入mounting狀態 - 沙箱激活:運行沙箱導出的 mount() 方法
- 執行生命週期鈎子方法:beforeMount
- 觸發微應用的 mount() 方法,並且傳遞對應的參數,比如
setGlobalState、onGlobalStateChange - 進入
mounted狀態,執行 mounted 掛載成功相關的生命週期方法 - 執行生命週期鈎子方法:afterMount
- 檢測單例模式下的相關邏輯
unmount 依次執行
- 執行生命週期鈎子方法:beforeUnmount
- 觸發微應用的 unmount() 方法
- 沙箱銷燬:運行沙箱導出的 unmount() 方法
- 執行生命週期鈎子方法:afterUnmount
- 觸發 render 進行 真實 DOM 的 卸載
- 檢測單例模式下的相關邏輯
const parcelConfigGetter = (remountContainer = initialContainer) => {
let appWrapperElement;
let appWrapperGetter;
const parcelConfig = {
name: appInstanceId,
bootstrap,
mount: [
// initial wrapper element before app mount/remount
async () => {
appWrapperElement = initialAppWrapperElement;
appWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => appWrapperElement
);
},
// 添加 mount hook, 確保每次應用加載前容器 dom 結構已經設置完畢
async () => {
const useNewContainer = remountContainer !== initialContainer;
if (useNewContainer || !appWrapperElement) {
appWrapperElement = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId
);
syncAppWrapperElement2Sandbox(appWrapperElement);
}
render(
{
element: appWrapperElement,
loading: true,
container: remountContainer,
},
"mounting"
);
},
mountSandbox,
// exec the chain after rendering to keep the behavior with beforeLoad
async () => execHooksChain(toArray(beforeMount), app, global),
async (props) =>
mount({
...props,
container: appWrapperGetter(),
setGlobalState,
onGlobalStateChange,
}),
// finish loading after app mounted
async () =>
render(
{
element: appWrapperElement,
loading: false,
container: remountContainer,
},
"mounted"
),
async () => execHooksChain(toArray(afterMount), app, global),
],
unmount: [
async () => execHooksChain(toArray(beforeUnmount), app, global),
async (props) => unmount({ ...props, container: appWrapperGetter() }),
unmountSandbox,
async () => execHooksChain(toArray(afterUnmount), app, global),
async () => {
render(
{ element: null, loading: false, container: remountContainer },
"unmounted"
);
offGlobalStateChange(appInstanceId);
appWrapperElement = null;
syncAppWrapperElement2Sandbox(appWrapperElement);
},
async () => {
if (
(await validateSingularMode(singular, app)) &&
prevAppUnmountedDeferred
) {
prevAppUnmountedDeferred.resolve();
}
},
],
};
if (typeof update === "function") {
parcelConfig.update = update;
}
return parcelConfig;
};
return parcelConfigGetter;
4. 監聽路由變化觸發 reroute()
本質就是觸發 loadApp() 進行應用具體邏輯的加載
當加載 single-spa 的代碼後,會直接監聽路由的變化,當路由發生變化時,會觸發reroute(),從而觸發 performAppChanges()
single-spa.performAppChanges() 進行舊的路由的卸載以及新的路由的加載
本質就是觸發
app.unmount()觸發微應用的卸載app.bootstrap()->app.mount()觸發微應用的加載
5. 樣式隔離
在上面的DOM 根容器的創建 & 處理 style 標籤樣式隔離分析中
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
const strictStyleIsolation =
typeof sandbox === "object" && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox);
let initialAppWrapperElement = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId
);
如果我們在 qiankun.start({sandbox: {}}) 傳入一個 sandbox 的配置對象數據,那麼我們就可以開啓
- 嚴格隔離模式
strictStyleIsolation=true - 實驗性的樣式隔離模式
experimentalStyleIsolation=true
sandbox -
boolean|{ strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }- 可選,是否開啓沙箱,默認為true。默認情況下沙箱可以確保單實例場景子應用之間的樣式隔離,但是無法確保主應用跟子應用、或者多實例場景的子應用樣式隔離。當配置為
{ strictStyleIsolation: true }時表示開啓嚴格的樣式隔離模式。這種模式下 qiankun 會為每個微應用的容器包裹上一個 shadow dom 節點,從而確保微應用的樣式不會對全局造成影響
注:上面兩種模式是互斥,不能同時存在
const scopedCSS = isEnableScopedCSS(sandbox);
function isEnableScopedCSS(sandbox) {
if (typeof sandbox !== "object") {
return false;
}
if (sandbox.strictStyleIsolation) {
return false;
}
return !!sandbox.experimentalStyleIsolation;
}
const strictStyleIsolation =
typeof sandbox === "object" && !!sandbox.strictStyleIsolation;
如果開啓了嚴格樣式隔離strictStyleIsolation,則創建一個 Shadow 包裹 importEntry 加載微應用得到的 HTML 模板數據
function createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId
) {
const containerElement = document.createElement("div");
containerElement.innerHTML = appContent;
const appElement = containerElement.firstChild;
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;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: "open" });
} else {
shadow = appElement.createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
if (scopedCSS) {
const attr = appElement.getAttribute(QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(QiankunCSSRewriteAttr, appInstanceId);
}
const styleNodes = appElement.querySelectorAll("style") || [];
forEach(styleNodes, (stylesheetElement) => {
process$1(appElement, stylesheetElement, appInstanceId);
});
}
return appElement;
}
如果開啓了experimentalStyleIsolation,則使用
processor = new ScopedCSS()- 使用
processor.process()進行樣式前綴的重寫
const process$1 = (appWrapper, stylesheetElement, appName) => {
if (!processor) {
processor = new ScopedCSS();
}
if (stylesheetElement.tagName === "LINK") {
console.warn(
"Feature: sandbox.experimentalStyleIsolation is not support for link element yet."
);
}
const mountDOM = appWrapper;
if (!mountDOM) {
return;
}
const tag = (mountDOM.tagName || "").toLowerCase();
if (tag && stylesheetElement.tagName === "STYLE") {
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
processor.process(stylesheetElement, prefix);
}
};
在 process()中,主要進行
- 調用
rewrite(rules, prefix)重寫規則,生成帶前綴的 CSS 文本 - 若
styleNode內容為空,則通過MutationObserver監聽動態添加的子節點,確保異步加載的樣式也能被處理
rewrite(rules, prefix) 主要分為 3 種情況進行處理:
ruleStyle(rule, prefix):處理普通 CSS 規則ruleMedia(rule, prefix):遞歸處理媒體查詢規則ruleSupport(rule, prefix):遞歸處理@supports條件規則
rewrite(rules, prefix = "") {
let css = "";
rules.forEach((rule) => {
switch (rule.type) {
case 1:
css += this.ruleStyle(rule, prefix);
break;
case 4:
css += this.ruleMedia(rule, prefix);
break;
case 12:
css += this.ruleSupport(rule, prefix);
break;
default:
if (typeof rule.cssText === "string") {
css += `${rule.cssText}`;
}
break;
}
});
return css;
}
5.1 ruleStyle()
通過正則匹配 rootSelectorRE 和 rootCombinationRE,匹配html、body、:root 等全局選擇器以及匹配 html 後跟隨其他選擇器的組合(如 html .class)
ruleStyle(rule, prefix) {
const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
const rootCombinationRE = /(html[^\w{[]+)/gm;
const selector = rule.selectorText.trim();
let cssText = "";
if (typeof rule.cssText === "string") {
cssText = rule.cssText;
}
if (selector === "html" || selector === "body" || selector === ":root") {
return cssText.replace(rootSelectorRE, prefix);
}
if (rootCombinationRE.test(rule.selectorText)) {
const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;
if (!siblingSelectorRE.test(rule.selectorText)) {
cssText = cssText.replace(rootCombinationRE, "");
}
}
cssText = cssText.replace(
/^[\s\S]+{/,
(selectors) => selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
if (rootSelectorRE.test(item)) {
return item.replace(rootSelectorRE, (m) => {
const whitePrevChars = [",", "("];
if (m && whitePrevChars.includes(m[0])) {
return `${m[0]}${prefix}`;
}
return prefix;
});
}
return `${p}${prefix} ${s.replace(/^ */, "")}`;
})
);
return cssText;
}
- 如果匹配到
html、body、:root,則新增前面的作用域[data-qiankun="app"] - 如果匹配到
html後跟隨其他選擇器的組合,則移除html+ 新增前面的作用域[data-qiankun="app"] - 如果匹配到其他選擇器,直接新增前面的作用域
[data-qiankun="app"]
/* 原始 CSS */
body { background: blue; }
.my-class { color: red; }
html .header { font-size: 20px; }
/* 處理後 CSS(假設 prefix 為 [data-qiankun="app"]) */
[data-qiankun="app"] { background: blue; }
[data-qiankun="app"] .my-class { color: red; }
[data-qiankun="app"] .header { font-size: 20px; }
5.2 ruleMedia()
遞歸調用 rewrite() 處理媒體查詢內部的規則,保持媒體查詢條件不變
ruleMedia(rule, prefix) {
const css = this.rewrite(arrayify(rule.cssRules), prefix);
return `@media ${rule.conditionText || rule.media.mediaText} {${css}}`;
}
/* 原始 CSS */
@media screen and (max-width: 600px) {
.box { width: 100%; }
}
/* 處理後 CSS */
@media screen and (max-width: 600px) {
[data-qiankun="app"] .box { width: 100%; }
}
5.3 ruleSupport()
遞歸調用 rewrite 處理 @supports 條件內部的規則,保持條件不變
ruleSupport(rule, prefix) {
const css = this.rewrite(arrayify(rule.cssRules), prefix);
return `@supports ${rule.conditionText || rule.cssText.split("{")[0]} {${css}}`;
}
/* 原始 CSS */
@supports (display: grid) {
.grid { display: grid; }
}
/* 處理後 CSS */
@supports (display: grid) {
[data-qiankun="app"] .grid { display: grid; }
}
6. 沙箱機制
沙箱機制主要是用來隔離微應用之間的全局變量和副作用,防止基座和微應用以及微應用和微應用之間相互干擾
qiankun 使用了三種沙箱實現:
ProxySandbox:支持Proxy的現代瀏覽器環境 並且 註冊微應用傳入{sandbox: {loose: true}}LegacySandbox:支持Proxy的現代瀏覽器環境,默認使用的沙箱SnapshotSandbox:不支持Proxy的舊瀏覽器環境
const useLooseSandbox = typeof sandbox === "object" && !!sandbox.loose;
function createSandboxContainer(...) {
let sandbox;
if (window.Proxy) {
sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) :
new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox });
} else {
sandbox = new SnapshotSandbox(appName);
}
}
6.1 ProxySandbox
class ProxySandbox {
constructor() {
const { fakeWindow, propertiesWithGetter } = createFakeWindow(
globalContext,
!!speedy
);
const proxy = new Proxy(fakeWindow, {
set: () => {},
get: ()=> {}
//...
});
}
active()
inactive()
}
6.1.1 createFackWindow()
使用 createFackWindow() 構建一個模擬的 window 全局對象
傳入參數:
- 傳入
globalContext = window speedy:微應用註冊時聲明,用於某些屬性的優化處理,為了解決某些情況下 with 導致的卡頓問題
獲取全局對象所有的屬性名(即 window 的所有屬性),然後篩選出不可配置的屬性(configurable = false)
這些不可配置的屬性一般都是原生屬性或者不可刪除的屬性
然後遍歷這些屬性 p,獲取屬性描述符 descriptor
對一些特殊屬性先進行處理:
- 瀏覽器安全相關的屬性,需允許沙箱內修改:
top、parent、self、window - 在性能優化模式下
speedy的document屬性
對上面這些屬性,先更改為可配置 configurable = true;如果沒有getter,則設置為可寫模式writeable = true
對於所有的全局屬性
- 對於有
getter的屬性,添加到propertiesWithGetter對象中(後續在Proxy中攔截這些屬性時,直接返回原始值,避免代理破壞原生行為) - 然後在
fakeWindow上定義這些屬性
最終返回全局對象 fakeWindow 和 特殊屬性記錄對象 propertiesWithGetter
const speedySandbox =
typeof sandbox === "object" ? sandbox.speedy !== false : true;
function createFakeWindow(globalContext, speedy) {
const propertiesWithGetter = /* @__PURE__ */ new Map();
const fakeWindow = {};
Object.getOwnPropertyNames(globalContext)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
return !(descriptor == null ? void 0 : descriptor.configurable);
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
if (descriptor) {
const hasGetter = Object.prototype.hasOwnProperty.call(
descriptor,
"get"
);
if (
p === "top" ||
p === "parent" ||
p === "self" ||
p === "window" || // window.document is overwriting in speedy mode
(p === "document" && speedy) ||
(inTest && (p === mockTop || p === mockSafariTop))
) {
descriptor.configurable = true;
if (!hasGetter) {
descriptor.writable = true;
}
}
if (hasGetter) propertiesWithGetter.set(p, true);
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
6.1.2 new Proxy(fakeWindow)
使用 Proxy 對 fakeWindow 進行劫持
const proxy = new Proxy(fakeWindow, {
set: ()=> {}
get: ()=> {}
//...
}
沙箱運行時(sandboxRunning = true),記錄修改的屬性到 upatedValueSet(無論是白名單還是非白名單屬性)
- 白名單屬性(比如
System、__cjsWrapper、React 調試鈎子)同步到 全局對象globalContext - 非白名單屬性則寫入
fakeWindow,如果 全局對象globalContext存在該屬性而fakeWindow不存在該屬性,則調整writable:true兼容之前的設置
沙箱非運行狀態則直接返回 true
set: (target, p, value): boolean => {
if (this.sandboxRunning) {
this.registerRunningApp(name, proxy);
// 白名單屬性同步到全局
if (typeof p === 'string' && globalVariableWhiteList.includes(p)) {
this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p);
globalContext[p] = value;
} else {
// 非白名單屬性寫入 fakeWindow
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
// 處理全局已存在的屬性(修正描述符)
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
const { writable, configurable, enumerable, set } = descriptor!;
if (writable || set) {
Object.defineProperty(target, p, { configurable, enumerable, writable: true, value });
}
} else {
target[p] = value;
}
}
updatedValueSet.add(p);
this.latestSetProp = p;
return true;
}
// 沙箱非活躍時警告
if (process.env.NODE_ENV === 'development') {
console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
}
return true;
}
對各種情況進行處理
- 防止逃逸:全局對象
window、self、globalThis代理,直接返回代理proxy -
特殊屬性:
- top/parent 如果你的主應用程序處於 iframe 上下文中,允許屬性逃逸,返回 globalContext,否則返回 proxy
- document 返回沙箱自己構建的 document
- eval 返回原生 eval
- 白名單屬性處理:直接返回全局對象
globalContext[p] - 凍結屬性處理:對於一些
configurable=false&writable=false的屬性,嘗試從globalContext->target進行判斷獲取 - 原生 API 修正:對於
fetch需要綁定原生上下文的方法進行重新綁定並且返回值
其它屬性(不是需要重新綁定的屬性 + 非凍結屬性),從globalContext[p]->target[p]進行判斷獲取
export const nativeGlobal = new Function('return this')();
const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([
['fetch', true],
['mockDomAPIInBlackList', process.env.NODE_ENV === 'test'],
]);
const cachedGlobalsInBrowser = array2TruthyObject(
globalsInBrowser.concat(process.env.NODE_ENV === 'test' ? ['mockNativeWindowFunction'] : []),
);
function isNativeGlobalProp(prop: string): boolean {
return prop in cachedGlobalsInBrowser;
}
get: (target, p) => {
this.registerRunningApp(name, proxy);
if (p === Symbol.unscopables) return unscopables;
// 代理全局對象(防止逃逸)
if (p === "window" || p === "self" || p === "globalThis") {
return proxy;
}
// 特殊屬性處理
if (p === "top" || p === "parent") {
// 如果你的主應用程序處於 iframe 上下文中,請允許這些屬性逃離沙盒
return globalContext === globalContext.parent ? proxy : globalContext[p];
}
if (p === "document") return this.document;
if (p === "eval") return eval;
// 白名單屬性直接返回全局值
if (globalVariableWhiteList.includes(p)) return globalContext[p];
// 凍結屬性直接返回(避免重綁定)
const actualTarget = propertiesWithGetter.has(p)
? globalContext
: p in target
? target
: globalContext;
if (isPropertyFrozen(actualTarget, p)) return actualTarget[p];
// 原生屬性綁定到原生上下文(如 fetch.bind(window))
if (isNativeGlobalProp(p) || useNativeWindowForBindingsProps.has(p)) {
const boundTarget = useNativeWindowForBindingsProps.get(p)
? nativeGlobal
: globalContext;
return rebindTarget2Fn(boundTarget, actualTarget[p]);
}
return actualTarget[p];
};
has:從cachedGlobalObjects、target、globalContext檢查是否具有該屬性getOwnPropertyDescriptor:優先fakeWindow,如果不存在,則從globalContext中獲取descriptor並且標記為可配置configurable=trueownKeys:合併fakeWindow和globalContext的 keydeleteProperty:從fakeWindow刪除屬性 +updatedValueSet刪除對應的記錄
has: (target, p) =>
p in cachedGlobalObjects || p in target || p in globalContext;
getOwnPropertyDescriptor: (target, p) => {
if (target.hasOwnProperty(p)) {
descriptorTargetMap.set(p, "target");
return Object.getOwnPropertyDescriptor(target, p);
}
if (globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
descriptorTargetMap.set(p, "globalContext");
if (descriptor && !descriptor.configurable) descriptor.configurable = true; // 兼容性調整
return descriptor;
}
return undefined;
};
ownKeys: (target) =>
uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target)));
deleteProperty: (target, p) => {
if (target.hasOwnProperty(p)) {
delete target[p];
updatedValueSet.delete(p);
}
return true;
};
6.1.3 active() & inactive()
active():激活沙箱,activeSandboxCount++inactive():在沙箱停用時恢復全局白名單屬性的原始值(在 new Proxy 的 set() 已經存儲到globalWhitelistPrevDescriptor中),否則直接在原生globalContext中刪除該屬性
active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
inactive() {
if (inTest || --activeSandboxCount === 0) {
// reset the global value to the prev value
Object.keys(this.globalWhitelistPrevDescriptor).forEach((p) => {
const descriptor = this.globalWhitelistPrevDescriptor[p];
if (descriptor) {
Object.defineProperty(this.globalContext, p, descriptor);
} else {
// @ts-ignore
delete this.globalContext[p];
}
});
}
this.sandboxRunning = false;
}
6.2 LegacySandbox
為了兼容性 singular 模式下依舊使用該沙箱,等新沙箱穩定之後再切換
跟ProxySandbox一樣,也是基於 Proxy 實現的沙箱
但是這個沙箱只考慮單例模式,直接操作原生的 window對象,記錄原始值然後實現卸載時恢復原生 window對象
6.2.1 Proxy.set()
在 set() 中
- 如果原生
window對象不存在該屬性,則添加到addedPropsMapInSandbox中 - 如果原生
window對象存在該屬性,則添加到modifiedPropsOriginalValueMapInSandbox中
並且使用 currentUpdatedPropsValueMap 進行該屬性的存儲,同時改變原生window對應的屬性
const setTrap = (p, value, originalValue, sync2Window = true) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
currentUpdatedPropsValueMap.set(p, value);
if (sync2Window) {
rawWindow[p] = value;
}
this.latestSetProp = p;
return true;
}
return true;
};
6.2.2 inactive()
在卸載時,使用之前記錄的 addedPropsMapInSandbox 和 modifiedPropsOriginalValueMapInSandbox 恢復 原生window對象
inactive() {
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, void 0, true));
this.sandboxRunning = false;
}
6.2.3 get()
在 get() 中,如果是特殊屬性,直接返回當前代理全局對象proxy,否則返回 rawWindow[p](因為原生的 window 已經被改變)
get(_, p) {
if (p === "top" || p === "parent" || p === "window" || p === "self") {
return proxy;
}
const value = rawWindow[p];
return rebindTarget2Fn(rawWindow, value);
},
僅處理
fn是可調用函數fn未被綁定過fn不是構造函數
如 window.console、window.atob 這類
然後將fn的this綁定到target,創建出新的綁定函數boundValue,複製fn的所有屬性到新創建的函數boundValue上 + 處理原型保證信息一致
重寫原來的fn.toString方法,如果新的函數boundValue沒有toString,則調用原來fn.toString()方法,如果有,則觸發boundValue的toString()
function rebindTarget2Fn(target, fn) {
if (isCallable(fn) && !isBoundedFunction(fn) && !isConstructable(fn)) {
const cachedBoundFunction = functionBoundedValueMap.get(fn);
if (cachedBoundFunction) {
return cachedBoundFunction;
}
const boundValue = Function.prototype.bind.call(fn, target);
Object.getOwnPropertyNames(fn).forEach((key) => {
if (!boundValue.hasOwnProperty(key)) {
Object.defineProperty(
boundValue,
key,
Object.getOwnPropertyDescriptor(fn, key)
);
}
});
if (
fn.hasOwnProperty("prototype") &&
!boundValue.hasOwnProperty("prototype")
) {
Object.defineProperty(boundValue, "prototype", {
value: fn.prototype,
enumerable: false,
writable: true,
});
}
if (typeof fn.toString === "function") {
const valueHasInstanceToString =
fn.hasOwnProperty("toString") && !boundValue.hasOwnProperty("toString");
const boundValueHasPrototypeToString =
boundValue.toString === Function.prototype.toString;
if (valueHasInstanceToString || boundValueHasPrototypeToString) {
const originToStringDescriptor = Object.getOwnPropertyDescriptor(
valueHasInstanceToString ? fn : Function.prototype,
"toString"
);
Object.defineProperty(
boundValue,
"toString",
Object.assign(
{},
originToStringDescriptor,
(
originToStringDescriptor == null
? void 0
: originToStringDescriptor.get
)
? null
: { value: () => fn.toString() }
)
);
}
}
functionBoundedValueMap.set(fn, boundValue);
return boundValue;
}
return fn;
}
通過上面的處理,確保綁定後的函數在沙箱內外行為一致,避免因為上下文切換導致報錯(微應用中調用時會拋出 Illegal invocation 異常)
6.2.4 active()
在恢復沙箱時,會從之前set() 存儲的currentUpdatedPropsValueMap中進行 window 對象屬性值的恢復
active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
}
this.sandboxRunning = true;
}
private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
if (value === undefined && toDelete) {
// eslint-disable-next-line no-param-reassign
delete (this.globalContext as any)[prop];
} else if (isPropConfigurable(this.globalContext, prop) && typeof prop !== 'symbol') {
Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true });
// eslint-disable-next-line no-param-reassign
(this.globalContext as any)[prop] = value;
}
}
6.3 SnapshotSandbox
- 在
active()時,使用一個windowSnapshot保存原生 window 對象的所有屬性,然後恢復之前的modifyPropsMap所有修改的屬性到 window 對象上 - 在
inactive()時,將目前所有的修改都存放到modifyPropsMap上去,然後使用windowSnapshot進行原生 window 對象的屬性恢復
class SnapshotSandbox {
constructor(name) {
//...
this.name = name;
this.proxy = window;
this.type = SandBoxType.Snapshot;
}
active() {
this.windowSnapshot = {};
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
Object.keys(this.modifyPropsMap).forEach((p) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
inactive() {
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
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;
}
patchDocument() {}
}
6.4 mount() & unmount()
在unmount()時,會
- 執行
patchAtBootstrapping()和patchAtMounting()拿到的free()方法,然後拿到free()執行完畢後返回的rebuild()存儲到sideEffectsRebuilders中 - 觸發
sandbox.inactive()
在進行mount()時,會按照順序執行
sandbox.active()- 執行上一次
patchAtBootstrapping()卸載時執行的free()返回的rebuild() patchAtMounting()- 執行上一次
patchAtMounting()卸載時執行的free()返回的rebuild() - 清除所有
rebuild()
const bootstrappingFreers = patchAtBootstrapping(
appName,
elementGetter,
sandbox,
scopedCSS,
excludeAssetFilter,
speedySandBox
);
return {
instance: sandbox,
async mount() {
sandbox.active();
const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(
0,
bootstrappingFreers.length
);
const sideEffectsRebuildersAtMounting = sideEffectsRebuilders.slice(
bootstrappingFreers.length
);
if (sideEffectsRebuildersAtBootstrapping.length) {
sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
}
mountingFreers = patchAtMounting(
appName,
elementGetter,
sandbox,
scopedCSS,
excludeAssetFilter,
speedySandBox
);
if (sideEffectsRebuildersAtMounting.length) {
sideEffectsRebuildersAtMounting.forEach((rebuild) => rebuild());
}
sideEffectsRebuilders = [];
},
async unmount() {
sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map(
(free) => free()
);
sandbox.inactive();
},
};
從下面代碼可以看出,
patchAtBootstrapping():順序執行的是patchLooseSandbox()/patchStrictSandbox(),然後拿到對應的free()patchAtMounting():順序執行的是patchInterval()->patchWindowListener()->patchHistoryListener()->patchLooseSandbox()/patchStrictSandbox(),然後拿到對應的free()
function patchAtBootstrapping(
appName,
elementGetter,
sandbox,
scopedCSS,
excludeAssetFilter,
speedySandBox
) {
const patchersInSandbox = {
[SandBoxType.LegacyProxy]: [
() =>
patchLooseSandbox(
appName,
elementGetter,
sandbox,
false,
scopedCSS,
excludeAssetFilter
),
],
[SandBoxType.Proxy]: [
() =>
patchStrictSandbox(
appName,
elementGetter,
sandbox,
false,
scopedCSS,
excludeAssetFilter,
speedySandBox
),
],
[SandBoxType.Snapshot]: [
() =>
patchLooseSandbox(
appName,
elementGetter,
sandbox,
false,
scopedCSS,
excludeAssetFilter
),
],
};
return patchersInSandbox[sandbox.type]?.map((patch) => patch());
}
export function patchAtMounting() {
const basePatchers = [
() => patchInterval(sandbox.proxy),
() => patchWindowListener(sandbox.proxy),
() => patchHistoryListener(),
];
const patchersInSandbox = {
[SandBoxType.LegacyProxy]: [
...basePatchers,
() =>
patchLooseSandbox(
appName,
elementGetter,
sandbox,
true,
scopedCSS,
excludeAssetFilter
),
],
[SandBoxType.Proxy]: [
...basePatchers,
() =>
patchStrictSandbox(
appName,
elementGetter,
sandbox,
true,
scopedCSS,
excludeAssetFilter,
speedySandBox
),
],
[SandBoxType.Snapshot]: [
...basePatchers,
() =>
patchLooseSandbox(
appName,
elementGetter,
sandbox,
true,
scopedCSS,
excludeAssetFilter
),
],
};
return patchersInSandbox[sandbox.type]?.map((patch) => patch());
}
6.4.1 patchInterval()
在微應用內部調用 setInterval() 或者 clearInterval() 時,會直接觸發原生的 window.setInterval() 和 window.clearInterval()
在 free() 中,會直接將所有註冊的 intervals 全部進行 clearInterval(),然後恢復全局方法 window.setInterval 和 window.clearInterval
重寫setInterval()和clearInterval()只是為了在free()的時候能夠移除所有的定時器
const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;
function patch(global: Window) {
let intervals: number[] = [];
global.clearInterval = (intervalId: number) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval.call(window, intervalId as any);
};
global.setInterval = (handler: CallableFunction, timeout?: number, ...args: any[]) => {
const intervalId = rawWindowInterval(handler, timeout, ...args);
intervals = [...intervals, intervalId];
return intervalId;
};
return function free() {
intervals.forEach((id) => global.clearInterval(id));
global.setInterval = rawWindowInterval;
global.clearInterval = rawWindowClearInterval;
return noop;
};
}
6.4.2 patchWindowListener()
跟 patchInterval() 類似,這裏對 window.addEventListener 和 window.removeEventListener 進行重寫,然後在 free() 時進行所有事件的移除和以及原生方法恢復
6.4.3 patchHistoryListener()
修復 UmiJS 相關的路由功能
跟patchInterval() 和 patchWindowListener() 類似,對window.g_history 進行重寫,然後在 free() 時進行所有 window.g_history.listen監聽的移除
這裏比較特殊的是:rebuild() 必須使用 window.g_history.listen 的方式重新綁定 listener,從而能保證 rebuild 這部分也能被捕獲到,否則在應用卸載後無法正確的移除這部分副作用
function patch() {
let rawHistoryListen = (_) => noop;
const historyListeners = [];
const historyUnListens = [];
if (window.g_history && isFunction(window.g_history.listen)) {
rawHistoryListen = window.g_history.listen.bind(window.g_history);
window.g_history.listen = (listener) => {
historyListeners.push(listener);
const unListen = rawHistoryListen(listener);
historyUnListens.push(unListen);
return () => {
unListen();
historyUnListens.splice(historyUnListens.indexOf(unListen), 1);
historyListeners.splice(historyListeners.indexOf(listener), 1);
};
};
}
return function free() {
let rebuild = noop;
if (historyListeners.length) {
rebuild = () => {
historyListeners.forEach((listener) =>
window.g_history.listen(listener)
);
};
}
historyUnListens.forEach((unListen) => unListen());
if (window.g_history && isFunction(window.g_history.listen)) {
window.g_history.listen = rawHistoryListen;
}
return rebuild;
};
}
6.4.4 patchLooseSandbox()
從下面代碼可以看出,主要邏輯集中在下面幾個方法中:
- patchHTMLDynamicAppendPrototypeFunctions()
- unpatchDynamicAppendPrototypeFunctions()
- recordStyledComponentsCSSRules()
- rebuildCSSRules()
function patchLooseSandbox() {
const { proxy } = sandbox;
let dynamicStyleSheetElements = [];
const unpatchDynamicAppendPrototypeFunctions =
patchHTMLDynamicAppendPrototypeFunctions(
() =>
checkActivityFunctions(window.location).some(
(name) => name === appName
),
() => ({
appName,
appWrapperGetter,
proxy,
strictGlobal: false,
speedySandbox: false,
scopedCSS,
dynamicStyleSheetElements,
excludeAssetFilter,
})
);
return function free() {
if (isAllAppsUnmounted()) unpatchDynamicAppendPrototypeFunctions();
recordStyledComponentsCSSRules(dynamicStyleSheetElements);
return function rebuild() {
rebuildCSSRules(dynamicStyleSheetElements, (stylesheetElement) => {
const appWrapper = appWrapperGetter();
if (!appWrapper.contains(stylesheetElement)) {
document.head.appendChild.call(appWrapper, stylesheetElement);
return true;
}
return false;
});
if (mounting) {
dynamicStyleSheetElements = [];
}
};
};
}
6.4.4.1 patchHTMLDynamicAppendPrototypeFunctions()
劫持並重寫 appendChild、insertBefore、removeChild等 DOM 操作方法,實現對微應用動態場景的 <style>、<link>、<script>標籤的隔離和管理,實現:
- 隔離微應用資源:將樣式轉化為內聯樣式插入到微應用中,將 JS 代碼轉化為沙箱代碼進行隔離,防止微應用的 CSS 和 JS 污染基座
- 動態資源跟蹤:記錄微應用動態創建的資源,便於後續微應用 unmount 時移除資源 + 重新激活微應用時恢復資源
function patchHTMLDynamicAppendPrototypeFunctions(
isInvokedByMicroApp,
containerConfigGetter
) {
const rawHeadAppendChild2 = HTMLHeadElement.prototype.appendChild;
//...
if (
rawHeadAppendChild2[overwrittenSymbol] !== true &&
rawBodyAppendChild[overwrittenSymbol] !== true &&
rawHeadInsertBefore2[overwrittenSymbol] !== true
) {
HTMLHeadElement.prototype.appendChild =
getOverwrittenAppendChildOrInsertBefore({
rawDOMAppendOrInsertBefore: rawHeadAppendChild2,
containerConfigGetter,
isInvokedByMicroApp,
target: "head",
});
//...
}
const rawHeadRemoveChild = HTMLHeadElement.prototype.removeChild;
//...
if (
rawHeadRemoveChild[overwrittenSymbol] !== true &&
rawBodyRemoveChild[overwrittenSymbol] !== true
) {
HTMLHeadElement.prototype.removeChild = getNewRemoveChild(
rawHeadRemoveChild,
containerConfigGetter,
"head",
isInvokedByMicroApp
);
//...
}
return function unpatch() {
HTMLHeadElement.prototype.appendChild = rawHeadAppendChild2;
HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;
HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore2;
};
}
從上面代碼可以知道,劫持重寫主要涉及到兩個方法
getOverwrittenAppendChildOrInsertBefore()重寫appendChild、insertBeforegetNewRemoveChild()重寫removeChild
6.4.4.1.1 getOverwrittenAppendChildOrInsertBefore()
function getOverwrittenAppendChildOrInsertBefore(opts) {
function appendChildOrInsertBefore(newChild, refChild = null) {
// opts參數:原始方法、是否由子應用調用、容器配置、目標容器(head/body)
const {
rawDOMAppendOrInsertBefore,
isInvokedByMicroApp,
containerConfigGetter,
target = "body",
} = opts;
let element = newChild;
// 非劫持標籤(非 script/style/link)或非子應用調用時,直接調用原始方法
if (!isHijackingTag(element.tagName) || !isInvokedByMicroApp(element)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild);
}
// ...
}
appendChildOrInsertBefore[overwrittenSymbol] = true; // 標記為已重寫
return appendChildOrInsertBefore;
}
isHijackingTag():<style>、<link>、<script>元素isInvokedByMicroApp():檢測當前的路由是否是 active 路由
在
patchLooseSandbox()中,isInvokedByMicroApp()僅僅檢測當前的路由是否是 active 路由但是在
patchStrictSandbox()中,isInvokedByMicroApp()是檢測當前的 element 是否是微應用動態創建的元素
// single-spa的checkActivityFunctions
function checkActivityFunctions() {
var location = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : window.location;
return apps.filter(function (app) {
return app.activeWhen(location);
}).map(toName);
}
// isInvokedByMicroApp = () => checkActivityFunctions(window.location).some((name) => name === appName)
if (!isHijackingTag(element.tagName) || !isInvokedByMicroApp(element)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
}
function getOverwrittenAppendChildOrInsertBefore(opts) {
function appendChildOrInsertBefore(newChild, refChild = null) {
// opts參數:原始方法、是否由子應用調用、容器配置、目標容器(head/body)
const {
rawDOMAppendOrInsertBefore,
isInvokedByMicroApp,
containerConfigGetter,
target = "body",
} = opts;
let element = newChild;
// 非劫持標籤(非 script/style/link)或非子應用調用時,直接調用原始方法
if (!isHijackingTag(element.tagName) || !isInvokedByMicroApp(element)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild);
}
switch (element.tagName) {
case LINK_TAG_NAME:
case STYLE_TAG_NAME: {
//...
}
case SCRIPT_TAG_NAME: {
//...
}
}
// ...
}
appendChildOrInsertBefore[overwrittenSymbol] = true; // 標記為已重寫
return appendChildOrInsertBefore;
}
劫持appendChild、insertBefore進行重寫後,如果插入的是
-
link和style標籤- 如果命中
excludeAssetFilter,則直接插入 - 否則將 link 轉化為 style 標籤進行內聯,並且通過 ScopedCSS 重寫該內聯 CSS 規則,添加前綴的命名空間,實現樣式隔離 + 記錄樣式到
dynamicStyleSheetElements中
- 如果命中
case LINK_TAG_NAME:
case STYLE_TAG_NAME: {
const { href, rel } = element;
// 如果資源被排除過濾器(excludeAssetFilter)捕獲,直接插入
if (excludeAssetFilter && href && excludeAssetFilter(href)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild);
}
// 標記元素的目標容器(head/body),並記錄到 appWrapper 中
defineNonEnumerableProperty(stylesheetElement, styleElementTargetSymbol, target);
const appWrapper = appWrapperGetter(); // 獲取子應用容器
if (scopedCSS) { // 啓用 CSS 作用域隔離時
if (element.tagName === "LINK" && rel === "stylesheet" && href) {
// 將 link 轉換為 style 標籤,並內聯 CSS 內容(防止全局污染)
stylesheetElement = convertLinkAsStyle(...);
}
// 通過 ScopedCSS 類重寫 CSS 規則,添加命名空間前綴
const scopedCSSInstance = new _ScopedCSS();
scopedCSSInstance.process(stylesheetElement, prefix);
}
// 插入到子應用容器的指定位置
const mountDOM = target === "head" ? getAppWrapperHeadElement(appWrapper) : appWrapper;
const result = rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode);
dynamicStyleSheetElements.push(stylesheetElement); // 記錄動態樣式表
return result;
}
-
script標籤- 如果命中
excludeAssetFilter,則直接插入,不做任何處理 - 如果
elemnet.src存在,則進行 js 文件的下載並存入緩存中,然後對每一個 JS 都調用geval(scriptSrc, inlineScript)外層進行沙箱代碼的包裹(with(window) { ...srcipt })並且執行,然後將動態插入的內容轉化為註釋節點 - 如果
elemnet.src不存在,説明是內聯 js,對每一個 JS 都調用geval(scriptSrc, inlineScript)外層進行沙箱代碼的包裹(with(window) { ...srcipt })並且執行,然後替換為註釋節點,然後將動態插入的內容轉化為註釋節點
- 如果命中
case SCRIPT_TAG_NAME: {
const { src, text } = element;
// 如果資源被排除或非可執行腳本類型,直接插入
if (excludeAssetFilter && src && excludeAssetFilter(src) || !isExecutableScriptType(element)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild);
}
if(src) {
// 外部js:執行子應用腳本
execScripts(null, [src], proxy, {fetch: fetch2, ...})
// 替換為註釋節點,防止重複執行
const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
dynamicScriptAttachedCommentMap.set(element, dynamicScriptCommentElement);
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode);
} else {
// 內聯js:執行子應用腳本
execScripts(proxy, [`<script>${text}</script>`], { strictGlobal, scopedGlobalVariables });
// 替換為註釋節點,防止重複執行
const dynamicInlineScriptCommentElement = document.createComment("dynamic inline script replaced by qiankun");
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, refChild);
}
}
6.4.4.1.2 getNewRemoveChild()
跟 getOverwrittenAppendChildOrInsertBefore() 類似,在兩個條件的判斷後:
isHijackingTag():<style>、<link>、<script>元素isInvokedByMicroApp():檢測當前的路由是否是 active 路由
才會觸發 removeChild() 的處理
也是針對兩種類型進行處理
-
link和style標籤:dynamicLinkAttachedInlineStyleMap獲取對應的 style 標籤(如果不存在,則還是 child),然後調用原生的 removeChild 進行刪除- 移除動態樣式
dynamicStyleSheetElements的數據對應的 child 數據,防止恢復微應用激活狀態時錯誤將它恢復
case STYLE_TAG_NAME:
case LINK_TAG_NAME: {
attachedElement = dynamicLinkAttachedInlineStyleMap.get(child) || child;
const dynamicElementIndex = dynamicStyleSheetElements.indexOf(attachedElement);
if (dynamicElementIndex !== -1) {
dynamicStyleSheetElements.splice(dynamicElementIndex, 1);
}
break;
}
const appWrapper = appWrapperGetter();
const container = target === "head" ? getAppWrapperHeadElement(appWrapper) : appWrapper;
if (container.contains(attachedElement)) {
return rawRemoveChild2.call(attachedElement.parentNode, attachedElement);
}
script標籤:dynamicScriptAttachedCommentMap獲取對應的 註釋節點 js(如果不存在,則還是 child),然後調用原生的 removeChild 進行刪除
case SCRIPT_TAG_NAME: {
attachedElement = dynamicScriptAttachedCommentMap.get(child) || child;
break;
}
const appWrapper = appWrapperGetter();
const container = target === "head" ? getAppWrapperHeadElement(appWrapper) : appWrapper;
if (container.contains(attachedElement)) {
return rawRemoveChild2.call(attachedElement.parentNode, attachedElement);
}
6.4.4.2 unpatchDynamicAppendPrototypeFunctions()
在觸發 free() 時,首先會觸發unpatchDynamicAppendPrototypeFunctions(),恢復劫持的 appendChild、removeChild、insertBefore 為原生方法
function patchHTMLDynamicAppendPrototypeFunctions() {
return function unpatch() {
HTMLHeadElement.prototype.appendChild = rawHeadAppendChild2;
HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;
HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore2;
};
}
const unpatchDynamicAppendPrototypeFunctions =
patchHTMLDynamicAppendPrototypeFunctions();
6.4.4.3 recordStyledComponentsCSSRules()
將動態添加的樣式添加到styledComponentCSSRulesMap進行存儲,等待下一次激活時觸發 rebuild() 時使用
function recordStyledComponentsCSSRules(styleElements) {
styleElements.forEach((styleElement) => {
if (
styleElement instanceof HTMLStyleElement &&
isStyledComponentsLike(styleElement)
) {
if (styleElement.sheet) {
styledComponentCSSRulesMap.set(
styleElement,
styleElement.sheet.cssRules
);
}
}
});
}
6.4.4.4 rebuildCSSRules()
在微應用重新掛載時,重建動態樣式表,從之前 free() 時記錄的 styledComponentCSSRulesMap 獲取對應的 cssRules,然後不斷調用 insertRule() 恢復樣式
防止在微應用切換時,之前動態插入的樣式丟失
return function rebuild() {
rebuildCSSRules(dynamicStyleSheetElements, (stylesheetElement) => {
const appWrapper = appWrapperGetter();
if (!appWrapper.contains(stylesheetElement)) {
document.head.appendChild.call(appWrapper, stylesheetElement);
return true;
}
return false;
});
if (mounting) {
dynamicStyleSheetElements = [];
}
};
function rebuildCSSRules(styleSheetElements, reAppendElement) {
styleSheetElements.forEach((stylesheetElement) => {
const appendSuccess = reAppendElement(stylesheetElement);
if (appendSuccess) {
if (
stylesheetElement instanceof HTMLStyleElement &&
isStyledComponentsLike(stylesheetElement)
) {
const cssRules = getStyledElementCSSRules(stylesheetElement);
if (cssRules) {
for (let i = 0; i < cssRules.length; i++) {
const cssRule = cssRules[i];
const cssStyleSheetElement = stylesheetElement.sheet;
cssStyleSheetElement.insertRule(
cssRule.cssText,
cssStyleSheetElement.cssRules.length
);
}
}
}
}
});
}
6.4.4.5 總結
通過 patchHTMLDynamicAppendPrototypeFunctions() 劫持並重寫 appendChild、insertBefore、removeChild等 DOM 操作方法,實現對微應用動態場景的 <style>、<link>、<script>標籤的隔離和管理,實現:
- 隔離微應用資源:將樣式轉化為內聯樣式插入到微應用中,將 JS 代碼轉化為沙箱代碼進行隔離,防止微應用的 CSS 和 JS 污染基座
- 動態資源跟蹤:記錄微應用動態創建的資源,便於後續微應用 unmount 時移除資源 + 重新激活微應用時恢復資源
並且通過 patchHTMLDynamicAppendPrototypeFunctions() 拿到對應的 free() 方法:恢復appendChild、insertBefore、removeChild為原生方法
這裏的free()方法是針對patchHTMLDynamicAppendPrototypeFunctions()的!
然後暴露出去 patchLooseSandbox的 free(),執行 free() 後可以獲取 rebuild() 方法
free():patchHTMLDynamicAppendPrototypeFunctions()的free()恢復多個劫持方法為原生方法 + 記錄動態插入的樣式dynamicStyleSheetElementsrebuild():利用 free() 記錄的dynamicStyleSheetElements進行樣式的重建(<style>插入到 DOM)
function patchLooseSandbox() {
const { proxy } = sandbox;
let dynamicStyleSheetElements = [];
const unpatchDynamicAppendPrototypeFunctions =
patchHTMLDynamicAppendPrototypeFunctions(
() =>
checkActivityFunctions(window.location).some(
(name) => name === appName
),
() => ({
appName,
appWrapperGetter,
proxy,
strictGlobal: false,
speedySandbox: false,
scopedCSS,
dynamicStyleSheetElements,
excludeAssetFilter,
})
);
return function free() {
if (isAllAppsUnmounted()) unpatchDynamicAppendPrototypeFunctions();
recordStyledComponentsCSSRules(dynamicStyleSheetElements);
return function rebuild() {
rebuildCSSRules(dynamicStyleSheetElements, (stylesheetElement) => {
const appWrapper = appWrapperGetter();
if (!appWrapper.contains(stylesheetElement)) {
document.head.appendChild.call(appWrapper, stylesheetElement);
return true;
}
return false;
});
if (mounting) {
dynamicStyleSheetElements = [];
}
};
};
}
6.4.5 patchStrictSandbox()
與 patchLooseSandbox() 相比較,由於沙箱可以是多個,因此會使用經過一系列邏輯得到當前的沙箱配置 containerConfig
然後還是使用 patchHTMLDynamicAppendPrototypeFunctions() 劫持並重寫 appendChild、insertBefore、removeChild等 DOM 操作方法,實現對微應用動態場景的 <style>、<link>、<script>標籤的隔離和管理
function patchStrictSandbox() {
const { proxy } = sandbox;
let containerConfig = proxyAttachContainerConfigMap.get(proxy);
//...
const { dynamicStyleSheetElements } = containerConfig;
const unpatchDynamicAppendPrototypeFunctions =
patchHTMLDynamicAppendPrototypeFunctions(
(element) => elementAttachContainerConfigMap.has(element),
(element) => elementAttachContainerConfigMap.get(element)
);
const unpatchDocument = patchDocument({ sandbox, speedy: speedySandbox });
return function free() {
if (isAllAppsUnmounted()) {
unpatchDynamicAppendPrototypeFunctions();
unpatchDocument();
}
recordStyledComponentsCSSRules(dynamicStyleSheetElements);
return function rebuild() {
rebuildCSSRules(dynamicStyleSheetElements, (stylesheetElement) => {
const appWrapper = appWrapperGetter();
const mountDom =
stylesheetElement[styleElementTargetSymbol] === "head"
? getAppWrapperHeadElement(appWrapper)
: appWrapper;
if (typeof refNo === "number" && refNo !== -1) {
rawHeadInsertBefore.call(mountDom, stylesheetElement, refNode);
} else {
rawHeadAppendChild.call(mountDom, stylesheetElement);
}
});
};
};
}
跟 patchLooseSandbox() 相比較,getOverwrittenAppendChildOrInsertBefore() 傳入的參數是不同的,這裏 isInvokedByMicroApp() 是使用 elementAttachContainerConfigMap 進行判斷,即如果不是當前沙箱創建的元素,則不劫持和重寫!
function getOverwrittenAppendChildOrInsertBefore(opts) {
function appendChildOrInsertBefore(newChild, refChild = null) {
// opts參數:原始方法、是否由子應用調用、容器配置、目標容器(head/body)
const {
rawDOMAppendOrInsertBefore,
isInvokedByMicroApp,
containerConfigGetter,
target = "body",
} = opts;
let element = newChild;
// 非劫持標籤(非 script/style/link)或非子應用調用時,直接調用原始方法
if (!isHijackingTag(element.tagName) || !isInvokedByMicroApp(element)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild);
}
// ...
}
appendChildOrInsertBefore[overwrittenSymbol] = true; // 標記為已重寫
return appendChildOrInsertBefore;
}
回到 patchStrictSandbox() 的分析,可以發現除了增加 patchDocument(),其他的邏輯基本是一致的,也就是:
通過 patchHTMLDynamicAppendPrototypeFunctions() 拿到對應的 free() 方法:恢復appendChild、insertBefore、removeChild為原生方法
然後暴露出去 patchStrictSandbox() 的 free()和 rebuild() 方法
free():恢復appendChild、insertBefore、removeChild為原生方法 + 記錄動態插入的樣式dynamicStyleSheetElements,然後增加了unpatchDocument()的處理rebuild():利用 free() 記錄的dynamicStyleSheetElements進行樣式的重建(<style>插入到 DOM)
下面我們將針對patchDocument()和unpatchDocument()展開分析
6.4.5.1 patchDocument()
顧名思義,就是對 document 進行劫持重寫
暫時不考慮 speedy 的情況,從下面代碼可以知道,本質就是劫持 document.createElement,然後遇到 script/style/link 時,也就是 document.createELement("script") 觸發 attachElementToProxy(),將動態創建的元素加入到 elementAttachContainerConfigMap 中
然後提供對應的 unpatch() 恢復 document.createElement
function patchDocument(cfg) {
const { sandbox, speedy } = cfg;
const attachElementToProxy = (element, proxy) => {
const proxyContainerConfig = proxyAttachContainerConfigMap.get(proxy);
if (proxyContainerConfig) {
elementAttachContainerConfigMap.set(element, proxyContainerConfig);
}
};
if (speedy) {...}
const docCreateElementFnBeforeOverwrite = docCreatePatchedMap.get(
document.createElement
);
if (!docCreateElementFnBeforeOverwrite) {
const rawDocumentCreateElement = document.createElement;
Document.prototype.createElement = function createElement2(
tagName,
options
) {
const element = rawDocumentCreateElement.call(this, tagName, options);
if (isHijackingTag(tagName)) {
const { window: currentRunningSandboxProxy } =
getCurrentRunningApp() || {};
if (currentRunningSandboxProxy) {
attachElementToProxy(element, currentRunningSandboxProxy);
}
}
return element;
};
if (document.hasOwnProperty("createElement")) {
document.createElement = Document.prototype.createElement;
}
docCreatePatchedMap.set(
Document.prototype.createElement,
rawDocumentCreateElement
);
}
return function unpatch() {
if (docCreateElementFnBeforeOverwrite) {
Document.prototype.createElement = docCreateElementFnBeforeOverwrite;
document.createElement = docCreateElementFnBeforeOverwrite;
}
};
}
而 elementAttachContainerConfigMap 就是上面 patchHTMLDynamicAppendPrototypeFunctions() 中傳入的 isInvokedByMicroApp() ,也就是説如果 script/style/link 不是當前微應用創建的元素,則不進行劫持重寫 appendChild、insertBefore、removeChild等方法
比如當前微應用創建了<script>,那麼它觸發script.appendChild(A)就會被劫持並且記錄 A 這個資源
const unpatchDynamicAppendPrototypeFunctions =
patchHTMLDynamicAppendPrototypeFunctions(
(element) => elementAttachContainerConfigMap.has(element),
(element) => elementAttachContainerConfigMap.get(element)
);
當考慮 speedy 時,我們知道這個模式是為了劫持 document從而提高性能
https://github.com/umijs/qiankun/pull/2271
在 patchDocument()的邏輯主要分為 3 個部分:
-
使用
Proxy劫持document的createElement()和querySelector()createElement()觸發attachElementToProxy()將當前的元素加入到elementAttachContainerConfigMap中querySelector()將直接使用微應用專用的qiankunHead,避免直接插入到基座的 head 元素中
const modifications = {};
const proxyDocument = new Proxy(document, {
/
* Read and write must be paired, otherwise the write operation will leak to the global
*/
set: (target, p, value) => {
switch (p) {
case "createElement": {
modifications.createElement = value;
break;
}
case "querySelector": {
modifications.querySelector = value;
break;
}
default:
target[p] = value;
break;
}
return true;
},
get: (target, p, receiver) => {
switch (p) {
case "createElement": {
const targetCreateElement =
modifications.createElement || target.createElement;
return function createElement2(...args) {
if (!nativeGlobal.__currentLockingSandbox__) {
nativeGlobal.__currentLockingSandbox__ = sandbox.name;
}
const element = targetCreateElement.call(target, ...args);
if (nativeGlobal.__currentLockingSandbox__ === sandbox.name) {
attachElementToProxy(element, sandbox.proxy);
delete nativeGlobal.__currentLockingSandbox__;
}
return element;
};
}
case "querySelector": {
const targetQuerySelector =
modifications.querySelector || target.querySelector;
return function querySelector(...args) {
const selector = args[0];
switch (selector) {
case "head": {
const containerConfig = proxyAttachContainerConfigMap.get(
sandbox.proxy
);
if (containerConfig) {
const qiankunHead = getAppWrapperHeadElement(
containerConfig.appWrapperGetter()
);
qiankunHead.appendChild = HTMLHeadElement.prototype.appendChild;
qiankunHead.insertBefore =
HTMLHeadElement.prototype.insertBefore;
qiankunHead.removeChild = HTMLHeadElement.prototype.removeChild;
return qiankunHead;
}
break;
}
}
return targetQuerySelector.call(target, ...args);
};
}
}
const value = target[p];
if (isCallable(value) && !isBoundedFunction(value)) {
return function proxyFunction(...args) {
return value.call(
target,
...args.map((arg) => (arg === receiver ? target : arg))
);
};
}
return value;
},
});
sandbox.patchDocument(proxyDocument);:將沙箱內部持有的 document 改變為new Proxy()代理的 document
class ProxySandbox {
public patchDocument(doc: Document) {
this.document = doc;
}
}
-
修復部分原型方法
MutationObserver.prototype.observe和Node.prototype.compareDocumentPosition直接將 target 改為基座的 document,避免報錯Node.prototype.parentNode子應用可能判斷document === html.parentNode,但代理document會導致結果為false
MutationObserver.prototype.observe和Node.prototype.compareDocumentPosition可以參考 https://github.com/umijs/qiankun/issues/2406 的描述Node.prototype.parentNode可以參考https://github.com/umijs/qiankun/issues/2408 的描述
MutationObserver.prototype.observe = function observe(target, options) {
const realTarget = target instanceof Document ? nativeDocument : target;
return nativeMutationObserverObserveFn.call(this, realTarget, options);
};
Node.prototype.compareDocumentPosition = function compareDocumentPosition(
node
) {
const realNode = node instanceof Document ? nativeDocument : node;
return prevCompareDocumentPosition.call(this, realNode);
};
Object.defineProperty(Node.prototype, "parentNode", {
get() {
const parentNode = parentNodeGetter.call(this);
if (parentNode instanceof Document) {
const proxy = getCurrentRunningApp()?.window;
if (proxy) return proxy.document;
}
return parentNode;
},
});
參考
- 微前端-李永寧的專欄