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-syntaxfor (const prop in obj) {// patch for clearInterval for compatible reason, see #1490if (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-reassigndelete (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 snapshotthis.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#L13if (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#L12has(_: 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 objectif (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/23const propertiesWithGetter = new Map<PropertyKey, boolean>();const fakeWindow = {} as FakeWindow;/*copy the non-configurable property of global to fakeWindowsee 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 wasExample: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 casenextTask(() => {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-ignoredelete 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 beforeif (!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-ignoretarget[p] = value;}if (variableWhiteList.indexOf(p) !== -1) {// @ts-ignoreglobalContext[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#L13if (p === 'window' || p === 'self') {return proxy;}// hijack globalWindow accessing with globalThis keywordif (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 sandboxif (globalContext === globalContext.parent) {return proxy;}return (globalContext as any)[p];}// proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnPropertyif (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#L12has(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 TypeErrorsee 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 objectif (descriptor && !descriptor.configurable) {descriptor.configurable = true;}return descriptor;}return undefined;},// trap to support iterator with sandboxownKeys(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-ignoredelete 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.慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。