架构机制

qiankun 的结构机制

qiankun(乾坤)框架其实是基于 single-spa 框架搭建而成的,简单来说就是 single-spa 的优化版。

架构图

大体实现思路:

  • 预加载资源:如果在应用注册配置中,有配置需要预加载的应用,则在初始化的同时去加载这些应用。
  • 初始化路由:根据配置的路由规则,与当前页面路径匹配,找到当前有效的应用信息。并且通过 popstate 监听页面路由变化,根据路由变化得到当前有效的应用
  • 代理部分 window 事件:由于每个应用可能各自会绑定一些 window 事件,因此劫持 window.addEventListener,将每个应用所绑定的事件记录下来,方便后续再切换路由时清除掉。
  • 加载资源:目通过加载目标页面,分析各种资源然后加载执行
  • 记录全局变量:在每个应用执行之前,记录当前全局变量,然后在应用被卸载的时候,清除掉所有全局变量,以免影响下个应用的执行。

传递注册信息给 single-spa

我们来看看 qiankun 的使用方式:

import { registerMicroApps, start } from "qiankun";

registerMicroApps([
  {
    name: "react app", // app name registered
    entry: "//localhost:7100",
    container: "#yourContainer",
    activeRule: "/yourActiveRule",
  },
  {
    name: "vue app",
    entry: { scripts: ["//localhost:7100/main.js"] },
    container: "#yourContainer2",
    activeRule: "/yourActiveRule2",
  },
]);

start();

实际上 qiankun 内部会把用户的应用注册信息包装后传递给 single-spa:

import { registerApplication } from "single-spa";
export function registerMicroApps(apps) {
  apps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;
    registerApplication({
      name,
      app: async () => {
        loader(true);
        const { mount, ...otherMicroAppConfigs } = await loadApp(
          { name, props, ...appConfig },
          frameworkConfiguration
        );
        return {
          mount: [
            async () => loader(true),
            ...toArray(mount),
            async () => loader(false),
          ],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

可以看到 mount 和 unmount 函数是由 loadApp 返回的。

loadApp 的实现

export async function loadApp(app, configuration) {
  const { template, execScripts } = await importEntry(entry); // 通过应用的入口链接即可获取到应用的html, js, css内容
  const sandboxInstance = createSandbox(); // 创建沙箱实例
  const global = sandboxInstance.proxy; // 获取一个沙箱全局上下文
  const mountSandbox = sandboxInstance.mount;
  const unmountSandbox = sandboxInstance.unmount;

  // 在这个沙箱全局上下文执行子项目的js代码
  const scriptExports = await execScripts(global);

  // 获取子项目导出的 bootstrap / mount / unmount
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
    scriptExports,
    appName,
    global
  );

  // 初始化事件模块
  const {
    onGlobalStateChange,
    setGlobalState,
    offGlobalStateChange,
  } = getMicroAppStateActions();

  // 传递给single-spa的mount, unmount方法实际是qiankun包装过的函数
  return {
    bootstrap,
    mount: async () => {
      awaitrender(template); // 把模板渲染到挂载区域
      mountSandbox(); // 挂载沙箱
      await mount({ setGlobalState, onGlobalStateChange }); // 调用应用的mount函数
    },
    ummount: async () => {
      await ummount(); // 调用应用的ummount函数
      unmountSandbox(); // 卸载沙箱
      offGlobalStateChange(); // 解除事件监听
      render(null); // 把渲染区域清空
    },
  };
}

看看 importEntry 的使用,这是一个独立的包 import-html-entry,通过解析一个 html 内容,返回 html, css,js 分离过的内容。例如一个子应用的入口 html 为如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>这里是标题</title>
    <link rel="stylesheet" href="./css/admin.css" />
    <style>
      .div {
        color: red;
      }
    </style>
  </head>
  <boyd>
    <div id="wrap">
      <div id="app"></div>
    </div>
    <script src="/static/js/app.12345.js"></script>
    <script>
      console.log("1");
    </script>
  </boyd>
</html>

被 qiankun 加载到页面后,最终生成的 html 结构为:

<meta charset="utf-8" />
<title>这里是标题</title>
<link rel="stylesheet" href="./css/admin.css" />
<style>
  .div {
    color: red;
  }
</style>
<div id="wrap">
  <div id="app"></div>
</div>
<!--  script /static/js/app.12345.js replaced by import-html-entry -->
<!-- inline scripts replaced by import-html-entry -->

看看 importEntry 返回的内容:

export function importEntry(entry, opts = {}) {
  // ... // parse html 过程忽略
  return {
    // 纯dom元素的内容
    template,
    // 一个可以接收自定义fetch方法的获取<script>标签的方法
    getExternalScripts: () => getExternalScripts(scripts, fetch),
    // 一个可以接收自定义fetch方法的获取<style>标签的方法
    getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
    // 一个接收全局上下文的执行函数,执行这个方法则模拟了一个应用加载时浏览器执行script脚本的逻辑
    execScripts: (proxy) => {},
  };
}

看看 getExternalScripts 的实现,实际上是用并行 fetch 模拟浏览器加载 <style> 标签的过程(注意此时还没有执行这些脚本), getExternalStyleSheets 与这个类似。


// scripts是解析html后得到的<scripts>标签的url的数组
export getExternalScripts(scripts, fetch = defaultFetch) {
  return Promise.all(scripts.map(script => {
    return fetch(scriptUrl).then(response => {
        return response.text();
    }));
  }))
}

然后看看 execScripts 的实现,可以通过给定的一个假 window 来执行所有 <script> 标签的脚本,这样就是真正模拟了浏览器执行 <script> 标签的行为。

export async execScripts(proxy) {
  // 上面的getExternalScripts加载得到的<scripts>标签的内容
  const scriptsTexts = await getExternalScripts(scripts)
  window.proxy = proxy;
  // 模拟浏览器,按顺序执行script
  for (let scriptsText of scriptsTexts) {
    // 调整sourceMap的地址,否则sourceMap失效
    const sourceUrl = '//# sourceURL=${scriptSrc}\n';
    // 通过iife把proxy替换为window, 通过eval来执行这个script
    eval(`
      ;(function(window, self){
        ;${scriptText}
        ${sourceUrl}
      }).bind(window.proxy)(window.proxy, window.proxy);
    `;)
  }
}

沙箱功能

沙箱主要用于解决程序的全局变量污染和内存泄漏问题。

  • 全局变量污染:多个应用都使用某个同名全局变量,例如 Vue。
  • 内存泄漏:内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。

常见的内存泄漏场景:意外的全局变量、泄漏到全局的闭包、DOM 泄漏、定时器、EventListener、console.log (开发环境)。

沙箱使用

export function createSandbox() {
  const sandbox = new LegacySandbox();
  // load或者bootstrap阶段产生的污染和泄漏
  const bootstrappingFreers = patchAtBootstrapping();
  let sideEffectsRebuilders = [];
  return {
    proxy: sandbox.proxy,
    // 沙箱被 mount, 可能是从 bootstrap 状态进入的 mount, 也可能是从 unmount 之后再次唤醒进入 mount
    async mount() {
      /* ------------------------------------------ 1. 启动/恢复 沙箱------------------------------------------ */
      sandbox.active();
      const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(
        0,
        bootstrappingFreers.length
      );
      // 重建应用 bootstrap 阶段的副作用,比如动态插入css
      sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
      /* ------------------------------------------ 2. 开启全局副作用监听 ------------------------------------------*/
      // render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化 bootstrap 阶段有 事件监听/定时器 等副作用,这些副作用无法清除
      mountingFreers = patchAtMounting(
        appName,
        elementGetter,
        sandbox,
        singular,
        scopedCSS,
        excludeAssetFilter
      );
      sideEffectsRebuilders = [];
    },
    // 恢复 global 状态,使其能回到应用加载之前的状态
    async unmount() {
      // 每个Freers释放后都会返回一个重建函数,如果该Freers不需要重建,则是返回一个空函数
      sideEffectsRebuilders = [...bootstrappingFreers].map((free) => free());
      sandbox.inactive();
    },
  };
}

看看 LegacySandbox 沙箱的实现,这个沙箱的作用主要处理全局变量污染,使用一个 proxy 来替换 window 来劫持所有的 window 操作。

class SingularProxySandbox {
  // 沙箱期间更新的全局变量
  addedPropsMapInSandbox = new Map();
  // 沙箱期间更新的全局变量
  modifiedPropsOriginalValueMapInSandbox = new Map();
  // 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot
  currentUpdatedPropsValueMap = new Map();
  sandboxRunning = true;
  active() {
    // 把上次该沙箱运行时的快照还原
    this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    this.sandboxRunning = true;
  }
  inactive() {
    // 沙箱销毁时把修改的值改回去
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
    // 沙箱销毁时把新增的值置空
    this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
    this.sandboxRunning = false;
  }
  constructor(name) {
    const proxy = new Proxy(window, {
      set(_, p, value) {
          // 如果当前 window 对象不存在该属性,则记录该属性是新增的
          if (!window.hasOwnProperty(p)) {
            addedPropsMapInSandbox.set(p, value);
          // 如果当前 window 对象存在该属性,且 map 中未记录过,则记录该属性被修改及保存修改前的值
          } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
            const originalValue = window[p];
            modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
          }
          // 不管新增还是修改,这个值都变成最新的快照记录起来
          currentUpdatedPropsValueMap.set(p, value);
          window[p] = value;
        }
      },
      get(_, p) {
        return window[p]
      },
    })
  }
}

除了全局变量污染的问题,还有其他的泄漏问题需要处理,这些泄漏问题 qiankun 使用不同的 patch 函数来劫持。

// 处理mount阶段和应用运行阶段产生的泄漏
export function patchAtMounting() {
  return [
    // 处理定时器泄漏
    patchInterval(),
    // 处理全局事件监听泄漏
    patchWindowListener(),
    patchHistoryListener(),
    // 这个严格不算泄漏,是监听动态插入页面的dom结构(包括script和style)
    patchDynamicAppend(),
  ];
}

// 处理load和bootstrap阶段产生的泄漏
export function patchAtBootstrapping() {
  return [patchDynamicAppend()];
}

一个 patch 的例子如下:

const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;

export default function patchInterval(global) {
  let intervals = [];

  global.clearInterval = (intervalId) => {
    intervals = intervals.filter((id) => id !== intervalId);
    return rawWindowClearInterval(intervalId);
  };

  global.setInterval = (handler, timeout, ...arg) => {
    const intervalId = rawWindowInterval(handler, timeout, ...args);
    intervals = [...intervals, intervalId];
    return intervalId;
  };

  // 返回释放这些泄漏的方法
  return function free() {
    intervals.forEach((id) => global.clearInterval(id));
    global.setInterval = rawWindowInterval;
    global.clearInterval = rawWindowClearInterval;
    // 这个patch有没有需要重建的场景,如果没有,则为空函数
    return function rebuild() {};
  };
}

这种返回取消功能的设计很精妙,在 vue 中也能找到类似设计。

// 监听返回取消监听方法,取消监听返回再重新监听的方法
const unwatch = this.$watch("xxx", () => {});
const rewatch = unwatch(); // 伪代码,实际上没有

我们来看最复杂的 patchDynamicAppend 实现,用于处理代码里动态插入 script 和 link 的场景。

const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
export default function patchDynamicAppend(mounting, proxy) {
  let dynamicStyleSheetElements = [];
  // 劫持插入函数
  HTMLHeadElement.prototype.appendChild = function (element) {
    switch (element.tagName) {
      case LINK_TAG_NAME:
      // 如果是动态插入<style>标签到body上,则调整插入的位置到子应用挂载区
      case STYLE_TAG_NAME: {
        dynamicStyleSheetElements.push(stylesheetElement);
        return rawHeadAppendChild.call(appWrapperGetter(), stylesheetElement);
      }
      // 如果是动态插入<script>标签,则使用execScripts来执行这个脚本,然后把脚本替换为一段注释文本表示已执行过
      case SCRIPT_TAG_NAME: {
        const { src, text } = element;
        execScripts(null, [src ? src : `<script>${text}</script>`], proxy);
        const dynamicScriptCommentElement = document.createComment(
          src
            ? `dynamic script ${src} replaced by qiankun`
            : "dynamic inline script replaced by qiankun"
        );
        return rawHeadAppendChild.call(
          appWrapperGetter(),
          dynamicScriptCommentElement
        );
      }
    }
    return rawHeadAppendChild.call(this, element);
  };
  // 这里free不需要释放什么东西,因为style元素会随着内容区清除而自然消失
  return function free() {
    // 这里需要再下次继续挂载这个应用时重建style元素
    return function rebuild() {
      dynamicStyleSheetElements.forEach((stylesheetElement) => {
        document.head.appendChild.call(appWrapperGetter(), stylesheetElement);
      });
      if (mounting) dynamicStyleSheetElements = [];
    };
  };
}

父子应用通信

qiankun 实现了一个简单的全局数据存储,作为 single-spa 事件的补充,父子应用都可以共同读写这个存储里的数据。

let globalState = {};
export function getMicroAppStateActions(id, isMaster) {
  return {
    // 事件变更回调
    onGlobalStateChange(callback, fireImmediately) {
      deps[id] = callback;
      const cloneState = cloneDeep(globalState);
      if (fireImmediately) {
        callback(cloneState, cloneState);
      }
    },
    // 设置全局状态
    setGlobalState(state) {
      const prevGlobalState = cloneDeep(globalState);
      Object.keys(deps).forEach((id) => {
        deps[id](cloneDeep(globalState), cloneDeep(prevGlobalState));
      });
      return true;
    },
    // 注销该应用下的依赖
    offGlobalStateChange() {
      delete deps[id];
    },
  };
}

关于预请求

预请求充分利用了 importEntry 把获取资源和执行资源分离的点来提前加载所有子应用的资源。

function prefetch(entry, opts) {
  if (!navigator.onLine || isSlowNetwork) {
    // Don't prefetch if in a slow network or offline
    return;
  }
  requestIdleCallback(async () => {
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(
      entry,
      opts
    );
    requestIdleCallback(getExternalStyleSheets);
    requestIdleCallback(getExternalScripts);
  });
}
apps.forEach(({ entry }) => prefetch(entry, opts));
上一页
下一页