微前端,关于他的好和应用场景,不少师兄们也都介绍过了,那么咱们使用的微前端方案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, }); }); }
13行, 调用single-spa 的 registerApplication 方法注册子应用。浏览器
传参:name、回调函数、activeRule 子应用激活的规则、props,主应用须要传给子应用的数据。app
src/loader.ts
// get the entry html content and script executor const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
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!'); } }
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 环境沙箱
render 沙箱
这么设计的目的是为了保证每一个子应用切换回来以后,还能运行在应用 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) );
当调用 get 从子应用 proxy/window 对象取值时,会直接从 window 对象中取值。对于非构造函数的取值将会对 this 指针绑定到 window 对象后,再返回函数。
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; }
相比较而言,ProxySandbox 是最完备的沙箱模式,彻底隔离了对 window 对象的操做,也解决了快照模式中子应用运行期间仍然会对 window 形成污染的问题。
src/sandbox/snapshotSandbox.ts
不支持 window.Proxy 属性时,将会使用 SnapshotSandbox 沙箱,这个沙箱主要有如下几个步骤:
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 实例用于通讯,该实例有三个方法,分别是:
offGlobalStateChange:取消 观察者 函数 - 该实例再也不响应 globalState 变化。
文|鬼灭关注得物技术,携手走向技术的云端