微前端,关于他的好和应用场景,不少师兄们也都介绍过了,那么咱们使用的微前端方案qiankun是如何去作到应用的“微前端”的呢?html
说到前端微服务,确定不能不提他的几个特性。前端
作到以上的这几点,那么咱们子应用就能多重组合,互不影响,面对大型项目的聚合,也不用担忧项目汇总后的维护、打包、上线的问题。ios
这篇分享,就会简单的读一读qiankun 的源码,从大概流程上,了解他的实现原理和技术方案。git
Arya- 公司的前端平台微服务基座github
Arya接入了权限平台的路由菜单和权限,能够动态挑选具备微服务能力的子应用的指定页面组合成一个新的平台,方便各个系统权限的下发和功能的汇聚。bootstrap
/src/apis.tsapi
export function start(opts: FrameworkConfiguration = {}) {
// 默认值设置
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const { prefetch, sandbox, singular, urlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;
// 检查 prefetch 属性,若是须要预加载,则添加全局事件 single-spa:first-mount 监听,在第一个子应用挂载后预加载其余子应用资源,优化后续其余子应用的加载速度。
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;
}
}
}
// 启动主应用- single-spa
startSingleSpa({ urlRerouteOnly });
frameworkStartedDefer.resolve();
}
复制代码
/src/apis.tspromise
export function registerMicroApps<T extends object = {}>(
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 () => {
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,
});
});
}
复制代码
src/loader.ts浏览器
// get the entry html content and script executor
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
复制代码
export function importEntry(entry, opts = {}) {
// ...
// html entry
if (typeof entry === 'string') {
return importHTML(entry, { fetch, getPublicPath, getTemplate });
}
// config entry
if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {
const { scripts = [], styles = [], html = '' } = entry;
const setStylePlaceholder2HTML = tpl => styles.reduceRight((html, styleSrc) => `${ genLinkReplaceSymbol(styleSrc) }${ html }`, tpl);
const setScriptPlaceholder2HTML = tpl => scripts.reduce((html, scriptSrc) => `${ html }${ genScriptReplaceSymbol(scriptSrc) }`, tpl);
return getEmbedHTML(getTemplate(setScriptPlaceholder2HTML(setStylePlaceholder2HTML(html))), styles, { fetch }).then(embedHTML => ({
// 这里处理同 importHTML , 省略
},
}));
} else {
throw new SyntaxError('entry scripts or styles should be array!');
}
}
复制代码
src/loader.tsmarkdown
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
return prevAppUnmountedDeferred.promise;
}
return undefined;
},
复制代码
const render = getRender(appName, appContent, container, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
render({ element, loading: true }, 'loading');
复制代码
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// appContent always wrapped with a singular div
const appElement = containerElement.firstChild as HTMLElement;
const containerElement = typeof container === 'string' ? document.querySelector(container) : container;
if (element) {
rawAppendChild.call(containerElement, element);
}
复制代码
在这个阶段,主应用已经将子应用基础的 HTML 结构挂载在了主应用的某个容器内,接下来还须要执行子应用对应的 mount 方法(如 Vue.$mount)对子应用状态进行挂载。
此时页面还能够根据 loading 参数开启一个相似加载的效果,直至子应用所有内容加载完成。
src/loader.ts
let global = window;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
if (sandbox) {
const sandboxInstance = createSandbox(
appName,
containerGetter,
Boolean(singular),
enableScopedCSS,
excludeAssetFilter,
);
// 用沙箱的代理对象做为接下来使用的全局对象
global = sandboxInstance.proxy as typeof window;
mountSandbox = sandboxInstance.mount;
unmountSandbox = sandboxInstance.unmount;
}
复制代码
这是沙箱核心判断逻辑,若是关闭了 sandbox 选项,那么全部子应用的沙箱环境都是 window,就很容易对全局状态产生污染。
src/sandbox/index.ts
这么设计的目的是为了保证每一个子应用切换回来以后,还能运行在应用 bootstrap 以后的环境下。
let sandbox: SandBox;
if (window.Proxy) {
sandbox = singular ? new LegacySandbox(appName) : new ProxySandbox(appName);
} else {
sandbox = new SnapshotSandbox(appName);
}
复制代码
src/sandbox/legacy/sandbox.ts
const proxy = new Proxy(fakeWindow, {
set(_: Window, p: PropertyKey, value: any): boolean {
if (self.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 若是当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
const originalValue = (rawWindow as any)[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
currentUpdatedPropsValueMap.set(p, value);
// 必须从新设置 window 对象保证下次 get 时能拿到已更新的数据
(rawWindow as any)[p] = value;
return true;
}
if (process.env.NODE_ENV === 'development') {
console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的状况下应该忽略错误
return true;
},
get(_: Window, p: PropertyKey): any {
if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
return proxy;
}
const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
has(_: Window, p: string | number | symbol): boolean {
return p in rawWindow;
},
});
复制代码
// 子应用脚本文件的执行过程:
eval(
// 这里将 proxy 做为 window 参数传入
// 子应用的全局对象就是该子应用沙箱的 proxy 对象
(function(window) {
/* 子应用脚本文件内容 */
})(proxy)
);
复制代码
LegacySandbox 的沙箱隔离是经过激活沙箱时还原子应用状态,卸载时还原主应用状态(子应用挂载前的全局状态)实现的,具体源码实如今 src/sandbox/legacy/sandbox.ts 中的 SingularProxySandbox 方法。
src/sandbox/proxySandbox.ts
constructor(name: string) {
this.name = name;
this.type = SandBoxType.Proxy;
const { updatedValueSet } = this;
const self = this;
const rawWindow = window;
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);
const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);
const proxy = new Proxy(fakeWindow, {
set(target: FakeWindow, p: PropertyKey, value: any): boolean {
if (self.sandboxRunning) {
// @ts-ignore
target[p] = value;
updatedValueSet.add(p);
interceptSystemJsProps(p, value);
return true;
}
if (process.env.NODE_ENV === 'development') {
console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的状况下应该忽略错误
return true;
},
get(target: FakeWindow, p: PropertyKey): any {
if (p === Symbol.unscopables) return unscopables;
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === 'window' || p === 'self') {
return proxy;
}
if (
p === 'top' ||
p === 'parent' ||
(process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
// if your master app in an iframe context, allow these props escape the sandbox
if (rawWindow === rawWindow.parent) {
return proxy;
}
return (rawWindow as any)[p];
}
// proxy.hasOwnProperty would invoke getter firstly, then its value represented as rawWindow.hasOwnProperty
if (p === 'hasOwnProperty') {
return hasOwnProperty;
}
// mark the symbol to document while accessing as document.createElement could know is invoked by which sandbox for dynamic append patcher
if (p === 'document') {
document[attachDocProxySymbol] = proxy;
// remove the mark in next tick, thus we can identify whether it in micro app or not
// this approach is just a workaround, it could not cover all the complex scenarios, such as the micro app runs in the same task context with master in som case
// fixme if you have any other good ideas
nextTick(() => delete document[attachDocProxySymbol]);
return document;
}
// eslint-disable-next-line no-bitwise
const value = propertiesWithGetter.has(p) ? (rawWindow as any)[p] : (target as any)[p] || (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
// trap in operator
// see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
has(target: FakeWindow, p: string | number | symbol): boolean {
return p in unscopables || p in target || p in rawWindow;
},
getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
/*
as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
*/
if (target.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(target, p);
descriptorTargetMap.set(p, 'target');
return descriptor;
}
if (rawWindow.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
descriptorTargetMap.set(p, 'rawWindow');
return descriptor;
}
return undefined;
},
// trap to support iterator with sandbox
ownKeys(target: FakeWindow): PropertyKey[] {
return uniq(Reflect.ownKeys(rawWindow).concat(Reflect.ownKeys(target)));
},
defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
const from = descriptorTargetMap.get(p);
/*
Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p),
otherwise it would cause a TypeError with illegal invocation.
*/
switch (from) {
case 'rawWindow':
return Reflect.defineProperty(rawWindow, p, attributes);
default:
return Reflect.defineProperty(target, p, attributes);
}
},
deleteProperty(target: FakeWindow, p: string | number | symbol): boolean {
if (target.hasOwnProperty(p)) {
// @ts-ignore
delete target[p];
updatedValueSet.delete(p);
return true;
}
return true;
},
});
this.proxy = proxy;
}
复制代码
src/sandbox/snapshotSandbox.ts
不支持 window.Proxy 属性时,将会使用 SnapshotSandbox 沙箱,这个沙箱主要有如下几个步骤:
2. 把window快照内的属性所有绑定在 modifyPropsMap 上,用于后续恢复变动。
3. 记录变动,卸载时若是不同,就恢复改变以前的window属性值。
SnapshotSandbox 沙箱就是利用快照实现了对 window 对象状态隔离的管理。相比较 ProxySandbox 而言,在子应用激活期间,SnapshotSandbox 将会对 window 对象形成污染,属于一个对不支持 Proxy 属性的浏览器的向下兼容方案。
src/sandbox/patchers/dynamicAppend.ts
对动态添加的脚本进行劫持的主要目的就是为了将动态脚本运行时的 window 对象替换成 proxy 代理对象,使子应用动态添加的脚本文件的运行上下文也替换成子应用自身。
src/loader.ts
unmountSandbox = sandboxInstance.unmount;
复制代码
src/sandbox/index.ts
/**
* 恢复 global 状态,使其能回到应用加载以前的状态
*/
async unmount() {
// 循环执行卸载函数-移除dom/样式/脚本等;修改状态
sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map(free => free());
sandbox.inactive();
},
复制代码
src/globalState.ts
qiankun内部提供了 initGlobalState 方法用于注册 MicroAppStateActions 实例用于通讯,该实例有三个方法,分别是:
文|鬼灭 关注得物技术,携手走向技术的云端