qiankun 版本: 2.0.20
single-spa 版本: 5.5.5
一 qiankun 使用方法
主应用
- 初始化主应用(可选)
- 注册子应用 registerMicroApps(apps, lifeCycles)
- 设置默认进入的子应用 setDefaultMountApp(defaultAppLink)
- 启动应用 start()
子应用
- 导出相应的生命周期钩子
/**
* bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() {
console.log('[vue] vue app bootstraped');
}
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
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;
}
/**
* 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
*/
export async function update(props) {
console.log('update props', props);
}
- 配置打包工具生成umd模块
//webpack
const packageName = require('./package.json').name;
module.exports = {
output: {
library: `${packageName}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
},
};
二 qiankun框架实现原理
2.1 简介
- 基于single-spa封装;
- 接入简单,只需配置需要的script和style文件,或者直接配置入口html文件,自动解析;
- 支持子应用样式隔离;
- 实现了安全沙箱,子应用间不互相影响;
2.2 实现原理
2.2.1 注册子应用
// src/api.ts
/*
* 调用single-spa注册子应用
* 在loadApp的Promise中添加了加载钩子函数、加载子应用、创建安全沙箱、创建生命周期钩子函数等一系列操作
*/
registerApplication({
name,
app: async () => {
loader(true);
await frameworkStartedDefer.promise;
const { mount, ...otherMicroAppConfigs } = await loadApp(
{ name, props, ...appConfig },
frameworkConfiguration,
lifeCycles,
);
return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
主要功能在loadApp()中实现:
// src/loader.ts
export async function loadApp<T extends object>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObject> {
...
// 获取子应用入口html内容和脚本执行器
// importEntry由'import-html-entry'库实现
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// 因为 single-spa 中 加载和初始化应用和卸载其他应用是并行进行的
// 所以在单应用模式下,需要等其他应用卸载完
if (await validateSingularMode(singular, app)) {
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}
...
// 子应用内的容器代码,通过解析子应用html文件和script,styles生成,已经把style内容生成一个style Node放到容器内
const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);
// 生成容器DOM对象
let element: HTMLElement | null = createElement(appContent, strictStyleIsolation);
// 样式隔离处理
if (element && isEnableScopedCSS(configuration)) {
const styleNodes = element.querySelectorAll('style') || [];
styleNodes.forEach(stylesheetElement => {
css.process(element!, stylesheetElement, appName);
});
}
// 存放子应用的容器获取(位于主应用中)
const container = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
// 子应用容器render函数
const render = getRender(appName, appContent, container, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
// 渲染容器到父应用容器,然后是能够显示出来了
render({ element, loading: true }, 'loading');
...
// 创建沙箱
let global = window;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
if (sandbox) {
const sandboxInstance = createSandbox(
appName,
containerGetter,
Boolean(singular),
enableScopedCSS,
excludeAssetFilter,
);
// 用沙箱的window代理对象作为接下来使用的全局对象
global = sandboxInstance.proxy as typeof window;
// 沙箱启动开始劫持各类全局监听,如:事件监听/定时器
mountSandbox = sandboxInstance.mount;
// 恢复到应用加载之前的状态
unmountSandbox = sandboxInstance.unmount;
}
const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } = mergeWith(
{},
getAddOns(global, assetPublicPath),
lifeCycles,
(v1, v2) => concat(v1 ?? [], v2 ?? []),
);
// 执行beforeLoad钩子函数
await execHooksChain(toArray(beforeLoad), app, global);
// get the lifecycle hooks from module exports
const scriptExports: any = await execScripts(global, !singular);
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports, appName, global);
// 获取子应用状态管理函数
const {
onGlobalStateChange,
setGlobalState,
offGlobalStateChange,
}: Record<string, Function> = getMicroAppStateActions(appInstanceId);
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap,
mount: [
... // 开发环境打开性能记录
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
return prevAppUnmountedDeferred.promise;
}
return undefined;
},
// 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
async () => {
// element 如果之前unmount时被销毁了,这里需要重新创建
element = element || createElement(appContent, strictStyleIsolation);
render({ element, loading: true }, 'mounting');
},
mountSandbox,
async () => execHooksChain(toArray(beforeMount), app, global),
// 执行子应用mount
async props => mount({ ...props, container: containerGetter(), setGlobalState, onGlobalStateChange }),
// 应用 mount 完成后结束 loading
async () => render({ element, loading: false }, 'mounted'),
async () => execHooksChain(toArray(afterMount), app, global),
// 初始化unmount 延迟函数,等到unmounted时resolve它
async () => {
if (await validateSingularMode(singular, app)) {
prevAppUnmountedDeferred = new Deferred<void>();
}
},
... // 开发环境性能监测
],
unmount: [
async () => execHooksChain(toArray(beforeUnmount), app, global),
async props => unmount({ ...props, container: containerGetter() }),
unmountSandbox,
async () => execHooksChain(toArray(afterUnmount), app, global),
async () => {
render({ element: null, loading: false }, 'unmounted');
offGlobalStateChange(appInstanceId);
// for gc
element = null;
},
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
prevAppUnmountedDeferred.resolve();
}
},
],
};
if (typeof update === 'function') {
parcelConfig.update = update;
}
return parcelConfig;
}
2.2.2 sandbox实现
基于 Proxy 实现的沙箱
2.2.2.1 最新版基于Proxy的实现
// src/sandbox/proxySandbox.ts
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);
const proxy = new Proxy(fakeWindow, {
set(target: FakeWindow, p: PropertyKey, value: any): boolean {
if (self.sandboxRunning) {
target[p] = value;
// 记录变化
updatedValueSet.add(p);
interceptSystemJsProps(p, value);
return true;
}
...
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
get(target: FakeWindow, p: PropertyKey): any { ... },
has(target: FakeWindow, p: string | number | symbol): boolean { ... },
getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined { ... },
ownKeys(target: FakeWindow): PropertyKey[] { ... },
defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean { ... },
deleteProperty(target: FakeWindow, p: string | number | symbol): boolean { ... },
})
createFakeWindow函数返回创建的fakeWindow对象。是部分不可配置属性的浅拷贝
如下图所示:
// src/sandbox/proxySandbox.ts
function createFakeWindow(global: Window) {
// 部分属性在部分浏览器里面可设置 get/set,在其他浏览器中不可设置
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;
/*
拷贝global的不可配置属性到fakeWindow
*/
Object.getOwnPropertyNames(global)
.filter(p => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
return !descriptor?.configurable;
})
.forEach(p => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
if (descriptor) {
const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
/*
让 top/self/window 属性变为可配置, 否则在返回时将导致类型错误。
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
*/
if (
p === 'top' ||
p === 'self' ||
p === 'window' ||
(process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
descriptor.configurable = true;
/*
在Safari/FF浏览器中 window.window/window.top/window.self 是访问器描述符, 我们要避免添加一个数据描述符
Example:
Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
*/
if (!hasGetter) {
descriptor.writable = true;
}
}
if (hasGetter) propertiesWithGetter.set(p, true);
// 把修改过后的属性描述添加到fakeWindow
// 冻结descriptor以避免被修改,如 zone.js
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
2.2.3 子应用资源加载
作为一个SPA的容器应用,本身是一套纯前端项目,要想展示微应用的页面除了采用iframe之外,要能先加载到微应用的页面内容。
qiankun 通过 html-import-entry 库来处理。html/script/style文件的加载是通过浏览器的fetch接口来处理的。html-import-entry库支持2种方式加载解析子应用:
- importEntry 传入代码、CSS样式url,创建一个
div容器template,加载CSS样式后以inline的方式插入容器,并生成一个script执行函数,以及加载所有script,style的Promise。 - importHTML 传入html的url,加载html后,解析其中包含的script/style的url,然后创建一个
div容器template,把html里面的内容放入容器中,注释script、style标签,加载CSS样式后以inline的方式插入容器,生成一个sciprt加载执行函数,以及加载所有script,style的Promise。
在script执行函数中,通过以下代码注入window的代理对象proxy,通过闭包实现window对象的替换
// src/index.js getExecutableScript
// scriptText 子App的代码脚本
// sourceUrl 源码链接
// bind(window.proxy) 是为了让 this 也指向 proxy
window.proxy = proxy;
// TODO 通过 strictGlobal 方式切换切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
? `;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
: `;(function(window, self){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy);`;
执行入口script后返回exports的内容(bootstrap, mount, unmount...)
// src/index.js execScripts
// bind window.proxy to change `this` reference in script
geval(getExecutableScript(scriptSrc, inlineScript, proxy, strictGlobal));
const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
resolve(exports);
2.2.4 应用启动 start
2.2.4.1 qiankun 中的start处理
start(opts)之后,先处理预加载和sanbox参数处理然后调用single-spa的start处理。
// src/api.ts
export function start(opts: FrameworkConfiguration = {}) {
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const { prefetch, sandbox, singular, urlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
if (sandbox) {
if (!window.Proxy) {
console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
// 快照沙箱只支持 singular 模式
if (!singular) {
console.error('[qiankun] singular is forced to be true when sandbox enable but proxySandbox unavailable');
frameworkConfiguration.singular = true;
}
}
}
startSingleSpa({ urlRerouteOnly });
frameworkStartedDefer.resolve();
}
2.2.4.1 single-spa 中的start处理
// src/start.js
export function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
核心在于reroute函数,通过Promise链串联起了整个流程,简直精彩
- 1 如果正在进行中,返回一个Promise
- 2 获取需要变化的App列表(unload/load/mount/unmount)
- 2.1 如果还未启动,执行loadApps()
- 2.2 如果已经启动,按以下顺序执行
- 2.2.1 先unmount需要unmount的app,并加入unload列表
- 2.2.2 unload需要unload的app
- 2.2.3 load并mount需要load的app,如果加载后不需要mount就执行unmount
- 2.2.4 mount需要mount的app
// src/navigatiton/reroute.js
function performAppChanges() {
return Promise.resolve().then(() => {
...
const unloadPromises = appsToUnload.map(toUnloadPromise);
const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));
const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
const unmountAllPromise = Promise.all(allUnmountPromises);
...
const loadThenMountPromises = appsToLoad.map((app) => {
return toLoadPromise(app).then((app) =>
tryToBootstrapAndMount(app, unmountAllPromise)
);
});
const mountPromises = appsToMount
.filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
.map((appToMount) => {
return tryToBootstrapAndMount(appToMount, unmountAllPromise);
});
return unmountAllPromise
.catch((err) => {
callAllEventListeners();
throw err;
})
.then(() => {
callAllEventListeners();
return Promise.all(loadThenMountPromises.concat(mountPromises))
.catch((err) => {
pendingPromises.forEach((promise) => promise.reject(err));
throw err;
})
.then(finishUpAndReturn);
});
});
}
single-spa的load其实就是执行registerApplication传入的loadApp获取钩子函数:
app.status = NOT_BOOTSTRAPPED;
app.bootstrap = flattenFnArray(appOpts, "bootstrap");
app.mount = flattenFnArray(appOpts, "mount");
app.unmount = flattenFnArray(appOpts, "unmount");
app.unload = flattenFnArray(appOpts, "unload");
app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
而unload就是把bootstrap/mount/unmount/unload这些去掉。