logo
程序员LEON

像素主题

分类

分类

标签

标签

封面

【造轮子】qiankun详解和手写

经验总结
book

阅读10分钟

timer

2022年07月29日 22:23

说到微前端,现在最火的方案就是qiankun。qiankun的特点是易用性和完备性很高。说白了就是能很方便、快速的接入,同时bug少,功能强大。


介绍

微前端已经火了一段时间了,就不介绍了,直接贴图得了。

qiankun intro

话不多少,本次主要做两件事情:

  1. 拆解和解析qiankun源码

  2. 尝试qiankun造轮子

分析qiankun原理

截止现在,有很多大神已经将qiankun剖析得一清二楚。从各个层面去讲的都有,参考资料如下:

参考这么多的文档资料,以及自己阅读源码之后,我对qiankun主要功能的理解大概有这些:

基于single-spa

qiankun基于single-spa,在它的基础上增加了很多功能。single-spa是基于Js Entry的方案的:需要将整个微应用打包成一个JS文件,包括图片、css等资源(缺点:体积庞大、无法并行加载资源、接入成本高)。而Html Entry则由于保留了html,通过html作为入口处理js、css等,天然的规避了这些问题。

qiankun使用single-spa做了两件事情:

  • 注册和管理微应用(single-spa内部存储了apps信息)

    qiankun的app注册接口,会调用single-sparegisterApplication接口,保留它的路由规则和部分字段。single-sparegisterApplication接口,app参数需要传入一个包含生命周期函数的对象或工厂函数。

    复制
    registerApplication({
      name: 'app1',
      activeWhen: '/app1',
      app: () => import("src/app1/main.js") // 即Js Entry。其中main.js需要导出四个生命周期函数
    })

    而qiankun由于不使用Js Entry,所以在app()异步函数里,qiankun不是来导入js文件,而是从Html Entry开始:fetch html文件 => 创建和设置子应用dom => 创建沙盒 => 执行子应用js文件(此时可获取到子应用导出的lifecycles) => 包装lifecycles并返回

  • 根据路由规则切换微应用(触发微应用状态变更,需要自己实现各个状态回调)

    single-spa已经实现了hash路由和history路由的监听,然后根据子应用的激活路由规则,来调用对应子应用的生命周期函数。qiankun就拿来直接用,反正最后都会进入前面我们提到的生命周期函数。

从Html Entry入手

前面提到,qiankun在app()函数里不是导入js而是处理的Html Entry。qiankun作者将这一部分封装成了一个独立的库import-html-entry。这里面包括的功能有:

  • fetch html文件
  • 处理html内容转为template字符串
  • 获取scripts,包括内部和外部scripts
  • 对script进行包装,控制js在指定的window中运行(js沙箱的基础)
  • 获取内部和外部样式表

可以看出这个库主要就是从Html Entry入手处理template、script和style,都只是处理,并没有执行,执行在qiankun源码中。不再细说,细节可以看它的源码或者其他大牛的解读。

Js沙箱

Js沙箱的目的是为了让子应用拥有自己私有的全局环境。防止子应用修改的全局变量影响到主应用或其他子应用。qiankun实现了三种Js沙箱

快照沙箱 - snapshotSandbox

snapshotSandbox是在不支持Proxy的浏览器环境下才会使用的。原理是在子应用挂载时,备份当前window存入snapshot(同时恢复上一次卸载时最后的状态);卸载时恢复window为snapshot的状态(同时记录应用变更了哪些状态)。

由于它会遍历window对象,所以性能最差。同时它修改了window对象,所以它无法做到主应用和子应用的隔离,只做到了单个子应用相互之间的隔离,多个子应用同时加载时也不行。

单例代理沙箱 - legacySandBox

legacySandBox是一个使用Proxy的沙箱。它的原理和快照沙箱一样是记录变更和恢复:创建一个window的proxy(子应用操作的是这个proxy),监听set()并收集更新(set仍然会同步window,get直接从window取)。在子应用挂载时,恢复window为上一次卸载时状态;卸载时恢复为挂载前状态。只不过由于使用了代理,它能准确知道哪些变量发生了变化,而不用像快照沙箱那样遍历整个window对象才能知道变化。

所以它的性能会好于快照沙箱,但是它仍然操作的是window全局对象,所以也无法做到主应用和子应用的隔离,多个子应用同时加载也一样有问题。

多例代理沙箱 - proxySandBox

proxySandBox也是一个使用Proxy技术的沙箱。只不过它是完全代理了window并替代之。每个子应用都使用的是自己的proxy而不是全局的window。它的原理是创建一个window的proxy。把window的属性都拷贝上去,完全就是一个副本。每个子应用唯一。子应用操作的一直都是这个proxy,所以挂载和卸载都不需要做什么处理。

由于window全局属性太多,处理的异常也非常多,有的函数和变量还必须从原始window获取。所以这部分代码还是很复杂的,作者估计也是做了非常多的bugfix。

好处也是非常明显,主应用window不会被污染,子应用之间也完全隔离,多个子应用同时加载也互不影响。

其中代理沙箱提供了window的代理proxy。qiankun会让子应用的js代码运行在这个proxy对象下(比如window、self、top、document、location等等)。这一部分是import-html-entry插件在处理javascript脚本时实现的。说白了就是eval + function + with让全局变量都从proxy中查询,并越过了严格模式的安全性错误。

Css隔离

Css隔离的目的是为了让子应用的Css与主应用或其他子应用的隔离开,防止相互影响。qiankun有三类隔离方式:

默认方式

qiankun的默认处理是将动态导入的样式表,比如使用webpack的style-loader、Vite中导入.css文件、使用css-in-js框架等,劫持appendChild等方法,将它们添加到子应用的dom容器下,卸载时则随着dom一起被移除。如果不做这个劫持的处理。那子应用的样式表将会出现在根节点<head>标签下,假如开启了Shadom DOM模式,子应用就找不到这些样式了。

对于子应用的内部样式表<style>、外部样式表<link ref="stylesheet">,不需要做拦截处理。因为它们会随着html被挂载到子应用的dom容器下。

函数调用关系:[src/loader.ts].loadApp => [src/sandbox/index].createSandboxContainer => [src/sandbox/patchers/index].patchAtBootstrapping源码位置)。

对于快照沙箱单例代理沙箱,使用patchLooseSandbox。它是通过劫持Head和Body元素的appendChildinsertBefore方法(源码),并把要添加的<style><link>元素插入到子应用的dom容器下(源码)。当然它只会在子应用激活时才会生效。

对于多例代理沙箱,使用patchStrictSandbox,也是和上面一样劫持了appendChildinsertBefore方法。由于多例代理沙箱下每个子应用使用自己的window代理,在这个方法中,对document也做了代理,此时仅使用document代理创建的样式表才能被插入到子应用容器下。

默认方式不需要手动开启。它能保证单例场景下子应用之间的样式隔离,但是无法保证主应用和子应用,及多实例时子应用之间的样式隔离。

Shadom DOM

qiankun支持使用Shadom DOM实现严格样式隔离,它会在子应用dom根节点创建Shadow DOM,就能确保子应用和主应用,子应用之间的样式污染问题。但是它并不是完美方案。缺点就是一些弹窗需要挂载到document.body下,这是就跳出了Shadow DOM边界,弹窗样式也就无法起作用了。

通过start({ sandbox: { strictStyleIsolation: true } })启用。

生成shadowDom的源码位置:loader.createElement

样式改写

qiankun会对子应用添加的样式改写,在子应用的根部dom节点增加一个data-qiankun属性,并对子应用的所有样式规则,添加一个div[data-qiankun="xxx"]的属性选择器,这样来保证只有子应用的dom树下,这些样式才会生效。(额外的,改写会替换body、html、:root选择器能保证主应用和子应用,子应用之间的样式隔离。

通过start({ sandbox: { experimentalStyleIsolation: true } })启用。

html entry中的内联和外联样式会在loader.createElement中被处理,最终会调用ScopedCSS.process方法:它会对<style>标签内的样式进行重写,并使用MutationObserver监听该标签内的内容变更,假如<style>中后续增删CSS规则,同样进行重写。

此外,对于动态添加的<style>标签,也会在劫持方法内对样式内容进行重写。

其他

非qiankun框架提供。在项目中我们可以有其他确保样式隔离的方案:唯一的css命名前缀、css modules、CSS-in-js。

样式重建

比如webpack和vite中,应用首次加载时,可能会有很多样式是动态导入的。正如上一节所说,qiankun默认会把这些样式插入到子应用的dom容器下。随着应用被卸载,它们也一并被移除。但是子应用再次加载的时候呢?如果qiankun不去对样式进行重建,会出现什么问题?我们分析一下。

  • 子应用首次加载。应用从html entry开始fetch,全部代码都会被执行。正如子应用在新tab里独立运行时一样。此过程中,对于动态导入的样式会被插入到dom中。等所有代码执行完毕后qiankun会调用子应用的mount钩子。让框架如ReactVue去接管dom。

  • 子应用卸载时,子应用的dom容器会被清空。此时,所有的<style>标签也会被移除。

  • 子应用再次加载时,qiankun不会让子应用和首次加载一样,从头fecth并执行,这样实在是太低效了。取而代之的是,qiankun会直接调用子应用的mount钩子。

这就带来问题了。只调用mount钩子,那所有的<style>标签已经被移除了,不恢复不就乱套了吗?

当然是这样,所以qiankun需要对样式进行重建。qiankun定义了三个类型:Patcher、Freer、RebuilderPatcher函数的返回值是Freer函数Freer函数的返回值是Rebuilder函数。分别代表给环境打补丁、还原打补丁前的环境、重建操作

可以看到patchLooseSandboxpatchStrictSandbox都是Patcher函数。我们重点关注它们的Rebuilder函数,它们俩代码都一样:在Rebuilder函数内,重建CSS规则,即恢复<style>标签。

清除副作用

子应用使用window.addEventListener或者setInterval等全局api时,如果子应用卸载时不移除掉,则会对其他应用带来副作用。

代码是在patchers.patchAtMounting方法中。调用patchIntervalpatchWindowListener来清除副作用的。patch方法内部,拦截了原生的方法,每次调用时记录下来。patch方法返回free()函数,用于在子应用卸载时,清除副作用

通信方案

  • 官方Actions方案

官方是事件监听的形式,监听全局状态的变更。主应用初始化状态,通过mount(props)生命周期下发到子应用。子应用可以监听和set。

实现原理是主应用负责初始化和存储全局states,提供接口到子应用,子应用添加listener,主应用管理listeners。在任意应用调用setState接口时,都触发listener回调。

  • SharedState方案

更常见的情况是,项目中已经集成了状态管理库ReduxZustand或其他。这时候就可以使用官方Actions方案当作桥梁,打通主应用、子应用的状态数据同步。并且子应用在独立运行的时候仍然使用自身的状态管理库获取数据,在嵌入主应用时,使用全局状态数据。主应用或子应用使用全局状态时,只需要使用一个hooks:

复制
// 获取和设置全局状态,响应式
const [app, setApp] = useSharedState('AppInfo');

实现原理就是主应用和子应用都可以创建自己的SharedState,即中间层,在这里去实现针对不同状态管理库的状态获取、设置和监听。子应用如果是在qiankun环境,则从主应用获取SharedState重载掉自己本地的。然后再提供一个Hooks Api。

复制
/**
 * 操作某一个共享状态的Hooks API。
 * 使用方法类似于React.useState,返回[state, setter]。
 */
export function useSharedState<K extends keyof SharedState>(stateName: K): readonly [StateTypeOf<SharedState[K]>, (d: StateTypeOf<SharedState[K]>) => void] {
  const so: SharedState[K] = state[stateName];
  // 使用useState,让其转为响应式的状态
  const [d, setD] = useState(so.get());
  // 监听主应用状态变化,并setState
  useEffect(() => {
    return so.subscribe(setD); // 组件销毁时,停止监听
  }, [so]);
  // 有共享状态时
  return [d, so.set] as const;
}

这里写得比较简单。有时间我再单独出一期方案详解。

自己造一个qiankun

qiankun原理介绍就这些,下面我们开始正题!自己模仿造一个qiankun轮子出来,抛开健壮性不谈,至少能用!

项目结构

保持qiankun的功能模块设计,特拆分几个文件如下:

复制
- qiankun.ts            # qiankun的主功能
- single-spa.ts         # 类似于single-spa的功能
- import-html-entry.ts  # 类似于import-html-entry的功能
- sandbox               # 存放沙箱
  - index.ts            # 沙箱容器,控制沙箱创建、加载、卸载
  - LegacySandbox.ts    # 单例代理沙箱
  - ProxySandbox.ts     # 多例代理沙箱
  - SnapshotSandbox.ts  # 快照沙箱
- patchers              # 存放副作用补丁
  - intervals.ts        # intervals副作用补丁
  - listeners.ts        # listeners副作用补丁
- globalState.ts        # 全局state状态

single-spa.ts

功能:管理注册应用、根据路由切换应用。写完后代码如下:

复制
export function registerApplication(appConfig: AppConfig) {
  _apps.push({
    status: AppStatus.NOT_LOADED,
    ...appConfig,
  });
}

let isStarted = false;

export function start() {
  if (isStarted) return;

  isStarted = true;
  reroute();
}

let _prevRoute: string, _nextRoute: string;

function reroute() {
  const tryFetchActiveApp = async (pathname: string) => {
    _prevRoute = _nextRoute;
    _nextRoute = pathname;
    const prevApp = _apps.find((app) => _prevRoute?.startsWith(app.activeWhen));
    // find the active app
    const activeApp = _apps.find((app) => pathname.startsWith(app.activeWhen));
    // if the previous app is the same as the active app, do nothing
    if (prevApp && activeApp && prevApp.name === activeApp.name) return;

    // unmount the previous app
    if (prevApp) {
      if (prevApp.status === AppStatus.MOUNTED)
        prevApp.status = AppStatus.NOT_LOADED;
        callOrArrayCall(prevApp.unmount, {});
    }
    // fetch the active app
    if (activeApp) {
      if (activeApp?.status === AppStatus.NOT_LOADED) {
        activeApp.status = AppStatus.MOUNTING;
        activeApp.app().then((lifeCycles) => {
          Object.assign(activeApp, lifeCycles);
          activeApp.status = AppStatus.MOUNTED;
          callOrArrayCall(activeApp.bootstrap, {});
          callOrArrayCall(activeApp.mount, {});
        });
      } else {
        callOrArrayCall(activeApp.bootstrap, {});
        callOrArrayCall(activeApp.mount, {});
      }
    }
  };

  function callOrArrayCall<ExtraProps extends any>(
    func: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>> | undefined,
    props: any
  ) {
    if (Array.isArray(func)) {
      func.forEach((fn) => fn(props));
    } else {
      func?.(props);
    }
  }

  // history.forward() or history.back() or history.go()
  window.addEventListener("popstate", () => {
    tryFetchActiveApp(window.location.pathname);
  });

  window.history.pushState = new Proxy(window.history.pushState, {
    apply(target, thisArg, args) {
      const pathname = args[2];
      tryFetchActiveApp(pathname);
      // @ts-ignore
      return target.apply(thisArg, args);
    },
  });

  window.history.replaceState = new Proxy(window.history.replaceState, {
    apply(target, thisArg, args) {
      const pathname = args[2];
      tryFetchActiveApp(pathname);
      // @ts-ignore
      return target.apply(thisArg, args);
    },
  });

  // 首次加载
  setTimeout(() => {
    tryFetchActiveApp(window.location.pathname);
  }, 400);
}

import-html-entry.ts

主要功能:导入html,加载css和javascript。写完后代码如下:

复制
export async function importEntry(entry: string) {
  return importHtml(entry);
}

async function importHtml(entry: string) {
  // fetch html entry of app
  const response = await fetch(entry);
  const text = await response.text();
  const html = document.createElement("div");
  html.innerHTML = text;

  // script will not be executed when html is appended to the DOM
  // so we need to create script elements and append them to the DOM
  const scripts = html.querySelectorAll("script");
  const scriptContents = await fetchScript(entry, ...scripts);

  return {
    template: html,
    execScripts: (proxy: Window) => {
      scriptContents.forEach((scriptContent) => {
        const code = getExecutableScript(
          scriptContent.src,
          scriptContent.code,
          { proxy }
        );
        evalCode(scriptContent.src, code);
      });
    },
  };
}

async function fetchScript(prefix: string, ...scripts: HTMLScriptElement[]) {
  let scriptContents: { code: string; src: string }[] = [];
  for (let i = 0; i < scripts.length; i++) {
    const script = scripts[i];
    if (script.textContent) {
      scriptContents.push({ code: script.textContent, src: "" });
      continue;
    }
    let src = script.attributes.getNamedItem("src")?.value;
    if (!src) continue;
    if (
      !src.startsWith("http") &&
      !src.startsWith("//") &&
      !src.startsWith("https")
    ) {
      src = prefix + "/" + src;
    }
    const response = await fetch(src);
    let scriptContent = await response.text();
    scriptContents.push({ code: scriptContent, src });
  }

  return scriptContents;
}

function getExecutableScript(
  scriptSrc: String,
  scriptText: string,
  opts: { proxy: Window }
) {
  const { proxy } = opts;

  const sourceUrl = `//# sourceURL=${scriptSrc}\n`;

  const globalWindow = (0, eval)("window");
  globalWindow.proxy = proxy;
  return `;(function(window, self, globalThis){with(window){;${scriptText}\n}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}

const evalCache: Record<string, any> = {};

export function evalCode(scriptSrc: string, code: string) {
  const key = scriptSrc;
  if (!evalCache[key]) {
    const functionWrappedCode = `(function(){${code}})`;
    evalCache[key] = (0, eval)(functionWrappedCode);
  }
  const evalFunc = evalCache[key];
  evalFunc.call(window);
}

sandbox/index.ts

主要功能:创建沙箱容器,初始化、加载和卸载沙箱,清除副作用。写完后代码如下:

复制
import { Freer, patchSideEffects } from "../patchers";
import LegacySandbox from "./js/LegacySandbox";
import ProxySandbox from "./js/ProxySandbox";
import SnapshotSandbox from "./js/SnapshotSandbox";

export type SandBox = {
  /** 沙箱的名字 */
  name: string;
  /** 沙箱导出的代理实体 */
  proxy: WindowProxy;
  /** 沙箱是否在运行中 */
  sandboxRunning: boolean;
  /** latest set property */
  latestSetProp?: PropertyKey | null;
  /** 启动沙箱 */
  active: () => void;
  /** 关闭沙箱 */
  inactive: () => void;
};

export function createSandboxContainer(
  appName: string,
  globalContext: Window,
  proxy: boolean = true,
  multi: boolean = true
) {
  let sandbox: SandBox;
  if (proxy) {
    sandbox = multi
      ? new ProxySandbox(appName, globalContext)
      : new LegacySandbox(appName, globalContext);
  } else {
    sandbox = new SnapshotSandbox(appName);
  }

  let mountingFreers: Freer[] = [];

  return {
    instance: sandbox,
    mount() {
      // 1. 启动/恢复沙箱
      sandbox.active();

      // 2. 开启全局变量补丁
      mountingFreers = patchSideEffects(sandbox.proxy);
    },

    unmount() {
      // 1. 释放全局变量补丁
      mountingFreers.forEach((free) => free());

      // 2. 关闭沙箱
      sandbox.inactive();
    },
  };
}

sandbox/SnapshotSandbox.ts

主要功能:快照沙箱。写完后代码如下:

复制
import { SandBox } from "..";

function iter(
  obj: typeof window | Record<any, any>,
  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);
    }
  }
}

export default class SnapshotSandbox implements SandBox {
  name: string;
  proxy: WindowProxy;
  sandboxRunning: boolean;

  private windowSnapshot!: Window;

  private modifyPropsMap: Record<any, any> = {};

  private deletePropsSet: Set<any> = new Set();

  constructor(name: string) {
    this.name = name;
    this.proxy = window;
    this.sandboxRunning = false;
  }

  active() {
    this.windowSnapshot = {} as Window;

    // 保存当前window到快照
    iter(window, (prop) => {
      this.windowSnapshot[prop] = window[prop];
    });

    // 恢复上次的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });

    // 删除上次删除的属性
    this.deletePropsSet.forEach((p: any) => {
      delete window[p];
    });

    // active
    this.sandboxRunning = true;
  }

  inactive() {
    this.modifyPropsMap = {};
    this.deletePropsSet.clear();

    iter(window, (prop) => {
      // 记录变更的属性到modifyPropsMap
      if (window[prop] !== this.windowSnapshot[prop]) {
        this.modifyPropsMap[prop] = window[prop];
        // 恢复回去
        window[prop] = this.windowSnapshot[prop];
      }

      delete this.windowSnapshot[prop];
    });

    iter(this.windowSnapshot, (prop) => {
      // 记录删除的属性到deletePropsSet
      if (!window.hasOwnProperty(prop)) {
        this.deletePropsSet.add(prop);

        window[prop] = this.windowSnapshot[prop];
      }
    });

    // inactive
    this.sandboxRunning = false;
  }
}

sandbox/LegacySandbox.ts

主要功能:单例代理沙箱。写完后代码如下:

复制
import { SandBox } from "..";

const callableFnCacheMap = new WeakMap();
const boundedMap = new WeakMap<CallableFunction, boolean>();
const fnRegexCheckCacheMap = new WeakMap<any | FunctionConstructor, boolean>();

function isCallable(fn: any) {
  if (callableFnCacheMap.has(fn)) {
    return true;
  }
  const naughtySafari =
    typeof document.all === "function" && typeof document.all === "undefined";
  const callable = naughtySafari
    ? typeof fn === "function" && typeof fn !== "undefined"
    : typeof fn === "function";
  if (callable) {
    callableFnCacheMap.set(fn, callable);
  }
  return callable;
}

function isBoundedFunction(fn: CallableFunction) {
  if (boundedMap.has(fn)) {
    return boundedMap.get(fn);
  }
  /*
   indexOf is faster than startsWith
   see https://jsperf.com/string-startswith/72
   */
  const bounded =
    fn.name.indexOf("bound ") === 0 && !fn.hasOwnProperty("prototype");
  boundedMap.set(fn, bounded);
  return bounded;
}

export function isConstructable(fn: () => any | FunctionConstructor) {
  // prototype methods might be changed while code running, so we need check it every time
  const hasPrototypeMethods =
    fn.prototype &&
    fn.prototype.constructor === fn &&
    Object.getOwnPropertyNames(fn.prototype).length > 1;

  if (hasPrototypeMethods) return true;

  if (fnRegexCheckCacheMap.has(fn)) {
    return fnRegexCheckCacheMap.get(fn);
  }

  /*
    1. 有 prototype 并且 prototype 上有定义一系列非 constructor 属性
    2. 函数名大写开头
    3. class 函数
    满足其一则可认定为构造函数
   */
  let constructable = hasPrototypeMethods;
  if (!constructable) {
    // fn.toString has a significant performance overhead, if hasPrototypeMethods check not passed, we will check the function string with regex
    const fnString = fn.toString();
    const constructableFunctionRegex = /^function\b\s[A-Z].*/;
    const classRegex = /^class\b/;
    constructable =
      constructableFunctionRegex.test(fnString) || classRegex.test(fnString);
  }

  fnRegexCheckCacheMap.set(fn, constructable);
  return constructable;
}

export function getTargetValue(target: any, value: any) {
  /*
    仅绑定 isCallable && !isBoundedFunction && !isConstructable 的函数对象,如 window.console、window.atob 这类。目前没有完美的检测方式,这里通过 prototype 中是否还有可枚举的拓展方法的方式来判断
    @warning 这里不要随意替换成别的判断方式,因为可能触发一些 edge case(比如在 lodash.isFunction 在 iframe 上下文中可能由于调用了 top window 对象触发的安全异常)
   */
  if (
    isCallable(value) &&
    !isBoundedFunction(value) &&
    !isConstructable(value)
  ) {
    const boundValue = Function.prototype.bind.call(value, target);
    for (const key in value) {
      boundValue[key] = value[key];
    }
    if (
      value.hasOwnProperty("prototype") &&
      !boundValue.hasOwnProperty("prototype")
    ) {
      Object.defineProperty(boundValue, "prototype", {
        value: value.prototype,
        enumerable: false,
        writable: true,
      });
    }

    return boundValue;
  }

  return value;
}

export default class LegacySandbox implements SandBox {
  name: string;
  proxy: WindowProxy;
  sandboxRunning = true;

  globalContext: Window;
  // 新增的属性
  addedPropsMapInSandbox = new Map();
  // 修改的属性
  modifiedPropsOriginalValueMap = new Map();
  // 始终记录的最新值
  currentUpdatedPropsValueMap = new Map();

  constructor(name: string, global: WindowProxy) {
    this.name = name;

    this.globalContext = global;
    const rawWindow = global as any;
    const fakeWindow = Object.create(null);

    const {
      addedPropsMapInSandbox,
      modifiedPropsOriginalValueMap,
      currentUpdatedPropsValueMap,
    } = this;

    const proxy = new Proxy(fakeWindow, {
      set: (_, p, value) => {
        if (this.sandboxRunning) {
          if (!rawWindow.hasOwnProperty(p)) {
            // 这是新增的属性,记录下来,用于inactive时删除
            addedPropsMapInSandbox.set(p, value);
          } else if (!modifiedPropsOriginalValueMap.has(p)) {
            // 这个属性被修改了,记录原始值,方便inactive时恢复window
            const originalVal = rawWindow[p];
            modifiedPropsOriginalValueMap.set(p, originalVal);
          }

          // 总是记录最新的值,方便active时同步到真实window
          currentUpdatedPropsValueMap.set(p, value);

          rawWindow[p] = value;
        }

        return 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);
      },

      has(_, p) {
        //返回boolean
        return p in rawWindow;
      },

      getOwnPropertyDescriptor(_, p) {
        const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
        // 如果属性不作为目标对象的自身属性存在,则不能将其设置为不可配置
        if (descriptor && !descriptor.configurable) {
          descriptor.configurable = true;
        }
        return descriptor;
      },
    });

    this.proxy = proxy;
  }

  active() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((value, key) =>
        this.setWindowProp(key, value)
      );
    }
    // active
    this.sandboxRunning = true;
  }

  inactive() {
    this.modifiedPropsOriginalValueMap.forEach((value, key) =>
      this.setWindowProp(key, value)
    );
    this.addedPropsMapInSandbox.forEach((_, key) =>
      this.setWindowProp(key, undefined, true)
    );

    // inactive
    this.sandboxRunning = false;
  }

  private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
    if (value === undefined && toDelete) {
      delete (this.globalContext as any)[prop];
    } else {
      (this.globalContext as any)[prop] = value;
    }
  }
}

sandbox/ProxySandbox.ts

主要功能:代理沙箱。写完后代码如下:

复制
import { without } from "lodash";

import { SandBox } from "..";
import { getTargetValue } from "./LegacySandbox";
import { array2TruthyObject, nativeGlobal } from "../../common";
import { globalsInBrowser, globalsInES2015 } from "../../globals";

const frozenPropertyCacheMap = new WeakMap<any, Record<PropertyKey, boolean>>();

export function isPropertyFrozen(target: any, p?: PropertyKey): boolean {
  if (!target || !p) {
    return false;
  }

  const targetPropertiesFromCache = frozenPropertyCacheMap.get(target) || {};

  if (targetPropertiesFromCache[p]) {
    return targetPropertiesFromCache[p];
  }

  const propertyDescriptor = Object.getOwnPropertyDescriptor(target, p);
  const frozen = Boolean(
    propertyDescriptor &&
      propertyDescriptor.configurable === false &&
      (propertyDescriptor.writable === false ||
        (propertyDescriptor.get && !propertyDescriptor.set))
  );

  targetPropertiesFromCache[p] = frozen;
  frozenPropertyCacheMap.set(target, targetPropertiesFromCache);

  return frozen;
}

// these globals should be recorded while accessing every time
const accessingSpiedGlobals = ["document", "top", "parent", "eval"];
const overwrittenGlobals = ["window", "self", "globalThis", "hasOwnProperty"];
export const cachedGlobals = Array.from(
  new Set(
    without(
      globalsInES2015.concat(overwrittenGlobals).concat("requestAnimationFrame"),
      ...accessingSpiedGlobals
    )
  )
);

const unscopables = array2TruthyObject(
  without(cachedGlobals, ...accessingSpiedGlobals.concat(overwrittenGlobals))
);

const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([
  ['fetch', true],
  ['mockDomAPIInBlackList', process.env.NODE_ENV === 'test'],
]);

const cachedGlobalsInBrowser = array2TruthyObject(
  globalsInBrowser.concat(process.env.NODE_ENV === 'test' ? ['mockNativeWindowFunction'] : []),
);

function isNativeGlobalProp(prop: string): boolean {
  return prop in cachedGlobalsInBrowser;
}

const globalVariableWhiteList: 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",
];

// 创建一个假的window对象,将window的属性拷贝到这个对象上。
// 后续对window的操作,都通过这个假的window对象进行。不影响真实的window对象。
function createFakeWindow(globalContext: Window, speedy: boolean) {
  // map always has the fastest performance in has checked scenario
  // see https://jsperf.com/array-indexof-vs-set-has/23
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as any;

  /*
   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" ||
          // window.document is overwriting in speedy mode
          (p === "document" && speedy)
        ) {
          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
        Object.defineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

export default class ProxySandbox implements SandBox {
  name: string;
  proxy: WindowProxy;
  private document = document;
  globalContext: Window;
  sandboxRunning = true;

  globalWhitelistPrevDescriptor: {
    [p in (typeof globalVariableWhiteList)[number]]:
      | PropertyDescriptor
      | undefined;
  } = {};

  constructor(name: string, global: WindowProxy) {
    this.name = name;
    const globalContext = global;
    this.globalContext = globalContext;

    const { fakeWindow, propertiesWithGetter } = createFakeWindow(global, true);
    const proxy = new Proxy(fakeWindow, {
      set: (target, p, value) => {
        if (this.sandboxRunning) {
          if (
            typeof p === "string" &&
            globalVariableWhiteList.indexOf(p) !== -1
          ) {
            this.globalWhitelistPrevDescriptor[p] =
              Object.getOwnPropertyDescriptor(globalContext, p);
            // @ts-ignore
            globalContext[p] = value;
          } else {
            // 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;
            }
          }
        }
        return true;
      },

      get: (target, p) => {
        if (p === Symbol.unscopables) return unscopables;
        // avoid who using window.window or window.self to escape the sandbox environment to touch the real 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") {
          // 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 this.document;
        }

        if (p === "eval") {
          return eval;
        }

        if (p === "string" && globalVariableWhiteList.indexOf(p) !== -1) {
          // @ts-ignore
          return globalContext[p];
        }

        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;
        }

        // non-native property return directly to avoid rebind
        if (
          !isNativeGlobalProp(p as string) &&
          !useNativeWindowForBindingsProps.has(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, {});
             // in nest sandbox fetch will be bind to proxy rather than window in master
             const proxyFetch = fetch.bind(proxy);
             proxyFetch('https://qiankun.com');
        */
        const boundTarget = useNativeWindowForBindingsProps.get(p)
          ? nativeGlobal
          : globalContext;
        return getTargetValue(boundTarget, value);
      },

      has(target: any, p: string | number | symbol): boolean {
        // property in cachedGlobalObjects must return true to avoid escape from get trap
        return p in target || p in globalContext;
      },

      deleteProperty: (target: any, p: string | number | symbol): boolean => {
        if (target.hasOwnProperty(p)) {
          delete target[p];

          return true;
        }

        return true;
      },
    });

    this.proxy = proxy;

    function hasOwnProperty(this: any, key: PropertyKey): boolean {
      // calling from hasOwnProperty.call(obj, key)
      if (this !== proxy && this !== null && typeof this === "object") {
        return Object.prototype.hasOwnProperty.call(this, key);
      }

      return (
        fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key)
      );
    }
  }

  active() {
    // active
    this.sandboxRunning = true;
  }

  inactive() {
    // inactive
    this.sandboxRunning = false;
  }
}

patchers/index.ts

主要功能:执行patcher清除副作用。写完后代码如下:

复制
import patchIntervals from "./intervals";
import patchListeners from "./listeners";

export type Freer = () => void;

export type Patcher = (global: WindowProxy) => Freer;

export function patchSideEffects(global: WindowProxy) {
  const patchers: Patcher[] = [patchIntervals, patchListeners];

  return patchers.map((patch) => patch(global));
}

patchers/intervals.ts

主要功能:清除setInterval副作用。写完后代码如下:

复制
import { Patcher } from ".";

const rawSetInterval = global.setInterval;
const rawClearInterval = global.clearInterval;

const patch: Patcher = (global) => {
  let intervals: number[] = [];

  global.setInterval = (handler: Function, timeout: number, ...args: any[]) => {
    const intervalId = rawSetInterval(handler, timeout, ...args);
    intervals.push(intervalId);
    return intervalId;
  };

  global.clearInterval = (intervalId: number) => {
    rawClearInterval(intervalId);
    intervals = intervals.filter((id) => id !== intervalId);
  };

  return function free() {
    intervals.forEach((id) => rawClearInterval(id));
    intervals = [];
    global.setInterval = rawSetInterval;
    global.clearInterval = rawClearInterval;
  };
};

export default patch;

patchers/listeners.ts

主要功能:清除addListener副作用。写完后代码如下:

复制
import { Patcher } from ".";

const rawAddListener = global.addEventListener;
const rawRemoveListener = global.removeEventListener;

const patch: Patcher = (global) => {
  let listeners: Map<string, EventListenerOrEventListenerObject[]> = new Map();

  global.addEventListener = (
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions
  ) => {
    rawAddListener(type, listener, options);
    if (!listeners.has(type)) {
      listeners.set(type, []);
    }
    listeners.get(type)!.push(listener);
  };

  global.removeEventListener = (type: string, listener: EventListenerOrEventListenerObject) => {
    rawRemoveListener(type, listener);
    if (listeners.has(type)) {
      listeners.set(
        type,
        listeners.get(type)!.filter((l) => l !== listener)
      );
    }
  }

  return function free() {
    listeners.forEach((ls, type) => {
      ls.forEach((listener) => {
        rawRemoveListener(type, listener);
      });
    });
    listeners.clear();
    global.addEventListener = rawAddListener;
    global.removeEventListener = rawRemoveListener;
  };
};

export default patch;

globalState.ts

主要功能:全局状态通信。写完后代码如下:

复制
import { cloneDeep } from "lodash";

declare type State = Record<string, any>;
declare type Listener = (state: State, prev: State) => void;

let globalState: State = {};
let listenerMap: Record<string, Listener> = {};

function emit(state: State) {
  for (let key in listenerMap) {
    listenerMap[key](state, cloneDeep(globalState));
  }
}

export function initGlobalState(state: State = {}) {
  globalState = cloneDeep(state);

  return getAppStateActions(`main-app`);
}

export function getAppStateActions(id: string) {
  return {
    onGlobalStateChange: (callback: Listener, fireImmediately?: boolean) => {
      listenerMap[id] = callback;
      if (fireImmediately) {
        callback(cloneDeep(globalState), {});
      }
    },
    offGlobalStateChange: () => {
      delete listenerMap[id];
    },
    setGlobalState: (state: State) => {
      state = cloneDeep(state);
      console.log(`[fake qiankun]global state set: ${JSON.stringify(state)}`);
      let changedKeys: string[] = [];
      for (let key in state) {
        if (globalState[key] !== state[key]) {
          changedKeys.push(key);
        }
      }
      if (changedKeys.length > 0) {
        emit(state);
      }
    },
  };
}

qiankun.ts

最后就是qiankun.ts,它是一个全局协调者的角色,组装各个模块的功能,暴露接口供用户调用。

主要功能:提供外部接口,内部使用single-spa.tsimport-html-entry,创建沙盘,管理全局状态,加载app。写完后代码如下:

复制
import { registerApplication, start as startSingleSpa } from "./single-spa";
import { importEntry } from "./import-html-entry";
import { createSandboxContainer } from "./sandbox";
import { getAppStateActions } from "./globalState";
export { initGlobalState } from "./globalState"

export type AppConfig = {
  name: string;
  entry: string;
  container: string;
  activeRule: string;
};

export function registerMicroApps(apps: AppConfig[]) {
  apps.forEach((app) => {
    registerApplication({
      name: app.name,
      activeWhen: app.activeRule,
      app: async () => {
        return loadApp(app);
      },
    });
  });
}

declare type FrameworkConfiguration = {
  proxy?: boolean;
  multi?: boolean;
  style?: boolean | { strict?: boolean; experiment?: boolean };
};
let frameworkConfiguration: FrameworkConfiguration = {};

export function start(opts: FrameworkConfiguration) {
  frameworkConfiguration = opts;
  startSingleSpa();
}

async function loadApp(app: AppConfig) {
  const parent = document.querySelector(app.container);
  if (!parent) throw new Error("container not found");
  parent.innerHTML = "";

  const { template, execScripts } = await importEntry(app.entry);

  const isStrictStyle =
    typeof frameworkConfiguration.style === "object" &&
    !!frameworkConfiguration.style.strict;

  const isScopedStyle =
    typeof frameworkConfiguration.style === "object" &&
    !isStrictStyle &&
    !!frameworkConfiguration.style.experiment;

  console.log(
    `[fake qiankun] ${
      isScopedStyle ? "scoped" : isStrictStyle ? "strict" : "none"
    } style isolation applied`
  );

  const appWrapperElement = createElement(
    template,
    isStrictStyle,
    isScopedStyle
  );

  parent.appendChild(appWrapperElement);

  let global: Window = window;

  // sandbox
  const sandboxContainer = createSandboxContainer(
    app.name,
    global,
    frameworkConfiguration.proxy,
    frameworkConfiguration.multi
  );

  global = sandboxContainer.instance.proxy;

  beforeLoad(global, app.entry);

  execScripts(global);

  // @ts-ignore
  const scriptExports = global[app.name];

  const { bootstrap, mount, unmount } = scriptExports;

  const appContainer = appWrapperElement.shadowRoot || appWrapperElement;

  const stateActions = getAppStateActions(app.name);

  return {
    bootstrap,
    mount: [
      sandboxContainer.mount,
      async () => mount({ name: app.name, container: appContainer, ...stateActions }),
      () => console.log(`[fake qiankun] app mounted: 【${app.name}`),
    ],
    unmount: [
      () => beforeUnmountAddOns(global),
      async () => unmount({ name: app.name, container: appContainer }),
      sandboxContainer.unmount,
      () => console.log(`[fake qiankun] app unmounted: 【${app.name}`),
    ],
  };
}

function beforeLoad(global: any, publicPath = "/") {
  global.__POWERED_BY_QIANKUN__ = true;
  global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = publicPath;
}

function beforeUnmountAddOns(global: any) {
  delete global.__POWERED_BY_QIANKUN__;
  delete global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

/** 创建app wrapper元素 */
function createElement(
  appElement: HTMLElement,
  strictStyle: boolean,
  scopedStyle: boolean
) {
  if (strictStyle) {
    const { innerHTML } = appElement;
    appElement.innerHTML = "";

    const shadow = appElement.attachShadow({ mode: "open" });

    shadow.innerHTML = innerHTML;
  }

  if (scopedStyle) {
    const attr = appElement.getAttribute("data-qiankun");
    if (!attr) {
      appElement.setAttribute("data-qiankun", "appid");
    }

    const styleNodes = appElement.querySelectorAll("style") || [];
    styleNodes.forEach((stylesheetElement: HTMLStyleElement) => {
      // css.process(appElement!, stylesheetElement, "appid");
    });
  }

  return appElement;
}

测试用例和贴图就先不加了,有时间补上。

unko