In fact, under the two major backgrounds of the birth of micro frontend, in the front-end community that advocates embracing change, new frameworks, technologies, and concepts can be seen emerging one after another, and with the evolution of WEB standards, front-end applications have better performance and faster development efficiency. However, with the higher complexity of applications, the wider team size involved, and higher performance requirements, application complexity has become an important bottleneck blocking business development. How to make existing systems embrace the latest technologies to improve productivity and decouple single applications is a challenge that front-end engineering has to face now.
If you encounter the following situations, you may need micro frontend:
Your monolithic application evolves from an ordinary application to a boulder application over a relatively long period of time due to the increase and change of participating personnel and teams, which leads to the problem of unmaintainable applications
As a portal website, it needs to integrate many systems, which are maintained by different teams, have different styles of code, and have various technical stacks. In order to aggregate, it can only take the form of iframe or MPA for aggregation
Why do you need a sandbox
In the micro frontend scenario, since multiple independent applications are organized together, conflicts are bound to occur without native isolation like iframe, such as global variable conflicts and style conflicts, which may lead to abnormal application styles and even functions. Unavailable. Therefore, in order to make the micro frontend available for production, a sandbox mechanism that allows each sub-application to achieve a certain degree of isolation is essential.
How to create a sandbox
Manual executable code
Conventional script loading is executed through script tags. To achieve sandboxing, because we need to control the opening and closing of the sandbox, we need to accurately grasp the execution timing of the script, so we need to find a suitable method for manually executing code
First of all, we think of eval. Since eval has security, performance and other issues, and is not conducive to debugging, what we have heard before is that the API of eval is not recommended.
But in the sandbox scenario of micro frontend, eval is indeed a better solution. For example, qiankun uses eval as a code executor.
New Function returns a new function by passing in a string as the body of the function, which can be used as a substitute for eval
Compared to eval, there are two more important differences:
Cannot access variables of the current environment, but can access global variables, which is more secure
You only need to process the incoming string once, and the subsequent repeated execution is the same function, while eval needs to be processed every time, with higher performance
Snapshot Sandbox
As the name suggests, that is, at a certain stage to the current operating environment to take a snapshot, and then when needed to restore the snapshot, so as to achieve isolation.
The principle of this thing is very similar to the CPU process switching in the operating system. When each process obtains the CPU usage right, it loads the context in the PCB (Process Control Block) into the corresponding register, and then the CPU starts to continue execution, waiting until the usage right is lost. Then save the information in the current register back to the PCB. The same is true of the snapshot sandbox. For each sub-application, the runtime loads the context saved internally into the corresponding variable, and saves the values of each variable in the current browser environment to the snapshot when it is destroyed.
Let’s take a look at the source code of the snapshot sandbox in qiankun, and my comments have been added to the source code.
functioniter(obj: typeofwindow, 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); } } }
/** Sandbox based on diff method, used for low version browsers that do not support proxy */ exportdefaultclassSnapshotSandbox implements SandBox { proxy: WindowProxy;
name: string;
type: SandBoxType;
sandboxRunning = true;
//Snapshot of the windows object, used to save the state of the window object before the current sandbox active private windowSnapshot!: Window;
//The value of the current sandbox modified on the window object between active and inactive private modifyPropsMap: Record<any, any> = {};
active() { Record the current snapshot to compare which properties have changed when inactive this.windowSnapshot = {} asWindow; iter(window, (prop) => { this.windowSnapshot[prop] = window[prop]; });
Restore the previous changes, that is, the variables modified between active and inactive last time Object.keys(this.modifyPropsMap).forEach((p: any) => { window[p] = this.modifyPropsMap[p]; });
this.sandboxRunning = true; }
inactive() { this.modifyPropsMap = {};
//Compare the properties on the current window object with the properties on the window object before active. If there is any difference, record it in modifyPropsMap, and reassign it back to window when active next time. iter(window, (prop) => { if (window[prop] ! this.windowSnapshot[prop]) { Record changes and restore the environment this.modifyPropsMap[prop] = window[prop]; window[prop] = this.windowSnapshot[prop]; } });
The proxy sandbox mainly creates a fakeWindow for each sandbox, then sets the proxy for the fakeWindow, and accesses the fakeWindow through the proxy, so its source code is mainly divided into two parts:
Create a fakeWindow with the’createFakeWindow 'function, which clones only the modifiable properties of the window object
Create ProxySandbox class, internally using fakeWindow created by’createFakeWindow’
Let’s take a look at’createFakeWindow ‘first. Speaking of which, what this function does is what it says in the comment:’ copy the non-configurable property of global to fakeWindow ’
functioncreateFakeWindow(globalContext: Window) { // map always has the fastest performance in has check scenario // see https://jsperf.com/array-indexof-vs-set-has/23 const propertiesWithGetter = newMap<PropertyKey, boolean>(); const fakeWindow = {} asFakeWindow;
/* 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 exist 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, }; }
Then there is the implementation of ProxySandbox, the key is that its constructor hijacked the set method of fakeWindow through proxy, and then in this way to count those properties have changed, and if it is a member of’globalVariableWhiteList ‘, then its descriptor is stored in’globalWhitelistPrevDescriptor’ and restored when inactive
Let’s first take a look at what this’globalVariableWhiteList 'is
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
const variableWhiteListInDev = process.env.NODE_ENV = 'test' || 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 constglobalVariableWhiteList: string[] = [ // 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, ];
Then take a look at the general structure of ProxySandbox, where I first hide the Proxy, and then introduce it one by one:
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 (process.env.NODE_ENV = 'test' || --activeSandboxCount = 0) { // reset the global value to the prev value Object.keys(this.globalWhitelistPrevDescriptor).forEach((p) => { const descriptor = this.globalWhitelistPrevDescriptor[p]; if (descriptor) { Object.defineProperty(this.globalContext, p, descriptor); } else { // @ts-ignore deletethis.globalContext[p]; } }); }
this.sandboxRunning = false; }
// the descriptor of global variables in whitelist before it been modified globalWhitelistPrevDescriptor: { [p intypeof globalVariableWhiteList[number]]: PropertyDescriptor | undefined } = {}; globalContext: typeofwindow;
privateregisterRunningApp(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); }); } } }
Here we temporarily do not see the role of the’latestSetProp, globalWhitelistPrevDescriptor 'several variables, we continue to look at the various configurations in the Proxy:
set
Set interception which will detect whether fakeWindow and globalContext have the current property to be modified, if so, then directly to fakeWindow on the property assignment, if not, only on globalContext, see if this property is writable or set method, only these two have one, it shows that the property itself can be modified, we add this property to fakeWindow and modify it is meaningful
At the same time, the set method will also check whether the currently modified property is one of the globalVariableWhiteList, and if so, add its descriptor to the globalWhitelistPrevDescriptor
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => { if (this.sandboxRunning) { this.registerRunningApp(name, proxy); // We must keep 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, set } = descriptor!; // only writable property can be overwritten // here we ignored accessor descriptor of globalContext as it makes no sense to trigger its logic(which might make sandbox escaping instead) // we force to set value by data descriptor if (writable || set) { Object.defineProperty(target, p, { configurable, enumerable, writable: true, value }); } } else { target[p] = value; }
// sync the property to globalContext if (typeof p = 'string' && globalVariableWhiteList.indexOf(p) ! -1) { this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p); // @ts-ignore globalContext[p] = value; }
updatedValueSet.add(p);
this.latestSetProp = p;
returntrue; }
if (process.env.NODE_ENV = 'development') { console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`); }
//In strict-mode, the handler.set of Proxy will throw a TypeError if it returns false, which should be ignored in the case of sandbox uninstallation returntrue; },
get
This get actually does a lot of testing. For example, if the property to be obtained is window or self, it returns the proxy itself. You can take a look at the following code for many specific branches
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 asany)[p]; }
// proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty if (p = 'hasOwnProperty') { return hasOwnProperty; }
if (p = 'document') { returndocument; }
if (p = 'eval') { returneval; }
const actualTarget = propertiesWithGetter.has(p) ? globalContext : p in target ? target : globalContext; const value = actualTarget[p];
// frozen value should return directly, see https://github.com/umijs/qiankun/issues/2015 if (isPropertyFrozen(actualTarget, p)) { return value; }
/* 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; returngetTargetValue(boundTarget, value); },
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; }
returnundefined; },
// trap to support iterator with sandbox ownKeys(target: FakeWindow): ArrayLike<string | symbol> { returnuniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target))); },
defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean { constfrom = 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': returnReflect.defineProperty(globalContext, p, attributes); default: returnReflect.defineProperty(target, p, attributes); } },
// makes sure `window instanceof Window` returns truthy in micro app getPrototypeOf() { returnReflect.getPrototypeOf(globalContext); },
patchers
Hijacking interval
This part of the function is mainly used to intercept the timers during the running of the sandbox, collect which timers, and be able to clear all timers when the sandbox exits
This function is the same as hijacking interval, hijacking all eventListeners, collecting all event listeners, and providing a function to clear all event listeners when the sandbox exits
/* There is also a margin for the listener to indicate that it has not been unloaded, there are two cases 1. App does not properly uninstall listener when unmout 2. listener is bound before applying mount. In the second case, the application needs to rebind the listener before the next mount. */ if (historyListeners.length) { rebuild = () => { //The listener must be rebound using window.g_history listen to ensure that the rebuild part can also be captured, otherwise this part of the side effect cannot be properly removed after the application is uninstalled historyListeners.forEach((listener) => (windowasany).g_history.listen(listener)); }; }
//uninstall the remaining listeners historyUnListens.forEach((unListen) =>unListen());
const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions( /* check if the currently specified application is active While we switch page from qiankun app to a normal react routing page, the normal one may load stylesheet dynamically while page rendering, but the url change listener must to wait until the current call stack is flushed. This scenario may cause we record the stylesheet from react routing page dynamic injection, and remove them after the url change triggered and qiankun app is unmouting see https://github.com/ReactTraining/history/blob/master/modules/createHashHistory.js#L222-L230 */ () =>checkActivityFunctions(window.location).some((name) => name = appName), () => ({ appName, appWrapperGetter, proxy, strictGlobal: false, speedySandbox: false, scopedCSS, dynamicStyleSheetElements, excludeAssetFilter, }), );
if (!mounting) calcAppCount(appName, 'increase', 'bootstrapping'); if (mounting) calcAppCount(appName, 'increase', 'mounting');
returnfunctionfree() { if (!mounting) calcAppCount(appName, 'decrease', 'bootstrapping'); if (mounting) calcAppCount(appName, 'decrease', 'mounting');
// release the overwrite prototype after all the micro apps unmounted if (isAllAppsUnmounted()) unpatchDynamicAppendPrototypeFunctions();
// As now the sub app content all wrapped with a special id container, // the dynamic style sheet would be removed automatically while unmoutting
returnfunctionrebuild() { rebuildCSSRules(dynamicStyleSheetElements, (stylesheetElement) => { const appWrapper = appWrapperGetter(); if (!appWrapper.contains(stylesheetElement)) { // Using document.head.appendChild ensures that appendChild invocation can also directly use the HTMLHeadElement.prototype.appendChild method which is overwritten at mounting phase document.head.appendChild.call(appWrapper, stylesheetElement); returntrue; }
returnfalse; });
// As the patcher will be invoked every mounting phase, we could release the cache for gc after rebuilding if (mounting) { dynamicStyleSheetElements = []; } }; }; }
// Get native global window with a sandbox disgusted way, thus we could share it between qiankun instances🤪 Object.defineProperty(nativeGlobal, '__proxyAttachContainerConfigMap__', { enumerable: false, writable: true });
// Share proxyAttachContainerConfigMap between multiple qiankun instance, thus they could access the same record nativeGlobal.__proxyAttachContainerConfigMap__ = nativeGlobal.__proxyAttachContainerConfigMap__ || newWeakMap<WindowProxy, ContainerConfig>(); constproxyAttachContainerConfigMap: WeakMap<WindowProxy, ContainerConfig> = nativeGlobal.__proxyAttachContainerConfigMap__;
if (!docCreateElementFnBeforeOverwrite) { const rawDocumentCreateElement = document.createElement; Document.prototype.createElement = function createElement<K extends keyof HTMLElementTagNameMap>( this: Document, tagName: K, options?: ElementCreationOptions, ): HTMLElement { const element = rawDocumentCreateElement.call(this, tagName, options); if (isHijackingTag(tagName)) { const { window: currentRunningSandboxProxy } = getCurrentRunningApp() || {}; if (currentRunningSandboxProxy) { const proxyContainerConfig = proxyAttachContainerConfigMap.get(currentRunningSandboxProxy); if (proxyContainerConfig) { elementAttachContainerConfigMap.set(element, proxyContainerConfig); } } }
return element; };
// It means it have been overwritten while createElement is an own property of document if (document.hasOwnProperty('createElement')) { document.createElement = Document.prototype.createElement; }
if (!mounting) calcAppCount(appName, 'increase', 'bootstrapping'); if (mounting) calcAppCount(appName, 'increase', 'mounting');
returnfunctionfree() { if (!mounting) calcAppCount(appName, 'decrease', 'bootstrapping'); if (mounting) calcAppCount(appName, 'decrease', 'mounting');
// release the overwritten prototype after all the micro apps unmounted if (isAllAppsUnmounted()) { unpatchDynamicAppendPrototypeFunctions(); unpatchDocumentCreate(); }
During execution, some global timers or listeners or some global variables will be intercepted and counted, which we collectively refer to as side effects, and then return the free function to be called when the sub-application is unmounted (ie unmount)
The free function will clear the side effects counted in the previous part internally. At the same time, if some side effects need to be restarted when the next sub-application is reactivated, put them into the rebuild returned by the free function, and finally call rebuild when mount.
/** * Generate application runtime sandbox * * There are two types of sandboxes: * 1. app environment sandbox * The app environment sandbox refers to the context in which the application will run after initialization. The environment sandbox of each application will only be initialized once, because the child application will only trigger bootstrap once. When switching sub-applications, what is actually switched is the app environment sandbox. * 2. render sandbox * The sub-application generates a sandbox before the app mount starts. After each sub-application switch, the render sandbox will reproduce and initialize. * The purpose of this design is to ensure that each sub-application can run in the environment after applying bootstrap after switching back. * * @paramappName * @paramelementGetter * @paramscopedCSS * @paramuseLooseSandbox * @paramexcludeAssetFilter * @paramglobalContext * @paramspeedySandBox */ exportfunctioncreateSandboxContainer( appName: string, elementGetter: () => HTMLElement | ShadowRoot, scopedCSS: boolean, useLooseSandbox?: boolean, excludeAssetFilter?: (url: string) => boolean, globalContext?: typeofwindow, speedySandBox?: boolean, ) { letsandbox: SandBox; if (window.Proxy) { sandbox = useLooseSandbox ? newLegacySandbox(appName, globalContext) : newProxySandbox(appName, globalContext); } else { sandbox = newSnapshotSandbox(appName); }
// some side effect could be be invoked while bootstrapping, such as dynamic stylesheet injection with style-loader, especially during the development phase const bootstrappingFreers = patchAtBootstrapping( appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter, speedySandBox, ); // mounting freers are one-off and should be re-init at every mounting time letmountingFreers: Freer[] = [];
letsideEffectsRebuilders: Rebuilder[] = [];
return { instance: sandbox,
/** * Sandbox is mounted * Possibly mount entered from bootstrap state It may also wake up from unmount and enter mount again. */ asyncmount() { /* ------------------------------------------ because of the context dependency (window), the following code execution order cannot be changed ------------------------------------------ */
// must rebuild the side effects which added at bootstrapping firstly to recovery to nature state if (sideEffectsRebuildersAtBootstrapping.length) { sideEffectsRebuildersAtBootstrapping.forEach((rebuild) =>rebuild()); }
/* ------------------------------------------ 2. Enable global variable patch ------------------------------------------*/ //When the render sandbox starts, it starts hijacking various global listeners. Try not to have side effects such as event listeners/timers during the application initialization phase mountingFreers = patchAtMounting(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter, speedySandBox);
/* ------------------------------------------ 3. Reset some initialization side effects ------------------------------------------*/ //The presence of a rebuilder indicates that some side effects need to be rebuilt if (sideEffectsRebuildersAtMounting.length) { sideEffectsRebuildersAtMounting.forEach((rebuild) =>rebuild()); }
// clean up rebuilders sideEffectsRebuilders = []; },
/** Restore the global state so that it can return to the state before the application loaded */ asyncunmount() { // record the rebuilders of window side effects (event listeners or timers) // note that the frees of mounting phase are one-off as it will be re-init at next mounting sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map((free) =>free());