Vol.4 扒一扒热门微前端代码

发布于:2024-05-08 ⋅ 阅读:(16) ⋅ 点赞:(0)

一. 前言

“微前端”概念大概是在2016年左右出现的,当时随着SPA应用的大面积使用以及前端工程复杂度越来越高,微前端作为一种新的架构模式被提出。经过这么多年的发展,微前端对我们前端来说已经不是一个新的技术了,相对应的微前端相关的框架也越来越多,也越来越成熟,所以本文主要就是对市面常用的微前端框架来做一个对比。

二. 微前端解决的问题

在使用一个新技术之前,我们通常会问自己两个问题:我真的需要它么?它解决了什么问题?

微前端要解决的是什么问题?

应用在迭代发展的过程中,不可避免的就是“熵增”,应用会慢慢变成“巨石”应用。此时研发人员无论是想要对应用进行架构升级/优化,或者想要拆解这个应用就会变得很困难。其次,应用拆分后,这些应用期望仍在相同的“系统”里面展示,并且各应用的迭代互不依赖。总结一下:

  • 微前端核心思想是:技术无关
    • 各应用的聚合/拆分,都与应用使用的技术无关,仅与业务功能有关。
  • 巨石应用的拆分。
  • 利用一个轻薄的父应用,把不同的子应用聚合起来,从产品形态上看起来像是一个“系统”。

为什么不用iframe?

看完上面微前端主要解决的问题时,我脑海中第一个反应就是:为什么不用iframe?明明iframe就能很好的解决微前端要解决所有问题,按照我们历史经验,这类问题就应该是使用iframe解决。那么iframe到底有什么缺点呢?

  • 浏览器赋予iframe的硬隔离既是它的优点,也是它的缺点。
  • iframe的硬隔离无法突破,导致大量的体验问题:
    • url/路由不同步,无法前进后退。
    • UI不同步,dom结构不共享,无法影响父容器的dom。
    • 全局上下文隔离,必须使用通信机制才能与父应用沟通。
    • 慢,子应用每次进入都是一次浏览器上下文重建、资源重新加载的过程。

你真的需要微前端吗?

目前社区最广的微前端框架qiankun的作者,曾经写过一篇文章,我感觉文章已经说得很清楚了,有兴趣的同学可以认真读一下上面的链接。这里我直接把结论贴一下:
满足以下条件,你才可能需要微前端:

  1. 系统本身是需要集成被集成的,一般有两种情况:
    1. 旧的系统不能下,新的需求还在来。
      没有一家商业公司会同意工程师以单纯的技术升级的理由,直接下线一个有着一定用户的存量系统的。而你大概又不能简单通过 iframe 这种「靠谱的」手段完成新功能的接入,因为产品说需要「弹个框弹到中间」
    2. 你的系统需要有一套支持动态插拔的机制
      这个机制可以是一套精心设计的插件体系,但一旦出现接入应用或被接入应用年代够久远、改造成本过高的场景,可能后面还是会过渡到各种微前端的玩法。
  2. 系统中的部件具备足够清晰的服务边界
    通过微前端手段划分服务边界,将复杂度隔离在不同的系统单元中,从而避免因熵增速度不一致带来的代码腐化的传染,以及研发节奏差异带来的工程协同上的问题。

当然还有最简单的判断标准:如果你的系统不需要考虑体验问题,那么请使用iframe。

三. 微前端框架演进

微前端框架从2016到现在,大致可以分为两个阶段:js-entry阶段html-entry阶段

js-entry模式

js-entry模式要求用户把子应用打包成一个bundleJs,然后主应用直接在一个div容器下加载这个js。这样子应用的js就会在主应用的也页面执行,子应用的html就会渲染在当前父应用的div下。
截屏2024-04-29 17.29.30.png
原理十分简单,但是相信大家也看出问题了,这种方案会导致:

  • 父子应用的css互相影响。
  • 没有js沙箱,父子应用的作用域也会互相影响。
  • 依赖子应用改造构建打包,子应用的改造成本高。

因此js-entry模式不太适合一般的业务,业界慢慢催生出更加完备的html-entry的方案。下面就会详细讲讲不同的框架,实际的实现原理有什么不同。

四. 微前端框架分析

业界主流框架

框架 团队 发布时间
qiankun 阿里 2019年10月
micro-app 京东 2019年3月
无界 微信 2020年初
Garfish 字节跳动 2021年9月

html-entry

这些主流的微前端框架基本都支持html-entry模式(部分框架如micro-app同时支持两种entry)。
html-entry模式顾名思义,就是直接使用html作为子应用的入口。通过html-entry的方式,子应用就可以把自己原有的url入口直接放入主应用中,这样子应用的接入成本和改造成本就会大大的降低。

一般来说,主应用会直接fetch配置好的url入口,拿到对应的入口html,然后会把这个html作为一个字符串进行分析,分析出html,css,js不同的资源,最后每个框架都会有自己的一套资源处理模式,实现js沙箱和css隔离。
截屏2024-04-30 14.32.25.png

分析方向

接下来就会按照以下几个点进行分析对比:

  • html/css隔离实现
  • js沙箱实现
  • 路由同步实现

qiankun

qiankun作为目前国内社区最大的微前端框架,底层主要依赖了single-spa来管理子应用的生命周期,包括子应用的挂载,卸载,全局状态管理等功能。

html和css的隔离实现

qiankun会为每个子应用的容器包裹一层。通过这个api实现了html/css的隔离。

shadow-dom可以理解为是浏览器提供的原生html/css隔离区域,与外层的结构完全隔离。

截屏2024-04-30 17.03.28.png
在实践过程中发现,这个特性并不是默认开启的,需要在启动时加入以下配置:

start({
  sandbox: {
    strictStyleIsolation: true
  }
})
  • 如果父应用足够的“轻薄”,那么一般情况下也可以不需要开启这个选项。
  • 因为每个子应用切换的时候,qiankun会把容器内子应用所有的结构和css卸载,然后在挂载新的子应用。
  • 这样子应用间不会存在互相影响的情况。

截屏2024-04-30 17.16.37.png

js沙箱实现

qiankun的js沙箱主要有两种模式:proxy沙箱SnapeShot沙箱

proxy沙箱

在子应用初始化时,qiankun会创建一个全新的空对象fakeWindow,然后使用Proxy对象创建一个代理对象,代理对象的target是全局window。

接下来我们先看看这个fakeWindow的创建过程,为了更清晰这里我们省略了一大部分无关代码:

var ProxySandbox = /*#__PURE__*/function () {
  function ProxySandbox(name) {
    /**
      一些属性初始化
    */

    /**
      这里利用createFakeWindow传入全局上下文(一般是window)
      生成一个fakeWindow对象
      然后使用proxy,对fakeWindow进行代理,并对对象的基本操作方法进行代理处理
      createFakeWindow代码请看下一部分
    */
    // 
    var _createFakeWindow = createFakeWindow(globalContext, !!speedy),
      fakeWindow = _createFakeWindow.fakeWindow,
      propertiesWithGetter = _createFakeWindow.propertiesWithGetter;
    
    var proxy = new Proxy(fakeWindow, {
      set: function set(target, p, value) {},
      get: function get(target, p) {},
      has: function has(target, p) {},
      getOwnPropertyDescriptor: function getOwnPropertyDescriptor(target, p) {},
      // trap to support iterator with sandbox
      ownKeys: function ownKeys(target) {},
      defineProperty: function defineProperty(target, p, attributes) {},
      deleteProperty: function deleteProperty(target, p) {},
      getPrototypeOf: function getPrototypeOf() {}
    });
    this.proxy = proxy;
  }
  // 返回ProxySandbox,并且给ProxySandbox原型链上加上一些方法
  return _createClass(ProxySandbox, [{
    key: "active",
    value: function active() {}
  }, {
    key: "inactive",
    value: function inactive() {}
  }, {
    key: "patchDocument",
    value: function patchDocument(doc) {}
  }, {
    key: "registerRunningApp",
    value: function registerRunningApp(name, proxy) {}
  }]);
}();

以下是createFakeWindow的实现:

function createFakeWindow(globalContext, speedy) {
  var propertiesWithGetter = new Map();
  // 创建了一个新的对象fakeWindow,可以理解为这个对象就是子应用的沙盒
  var fakeWindow = {};

  /**
    遍历传入的全局上下文里面的所有属性和方法
    并把里面所有的属性和方法,都复制一份给fakeWindow
    这样每个子应用的fakeWindow都是隔离,子应用修改全局对象,也并不会影响主应用和其他应用
  */
  Object.getOwnPropertyNames(globalContext).filter(function (p) {
    var descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
    return !(descriptor === null || descriptor === void 0 ? void 0 : descriptor.configurable);
  }).forEach(function (p) {
    /**
      获取当前属性的详情信息,包括value,writable,enumerable等
    */
    var descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
    if (descriptor) {
      // 判断是否有getter
      var hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
      
      /**
        保障所有全局的属性都可以被遍历和修改,授权子应用有改变这些全局变量的能力
      */
      if (p === 'top' || p === 'parent' || p === 'self' || p === 'window' ||
      p === 'document' && speedy || inTest && (p === mockTop || p === mockSafariTop)) {
        descriptor.configurable = true;
        
        if (!hasGetter) {
          descriptor.writable = true;
        }
      }
      if (hasGetter) propertiesWithGetter.set(p, true);

      /**
        var rawObjectDefineProperty = Object.defineProperty;
        qiankun底层依赖了zone.js,导致Object.defineProperty被覆盖。
        把当前这个属性/方法,交给新对象fakeWindow,
      */
      rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
    }
  });
  return {
    fakeWindow: fakeWindow,
    propertiesWithGetter: propertiesWithGetter
  };
}

初始化完成了fakewindow的创建后,那么后续子应用就拥有了自己的沙箱:

  • 当子应用试图读取全局对象的某个属性时,Proxy 的get处理程序会被触发。
    • 在这个处理程序中,qiankun 会先从fakeWindow中查找该属性,如果找到了就返回fakeWindow中的属性值;如果没找到,就返回window中的属性值。这样,子应用就无法读取其他应用或主应用的全局变量。
  • 当子应用试图写入全局对象的某个属性时,Proxy 的set处理程序会被触发。
    • 在这个处理程序中,qiankun 会将新的属性值写入fakeWindow,而不是真正的window。这样,子应用就无法修改其他应用或主应用的全局变量。
  • 当子应用卸载时,qiankun 会销毁该应用的fakeWindow,从而彻底清除该应用所产生的全局变量,避免内存泄露。

Snapshot 沙箱

在不支持proxy的浏览器,或者用户主动在启动时候配置为Snapshot模式时,此时会使用到Snapshot沙箱。
在qiankun中,Snapshot沙箱实现比较简单,在应用加载时,它会快照当前的全局状态,在应用卸载时,再基于此快照恢复全局状态到应用加载前的状态。相当于我们玩游戏时的存档和读档机制

接下来我们先看看Snapshot沙箱的创建过程,为了更清晰这里我们省略了一大部分无关代码:

var SnapshotSandbox = /*#__PURE__*/function () {
  function SnapshotSandbox(name) {
    /**
      一些属性的定义
    */
  }
  /**
    snapShot沙箱主要在沙箱的各个生命周期中进行运作
    分别在子应用active(激活),inactive(卸载)
  */
  return _createClass(SnapshotSandbox, [{
    /**
      子应用激活的时候
      会记录当前全局(主应用)对象的一个快照
      并且把全局对象的属性恢复成子应用之前的快照
      删除一些当前子应用删除过的属性
    */
    key: "active",
    value: function active() {
      var _this = this;
      // 记录当前快照
      this.windowSnapshot = {};
      iter(window, function (prop) {
        _this.windowSnapshot[prop] = window[prop];
      });
      // 恢复之前的变更
      Object.keys(this.modifyPropsMap).forEach(function (p) {
        window[p] = _this.modifyPropsMap[p];
      });
      // 删除之前删除的属性
      this.deletePropsSet.forEach(function (p) {
        delete window[p];
      });
      this.sandboxRunning = true;
    }
  }, {
    /**
      子应用卸载的时候
      记录当前子应用的上下文的快照
      然后根据切换的子应用的快照恢复环境
    */
    key: "inactive",
    value: function inactive() {
      var _this2 = this;
      this.modifyPropsMap = {};
      this.deletePropsSet.clear();
      iter(window, function (prop) {
        if (window[prop] !== _this2.windowSnapshot[prop]) {
          // 记录变更,恢复环境
          _this2.modifyPropsMap[prop] = window[prop];
          window[prop] = _this2.windowSnapshot[prop];
        }
      });
      iter(this.windowSnapshot, function (prop) {
        if (!window.hasOwnProperty(prop)) {
          // 记录被删除的属性,恢复环境
          _this2.deletePropsSet.add(prop);
          window[prop] = _this2.windowSnapshot[prop];
        }
      });
      if (process.env.NODE_ENV === 'development') {
        console.info("[qiankun:sandbox] ".concat(this.name, " origin window restore..."), Object.keys(this.modifyPropsMap), this.deletePropsSet.keys());
      }
      this.sandboxRunning = false;
    }
  }, {
    key: "patchDocument",
    value: function patchDocument() {}
  }]);
}();

可以看出来Snapshot沙箱其实会一直修改全局的变量,这种操作会由于第三方库或业务代码影响,带来不可预计的问题,如果不是兼容性问题,不太建议使用这种沙箱模式。

路由同步实现

qiankun 框架有两种实现子应用和主应用路由同步的方式:hash 模式history 模式

hash模式

qiankun 默认用的是 hash 模式,即主应用的 URL 是

registerMicroApps(
  [
    {
      name: 'react16',
      entry: '//localhost:7100',
      container: '#subapp-viewport',
      loader,
      activeRule: '/react16'
    },
    {
      name: 'react15',
      entry: '//localhost:7102',
      container: '#subapp-viewport',
      loader,
      activeRule: '/react15',
    }
    {
      name: 'vue3',
      entry: '//localhost:7105',
      container: '#subapp-viewport',
      loader,
      activeRule: '/vue3',
    },
  ],
);

根据name属性,访问这个字应用路由就可以在主应用的url后加上/name#即可,/name#后面就可以直接放子应用的hash路由了:
截屏2024-05-05 18.29.43.png
要注意的是,使用的路由方式由子应用决定,子应用配置路由方式时,需要指定路由方式:

router = new VueRouter({
  base: window.__POWERED_BY_QIANKUN__ ? '/vue' : '/',
  mode: 'hash',
  routes,
});
实现代码
  • hash 模式的实现方法主要基于浏览器的 hashchange 事件。当路由发生变化时,qiankun 通过监听这个事件,实现对子应用的加载、卸载以及路由的同步。
  • qiankun这里底层依赖了single-spa
  • 在single-spa中,绑定了主应用的hashchange和popstate的监听,监听到路由变化时执行urlReroute

截屏2024-05-05 20.08.15.png
urlReroute实现如下,以下为删减后的代码:

export function reroute(
  pendingPromises = [],
  eventArguments,
  silentNavigation = false
) {
  /**
    判断是否有应用在切换中,如果有则本次路由变化放入堆栈中
    等当前切换完成再拿出来执行
  */
  if (appChangeUnderway) {
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }

  // 获取当前需要进行的所有应用改变操作
  // 包括需要卸载(unload)、需要卸载和挂载(unmount/mount)以及需要加载(load)的应用。
  const { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
    getAppChanges();

  // 如果已经启动完成
  if (isStarted()) {
    // 则扭转应用状态
    // 把上面获取的应用需要的变化连接为数组(appsToUnload, appsToUnmount, appsToLoad, appsToMount)
    // 执行对应的应用变化
  } 
  else {
    // 如果应用没有启动,则直接加载子应用
  }
  
  function cancelNavigation(val = true) {
    // 取消正在进行的页面跳转
    // 并在导航取消时,在控制台输出错误信息
  }

  // 上面如果子应用未激活,则会用此方法加载子应用
  function loadApps() {
    return Promise.resolve().then(() => {
      // 需要加载的子应用,并执行toLoadPromise
      const loadPromises = appsToLoad.map(toLoadPromise);

      return (
        // 加载所有需要加载的子应用
        Promise.all(loadPromises)
          // 启动事件监听
          .then(callAllEventListeners)
          .finally(() => {
            // 无论成功失败,写入日志
          })
      );
    });
  }
  /**
    重点
    上面如果子应用已经激活
    则会把对应需要执行的改变传入此函数
  */
  function performAppChanges() {
    return Promise.resolve().then(() => {
      // 主动出发single-spa的自定义事件
      // 如果子应用没改变,就触发before-no-app-change
      // 如果有改变,就触发before-app-change
      // getCustomEventDetail是用来获取自定义事件细节的
      // 包括:应用状态,旧url,新url,改变的应用个数等
      fireSingleSpaEvent(
        appsThatChanged.length === 0
          ? "before-no-app-change"
          : "before-app-change",
        getCustomEventDetail(true)
      );

      // 触发before-routing-event事件
      fireSingleSpaEvent(
        "before-routing-event",
        getCustomEventDetail(true, { cancelNavigation })
      );

      
      return Promise.all(cancelPromises).then((cancelValues) => {
        const navigationIsCanceled = cancelValues.some((v) => v);
  
        if (navigationIsCanceled) {
          // 如果有取消跳转的promise,则终止跳转,并恢复旧url

          // 并使用旧url重新执行reroute
          return reroute(pendingPromises, eventArguments, true);
        }

        // 如果未被终止,则往下执行用用挂载,卸载等操作

        // 卸载的子应用生成unloadPromises
        const unloadPromises = appsToUnload.map(toUnloadPromise);

        // 对需要卸载挂载的应用先应用toUnmountPromise
        // 然后将执行完毕的挂载Promise用toUnloadPromise处理
        // 生成一个Promise数组unmountUnloadPromises。
        const unmountUnloadPromises = appsToUnmount
          .map(toUnmountPromise)
          .map((unmountPromise) => unmountPromise.then(toUnloadPromise));

        // 合并
        const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);

        // 执行所有promise
        const unmountAllPromise = Promise.all(allUnmountPromises);

        let unmountFinishedTime;

        // 处理挂载和加载操作
        const loadThenMountPromises = appsToLoad.map((app) => {
          return toLoadPromise(app).then((app) =>
            tryToBootstrapAndMount(app, unmountAllPromise)
          );
        });

        const mountPromises = appsToMount
          .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
          .map((appToMount) => {
            return tryToBootstrapAndMount(appToMount, unmountAllPromise);
          });

        // 先执行卸载
        return unmountAllPromise
          .catch((err) => {
            callAllEventListeners();
            throw err;
          })
          .then(() => {
            /* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
             * events (like hashchange or popstate) should have been cleaned up. So it's safe
             * to let the remaining captured event listeners to handle about the DOM event.
             */
            callAllEventListeners();
            // 再执行挂载
            return Promise.all(loadThenMountPromises.concat(mountPromises))
              .catch((err) => {
                pendingPromises.forEach((promise) => promise.reject(err));
                throw err;
              })
              .then(finishUpAndReturn)
          });
      });
    });
  }
}

在qiankun中,路由的每次变化,会先收集会变化的应用,然后执行应用的相关变化,包括卸载,加载,挂载等。当然底层是single-spa实现的。

history模式

与hash模式不同,history模式主要对history.pushState和history.replaceState事件做的重新封装:
截屏2024-05-05 20.58.42.png

function patchedUpdateState(updateState, methodName) {
  return function () {
    // 获取当前url
    const urlBefore = window.location.href;
    // 调用原生的pushState或replaceState方法,并获取结果
    const result = updateState.apply(this, arguments);
    // 获取变更后的url
    const urlAfter = window.location.href;

    // 如果在reroute模式下,且更新前后路由不一致,则手动触发popstate
    // 是为了手动派发一下事件,使监听了此事件的代码触发
    // 这里很关键,这里会触发上面hash模式代码分析中的reroute方法
    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      window.dispatchEvent(
        createPopStateEvent(window.history.state, methodName)
      );
    }

    // 返回更新后的结果
    return result;
  };
}

截屏2024-05-05 20.08.15.png

qiankun的问题

qiankun基于single-app已经解决的很大一部分问题,并且他的社区活跃度是最高的,但是他仍然存在一些没解决的问题:

  • 无法激活多个子应用,子应用的保活困难
  • 改造成本大,从构建,到生命周期,路由等
  • js隔离沙箱在部分场景下性能较差

无界

无界是微信团队推出的微前端框架,他的js沙箱机制尤为特殊,接下来会着重讲一下。
有兴趣的同学可以直接进入官网体验,无需本地部署:

html和css的隔离实现

截屏2024-05-05 21.18.20.png
可以看到,与qiankun类似,无界也是利用了shawdom的特性,把html和css进行隔离。
与qiankun不同的是,无界还使用了web-component,在自定义节点里面进行子应用挂载。

js沙箱

无界的js沙箱尤为特殊,它使用了iframe作为js执行环境,由于iframe沙箱是浏览器天然隔离的,所以它的隔离性应该是最佳的。

可以看到页面中有一个iframe元素,并且里面没有放置任何结构和样式,只是加载了很多不同的js:
截屏2024-05-05 21.21.26.png
那么iframe里面的js,又是如何作用在主应用中的shadow-dom呢?

iframe沙箱原理

沙箱创建:

由于源码过大,以下只保留iframe,proxy和无界类的相关内容,完整代码可以在wujie的github中查询,路径在下面代码块。 感叹一下,wujie的源码实在是太好读,到处都是中文注释。

/**
  iframe创建
  packages/wujie-core/src/sandbox.ts
*/
import {
  iframeGenerator,
  recoverEventListeners,
  recoverDocumentListeners,
  insertScriptToIframe,
  patchEventTimeStamp,
} from "./iframe";

/**
  proxy对象生成方法
*/
import { proxyGenerator, localGenerator } from "./proxy";


/**
 * 基于 Proxy和iframe 实现的沙箱
 * 沙箱类
 */
export default class Wujie {
  /**
    无界的子应用会初始化很多属性(约30+)
    其中我们本次分析需要关注的是三个属性
    proxy,proxyDocument,proxyLocation
  */
  /** window代理 */
  public proxy: WindowProxy;
  /** document代理 */
  public proxyDocument: Object;
  /** location代理 */
  public proxyLocation: Object;
  /** js沙箱 */
  public iframe: HTMLIFrameElement;
  /** css沙箱 */
  public shadowRoot: ShadowRoot;

  /** 
   * 重点
   * 子应用初始化时创建沙箱的构建函数
   * 初始化的时候会调用proxyGenerator生成三个代理对象:proxyWindow, proxyDocument, proxyLocation
   * 这三个代理对象就是实现js隔离的核心
   * @param id 子应用的id,唯一标识
   * @param url 子应用的url,可以包含protocol、host、path、query、hash
   */
  constructor(options: {
    name: string;
    url: string;
    attrs: { [key: string]: any };
    degradeAttrs: { [key: string]: any };
    fiber: boolean;
    degrade;
    plugins: Array<plugin>;
    lifecycles: lifecycles;
  }) {

    // 创建目标地址的解析
    const { urlElement, appHostPath, appRoutePath } = appRouteParse(url);
    const { mainHostPath } = this.inject;
    // 创建iframe
    this.iframe = iframeGenerator(this, attrs, mainHostPath, appHostPath, appRoutePath);

    if (this.degrade) {
      const { proxyDocument, proxyLocation } = localGenerator(this.iframe, urlElement, mainHostPath, appHostPath);
      this.proxyDocument = proxyDocument;
      this.proxyLocation = proxyLocation;
    } else {
      const { proxyWindow, proxyDocument, proxyLocation } = proxyGenerator(
        this.iframe,
        urlElement,
        mainHostPath,
        appHostPath
      );
      this.proxy = proxyWindow;
      this.proxyDocument = proxyDocument;
      this.proxyLocation = proxyLocation;
    }
    this.provide.location = this.proxyLocation;

    addSandboxCacheWithWujie(this.id, this);
  }


  
  public async active(options: {
    /** 
      入参
     */
  }): Promise<void> {
    /** 
      入参转换为实例属性
     */

    /** 激活子应用的方法
     * 1、同步路由
     * 2、动态修改iframe的fetch
     * 3、准备shadow
     * 4、准备子应用注入
     */
  }

  
  public async start(getExternalScripts: () => ScriptResultList): Promise<void> {
    /** 启动子应用的方法
     * 1、主要功能是运行js
     * 2、处理兼容样式
     */
  }

  
  public mount(): void {
    /**
     * 框架主动发起mount,如果子应用是异步渲染实例,比如将生命周__WUJIE_MOUNT放到async函数内
     * 此时如果采用fiber模式渲染(主应用调用mount的时机也是异步不确定的),框架调用mount时可能
     * 子应用的__WUJIE_MOUNT还没有挂载到window,所以这里封装一个mount函数,当子应用是异步渲染
     * 实例时,子应用异步函数里面最后加上window.__WUJIE.mount()来主动调用
     */
  }

  public unmount(): void {
    /** 卸载子应用需要做的操作 */
  }

  public destroy() {
    /** 销毁子应用需要做的操作 */
  }

  public rebuildStyleSheets(): void {
    /** 当子应用再次激活后,只运行mount函数,样式需要重新恢复 */
  }
}

proxyGenerator主要是生成了三个代理对象:proxyWindow,proxyDocument,proxyLocation。
以下是详细的代码解析:

// packages/wujie-core/src/proxy.ts

/** 这里还引入了上面ifame的模块 */
import { patchElementEffect, renderIframeReplaceApp } from "./iframe";
// 其他依赖导入

/**
 * location href 的set劫持操作
 */
function locationHrefSet(iframe: HTMLIFrameElement, value: string, appHostPath: string): boolean {
  const { shadowRoot, id, degrade, document, degradeAttrs } = iframe.contentWindow.__WUJIE;
  let url = value;
  if (!/^http/.test(url)) {
    let hrefElement = anchorElementGenerator(url);
    url = appHostPath + hrefElement.pathname + hrefElement.search + hrefElement.hash;
    hrefElement = null;
  }
  iframe.contentWindow.__WUJIE.hrefFlag = true;
  if (degrade) {
    const iframeBody = rawDocumentQuerySelector.call(iframe.contentDocument, "body");
    renderElementToContainer(document.documentElement, iframeBody);
    renderIframeReplaceApp(window.decodeURIComponent(url), getDegradeIframe(id).parentElement, degradeAttrs);
  } else renderIframeReplaceApp(url, shadowRoot.host.parentElement, degradeAttrs);
  pushUrlToWindow(id, url);
  return true;
}

/**
 * 非降级情况下window、document、location代理
 * 入参是iframe对象等
 * 出参抛出核心的三个对象:proxyWindow,proxyDocument,proxyLocation
 */
export function proxyGenerator(
  iframe: HTMLIFrameElement,
  urlElement: HTMLAnchorElement,
  mainHostPath: string,
  appHostPath: string
): {
  proxyWindow: Window;
  proxyDocument: Object;
  proxyLocation: Object;
} {
  /** 
    proxyWindow的生成
    利用proxy生成当前iframe全局window对象的一个代理对象
    并且对部分的window属性/方法,进行了劫持
    补充说明:接下来会用到很多target.__WUJIE,这里指向的是上面代码的沙箱实例
  */
  const proxyWindow = new Proxy(iframe.contentWindow, {
    get: (target: Window, p: PropertyKey): any => {
      // location进行劫持 让它指向下面会生成的proxyLocation
      if (p === "location") {
        return target.__WUJIE.proxyLocation;
      }
      // 判断自身,使iframe内部使用window等对象时,指向proxyWindow,也就是当前这个proxy对象
      if (p === "self" || (p === "window" && Object.getOwnPropertyDescriptor(window, "window").get)) {
        return target.__WUJIE.proxy;
      }
      /**
        针对这两种情况,直接返回属性,无需走getTargetValue
      */
      if (p === "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__" || p === "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__") {
        return target[p];
      }

      /**
        获取属性的基本信息,如果是不可变更或不可枚举的,直接返回属性值
      */
      const descriptor = Object.getOwnPropertyDescriptor(target, p);
      if (descriptor?.configurable === false && descriptor?.writable === false) {
        return target[p];
      }
      /**
        getTargetValue一般情况下直接返回target[p]
        如果p为可执行函数时,会使用call把target作为this传入,保正上下文一致
        并且内部会有缓存机制,重复获取的值会从缓存中取出
      */
      return getTargetValue(target, p);
    },

    set: (target: Window, p: PropertyKey, value: any) => {
      checkProxyFunction(value);
      target[p] = value;
      return true;
    },

    has: (target: Window, p: PropertyKey) => p in target,
  });

  /** 
    proxyDocument的生成
    直接使用空对象,大概率是为了不影响原来的document对象
  */
  const proxyDocument = new Proxy(
    {},
    {
      get: function (_fakeDocument, propKey) {
        // 这里拿的是主应用的document对象
        const document = window.document;
        // 从沙箱实例中导出变量
        const { shadowRoot, proxyLocation } = iframe.contentWindow.__WUJIE;
        // iframe初始化完成后,webcomponent还未挂在上去,此时运行了主应用代码,必须中止
        if (!shadowRoot) stopMainAppRun();
        const rawCreateElement = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_ELEMENT__;
        const rawCreateTextNode = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_TEXT_NODE__;
        /**
          这里是为了覆盖createElement和createTextNode方法
          然后返回一个拥有apply拦截器proxy对象,在这两个方法被调用的时候触发以下逻辑
          1. 判断真正需要调用的方法
          2. 把iframe的document对象作为this传入,生成节点
          3. 调用patchElementEffect,为节点添加属性:baseURI和ownerDocument
          (1)baseURI存储的是iframe的url
          (2)ownerDocument存储的是iframe的document对象
          4. 返回元素
        */
        if (propKey === "createElement" || propKey === "createTextNode") {
          return new Proxy(document[propKey], {
            apply(_createElement, _ctx, args) {
              const rawCreateMethod = propKey === "createElement" ? rawCreateElement : rawCreateTextNode;
              const element = rawCreateMethod.apply(iframe.contentDocument, args);
              patchElementEffect(element, iframe.contentWindow);
              return element;
            },
          });
        }
        /**
          劫持路由相关方法,使其返回代理路由对象的内容
        */
        if (propKey === "documentURI" || propKey === "URL") {
          return (proxyLocation as Location).href;
        }

        // from shadowRoot
        /**
          对getElementsByTagName,getElementsByClassName,getElementsByName方法劫持
        */
        if (
          propKey === "getElementsByTagName" ||
          propKey === "getElementsByClassName" ||
          propKey === "getElementsByName"
        ) {
          /**
            这里返回querySelectorAll的代理,是为了能更全面的处理dom方法
            因为这三个被劫持的方法基本都是querySelectorAll的子集
            这里使用querySelectorAll为了能统一处理逻辑
            1. 如果上下文不是iframe的document对象,则直接调用
            2. 由于不同的dom方法入参不一样,需要抹平
            3. 返回执行的结果
          */
          return new Proxy(shadowRoot.querySelectorAll, {
            apply(querySelectorAll, _ctx, args) {
              let arg = args[0];
              if (_ctx !== iframe.contentDocument) {
                return _ctx[propKey].apply(_ctx, args);
              }

              if (propKey === "getElementsByTagName" && arg === "script") {
                return iframe.contentDocument.scripts;
              }
              if (propKey === "getElementsByClassName") arg = "." + arg;
              if (propKey === "getElementsByName") arg = `[name="${arg}"]`;

              let res: NodeList[] | [];
              try {
                res = querySelectorAll.call(shadowRoot, arg);
              } catch (error) {
                res = [];
              }

              return res;
            },
          });
        }

        /**
          对getElementById方法劫持
        */
        if (propKey === "getElementById") {
          return new Proxy(shadowRoot.querySelector, {
            // case document.querySelector.call
            apply(target, ctx, args) {
              if (ctx !== iframe.contentDocument) {
                return ctx[propKey]?.apply(ctx, args);
              }
              try {
                return (
                  target.call(shadowRoot, `[id="${args[0]}"]`) ||
                  iframe.contentWindow.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__.call(
                    iframe.contentWindow.document,
                    `#${args[0]}`
                  )
                );
              } catch (error) {
                warn(WUJIE_TIPS_GET_ELEMENT_BY_ID);
                return null;
              }
            },
          });
        }
        /**
          对querySelector/querySelectorAll方法劫持
        */
        if (propKey === "querySelector" || propKey === "querySelectorAll") {
          const rawPropMap = {
            querySelector: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__",
            querySelectorAll: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__",
          };
          return new Proxy(shadowRoot[propKey], {
            apply(target, ctx, args) {
              if (ctx !== iframe.contentDocument) {
                return ctx[propKey]?.apply(ctx, args);
              }
              // 二选一,优先shadowDom,除非采用array合并,排除base,防止对router造成影响
              return (
                target.apply(shadowRoot, args) ||
                (args[0] === "base"
                  ? null
                  : iframe.contentWindow[rawPropMap[propKey]].call(iframe.contentWindow.document, args[0]))
              );
            },
          });
        }
        /**
          核心处理结束,剩余是对其余属性/方法进行类似的劫持
        */
      },
    }
  );

  /** 
    proxyLocation的生成
  */
  const proxyLocation = new Proxy(
    {},
    {
      get: function (_fakeLocation, propKey) {
        // iframe的location属性
        const location = iframe.contentWindow.location;
        if (
          propKey === "host" ||
          propKey === "hostname" ||
          propKey === "protocol" ||
          propKey === "port" ||
          propKey === "origin"
        ) {
          return urlElement[propKey];
        }
        if (propKey === "href") {
          return location[propKey].replace(mainHostPath, appHostPath);
        }
        if (propKey === "reload") {
          warn(WUJIE_TIPS_RELOAD_DISABLED);
          return () => null;
        }
        if (propKey === "replace") {
          return new Proxy(location[propKey], {
            apply(replace, _ctx, args) {
              return replace.call(location, args[0]?.replace(appHostPath, mainHostPath));
            },
          });
        }
        return getTargetValue(location, propKey);
      },
      set: function (_fakeLocation, propKey, value) {
        // 如果是跳转链接的话重开一个iframe
        if (propKey === "href") {
          return locationHrefSet(iframe, value, appHostPath);
        }
        iframe.contentWindow.location[propKey] = value;
        return true;
      },
      ownKeys: function () {
        return Object.keys(iframe.contentWindow.location).filter((key) => key !== "reload");
      },
      getOwnPropertyDescriptor: function (_target, key) {
        return { enumerable: true, configurable: true, value: this[key] };
      },
    }
  );
  // 最后返回这三个代理对象
  return { proxyWindow, proxyDocument, proxyLocation };
}

生成这三个对象之后,这时大家会有以后,那这三个代理对象是新的,那是什么时候被使用的呢?
还记得这个iframe沙箱有个start方法(可以回看iframe.js),这个start方法执行了fetch回来的各种js,执行的时候其实依赖了一个insertScriptToIframe方法,这个方法做了几件事:

  • 在iframe内创建script标签执行外联脚本,使用jsLoader执行內联脚本
  • 对内联的脚本直接使用一个IIFE函数包起来,并且使用bind修改对应window,location对象的指向到对应的proxy对象。
  • 对外联脚本,由于整个iframe在初始化的时候,就已经对全局对象做过处理了,所以直接在iframe内执行时,使用的就是处理过后的全局对象,有兴趣的可以查阅:
    • packages/wujie-core/src/iframe.ts
    • initIframeDom里面的patchWindowEffect,patchWindowEffect等方法
/**
 * iframe插入脚本
 * @param scriptResult script请求结果
 * @param iframeWindow
 * @param rawElement 原始的脚本
 */
export function insertScriptToIframe(
  scriptResult: ScriptObject | ScriptObjectLoader,
  iframeWindow: Window,
  rawElement?: HTMLScriptElement
) {
  const { src, module, content, crossorigin, crossoriginType, async, attrs, callback, onload } =
    scriptResult as ScriptObjectLoader;
  const scriptElement = iframeWindow.document.createElement("script");
  const nextScriptElement = iframeWindow.document.createElement("script");
  const { replace, plugins, proxyLocation } = iframeWindow.__WUJIE;
  const jsLoader = getJsLoader({ plugins, replace });
  let code = jsLoader(content, src, getCurUrl(proxyLocation));
  // 添加属性
  attrs &&
    Object.keys(attrs)
      .filter((key) => !Object.keys(scriptResult).includes(key))
      .forEach((key) => scriptElement.setAttribute(key, String(attrs[key])));

  // 内联脚本
  if (content) {
    // patch location
    if (!iframeWindow.__WUJIE.degrade && !module) {
      code = `(function(window, self, global, location) {
      ${code}
}).bind(window.__WUJIE.proxy)(
  window.__WUJIE.proxy,
  window.__WUJIE.proxy,
  window.__WUJIE.proxy,
  window.__WUJIE.proxyLocation,
);`;
    }
    const descriptor = Object.getOwnPropertyDescriptor(scriptElement, "src");
    // 部分浏览器 src 不可配置 取不到descriptor表示无该属性,可写
    if (descriptor?.configurable || !descriptor) {
      // 解决 webpack publicPath 为 auto 无法加载资源的问题
      try {
        Object.defineProperty(scriptElement, "src", { get: () => src || "" });
      } catch (error) {
        console.warn(error);
      }
    }
  } else {
    src && scriptElement.setAttribute("src", src);
    crossorigin && scriptElement.setAttribute("crossorigin", crossoriginType);
  }
  module && scriptElement.setAttribute("type", "module");
  scriptElement.textContent = code || "";
  nextScriptElement.textContent =
    "if(window.__WUJIE.execQueue && window.__WUJIE.execQueue.length){ window.__WUJIE.execQueue.shift()()}";

  // 获取iframe的头部,并且把script标签插入到头部
  const container = rawDocumentQuerySelector.call(iframeWindow.document, "head");
  const execNextScript = () => !async && container.appendChild(nextScriptElement);
  const afterExecScript = () => {
    onload?.();
    execNextScript();
  };

  // 外联脚本执行后的处理
  const isOutlineScript = !content && src;
  if (isOutlineScript) {
    scriptElement.onload = afterExecScript;
    scriptElement.onerror = afterExecScript;
  }
  container.appendChild(scriptElement);

  // 调用回调
  callback?.(iframeWindow);
  // 内联脚本执行后的处理
  !isOutlineScript && afterExecScript();
}

总结一下

  • 这三个对象:proxyWindow,proxyDocument,proxyLocation。可以理解为是主应用的window,document,location对象的代理。
  • wujie对这些基础对象里面的基础方法基本都做了劫持。
  • 完成这三个对象创建后,wujie会把这三个对象挂载在沙箱的实例属性中。
  • 在当前这个iframe沙箱启用时,就会把对应的js加载执行,并且把对应的proxy对象覆盖全局对象。

路由同步

跟qiankun类似,wujie底层也是对hashchange和popstate这两个api监听,不过是监听的是iframe内部的行为,再把对应的url同步到主应用的地址栏上:

/**
 * 子应用前进后退,同步路由到主应用
 * @param iframeWindow
 */
export function syncIframeUrlToWindow(iframeWindow: Window): void {
  iframeWindow.addEventListener("hashchange", () => syncUrlToWindow(iframeWindow));
  iframeWindow.addEventListener("popstate", () => {
    syncUrlToWindow(iframeWindow);
  });
}

wujie的接入成本

从官方的demo()我们不难发现,我们可以把任意的url放入输入框中。这也就意味着,如果没有特殊要求,子应用无需任何改造,直接无缝接入,接入成本基本为0。

micro-app

micro-app是京东推出的微前端框架,是基于Web Component 原生组件进行渲染的微前端框架。这是他们的线上demo链接:

micro-app与qiankun原理十分相似,它与qiankun不同的地方是:qiankun使用的是div包裹子应用,而micro-app使用的是web-component包裹的子应用。

qiankun和micro-app对比

qiankun

截屏2024-04-30 17.03.28.png

micro-app

可以看到micro-app最外层是使用web-component包裹的:
截屏2024-05-07 16.32.35.png

利用web-component的特性,micro-app能更好的实现dom和css隔离。并且web-component是一个新的web组件化标准,能更好的跨框架复用。当然,新的特性就会带来新的问题,web-component的兼容性肯定没有div那么好,估计这也是qiankun使用div的原因。

五. 个人感悟

在经过一轮对比之后,我们团队最后选择的是wujie,虽然它的生态没有qiankun那么好,但是它的iframe沙箱和零成本接入对我们来说吸引力十足。当然qiankun和micro-app的实现同样优秀,通过这一轮的源码阅读,学习到了很多大佬的编码技巧,很多技巧从来都没想过,实在是受益良多。
同时,之前再跟一位公司大佬交流,我感叹了一下这些框架的骚操作,觉得很牛逼,这时大佬问了个问题:这些框架是不是都是国内的?我很惊讶问他怎么知道的。他说:从jq年代作者已经倡导不要去修改全局变量,不然框架间会互相影响,国外的作者一般不这么做。当然经过代码的阅读,我们发现这几个框架对原生的全局对象保护的很好,尽量都没有修改全局对象的属性,所以问题应该不大。不过确实如果子应用使用的某些第三方包也调用子应用的全局对象时,就会被劫持,此时第三方包的特性可能就会有问题,这也是我们后续开发需要避免的地方。