微前端,关于他的好和应用场景,很多师兄们也都介绍过了,那么我们使用的微前端方案qiankun是如何去做到应用的“微前端”的呢?
几个特性
说到前端微服务,肯定不能不提他的几个特性。
- 子应用并行
- 父子应用通信
预加载
- 空闲时预加载子应用的资源
- 公共依赖的加载
- 按需加载
- JS沙箱
- CSS隔离
做到以上的这几点,那么我们子应用就能多重组合,互不影响,面对大型项目的聚合,也不用担心项目汇总后的维护、打包、上线的问题。
这篇分享,就会简单的读一读qiankun 的源码,从大概流程上,了解他的实现原理和技术方案。
我们的应用怎么配置?-加入微前端Arya的怀抱吧
Arya- 公司的前端平台微服务基座
Arya接入了权限平台的路由菜单和权限,可以动态挑选具有微服务能力的子应用的指定页面组合成一个新的平台,方便各个系统权限的下发和功能的汇聚。
创建流程
初始化全局配置 - start(opts)
/src/apis.ts
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();
}
- start 函数负责初始化一些全局设置,然后启动应用。
- 这些初始化的配置参数有一部分将在 registerMicroApps 注册子应用的回调函数中使用。
registerMicroApps(apps, lifeCycles?) - 注册子应用
/src/apis.ts
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,
});
});
}
13行, 调用single-spa 的 registerApplication 方法注册子应用。
传参:name、回调函数、activeRule 子应用激活的规则、props,主应用需要传给子应用的数据。
- 在符合 activeRule 激活规则时将会激活子应用,执行回调函数,返回生命周期钩子函数。
获取子应用资源 - import-html-entry
src/loader.ts
// get the entry html content and script executor
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
- 使用 import-html-entry 拉取子应用的静态资源。
- 调用之后返回的对象如下:
- 拉取代码如下
GitHub地址:https://github.com/kuitos/imp...
- 如果能拉取静态资源,是否可以做简易的爬虫服务每日爬取页面执行资源是否加载正确?
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!'); } }
主应用挂载子应用 HTML 模板
src/loader.ts
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');
render 函数内中将拉取的资源挂载到指定容器内的节点。
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
app 环境沙箱
- app 环境沙箱是指应用初始化过之后,应用会在什么样的上下文环境运行。每个应用的环境沙箱只会初始化一次,因为子应用只会触发一次 bootstrap 。
- 子应用在切换时,实际上切换的是 app 环境沙箱。
render 沙箱
- 子应用在 app mount 开始前生成好的的沙箱。每次子应用切换过后,render 沙箱都会重现初始化。
这么设计的目的是为了保证每个子应用切换回来之后,还能运行在应用 bootstrap 之后的环境下。
let sandbox: SandBox; if (window.Proxy) { sandbox = singular ? new LegacySandbox(appName) : new ProxySandbox(appName); } else { sandbox = new SnapshotSandbox(appName); }
- SandBox 内部的沙箱主要是通过是否支持 window.Proxy 分为 LegacySandbox 和 SnapshotSandbox 两种。
LegacySandbox-单实例沙箱
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;
},
});
- 以简单理解为子应用的 window 全局对象,子应用对全局属性的操作就是对该 proxy 对象属性的操作。
// 子应用脚本文件的执行过程:
eval(
// 这里将 proxy 作为 window 参数传入
// 子应用的全局对象就是该子应用沙箱的 proxy 对象
(function(window) {
/* 子应用脚本文件内容 */
})(proxy)
);
- 当调用 set 向子应用 proxy/window 对象设置属性时,所有的属性设置和更新都会先记录在 addedPropsMapInSandbox 或 modifiedPropsOriginalValueMapInSandbox 中,然后统一记录到 currentUpdatedPropsValueMap 中。
- 修改全局 window 的属性,完成值的设置。
当调用 get 从子应用 proxy/window 对象取值时,会直接从 window 对象中取值。对于非构造函数的取值将会对 this 指针绑定到 window 对象后,再返回函数。
LegacySandbox 的沙箱隔离是通过激活沙箱时还原子应用状态,卸载时还原主应用状态(子应用挂载前的全局状态)实现的,具体源码实现在 src/sandbox/legacy/sandbox.ts 中的 SingularProxySandbox 方法。ProxySandbox 多实例沙箱
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; }
- 当调用 set 向子应用 proxy/window 对象设置属性时,所有的属性设置和更新都会命中 updatedValueSet,存储在 updatedValueSet (18行 updatedValueSet.add(p) )集合中,从而避免对 window 对象产生影响。
- 当调用 get 从子应用 proxy/window 对象取值时,会优先从子应用的沙箱状态池 updatedValueSet 中取值,如果没有命中才从主应用的 window 对象中取值。对于非构造函数的取值将会对 this 指针绑定到 window 对象后,再返回函数。
- 如此一来,ProxySandbox 沙箱应用之间的隔离就完成了,所有子应用对 proxy/window 对象值的存取都受到了控制。设置值只会作用在沙箱内部的 updatedValueSet 集合上,取值也是优先取子应用独立状态池(updateValueMap)中的值,没有找到的话,再从 proxy/window 对象中取值。
相比较而言,ProxySandbox 是最完备的沙箱模式,完全隔离了对 window 对象的操作,也解决了快照模式中子应用运行期间仍然会对 window 造成污染的问题。
SnapshotSandbox
src/sandbox/snapshotSandbox.ts
不支持 window.Proxy 属性时,将会使用 SnapshotSandbox 沙箱,这个沙箱主要有以下几个步骤:
- 激活时给Window打个快照。
- 把window快照内的属性全部绑定在 modifyPropsMap 上,用于后续恢复变更。
- 记录变更,卸载时如果不一样,就恢复改变之前的window属性值。
SnapshotSandbox 沙箱就是利用快照实现了对 window 对象状态隔离的管理。相比较 ProxySandbox 而言,在子应用激活期间,SnapshotSandbox 将会对 window 对象造成污染,属于一个对不支持 Proxy 属性的浏览器的向下兼容方案。
动态添加样式表文件劫持
src/sandbox/patchers/dynamicAppend.ts
避免主应用、子应用样式污染。
- 主应用编译是classID加上hash码,避免主应用影响子应用的样式。
子-子之间避免。
- 当前子应用处于激活状态,那么动态 style 样式表就会被添加到子应用容器内,在子应用卸载时样式表也可以和子应用一起被卸载,从而避免样式污染。
子应用的动态脚本执行
对动态添加的脚本进行劫持的主要目的就是为了将动态脚本运行时的 window 对象替换成 proxy 代理对象,使子应用动态添加的脚本文件的运行上下文也替换成子应用自身。
卸载沙箱 - unmountSandbox
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 实例用于通信,该实例有三个方法,分别是:
- setGlobalState:设置 globalState - 设置新的值时,内部将执行 浅检查,如果检查到 globalState 发生改变则触发通知,通知到所有的 观察者 函数。
- onGlobalStateChange:注册 观察者 函数 - 响应 globalState 变化,在 globalState 发生改变时触发该 观察者 函数。
offGlobalStateChange:取消 观察者 函数 - 该实例不再响应 globalState 变化。
公共资源的提取
回顾
文|鬼灭
关注得物技术,携手走向技术的云端
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。