本文来自OPPO互联网技术团队,转载请注名作者。同时欢送关注咱们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及流动。

微前端是指存在于浏览器中的微服务,通常由许多组件组成,并应用相似于 React、Vue 和 Angular 等框架来渲染组件,每个微前端能够由不同的团队进行治理,并能够自主抉择框架。

每个微前端都领有独立的 git 仓库、package.json 和构建工具配置。因而,能够拆分一些巨石利用为多个独立的模块再组合起来,利用间独立保护及上线,互不烦扰。

本文通过一些精简代码的形式介绍微前端框架qiankun的原理及OPPO云在这下面的一些实际。

注:本文默认读者应用过qiankun框架,且文中应用的qiankun版本为:2.0.9

1. qiankun 的前身 single-spa

qiankun 是一个基于 single-spa 的微前端实现库,在qiankun还未诞生前,用户通常应用single-spa来解决微前端的问题,所以咱们先来理解single-spa

咱们先来上一个例子,并逐渐剖析每一步产生了什么。

import { registerApplication, start } from "single-spa";registerApplication(  "foo",  () => System.import("foo"),  (location) => location.pathname.startsWith("foo"));registerApplication({  name: "bar",  loadingFn: () => import("bar.js"),  activityFn: (location) => location.pathname.startsWith("bar"),});start();
  • appName: string 利用的名字将会在 single-spa 中注册和援用, 并在开发工具中标记
  • loadingFn: () => 必须是一个加载函数,返回一个利用或者一个 Promise
  • activityFn: (location) => boolean 判断以后利用是否沉闷的办法
  • customProps?: Object 可选的传递自定义参数

1.1 元数据处理

首先,single-spa会对上述数据进行标准化解决,并增加上状态,最终转化为一个元数据数组,例如上述数据会被转为:

[{  name: 'foo',  loadApp: () => System.import('foo'),  activeWhen: location => location.pathname.startsWith('foo'),  customProps: {},  status: 'NOT_LOADED'},{  name: 'bar',  loadApp: () => import('bar.js'),  activeWhen: location => location.pathname.startsWith('bar')  customProps: {},  status: 'NOT_LOADED'}]

1.2 路由劫持

single-spa外部会对浏览器的路由进行劫持,所有的路由办法路由事件都确保先进入single-spa进行对立调度。

// We will trigger an app change for any routing events.window.addEventListener("hashchange", urlReroute);window.addEventListener("popstate", urlReroute);// Monkeypatch addEventListener so that we can ensure correct timingconst originalAddEventListener = window.addEventListener;window.addEventListener = function(eventName, fn) {  if (typeof fn === "function") {    if (      ["hashchange", "popstate"].indexOf(eventName) >= 0 &&      !find(capturedEventListeners[eventName], (listener) => listener === fn)    ) {      capturedEventListeners[eventName].push(fn);      return;    }  }  return originalAddEventListener.apply(this, arguments);};
function patchedUpdateState(updateState, methodName) {  return function() {    const urlBefore = window.location.href;    const result = updateState.apply(this, arguments);    const urlAfter = window.location.href;    if (!urlRerouteOnly || urlBefore !== urlAfter) {      urlReroute(createPopStateEvent(window.history.state, methodName));    }  };}window.history.pushState = patchedUpdateState(  window.history.pushState,  "pushState");window.history.replaceState = patchedUpdateState(  window.history.replaceState,  "replaceState");

以上是劫持代码的精简版,能够看到,所有的劫持都指向了一个进口函数urlReroute

1.3 urlReroute 对立处理函数

每次路由变动,都进入一个雷同的函数进行解决:

let appChangeUnderway = false,  peopleWaitingOnAppChange = [];export async function reroute(pendingPromises = [], eventArguments) {  // 依据不同的条件把利用分到不同的待处理数组里  const {    appsToUnload,    appsToUnmount,    appsToLoad,    appsToMount,  } = getAppChanges();  // 如果在变更进行中还进行了新的路由跳转,则进入一个队列中排队,  if (appChangeUnderway) {    return new Promise((resolve, reject) => {      peopleWaitingOnAppChange.push({ resolve, reject, eventArguments });    });  }  // 标记此次变更正在执行中,  appChangeUnderway = true;  await Promise.all(appsToUnmount.map(toUnmountPromise)); // 待卸载的利用先执行unmount  await Promise.all(appsToUnload.map(toUnloadPromise)); // 待销毁的利用先销毁  await Promise.all(appsToLoad.map(toLoadPromise)); // 待加载的利用先执行load  await Promise.all(appsToBootstrap.map(toBootstrapPromise)); // 待bootstrap的利用执行bootstrap  await Promise.all(appsMount.map(toMountPromise)); // 待挂载的利用执行mount  appChangeUnderway = false;  // 如果排队的队列中还有路由变更,则进行新的一轮reroute循环  reroute(peopleWaitingOnAppChange);}

接下来看看分组函数在做什么。

1.4 getAppChanges 利用分组

每次路由变更都先依据利用的activeRule规定把利用分组。

export function getAppChanges() {  const appsToUnload = [],    appsToUnmount = [],    appsToLoad = [],    appsToMount = [];  apps.forEach((app) => {    const appShouldBeActive =      app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);    switch (app.status) {      case LOAD_ERROR:      case NOT_LOADED:        if (appShouldBeActive) appsToLoad.push(app);      case NOT_BOOTSTRAPPED:      case NOT_MOUNTED:        if (!appShouldBeActive) {          appsToUnload.push(app);        } else if (appShouldBeActive) {          appsToMount.push(app);        }      case MOUNTED:        if (!appShouldBeActive) appsToUnmount.push(app);    }  });  return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };}

1.5 对于状态字段的枚举

single-spa对利用划分了一下的状态

export const NOT_LOADED = "NOT_LOADED"; // 还未加载export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 加载源码中export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 已加载源码,还未bootstrapexport const BOOTSTRAPPING = "BOOTSTRAPPING"; // bootstrap中export const NOT_MOUNTED = "NOT_MOUNTED"; // bootstrap结束,还未mountexport const MOUNTING = "MOUNTING"; // mount中export const MOUNTED = "MOUNTED"; // mount完结export const UPDATING = "UPDATING"; // updata中export const UNMOUNTING = "UNMOUNTING"; // unmount中export const UNLOADING = "UNLOADING"; // unload中export const LOAD_ERROR = "LOAD_ERROR"; // 加载源码时加载失败export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 在load,bootstrap,mount,unmount阶段产生脚本谬误

咱们能够在开发时应用官网的调试工具疾速查看每次路由变更后每个利用的状态:

single-spa应用了无限状态机的设计思维:

  • 事物领有多种状态,任一时间只会处于一种状态不会处于多种状态;
  • 动作能够扭转事物状态,一个动作能够通过条件判断,扭转事物到不同的状态,然而不能同时指向多个状态,一个工夫,就一个状态;
  • 状态总数是无限的。

无限状态机的其余例子: Promise 、 红绿灯

1.6 single-spa 的事件零碎

基于浏览器原生的事件零碎,无框架耦合,全局开箱可用。

// 接管形式window.addEventListener("single-spa:before-routing-event", (evt) => {  const {    originalEvent,    newAppStatuses,    appsByNewStatus,    totalAppChanges,  } = evt.detail;  console.log(    "original event that triggered this single-spa event",    originalEvent  ); // PopStateEvent | HashChangeEvent | undefined  console.log(    "the new status for all applications after the reroute finishes",    newAppStatuses  ); // { app1: MOUNTED, app2: NOT_MOUNTED }  console.log(    "the applications that changed, grouped by their status",    appsByNewStatus  ); // { MOUNTED: ['app1'], NOT_MOUNTED: ['app2'] }  console.log(    "number of applications that changed status so far during this reroute",    totalAppChanges  ); // 2});

1.7 single-spa 亮点与有余

亮点

  • 全异步编程,对于用户须要提供的 load,bootstrap,mount,unmount 均应用 promise 异步的模式解决,不论同步、异步都能 hold 住
  • 通过劫持路由,能够在每次路由变更时先判断是否须要切换利用,再交给子利用去响应路由
  • 标准化每个利用的挂载和卸载函数,不耦合任何框架,只有子利用实现了对应接口即可接入零碎中

有余

  • load 办法须要晓得子项目的入口文件
  • 把多个利用的运行时集成起来须要我的项目间自行处理内存透露,款式净化问题
  • 没有提供父子数据通信的形式

2. qiankun 退场

为了解决single-spa的一些有余,以及保留single-spa中优良的理念,所以qiankunsingle-spa的根底上进行了更进一步的拓展。

以下是qiankun官网给的能力图:

咱们来看看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();

是不是有点像single-spa的注册形式?

2.1 传递注册信息给 single-spa

实际上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,    });  });}

能够看到mountunmount函数是由loadApp返回的。

2.2 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); // 把渲染区域清空    },  };}

2.3 importEntry 的实现

看看 importEntry 的应用,这是一个独立的包 import-html-entry,通过解析一个 html 内容,返回html, cssjs拆散过的内容。

例如一个子利用的入口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);    `;)  }}

2.4 全局变量净化与内存透露

看沙箱性能前先聊一聊沙箱,沙箱次要用于解决程序的全局变量净化内存透露问题。

  • 全局变量净化: 多个利用都应用某个同名全局变量,例如 Vue。
  • 内存透露: 内存透露指因为忽略或谬误造成程序未能开释曾经不再应用的内存。内存透露并非指内存在物理上的隐没,而是应用程序调配某段内存后,因为设计谬误,导致在开释该段内存之前就失去了对该段内存的管制,从而造成了内存的节约。

    常见的内存透露场景:

    1. 意外的全局变量
    2. 透露到全局的闭包
    3. DOM 透露
    4. 定时器
    5. EventListener
    6. console.log (开发环境)

上面咱们看看qiankun要如何解决下面的问题。

2.5 qiankun 如何应用沙箱

能够联合上文loadApp的逻辑看看,本文探讨的是LegacySandbox沙箱。

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实现,用于解决代码里动静插入scriptlink的场景。

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 = [];    };  };}

2.6 父子利用通信

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

2.7 对于预申请

预申请充分利用了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));

以上分享了qiankunsingle-spa的原理,总的来说qiankun更面向一些子项目不可控,并且开发者不会刻意解决净化和内存透露的场景,而single-spa则更纯正的是一个路由控制器,所有的净化和透露问题都须要开发者自行束缚。

3. OPPO 云实际

OPPO云在实际qiankun微前端的落地过程中,也摸索出一些教训可进行分享。

3.1 对于沙箱

qiankun 的沙箱不是万能的

  • 沙箱只有一层的劫持,例如 Date.prototype.xxx 这样的改变是不会被还原的
  • 目前沙箱对于全局变量的作用在于屏蔽,而不是革除,并且屏蔽后这部分内存是保留的,后续会凋谢自定义沙箱的能力
  • 对于内存透露的概念,能够理解一下“常驻内存”的概念

    常驻内存是一种辅助工具程序,能伪装退出,而仍驻留于内存当中,让你运行其它的利用,当你再切回利用时,能够立刻利用这些内存,而不须要再次耗时创立

  • 排查内存问题时请应用无痕模式以及不应用任何 chrome 拓展,也举荐应用生产构建来排查

3.2 提取公共库

  • qiankun不倡议共享依赖,放心原型链净化等问题。 single-spa推荐共享大型依赖,须要小心解决净化问题,它们都是举荐应用webpackexternal来共享依赖库。
  • 咱们也推荐共享大的公共依赖,也是应用webpackexternal来共享依赖库,不过是每个子利用加载时都反复再加载一次库,相当于节俭了雷同库的下载工夫,也保障了不同子利用间不会产生原型链净化,属于折中的计划。

参考链接

  • qiankun
  • single-spa
  • 指标是最欠缺的微前端解决方案