The introduction

Recently in the micro front end related code and framework, JS sandbox is one of the key micro front end. But the JS sandbox isn’t just for microfronts. Instead, many scenarios have been useful for a long, long time. Some javascript runtime environments on OJ platforms, for example, may (just guessing) run directly from the browser’s JS sandbox. Another example of common rookie tutorial has an online editor, can directly input JS statements to run, but also used the JS sandbox.

But in OJ platforms or beginner tutorials, apis can be roughly restricted and filtered to prevent them from impacting the host environment. The JS sandbox of the micro front end is required to have the ability to run the sub-application completely, so the JS sandbox of the micro front end is more perfect.


Three sandbox modes in Qiankun will explain how to implement sandbox isolation on Windows

The snapshot thought

The idea of snapshot is easy to understand, like when mom is not at home secretly drink things in the refrigerator, before taking a note of how the bottle is placed, after eating, put the empty bottle back in its original place, so that mom does not find.

First, each child app has a snapshot object that holds the temporary snapshot and modifyPropsMap object that holds the current child app state

Before mounting the child application

  1. Iterate over the window stored in a snapshot object
  2. Take out the modifyPropsMap status of the child app and update window

Uninstalling child applications

  1. Iterate over the state modifyPropsMap in which the window is stored in the child application
  2. Iterate over window, compare with snapshot, restore if different

SnapshotSandBox

Specific code:


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
    // Compatible with IE clearInterval
    if (obj.hasOwnProperty(prop) || prop === 'clearInterval') { callbackFn(prop); }}}/** * Diff - based sandbox for older browsers that do not support Proxy */
export default class SnapshotSandbox implements SandBox { private windowSnapshot! : Window; private modifyPropsMap: Record<any, any> = {};active() {
    // Record the current snapshot
    // Enter the state before the child application
    this.windowSnapshot = {} as Window;
    iter(window.(prop) = > {
      this.windowSnapshot[prop] = window[prop];
    });

    // Restore the previous changes
    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]) {    / / light
        // Record the changes and restore the environment
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop]; }});this.sandboxRunning = false; }}Copy the code

Question:

  1. Shallow comparison, resulting in more than two level of property changes cannot be restored
  2. In extreme cases where there are many window attributes and JS sandbox switches frequently, the efficiency of DIff comparison is low

LegacySandBox

Take a look at an improved version of qiankun’s snapshot-based implementation

First of all, its core idea is still the idea of snapshot, but it uses three state pools, respectively for the state of the original master application when the child application is unloaded and the state of atomic application when the child application is loaded. The snapshot is split into two array addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, facilitate window reduction, And currentUpdatedPropsValueMap corresponding is the current application status

Simply speaking, the problem of low efficiency of DIFF comparison is solved by using the space of three tables.

This code is a bit long, so let’s look at its private properties and hook functions first

export default class LegacySandbox implements SandBox {
  /** New global variable */ added during sandbox
  private addedPropsMapInSandbox = new Map<PropertyKey, any>();

  /** Global variables updated during sandbox */
  private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();

  /** Keeps a map of updated (new and modified) global variables, used to do snapshot */ at any time
  private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

  active() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) = > this.setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

  inactive() {
    // restore global props to initial snapshot
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) = > this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) = > this.setWindowProp(p, undefined.true));
  }

  / /...
}
Copy the code

Very easy to understand ~

When child application activation The current state of the child application currentUpdatedPropsValueMap updates to the window

Restore Window from the previous snapshot when the child application is uninstalled


  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)) {// If the property exists in the current Window object and is not recorded in the Record map, the initial value of the property is recorded
          modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
        }

        currentUpdatedPropsValueMap.set(p, value);

        if (sync2Window) {
          // The window object must be reset to get updated data the next time you get it
          (rawWindow as any)[p] = value; // Essentially, we still operate on the window object
        }

        this.latestSetProp = p;

        return true;
      }
      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 {
        // Prevent escape from sandbox
        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 {... }, getOwnPropertyDescriptor(_: Window,p: PropertyKey): PropertyDescriptor | undefined{... }, defineProperty(_: Window,p: string | symbol, attributes: PropertyDescriptor): boolean {... });this.proxy = proxy; }}Copy the code

As you can see, the setTrap is fired in the setter, making changes to the three state tables and operating on the window. And the getter does something to prevent it from escaping the sandbox, and then it passes to the getTargetValue function, The getTargetValue function deals with the Illegal Invocation of window.console and Window. atob apis in microapplications

Problem: Although the diff efficiency problem is solved, property changes and deletions above level 2 cannot trigger setters and still cannot be restored clean

Summary of snapshot idea

It can be seen that when Qiankun was implemented, shallow comparison was made, probably because deep comparison was inefficient and not suitable for extreme environments. We can also see the limitations of the idea of snapshot. It is necessary to make a choice between what resolution to record a photo and what degree of restoration to restore it.






Since the snapshot thought has its original rational limitation, can we change another way of thinking? There is, in fact, the idea of agency

The agent thought

Since there is no way to restore completely, I will not restore, AS long as I can copy it, the idea of proxy is like this, based on the original Window, proxy many FakeWindows, one child application for one fakeWindow

The proxy idea is nice, but there are some limitations to actually implementing the JS sandbox:

  1. Some Windows apis are not allowed to run on fakeWindow and will be thrownTypeError: Failed to execute 'fetch' on 'Window': Illegal invocationThe error. Fetch, Console, ATOB, etc
  2. Some JS libraries mount global variables that don’t want to be sandboated, such as some browser plug-ins (such as React Developer Tools), or some poly fill libraries, such as SystemJS, or React webpack hot Reload variables__REACT_ERROR_OVERLAY_GLOBAL_HOOK__

However, Qiankun still made compatible processing for these situations, and realized the JS sandbox based on proxy that can be used in the production environment. We mainly grasp the idea of its proxy here, so we made some simplification of the source code

First let’s see how fakeWindow is proxy based on the original window

function createFakeWindow(globalContext: Window) {
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

// Copy non-configurable properties from the window object
// For example: Window, document, location are all properties that hang on window and are not configurable
// Copy it to fakeWindow, which indirectly avoids the child application from directly manipulating these property methods on the global 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');

        // omit compatibility code...
        // make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.

        if (hasGetter) propertiesWithGetter.set(p, true);

        // freeze the descriptor to avoid being modified by zone.js
        // Copy the attributes to the fakeWindow object
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor)); }});return {
    fakeWindow,
    propertiesWithGetter,
  };
}
Copy the code

So you can see, with createFakeWindow, we get the fakeWindow and the propertiesWithGetter, and the properties of this fakeWindow are all non-configurable properties of the original window, so we get a clean fakeWindow, It is clean because the fakeWindow has only the necessary attributes.

What does propertiesWithGetter do, so we’re going to have to look at ProxySandBox’s source code, okay

Since the proxy idea doesn’t need to restore or operate on the global window, the hook function doesn’t do much, so we’ll look directly at the constructor

  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);    // Register the microfront-end application currently in operation
          if(! target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {// If fakeWindow does not have this prop, but real window does, assign to the descriptor of the real window
            const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
            const{ writable, configurable, enumerable } = descriptor! ;if (writable) {
              Object.defineProperty(target, p, { configurable, enumerable, writable, value, }); }}else {
            target[p] = value;
          }

          if(variableWhiteList.indexOf(p) ! = = -1) {
            // Overwrite the value of the real window directly
            globalContext[p] = value;
          }
          // Maintain the update list
          updatedValueSet.add(p);

          this.latestSetProp = p;

          return true;
        }
        return true;
      },

      get: (target: FakeWindow, p: PropertyKey): any= > {
        this.registerRunningApp(name, proxy);
        
        // Omit some code handling

        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);
      }
      // Omit some other methods such as has, defineProperty, getOwnPropertyDescriptor processing
    });

    this.proxy = proxy;

    activeSandboxCount++;
  }
Copy the code

Const {fakeWindow, propertiesWithGetter} = createFakeWindow(globalContext); And then we override the setter and getter for fakeWindow with const proxy = new proxy (fakeWindow, handler)

Setters: If fakeWindow does not have the prop, but real window does, assign to the descriptor of the real window. Otherwise, assign directly to fakeWindow. Notice that this is a fakeWindow operation, not a real window

Setters also handle special cases like the one mentioned above (some JS libraries mount global variables that don’t want to be sandboiled)

if(variableWhiteList.indexOf(p) ! = = -1) {
  // Overwrite the value of the real window directly
  globalContext[p] = value;
}
Copy the code

Here variableWhiteList = [‘ System ‘, ‘__cjsWrapper’… variableWhiteListInDev];

And the getter’s logic is: Handle boundary conditions such as document, eval, window, top, self, globalThis, or enhance, or escape from the sandbox, or prevent escape from the sandbox, Or run it directly on the real Window (fix TypeError when some of the Window APIS are mounted on fakeWindow). This processing is handed over to the getTargetValue function.

Compared to the snapshot idea: solved the problem of variable contamination (does not modify the real window, but also listens for the DELETE operator)

conclusion

It can be seen that compared with the idea of snapshot, the idea of proxy is more suitable for the actual application scenarios of micro-front-end, and it is also a method to solve the coexistence of multiple sub-application instances. But the source code implementation here is more of a sandbox isolation where the JS logic has little to do with the execution environment.

For DOM operations involving documents, intercepting global listening events, more implementations are needed to isolate. / SRC/Sandbox/Patchers

How does it work?

Now we make containers, but the content doesn’t know how to put it in, let alone make it run.

First of all, JS sandbox is naturally JS code, we process JS code, are treated as a string, whether from the user editor input JS code string, or asynchronous request JS code string.

So how do you get JS code strings to run? There are usually two ways: eval or dynamically creating script tags

Qiankun uses Eval, while icestark, another micro-front-end framework, uses a method of dynamically creating script tags.

An import-HTml-Entry module was built in to fetch chapi. Divide HTML files into template, CSS, scripts, and entry, and expose execScripts, getExternalScripts, and getExternalStyleSheets methods.

The point is execScripts, which has a core method called Geval

const geval = (scriptSrc, inlineScript) = > {
    const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
    const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal);
    (0.eval)(code);
    afterExec(inlineScript, scriptSrc);
};
Copy the code

Const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal); (0, eval)(code); These two lines of code

Let’s expand getExecutableScript

function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
	const sourceUrl = isInlineCode(scriptSrc) ? ' ' : `//# sourceURL=${scriptSrc}\n`;

	// Get the global window in this way. Since script is also run in the global scope, we must ensure that the window.proxy binding is bound to the global window as well
	// In nested cases, window.proxy is set to the inner application's Window, while the code actually runs in the global scope. This will cause the window.proxy in the closure to take the outermost microapplication's proxy
	const globalWindow = (0.eval) ('window');
	globalWindow.proxy = proxy;
	// TODO switches with closure through strictGlobal and merges with closure after strictGlobal
	return strictGlobal
		? `; (function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy); `
		: `; (function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy); `;
}
Copy the code

At the same time combine how to use the code to see

const scriptExports = await execScripts(global, sandbox && ! useLooseSandbox);Copy the code

As you can see, when Qiankun used sandbox mode, it passed in getExecutableScript strictGlobal as true, that is, with(window) was wrapped around the code string. To extend the scope of this code (see Javascript Advanced Programming – 3rd edition – Section 4.2.1 extending scope chains)

And what does (0, eval)(code) mean? First, the comma operator (0, eval) executes each variable from left to right, and then returns the last variable. Here’s an example:

const obj = {
  method() { console.log(this); return this; }
}
obj.method() === window  // { method: f() }; false
(0, obj.method)() === window // Window; true
Copy the code

(0, obj.method)() = const method = obj.method’; method();

To help us understand this, let’s take a little red Book quote: When the parser finds that the eval() method is called in code, it parses the passed argument as if it were an actual ECMAScript statement and inserts the result of the execution in its original place. Code executed through eval() is considered part of the execution environment that contains the call, so the code executed has the same scope chain as the execution environment.

Ok, we focus on this sentence executed code has the same scope chain and the execution environment, so the eval which scope, how to know the current in the answer is: I’m sorry, I didn’t understand, can consult stackoverflow.com/questions/1… indirect call to eval() will set its scope to global`

We can prove it with some examples

(() = > {
  eval("var a = 5")
  console.log(a)  / / 5}) ()console.log(a)  // ReferenceError: a is not defined

(() = > {
  eval.bind(window) ("var b = 5")
})()
console.log(b)  / / 5

(() = >{(0.eval) ("var c = 5")
})()
console.log(c)  / / 5
Copy the code

So you can see that we have promoted code’s execution environment to the global environment by (0, eval)(code), so that the code’s scope is not confined to the poor geval function scope, and with(fakeWindow) let the child application’s JS code execute inside the JS sandbox, so that it can stay in place.