整体核心流程
源码分析
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
代码执行拿到对应的声明周期方法:
bootstarp
mount
unmount
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=true
ownKeys
:合并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
、insertBefore
getNewRemoveChild()
重写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()
恢复多个劫持方法为原生方法 + 记录动态插入的样式dynamicStyleSheetElements
rebuild()
:利用 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;
},
});
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。