微前端 qiankun@2.10.5 源码分析(二)
我们继续上一节的内容。
loadApp 方法
找到 src/loader.ts
文件的第 244 行:
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
...
// 根据入口文件获取应用信息
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 在执行应用入口文件的之前先加载其它的资源文件
// 比如:http://127.0.0.1:7101/static/js/chunk-vendors.js 文件
await getExternalScripts();
...
// 创建当前应用元素,并且替换入口文件的 head 元素为 qiankun-head
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
...
// 创建 css scoped,跟 vue scoped 的一样
const scopedCSS = isEnableScopedCSS(sandbox);
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
const initialContainer = 'container' in app ? app.container : undefined;
// 获取渲染器,也就是在第一步中执行的 render 方法
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
// 将子应用的 initialAppWrapperElement 元素插入挂载节点 initialContainer
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
// 创建一个 initialAppWrapperElement 元素的获取器
const initialAppWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => initialAppWrapperElement,
);
let global = globalContext;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
// enable speedy mode by default
const speedySandbox = typeof sandbox === 'object' ? sandbox.speedy !== false : true;
let sandboxContainer;
if (sandbox) {
// 创建沙盒
sandboxContainer = createSandboxContainer(
appInstanceId,
// FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
speedySandbox,
);
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
const {
beforeUnmount = [],
afterUnmount = [],
afterMount = [],
beforeMount = [],
beforeLoad = [],
} = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));
// 调用 beforeLoad 生命周期
await execHooksChain(toArray(beforeLoad), app, global);
// 获取子应用模块信息
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
// 获取子应用模块信息导出的生命周期
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);
// 全局状态
const { onGlobalStateChange, setGlobalState, offGlobalStateChange }: Record<string, CallableFunction> =
getMicroAppStateActions(appInstanceId);
// 返回 spa 需要的钩子信息
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap, // bootstrap 钩子信息
mount: [ // mount 钩子信息
...
],
unmount: [ // unmount 钩子信息
...
],
};
// update 钩子信息
if (typeof update === 'function') {
parcelConfig.update = update;
}
return parcelConfig;
};
return parcelConfigGetter;
}
代码有点多,loadApp
算是 qiankun 框架最重要的一个方法了,不要慌,我们一步一步的来!
importEntry 方法
在 loadApp
方法中,使用了 importEntry
方法去根据子应用入口加载子应用信息:
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
...
// 根据入口文件获取应用信息
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 在执行应用入口文件的之前先加载其它的资源文件
// 比如:http://127.0.0.1:7101/static/js/chunk-vendors.js 文件
await getExternalScripts();
...
}
importEntry
方法是 import-html-entry 库中提供的方法:
import-html-entry
以 html 文件为应用的清单文件,加载里面的(css、js),获取入口文件的导出内容。
Treats the index html as manifest and loads the assets(css,js), get the exports from entry script.
<!-- subApp/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>test</title> </head> <body> <!-- mark the entry script with entry attribute --> <script src="https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js" entry></script> <script src="https://unpkg.com/react@16.4.2/umd/react.production.min.js"></script> </body> </html> import importHTML from 'import-html-entry'; importHTML('./subApp/index.html') .then(res => { console.log(res.template); res.execScripts().then(exports => { const mobx = exports; const { observable } = mobx; observable({ name: 'kuitos' }) }) });
更多 import-html-entry 库的内容,小伙伴们自己去看官网哦!
我们可以来测试一下,比如我们在第一步中注册的子应用信息:
{
name: 'vue',
entry: '//localhost:7101',
container: '#subapp-viewport',
loader,
activeRule: '/vue',
}
vue 子应用的入口是 //localhost:7101
,我们首先用 fetch
直接访问一下入口文件:
ok,可以看到,这是一个很普通的 vue 项目的入口文件,接着我们用 import-html-entry 库中提供的 importEntry
方法去测试一下:
import {importEntry} from "import-html-entry";
;(async ()=>{
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry("//localhost:7101");
console.log("template", template);
const externalScripts = await getExternalScripts();
console.log("externalScripts", externalScripts);
const module = await execScripts();
console.log("module", module);
console.log("assetPublicPath", assetPublicPath);
console.log("assetPublicPath", assetPublicPath);
})()
我们运行看效果:
console.log("template", template)
的结果:
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vue App</title>
<!-- prefetch/preload link /static/js/about.js replaced by import-html-entry --><!-- prefetch/preload link /static/js/app.js replaced by import-html-entry --><!-- prefetch/preload link /static/js/chunk-vendors.js replaced by import-html-entry --></head>
<body>
<div id="app"></div>
<!-- script http://localhost:7101/static/js/chunk-vendors.js replaced by import-html-entry --><!-- script http://localhost:7101/static/js/app.js replaced by import-html-entry --></body>
</html>
可以看到,我们的 js 文件都被 import-html-entry
框架给注释掉了,所以 template
返回的是一个被处理过后的入口模版文件,里面的 js、css 资源文件都被剔除了。
console.log("externalScripts", externalScripts);
的结果:
返回了原模版文件中两个 js 文件:
<script type="text/javascript" src="/static/js/chunk-vendors.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script>
的文本内容了:
console.log("module", module);
结果:
返回的是 vue 项目入口文件 examples/vue/src/main.js
导出的几个生命周期方法:
...
export async function bootstrap() {
console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
console.log('[vue] props from main framework', props);
storeTest(props);
render(props);
}
export async function unmount() {
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
router = null;
}
console.log("assetPublicPath", assetPublicPath);
返回的是入口文件的公共路径 publishPath
:
http://localhost:7101/
ok,到这里我们已经获取到子应用的信息了,我们继续分析 loadApp
方法。
接下来看看 qiankun 是如何做到子应用样式隔离的。
scoped css
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
...
// 根据入口文件获取应用信息
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 在执行应用入口文件的之前先加载其它的资源文件
// 比如:http://127.0.0.1:7101/static/js/chunk-vendors.js 文件
await getExternalScripts();
...
}
// 获取子应用模版节点
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox);
// 创建 css scoped,跟 vue scoped 的一样
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
// 获取挂载节点
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
// 获取渲染器,也就是在第一步中执行的 render 方法
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
// 将子应用的 initialAppWrapperElement 元素插入挂载节点 initialContainer
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
const initialAppWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => initialAppWrapperElement,
);
...
}
可以看到,获取子应用的入口文件后,首先调用了 getDefaultTplWrapper
方法创建了一个子应用模版节点:
// 获取子应用模版节点
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
在 src/utils.ts
文件中找到该方法:
export function getDefaultTplWrapper(name: string, sandboxOpts: FrameworkConfiguration['sandbox']) {
return (tpl: string) => {
let tplWithSimulatedHead: string;
// 替换入口模版文件中的 <head> 标签为 <qiankun-head>
if (tpl.indexOf('<head>') !== -1) {
tplWithSimulatedHead = tpl
.replace('<head>', `<${qiankunHeadTagName}>`)
.replace('</head>', `</${qiankunHeadTagName}>`);
} else {
tplWithSimulatedHead = `<${qiankunHeadTagName}></${qiankunHeadTagName}>${tpl}`;
}
// 创建模版入口元素 div,将子应用的信息设置到该节点的属性中
return `<div id="${getWrapperId(
name,
)}" data-name="${name}" data-version="${version}" data-sandbox-cfg=${JSON.stringify(
sandboxOpts,
)}>${tplWithSimulatedHead}</div>`;
};
}
可以看到,替换了原来的 head 元素,然后在入口模版元素外包裹了一个 div 元素,最后将应用的基本信息设置到该节点的属性中。
获取到 appContent
节点后,然后就进行 css 样式隔离:
const scopedCSS = isEnableScopedCSS(sandbox);
// 创建 css scoped,跟 vue scoped 的一样
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
我们重点看一下 qiankun 是如何做到子应用样式隔离。
首先找到 src/loader.ts
第 67 行的 createElement
方法:
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// appElement 节点
const appElement = containerElement.firstChild as HTMLElement;
// 如果设置了强行样式隔离,就利用 ShadowDOM 进行样式隔离
if (strictStyleIsolation) {
// 判断是否支持 ShadowDOM 节点
if (!supportShadowDOM) {
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
// 利用 ShadowDOM 进行样式隔离
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
// 对入口文件中的 <style> 标签中的样式进行深度遍历,全部加上 scoped
if (scopedCSS) {
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
}
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}
return appElement;
}
ok,有小伙伴可以要问了,为什么需要样式隔离干什么呢?
我们来测试一下不加样式隔离的场景。
我们修改一下主应用的 examples/main/render/VueRender.js
:
import Vue from 'vue/dist/vue.esm';
function vueRender({ loading }) {
return new Vue({
template: `
<div id="subapp-container">
<!-- 主应用中的样式测试隔离元素 -->
<h1 class="test-class">test-class</h1>
<h4 v-if="loading" class="subapp-loading">Loading...</h4>
<!-- 子应用挂载节点 -->
<div id="subapp-viewport"></div>
</div>
`,
el: '#subapp-container',
data() {
return {
loading,
};
},
});
}
let app = null;
export default function render({ loading }) {
if (!app) {
app = vueRender({ loading });
} else {
app.loading = loading;
}
}
接着我们修改一下子应用 react16 的入口文件 examples/react16/public/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link href="%PUBLIC_URL%/favicon.ico" rel="icon"/>
<meta content="width=device-width, initial-scale=1" name="viewport"/>
<meta content="#000000" name="theme-color"/>
<meta
content="Web site created using create-react-app"
name="description"
/>
<link href="logo192.png" rel="apple-touch-icon"/>
<link href="%PUBLIC_URL%/manifest.json" rel="manifest"/>
<title>React App</title>
<!-- 样式隔离测试样式 -->
<style>
.test-class {
color: red;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
可以看到,我们在子应用中添加了一个测试样式:
<style>
.test-class {
color: red;
}
</style>
保存运行看效果:
可以看到,如果我们没有进行子应用样式隔离,子应用中的样式会污染主应用中的样式。
那在 qiankun 中如何启用样式隔离呢?
我们只需要在启动应用的时候传给 qiankun 就可以了,我们修改一下 examples/main/index.js
文件开启样式隔离:
/**
* Step4 启动应用
*/
start({
sandbox: {
strictStyleIsolation: true, // 开启子应用样式隔离,默认 false 关闭
}
});
运行看效果:
可以看到,当开启了样式隔离后,子应用中的样式就不会污染主应用了。
那么为什么 qiankun 默认关闭样式隔离呢?我想可以能是考虑以下原因:
- ShadowDOM 兼容性不太好。
- 一般子应用中都会有样式命名规范,比如 vue 的 scoped、BEM 命名规范等,所以一般不用考虑。
- 子应用切换后节点都会被移除,所以不会引起子应用样式相互污染。
扩展:
qiankun 还可以对入口模版的 <style>
标签中的模版做深度遍历,对每个元素加上 scoped,从而来做到样式隔离。
对应的源码为:
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
...
// 对入口文件中的 <style> 标签中的样式进行深度遍历,全部加上 scoped
if (scopedCSS) {
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
}
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}
return appElement;
}
那么如何开启 scopedCSS
呢?也是在启动 qiankun 的时候。
我们修改一下 examples/main/index.js
入口的 start
方法:
/**
* Step4 启动应用
*/
start({
sandbox: {
experimentalStyleIsolation: true, // 开启 scopedCSS(还在实验中的属性,不推荐使用!!!)
}
});
运行看效果:
可以看到,当我们启用了 scopedCSS
后,qiankun 会给在每一个样式加上一个 div[data-qiankun="react16"]
元素,将当前元素作为该元素的后代样式。
具体源码就不分析了,大概就是利用 css 元素深度遍历,然后添加 scoped 元素,最后利用 MutationObserver
监听节点的变化,给每一个节点都加上 scoped 元素。
了解一下原理就行了,目前该属性还在试验中,不推荐使用!!!
ok,介绍完样式隔离,下面就到了最重要的应用沙盒隔离了。
sandbox 沙盒
why:为什么需要沙盒隔离?
因为我们的应用都运行在一个主应用中,我们会用的全局变量 window 中的任何东西,也会对它进行各种改造,所以为了避免对全局变量的污染,qiankun 会为每一个应用创建一个 sanbox 环境,这样就不会污染全局变量了。
ok,了解为什么需要 sanbox 后,我们继续分析我们的源码。
回到 src/loader.ts
文件的 loadApp
方法:
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
...
// 根据入口文件获取应用信息
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 在执行应用入口文件的之前先加载其它的资源文件
// 比如:http://127.0.0.1:7101/static/js/chunk-vendors.js 文件
await getExternalScripts();
...
}
// 获取子应用模版节点
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox);
// 创建 css scoped,跟 vue scoped 的一样
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
// 获取挂载节点
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
// 获取渲染器,也就是在第一步中执行的 render 方法
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
// 将子应用的 initialAppWrapperElement 元素插入挂载节点 initialContainer
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
...
let sandboxContainer;
// 如果开启了沙盒,就给每一个应用创建一个沙盒环境(默认开启)
if (sandbox) {
sandboxContainer = createSandboxContainer(
appInstanceId,
// FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
speedySandbox,
);
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
...
}
可以看到,当应用开启了沙盒后,qiankun 会利用 createSandboxContainer
方法给每一个应用创建一个沙盒容器 sandboxContainer
。
找到 src/sandbox/index.ts
文件中的createSandboxContainer
方法:
export function createSandboxContainer(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
scopedCSS: boolean,
useLooseSandbox?: boolean,
excludeAssetFilter?: (url: string) => boolean,
globalContext?: typeof window,
speedySandBox?: boolean,
) {
let sandbox: SandBox;
// 兼容性处理
if (window.Proxy) {
sandbox = useLooseSandbox
? new LegacySandbox(appName, globalContext)
: new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox });
} else {
sandbox = new SnapshotSandbox(appName);
}
// some side effect could be invoked while bootstrapping, such as dynamic stylesheet injection with style-loader, especially during the development phase
const bootstrappingFreers = patchAtBootstrapping(
appName,
elementGetter,
sandbox,
scopedCSS,
excludeAssetFilter,
speedySandBox,
);
// mounting freers are one-off and should be re-init at every mounting time
let mountingFreers: Freer[] = [];
let sideEffectsRebuilders: Rebuilder[] = [];
return {
instance: sandbox,
/**
* 沙箱被 mount
* 可能是从 bootstrap 状态进入的 mount
* 也可能是从 unmount 之后再次唤醒进入 mount
*/
async mount() {
/* ------------------------------------------ 因为有上下文依赖(window),以下代码执行顺序不能变 ------------------------------------------ */
/* ------------------------------------------ 1. 启动/恢复 沙箱------------------------------------------ */
sandbox.active();
const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(0, bootstrappingFreers.length);
const sideEffectsRebuildersAtMounting = sideEffectsRebuilders.slice(bootstrappingFreers.length);
// must rebuild the side effects which added at bootstrapping firstly to recovery to nature state
if (sideEffectsRebuildersAtBootstrapping.length) {
sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
}
/* ------------------------------------------ 2. 开启全局变量补丁 ------------------------------------------*/
// render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化阶段有 事件监听/定时器 等副作用
mountingFreers = patchAtMounting(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter, speedySandBox);
/* ------------------------------------------ 3. 重置一些初始化时的副作用 ------------------------------------------*/
// 存在 rebuilder 则表明有些副作用需要重建
if (sideEffectsRebuildersAtMounting.length) {
sideEffectsRebuildersAtMounting.forEach((rebuild) => rebuild());
}
// clean up rebuilders
sideEffectsRebuilders = [];
},
/**
* 恢复 global 状态,使其能回到应用加载之前的状态
*/
async unmount() {
// record the rebuilders of window side effects (event listeners or timers)
// note that the frees of mounting phase are one-off as it will be re-init at next mounting
sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map((free) => free());
sandbox.inactive();
},
};
}
可以看到,主要就是创建了一个 sandbox
沙盒对象,然后返回了 mount
跟 unmount
方法给 single-spa 调用,当子应用渲染的时候会调用 mount
方法,当子应用销毁的时候会调用 unmount
方法:
- mount 方法:会调用
sandbox.active()
方法启用沙盒,会创建并收集当前子应用的一些副作用,比如setTimeout
、setInterval
、addEventListener
等。 - unmount 方法:会调用
sandbox.inactive()
方法关闭沙盒,移除当前子应用的一些副作用,比如setTimeout
、setInterval
、addEventListener
等。
可以看到,主要是为了在切换子应用的时候开启和关闭沙盒,清除一些副作用,来防止内存泄漏。
因为大多数浏览器是支持 window.Proxy
的,所以我们就直接分析这里的 ProxySandbox
对象了:
export function createSandboxContainer(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
scopedCSS: boolean,
useLooseSandbox?: boolean,
excludeAssetFilter?: (url: string) => boolean,
globalContext?: typeof window,
speedySandBox?: boolean,
) {
// 是否支持 window.Proxy
if (window.Proxy) {
sandbox = useLooseSandbox
? new LegacySandbox(appName, globalContext)
: new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox });
} else {
sandbox = new SnapshotSandbox(appName);
}
...
}
找到 src/sandbox/proxySandbox.ts
文件:
// 伪造一个 window 对象
function createFakeWindow(globalContext: Window, speedy: boolean) {
const fakeWindow = {} as FakeWindow;
// 获取 window 所有不可以配置或者删除的属性
Object.getOwnPropertyNames(globalContext)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
return !descriptor?.configurable;
})
.forEach((p) => {
const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
...
// 将这些属性都赋值给 fakeWindow
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
// 可以通过 fakeWindow 获取到的一些属性
if (hasGetter) propertiesWithGetter.set(p, true);
}
});
// 返回伪造的 window 对象
return {
fakeWindow,
propertiesWithGetter,
};
}
let activeSandboxCount = 0;
/**
* 基于 Proxy 实现的沙箱
*/
export default class ProxySandbox implements SandBox {
...
// 激活沙盒
active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
// 关闭沙盒
inactive() {
...
this.sandboxRunning = false;
}
constructor(name: string, globalContext = window, opts?: { speedy: boolean }) {
...
// 伪造一个 window 对象 fakeWindow
const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext, !!speedy);
...
// 创建代理对象 proxy 去代理 fakeWindow 对象
const proxy = new Proxy(fakeWindow, {
// 触发 fakeWindow 对象的 get 方法
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
this.registerRunningApp(name, proxy){
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;
}
...
return true;
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
// 触发 fakeWindow 对象的 set 方法
get: (target: FakeWindow, p: PropertyKey): any => {
this.registerRunningApp(name, proxy);
if (p === Symbol.unscopables) return unscopables;
// avoid who using window.window or window.self to escape the sandbox environment to touch the real window
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === 'window' || p === 'self') {
return proxy;
}
// hijack globalWindow accessing with globalThis keyword
if (p === 'globalThis' || (inTest && p === mockGlobalThis)) {
return proxy;
}
...
return getTargetValue(boundTarget, value);
},
// 触发 fakeWindow 对象的 has 方法
has(target: FakeWindow, p: string | number | symbol): boolean {
// property in cachedGlobalObjects must return true to avoid escape from get trap
return p in cachedGlobalObjects || p in target || p in globalContext;
},
...
}
这里简化了很多代码,主要就是创建了一个伪装的 window
对象 fakeWindow
,然后对 fakeWindow
对象的get
、set
、has
等方法进行代理,最后返回这个 fakeWindow
对象的代理对象 proxy
,这样每个子应用都有一个自己的 window
对象了。
ok,我们继续回到 src/loader.ts
文件的第 321 行:
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
...
// 根据入口文件获取应用信息
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 在执行应用入口文件的之前先加载其它的资源文件
// 比如:http://127.0.0.1:7101/static/js/chunk-vendors.js 文件
await getExternalScripts();
...
}
// 获取子应用模版节点
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox);
// 创建 css scoped,跟 vue scoped 的一样
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
// 获取挂载节点
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
// 获取渲染器,也就是在第一步中执行的 render 方法
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
// 将子应用的 initialAppWrapperElement 元素插入挂载节点 initialContainer
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
...
let sandboxContainer;
// 如果开启了沙盒,就给每一个应用创建一个沙盒环境(默认开启)
if (sandbox) {
sandboxContainer = createSandboxContainer(
appInstanceId,
// FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
speedySandbox,
);
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy as typeof window;
// 返回沙盒容器的 mount 方法用来开启沙盒
mountSandbox = sandboxContainer.mount;
// 返回沙盒容器的 unmount 方法用来关闭沙盒
unmountSandbox = sandboxContainer.unmount;
}
...
}
可以看到,接下来将使用沙箱的代理对象 global
作为全局对象 window
,并且返回了开启沙盒和关闭沙盒的方法给 single-spa 调用。
ok,我们接着往下分析 loadApp
方法。
找到 src/loader.ts
文件的第 337 行:
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
...
// 根据入口文件获取应用信息
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 在执行应用入口文件的之前先加载其它的资源文件
// 比如:http://127.0.0.1:7101/static/js/chunk-vendors.js 文件
await getExternalScripts();
...
}
// 获取子应用模版节点
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox);
// 创建 css scoped,跟 vue scoped 的一样
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
// 获取挂载节点
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
// 获取渲染器,也就是在第一步中执行的 render 方法
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
// 将子应用的 initialAppWrapperElement 元素插入挂载节点 initialContainer
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
...
let sandboxContainer;
// 如果开启了沙盒,就给每一个应用创建一个沙盒环境(默认开启)
if (sandbox) {
sandboxContainer = createSandboxContainer(
appInstanceId,
// FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
speedySandbox,
);
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy as typeof window;
// 返回沙盒容器的 mount 方法用来开启沙盒
mountSandbox = sandboxContainer.mount;
// 返回沙盒容器的 unmount 方法用来关闭沙盒
unmountSandbox = sandboxContainer.unmount;
}
// 获取生命周期
const {
beforeUnmount = [],
afterUnmount = [],
afterMount = [],
beforeMount = [],
beforeLoad = [],
} = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));
// 调用 beforeLoad 生命周期
//在 beforeLoad 方法里主要给 window 的代理对象 fakeWindow 设置一些变量
// 比如:global.__POWERED_BY_QIANKUN__ = true;
await execHooksChain(toArray(beforeLoad), app, global);
// 执行当前应用的入口文件
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
// 获取应用导出的 bootstrap、mount 等方法
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);
...
const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
let appWrapperElement: HTMLElement | null;
let appWrapperGetter: ReturnType<typeof getAppWrapperGetter>;
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap, // 返回 bootstrap 方法
// 返回 mount 方法
mount: [
// 如果设置了单个应用运行,则需要等待上一个应用结束
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
return prevAppUnmountedDeferred.promise;
}
return undefined;
},
// 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) {
// element will be destroyed after unmounted, we need to recreate it if it not exist
// or we try to remount into a new container
appWrapperElement = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId);
syncAppWrapperElement2Sandbox(appWrapperElement);
}
render({ element: appWrapperElement, loading: true, container: remountContainer }, 'mounting');
},
// 开启沙盒
mountSandbox,
// 调用 beforeMount 生命周期
async () => execHooksChain(toArray(beforeMount), app, global),
// 调用 mount 生命周期,开始渲染子应用
async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
// 将子应用状态修改为 mounted
async () => render({ element: appWrapperElement, loading: false, container: remountContainer }, 'mounted'),
// 触发 afterMount 生命周期
async () => execHooksChain(toArray(afterMount), app, global),
],
unmount: [
// 触发 beforeUnmount 生命周期
async () => execHooksChain(toArray(beforeUnmount), app, global),
// 调用 unmount 生命周期,开始卸载子应用
async (props) => unmount({ ...props, container: appWrapperGetter() }),
// 关闭沙盒
unmountSandbox,
// 调用 afterUnmount 生命周期
async () => execHooksChain(toArray(afterUnmount), app, global),
async () => {
// 将子应用状态修改为 unmounted
render({ element: null, loading: false, container: remountContainer }, 'unmounted');
offGlobalStateChange(appInstanceId);
// for gc
appWrapperElement = null;
syncAppWrapperElement2Sandbox(appWrapperElement);
},
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
prevAppUnmountedDeferred.resolve();
}
},
],
};
// 触发子应用的 update 方法
if (typeof update === 'function') {
parcelConfig.update = update;
}
return parcelConfig;
};
return parcelConfigGetter;
}
ok,可能有些童鞋要问了:“沙箱对象 sandbox(也就是我们当前 loadApp 方法中的 global 对象),到底是怎么被子应用使用的呢?”
我们可以重点看到这么一段代码:
// 执行当前应用的入口文件
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
其实重点是 execScripts
方法,那么它在执行子应用入口文件的时候,到底了什么呢?
其实原理很简单,就是用到了 with(){}
语句加 eval
,我们来模拟一下 execScripts
方法的操作:
// 创建一个 fakeWindow
const fakeWindow = {
name: "FakeWindow"
};
// 给 fakeWindow 设置代理对象
const proxy = new Proxy(fakeWindow, {
get(target, p){
if("window" === p || "self" === p){
return target;
}
return target[p];
},
set(target, p, value){
target[p] = value;
}
});
window.proxy = proxy;
// 根据子应用入口文件封装执行的代码
const functionWrappedCode = `
(function(){
;(function(window, self, globalThis){
with(window){
// 子应用入口文件代码--start
window.mount = ()=>{
console.log("mount");
console.log(window);
}
window.unmount= ()=>{
console.log("unmount");
console.log(window);
}
// 子应用入口文件代码--end
}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);
})
`;
// 调用 eval 执行封装过后的代码
(0, eval)(functionWrappedCode).call(window);
// 执行 mount 方法
proxy.mount();
// 执行 unmount 方法
proxy.unmount();
可以看到,假设我们子应用的入口文件代码为:
window.mount = ()=>{
console.log("mount");
console.log(window);
}
window.unmount= ()=>{
console.log("unmount");
console.log(window);
}
我们在各自的子应用中随意用 window 对象,这是一个很正常的操作,经过我们 with
封装后,我们执行看效果:
可以看到,经过处理后,我们在子应用中用到的 window
已经被我们替换成了 fakeWindow
,这样每一个子应用就都有一个自己的全局变量 window
了,这样就不会污染全局对象,这就是一个沙盒。
ok,分析完 loadApp
方法后,我们回到最初的 registerMicroApps
方法中。
找到 src/apis.ts
文件的第 80 行:
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
// 过滤未注册过的应用,防止多次注册
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 () => {
// 修改页面状态为 loading
loader(true);
// 等待 start 方法的调用
await frameworkStartedDefer.promise;
// 加载当前子应用,获取子应用的 mount 方法
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,
});
});
}
single-spa
可以看到,当 loadApp
方法执行完成后,会返回应用的 mount
、bootstrap
、unmount
等方法,接下来会将这些方法传给 single-spa
的 registerApplication
方法,最后就是 single-spa
的事情了。
single-spa
会监听当前路由的变化,通过每个应用提供的的 activeWhen
来匹配出需要渲染的应用,接着调用该应用的 mount
方法对其进行渲染。
ok, qiankun 框架的源码到这我们就算是分析完了。
总结
整个源码分析下来我们会发现,要写出这么牛逼的框架,除了需要很扎实的 js 基础外,还需要有很强的架构意识,真的由衷佩服作者大大,请收下我的膝盖!!!
下一个 wujie 见!!!