浅谈qiankun微前端
qiankun是single-spa二开;使用场景:不同技术栈,不同团队,独立开发部署、增量升级;总结:解耦;
主应用: 具有整合-输入子应用的html入口;
子应用 与single-spa基本一致,导出了三个生命周期函数 (bootstrap mount unmout)
js沙箱: 三个沙箱(快照沙箱、支持单应用的代理沙箱、多应用代理沙箱)
Snapshot Sandbox 源码解析:里面的iter方法,将遍历window属性的代码抽离出来了,调用这个工具方法后,我们只需要专注于迭代到相应属性时候需要进行的处理。
import type { SandBox } from '../interfaces'; import { SandBoxType } from '../interfaces'; function iter(obj: typeof window, callbackFn: (prop: any) => void) { // eslint-disable-next-line guard-for-in, no-restricted-syntax for (const prop in obj) { // patch for clearInterval for compatible reason, see #1490 if (obj.hasOwnProperty(prop) || prop === 'clearInterval') { callbackFn(prop); } } } /** * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器 */ export default class SnapshotSandbox implements SandBox { proxy: WindowProxy; name: string; type: SandBoxType; sandboxRunning = true; private windowSnapshot!: Window; private modifyPropsMap: Record<any, any> = {}; constructor(name: string) { this.name = name; this.proxy = window; this.type = SandBoxType.Snapshot; } active() { // 记录当前快照 this.windowSnapshot = {} as Window; iter(window, (prop) => { this.windowSnapshot[prop] = window[prop]; }); // 恢复之前的变更 Object.keys(this.modifyPropsMap).forEach((p: any) => { window[p] = this.modifyPropsMap[p]; }); this.sandboxRunning = true; } inactive() { this.modifyPropsMap = {}; iter(window, (prop) => { if (window[prop] !== this.windowSnapshot[prop]) { // 记录变更,恢复环境 this.modifyPropsMap[prop] = window[prop]; window[prop] = this.windowSnapshot[prop]; } }); if (process.env.NODE_ENV === 'development') { console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap)); } this.sandboxRunning = false; } }
LegacySandbox 过三个变量来记住沙箱激活后window发生变化过的所有属性,这样在后续的状态还原时候就不再需要遍历window的所有属性来进行对比,提升了程序运行的性能。但是这仍然改变不了这种机制仍然污染了window的状态的事实,因此也就无法承担起同时支持多个微应用运行的任务。
import type { SandBox } from '../../interfaces'; import { SandBoxType } from '../../interfaces'; import { getTargetValue } from '../common'; function isPropConfigurable(target: WindowProxy, prop: PropertyKey) { const descriptor = Object.getOwnPropertyDescriptor(target, prop); return descriptor ? descriptor.configurable : true; } /** * 基于 Proxy 实现的沙箱 * TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换 */ export default class LegacySandbox implements SandBox { /** 沙箱期间新增的全局变量 */ private addedPropsMapInSandbox = new Map<PropertyKey, any>(); /** 沙箱期间更新的全局变量 */ private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>(); /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */ private currentUpdatedPropsValueMap = new Map<PropertyKey, any>(); name: string; proxy: WindowProxy; globalContext: typeof window; type: SandBoxType; sandboxRunning = true; latestSetProp: PropertyKey | null = null; private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) { if (value === undefined && toDelete) { // eslint-disable-next-line no-param-reassign delete (this.globalContext as any)[prop]; } else if (isPropConfigurable(this.globalContext, prop) && typeof prop !== 'symbol') { Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true }); // eslint-disable-next-line no-param-reassign (this.globalContext as any)[prop] = value; } } active() { if (!this.sandboxRunning) { this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v)); } this.sandboxRunning = true; } inactive() { if (process.env.NODE_ENV === 'development') { console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [ ...this.addedPropsMapInSandbox.keys(), ...this.modifiedPropsOriginalValueMapInSandbox.keys(), ]); } // renderSandboxSnapshot = snapshot(currentUpdatedPropsValueMapForSnapshot); // restore global props to initial snapshot this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v)); this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true)); this.sandboxRunning = false; } constructor(name: string, globalContext = window) { this.name = name; this.globalContext = globalContext; this.type = SandBoxType.LegacyProxy; const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this; const rawWindow = globalContext; const fakeWindow = Object.create(null) as Window; const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => { if (this.sandboxRunning) { if (!rawWindow.hasOwnProperty(p)) { addedPropsMapInSandbox.set(p, value); } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) { // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值 modifiedPropsOriginalValueMapInSandbox.set(p, originalValue); } currentUpdatedPropsValueMap.set(p, value); if (sync2Window) { // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据 (rawWindow as any)[p] = value; } this.latestSetProp = p; 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; }; const proxy = new Proxy(fakeWindow, { set: (_: Window, p: PropertyKey, value: any): boolean => { const originalValue = (rawWindow as any)[p]; return setTrap(p, value, originalValue, true); }, get(_: Window, p: PropertyKey): any { // avoid who using window.window or window.self to escape the sandbox environment to touch the really window // or use window.top to check if an iframe context // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13 if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') { return proxy; } const value = (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(_: Window, p: string | number | symbol): boolean { return p in rawWindow; }, getOwnPropertyDescriptor(_: Window, p: PropertyKey): PropertyDescriptor | undefined { const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p); // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object if (descriptor && !descriptor.configurable) { descriptor.configurable = true; } return descriptor; }, defineProperty(_: Window, p: string | symbol, attributes: PropertyDescriptor): boolean { const originalValue = (rawWindow as any)[p]; const done = Reflect.defineProperty(rawWindow, p, attributes); const value = (rawWindow as any)[p]; setTrap(p, value, originalValue, false); return done; }, }); this.proxy = proxy; } }
ProxySandbox 源码解析:它将window上的所有属性遍历拷贝生成一个新的fakeWindow对象,紧接着使用proxy代理这个fakeWindow,用户对window操作全部被拦截下来,只作用于在这个fakeWindow之上
import type { SandBox } from '../interfaces'; import { SandBoxType } from '../interfaces'; import { nativeGlobal, nextTask } from '../utils'; import { getTargetValue, setCurrentRunningApp, getCurrentRunningApp } from './common'; type SymbolTarget = 'target' | 'globalContext'; type FakeWindow = Window & Record<PropertyKey, any>; /** * fastest(at most time) unique array method * @see https://jsperf.com/array-filter-unique/30 */ function uniq(array: Array<string | symbol>) { return array.filter(function filter(this: PropertyKey[], element) { return element in this ? false : ((this as any)[element] = true); }, Object.create(null)); } // zone.js will overwrite Object.defineProperty const rawObjectDefineProperty = Object.defineProperty; const variableWhiteListInDev = process.env.NODE_ENV === 'development' || window.__QIANKUN_DEVELOPMENT__ ? [ // for react hot reload // see https://github.com/facebook/create-react-app/blob/66bf7dfc43350249e2f09d138a20840dae8a0a4a/packages/react-error-overlay/src/index.js#L180 '__REACT_ERROR_OVERLAY_GLOBAL_HOOK__', ] : []; // who could escape the sandbox const variableWhiteList: PropertyKey[] = [ // FIXME System.js used a indirect call with eval, which would make it scope escape to global // To make System.js works well, we write it back to global window temporary // see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/evaluate.js#L106 'System', // see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/instantiate.js#L357 '__cjsWrapper', ...variableWhiteListInDev, ]; /* variables who are impossible to be overwrite need to be escaped from proxy sandbox for performance reasons */ const unscopables = { undefined: true, Array: true, Object: true, String: true, Boolean: true, Math: true, Number: true, Symbol: true, parseFloat: true, Float32Array: true, isNaN: true, Infinity: true, Reflect: true, Float64Array: true, Function: true, Map: true, NaN: true, Promise: true, Proxy: true, Set: true, parseInt: true, requestAnimationFrame: true, }; const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([ ['fetch', true], ['mockDomAPIInBlackList', process.env.NODE_ENV === 'test'], ]); function createFakeWindow(globalContext: Window) { // map always has the fastest performance in has check scenario // see https://jsperf.com/array-indexof-vs-set-has/23 const propertiesWithGetter = new Map<PropertyKey, boolean>(); const fakeWindow = {} as FakeWindow; /* copy the non-configurable property of global to fakeWindow 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. */ Object.getOwnPropertyNames(globalContext) .filter((p) => { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); return !descriptor?.configurable; }) .forEach((p) => { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); if (descriptor) { const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get'); /* make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return. see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get > The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property. */ if ( p === 'top' || p === 'parent' || p === 'self' || p === 'window' || (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop')) ) { descriptor.configurable = true; /* The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was 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); // freeze the descriptor to avoid being modified by zone.js // see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71 rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor)); } }); return { fakeWindow, propertiesWithGetter, }; } let activeSandboxCount = 0; /** * 基于 Proxy 实现的沙箱 */ export default class ProxySandbox implements SandBox { /** window 值变更记录 */ private updatedValueSet = new Set<PropertyKey>(); name: string; type: SandBoxType; proxy: WindowProxy; globalContext: typeof window; sandboxRunning = true; latestSetProp: PropertyKey | null = null; private registerRunningApp(name: string, proxy: Window) { if (this.sandboxRunning) { const currentRunningApp = getCurrentRunningApp(); if (!currentRunningApp || currentRunningApp.name !== name) { setCurrentRunningApp({ name, window: proxy }); } // FIXME if you have any other good ideas // 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 complex cases, such as the micro app runs in the same task context with master in some case nextTask(() => { setCurrentRunningApp(null); }); } } active() { if (!this.sandboxRunning) activeSandboxCount++; this.sandboxRunning = true; } inactive() { if (process.env.NODE_ENV === 'development') { console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [ ...this.updatedValueSet.keys(), ]); } if (--activeSandboxCount === 0) { variableWhiteList.forEach((p) => { if (this.proxy.hasOwnProperty(p)) { // @ts-ignore delete this.globalContext[p]; } }); } this.sandboxRunning = false; } constructor(name: string, globalContext = window) { this.name = name; this.globalContext = globalContext; this.type = SandBoxType.Proxy; const { updatedValueSet } = this; const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext); const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>(); const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key); const proxy = new Proxy(fakeWindow, { set: (target: FakeWindow, p: PropertyKey, value: any): boolean => { if (this.sandboxRunning) { this.registerRunningApp(name, proxy); // We must kept its description while the property existed in globalContext before if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); const { writable, configurable, enumerable } = descriptor!; if (writable) { Object.defineProperty(target, p, { configurable, enumerable, writable, value, }); } } else { // @ts-ignore target[p] = value; } if (variableWhiteList.indexOf(p) !== -1) { // @ts-ignore globalContext[p] = value; } updatedValueSet.add(p); this.latestSetProp = p; 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 => { 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 really 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') { 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 (globalContext === globalContext.parent) { return proxy; } return (globalContext as any)[p]; } // proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty if (p === 'hasOwnProperty') { return hasOwnProperty; } if (p === 'document') { return document; } if (p === 'eval') { return eval; } const value = propertiesWithGetter.has(p) ? (globalContext as any)[p] : p in target ? (target as any)[p] : (globalContext as any)[p]; /* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation' See this code: const proxy = new Proxy(window, {}); const proxyFetch = fetch.bind(proxy); proxyFetch('https://qiankun.com'); */ const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext; return getTargetValue(boundTarget, 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 globalContext; }, 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 (globalContext.hasOwnProperty(p)) { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); descriptorTargetMap.set(p, 'globalContext'); // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object if (descriptor && !descriptor.configurable) { descriptor.configurable = true; } return descriptor; } return undefined; }, // trap to support iterator with sandbox ownKeys(target: FakeWindow): ArrayLike<string | symbol> { return uniq(Reflect.ownKeys(globalContext).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 'globalContext': return Reflect.defineProperty(globalContext, p, attributes); default: return Reflect.defineProperty(target, p, attributes); } }, deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => { this.registerRunningApp(name, proxy); if (target.hasOwnProperty(p)) { // @ts-ignore delete target[p]; updatedValueSet.delete(p); return true; } return true; }, // makes sure `window instanceof Window` returns truthy in micro app getPrototypeOf() { return Reflect.getPrototypeOf(globalContext); }, }); this.proxy = proxy; activeSandboxCount++; } }
css隔离 有2种方案,一种是基于shadowDom;(缺陷:样式弹窗脱离shadow tree会污染) 一种类似vue 的scoped给每一个子应用加一个约束;
预加载
html entry 通过http请求加载制定地址的首屏内容html,解析-template script entry styles 暴漏一个promise对象
{ // template 是link替换为style之后的template template: embedHTML, // 静态资源文件 assetPublicPath, // 获取外部脚本 getExternalScripts: () => getExternalScripts(scripts, fetch), // 获取外部样式文件 getExternalStyleSheets: () => getExternalStylesSheets(styles, fetch), // 脚本执行器, 让js代码在指定上下文中运行 execScripts: (proxy, strictGlobal) => { if(!scripts.length){ return Promise.resolve() } return execScripts(entry, scripts, proxy, {fetch, strictGlobal}) } }
js entry 加载的不是应用本身,是js导出文件,并且含有生命周期;
应用通讯
主基座创建全局的globalState对象,修改值变化方法:setGlobalState 监听值变化:onGlobalStateChange
// ------------------主应用------------------ import { initGlobalState, MicroAppStateActions } from 'qiankun'; // 初始化 state const actions: MicroAppStateActions = initGlobalState(state); // 在当前应用监听全局状态,有变更触发 callback actions.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); }); // 按一级属性设置全局状态,微应用中只能修改已存在的一级属性 actions.setGlobalState(state); // 移除当前应用的状态监听,微应用 umount 时会默认调用 actions.offGlobalStateChange(); // ------------------子应用------------------ // 从生命周期 mount 中获取通信方法,使用方式和 master 一致 export function mount(props) { props.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); }); props.setGlobalState(state); }
源码解析:initGlobalState函数的执行中完成了一个发布订阅模式的创建工作,并返回了相关的订阅/发布/注销方法
import { cloneDeep } from 'lodash'; import type { OnGlobalStateChangeCallback, MicroAppStateActions } from './interfaces'; // 全局状态 let globalState: Record<string, any> = {}; // 缓存相关的订阅者 const deps: Record<string, OnGlobalStateChangeCallback> = {}; // 触发全局监听 function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) { Object.keys(deps).forEach((id: string) => { if (deps[id] instanceof Function) { // 依次通知订阅者 deps[id](cloneDeep(state), cloneDeep(prevState)); } }); } // 初始化 export function initGlobalState(state: Record<string, any> = {}) { if (state === globalState) { console.warn('[qiankun] state has not changed!'); } else { const prevGlobalState = cloneDeep(globalState); globalState = cloneDeep(state); emitGlobal(globalState, prevGlobalState); } // 返回相关方法,形成闭包存储相关状态 return getMicroAppStateActions(`global-${+new Date()}`, true); } export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions { return { /** * onGlobalStateChange 全局依赖监听 * * 收集 setState 时所需要触发的依赖 * * 限制条件:每个子应用只有一个激活状态的全局监听,新监听覆盖旧监听,若只是监听部分属性,请使用 onGlobalStateChange * * 这么设计是为了减少全局监听滥用导致的内存爆炸 * * 依赖数据结构为: * { * {id}: callback * } * * @param callback * @param fireImmediately 是否立即执行callback */ onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) { if (!(callback instanceof Function)) { console.error('[qiankun] callback must be function!'); return; } if (deps[id]) { console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`); } / 注册订阅 deps[id] = callback; if (fireImmediately) { const cloneState = cloneDeep(globalState); callback(cloneState, cloneState); } }, /** * setGlobalState 更新 store 数据 * * 1. 对输入 state 的第一层属性做校验,只有初始化时声明过的第一层(bucket)属性才会被更改 * 2. 修改 store 并触发全局监听 * * @param state */ setGlobalState(state: Record<string, any> = {}) { if (state === globalState) { console.warn('[qiankun] state has not changed!'); return false; } const changeKeys: string[] = []; const prevGlobalState = cloneDeep(globalState); globalState = cloneDeep( Object.keys(state).reduce((_globalState, changeKey) => { if (isMaster || _globalState.hasOwnProperty(changeKey)) { changeKeys.push(changeKey); return Object.assign(_globalState, { [changeKey]: state[changeKey] }); } console.warn(`[qiankun] '${changeKey}' not declared when init state!`); return _globalState; }, globalState), ); if (changeKeys.length === 0) { console.warn('[qiankun] state has not changed!'); return false; } // 触发全局监听 emitGlobal(globalState, prevGlobalState); return true; }, // 注销该应用下的依赖 offGlobalStateChange() { delete deps[id]; return true; }, }; }
iframe那不行?
1.url不同步,浏览器刷新iframe url 状态丢失、后退前进按钮无法使用;
2.全文上下文完全隔离,内存变量不共享;iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
3.慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。