likes
comments
collection
share

不懂qiankun原理?这篇文章五张图片带你迅速通晓

作者站长头像
站长
· 阅读数 41

前言

这是一篇以通俗易懂的方式剖析qiankun运行原理的文章。通过这篇文章,读者们可以知道:

  1. single-spa的使用入门
  2. qiankun是如何基于single-spa的基础上运行的
  3. qiankun内置的js沙箱的原理以及其作用

single-spa入门

qiankun是基于single-spa实现的,因此我们有必要了解single-spa,它也是一个微前端框架,该框架的使用方式如下所示:

import * as singleSpa from "single-spa";

// 通过registerApplication注册单个子应用
// 当url为/app1时,加载app1子应用,加载方式为请求'/app1'路径获取资源
singleSpa.registerApplication({
  name: "app1",
  app1: () => import("/app1"),
  activeWhen: "/app1",
});

// 当url为/app2时,加载app2子应用,加载方式为请求'/app2'路径获取资源
singleSpa.registerApplication({
  name: "app2",
  app2: () => import("/app2"),
  activeWhen: "/app2",
});

// 在调用start()之前。如果url匹配已注册的子应用的activeWhen规则,则只会加载子应用资源,但不会初始化,挂载或卸载子应用
// 之所以拆分出start,是为了更好地控制整个微前端的执行流程。举个例子,如果当前路由匹配已注册的子应用的路由规则,但子应用初始化时需要主应用正在请求的用户信息,那我们可以等主应用获取到用户信息中再执行start
singleSpa.start();

而被加载的子应用中,其加载路径对应的资源必须是一个js文件,js文件中必须通过export导出三个函数:bootstrapmountunmount,如下所示,这三个函数会作为生命周期钩子函数在singleSpa获取子应用资源后被依次执行。

let domEl;

export function bootstrap(props) {
  return Promise.resolve().then(() => {
    domEl = document.createElement("div");
    domEl.id = "app1";
    document.body.appendChild(domEl);
  });
}

export function mount(props) {
  return Promise.resolve().then(() => {
    // 在这里通常使用框架将ui组件挂载到dom。请参阅https://single-spa.js.org/docs/ecosystem.html。
    domEl.textContent = "App 1 is mounted!";
  });
}
export function unmount(props) {
  return Promise.resolve().then(() => {
    // 在这里通常是通知框架把ui组件从dom中卸载。参见https://single-spa.js.org/docs/ecosystem.html
    domEl.textContent = "";
  });
}

当我们的子应用使用了vue或者react时,我们可以分别使用single-spa-vuesingle-spa-react这两个库去生成上述三个函数,如下所示:

React

不懂qiankun原理?这篇文章五张图片带你迅速通晓

Vue2

不懂qiankun原理?这篇文章五张图片带你迅速通晓

Vue3

不懂qiankun原理?这篇文章五张图片带你迅速通晓

对于上述代码中值得一提的两点:

  1. 上面图中的props.domElement是主应用中用于挂载子应用的DOM元素,通常称为应用基座。通常我们会在主应用中把应用基座传给子应用,而不是在子应用中通过css selector规则写死主应用中应用基座的位置,如下所示:

    singleSpa.registerApplication({
      name: "vue-app1",
      app1: () => import("/vue-app1"),
      activeWhen: "/vue-app1",
      // 在customProps属性中的domElement字段传入应用基座,customProps在生命周期钩子函数执行时会被作为参数传入
      customProps: {
        domElement: document.querySelectot("#micro-app"),
      },
    });
    
  2. single-spa-vue中会在应用基座里创建一个子元素div.single-spa-container作为vue实例挂载的元素,主要原因是vue挂载会完全替换掉挂载元素,例如:

    <!-- 如果在开发代码中这么编写 -->
    <html>
      <head>
        <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
      </head>
      <body>
        <!-- 挂载元素 -->
        <div id="app"></div>
      </body>
    
      <script type="text/javascript">
        new Vue({
          el: "#app",
          template: "<div>Hello Vue</div>",
        });
      </script>
    </html>
    
    <!-- 最终运行后的页面代码为 -->
    <html>
      <head>
        <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
      </head>
      <body>
        <!-- 注意此处的挂载元素被完全替换 -->
        <div>Hello Vue</div>
      </body>
    </html>
    

    但如果在react中就不会出现这种情况,因为react是把渲染内容作为子元素挂载到挂载元素里。而vue这种在微前端应用中会替换掉整个应用基座,这样子导致的后果是:在切换成别的页面时,执行子应用的unmount周期函数销毁vue实例,此时不会恢复应用基座DOM,然后再次切换进入该子应用页面时,会因为找不到应用基座而报错。

    因此single-spa-vue采取的做法是在应用基座里新建一个子元素div.single-spa-container作为vue实例挂载的元素。这样子就避免了应用基座被取替的情况。


single-spa提供了一个成熟的微前端实现方式:监听url以加载、挂载、卸载子应用。而qiankun则是基于single-spa上实现了更多功能。

站在single-spa的肩膀上,qiankun做了什么?

qiankunsingle-spa最大的区别是:single-spa提供的注册子应用 APIregisterApplication中,请求的子应用资源类型是js。而qiankun提供的注册子应用 APIregisterMicroApps中,请求的子应用资源是html文件,即子应用打包后的入口页面,如下所示。

registerMicroApps([
  {
    name: "app1",
    // 子应用app1的入口页面网址
    entry: "//localhost:8080",
    container: "#container",
    activeRule: "/react",
  },
]);

registerMicroApps内部调用了single-sparegisterApplication。下面用伪代码的形式展示是如何调用的:

// 伪代码形式展示registerMicroApps内部细节
registerApplication({
  name,
  app: async () => {
    // 1. 加载和解析`html`资源,获取页面、样式资源链接、脚本资源链接三种数据
    // 2. 对外部样式资源(css)进行加载处理,生成`render`函数
    // 3. 生成 `js`沙箱
    // 4. 加载外部脚本资源(js),且进行包装、执行
    // 5. 从入口文件里获取生命周期钩子函数:`bootstrap`、`mount`、`unmount`,然后当作registerApplication的app形参中的返回数据,如下所示
    return {
      bootstrap,
      mount:[
        //  `render`函数用于把处理后的html页面插入到应用基座上
        render()
        mount()
      ],
      unmount: ,
    };
  },
  activeWhen: activeRule,
  customProps: props,
});

下面会一步一步对上述过程中的每一步进行分析:

1. 加载和解析html资源

qiankun会调用import-html-entry这个第三方库去加载和处理html资源。import-html-entry会在获取html页面后会通过正则表达式解析并提取其中的link元素和script元素。

假设存在子应用的html是以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="/favicon.ico" />
    <title>React App</title>
    <link href="/react-app/static/css/main.css" rel="stylesheet" />
    <style type="text/css">
      .contaier {
        width: auto;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script>
      function(){}
    </script>
    <script defer="defer" src="/react-app/static/js/chunk1.js"></script>
    <script defer="defer" src="/react-app/static/js/main.js"></script>
  </body>
</html>

经过解析后会返回template(html页面)scripts(script外链或脚本内容列表)entry(js入口文件链接)styles(links外链列表)四个值,他们的值分别如下所示:

// 对 内联脚本 和 资源类型为javascript 的script取替成<!--  xxx replaced by import-html-entry -->标志位
// 对资源类型为stylesheet的link取替成<!--  xxx replaced by import-html-entry -->标志位
template = `
  <html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="/favicon.ico" />
    <title>React App</title>
    <!--  link http://xxx/react-app/static/css/main.css replaced by import-html-entry -->
    <style type="text/css">
      .contaier {
        width: auto;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <!-- inline scripts replaced by import-html-entry -->
    <!--  script http://xxx/react-app/static/js/chunk1.js replaced by import-html-entry -->
    <!--  script http://xxx/react-app/static/js/main.js replaced by import-html-entry -->
  </body>
</html>
`;

// 集成所有 内联脚本 和 资源类型为javascript的script外链
scripts = [
  "\x3Cscript>\n      function(){}\n    \x3C/script>",
  "http://xxx/react-app/static/js/chunk1.js",
  "http://xxx/react-app/static/js/main.js",
];

// 如果存在带entry的script元素,则entry为script元素的外链或内联脚本。否则为scripts最后的元素
entry = "http://xxxx/react-app/static/js/main.js";

// 集成所有 资源类型为stylesheet的link外链
styles = ["http://xxx/react-app/static/css/main.css"];

运行流程图:

不懂qiankun原理?这篇文章五张图片带你迅速通晓

2. 对外部样式资源(css)进行加载处理,生成render函数

对上一步骤中获取的styles,也就是样式资源外链进行请求,拿到css代码,然后生成内联样式<style>xxx</style>插入且取替步骤一中template对应的标志位上,假如"http://xxx/react-app/static/css/main.css"链接对应样式资源的css代码如下所示:

.toolbar {
  width: 100%;
}

则插入后的template代码如下所示:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="/favicon.ico" />
    <title>React App</title>
    <style>
      /* http://xxx/react-app/static/css/main.css */
      .toolbar {
        width: 100%;
      }
    </style>
    <style type="text/css">
      .contaier {
        width: auto;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <!-- inline scripts replaced by import-html-entry -->
    <!--  script http://xxx/react-app/static/js/main.js replaced by import-html-entry -->
  </body>
</html>

完成这一步后会生成render函数,render函数用于把上述的template插入到应用基座上。render函数会在single-spa里的mount生命周期中执行,即放入到registerApplicationapp参数的返回的mount属性里。

流程图:

不懂qiankun原理?这篇文章五张图片带你迅速通晓

3. 生成 js沙箱

qiankun会根据用户设置的sandbox属性来生成 js沙箱js沙箱 用于隔离应用间的window环境,防止子应用在执行代码期间影响了其他应用设置在window上的属性。qiankun内置的沙箱有三种:ProxySandboxLegacySandboxSnapshotSandbox,使用场景如下所示:

  • ProxySandboxsandbox没有设置或设置为true,且浏览器环境支持ES6-Proxy
  • LegacySandboxsandbox设置为对象,且浏览器环境支持ES6-Proxy
  • SnapshotSandbox:浏览器环境不支持ES6-Proxy

为了让大家更好理解沙箱的原理,这里放出简化版的ProxySandbox源码:

export default class ProxySandbox implements SandBox {
  // 用于记录更改过的window属性
  private updatedValueSet = new Set<PropertyKey>();
  name: string;
  // 用于存放全局对象Window
  globalContext: typeof window;
  // 用于记录最后被修改的属性
  latestSetProp: string | number | symbol | null = null;

  constructor(name: string, globalContext = window) {
    this.name = name;
    this.globalContext = globalContext;

    // createFakeWindow会创建一个纯对象,然后把全局对象Window所有属性及其值复制到该纯对象上
    const { fakeWindow } = createFakeWindow(globalContext);

    // 创建以fakeWindow作为目标对象的Proxy实例
    this.proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
          // We must kept 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 } = descriptor!;
            if (writable) {
              Object.defineProperty(target, p, {
                configurable,
                enumerable,
                writable,
                value,
              });
            }
          } else {
            // @ts-ignore
            target[p] = value;
          }
          // 白名单制度,如果修改的属性在白名单里,则会把值同步到全局对象Window上
          if (variableWhiteList.indexOf(p) !== -1) {
            // @ts-ignore
            globalContext[p] = value;
          }

          updatedValueSet.add(p);

          this.latestSetProp = p;

          return true;
      },
      get: (target: FakeWindow, p: PropertyKey): any => {
        return ? (globalContext as any)[p]
          : p in target
          ? (target as any)[p]
          : (globalContext as any)[p];
      },
    })
}

ProxyWindow里创建了一个带有全局对象Window所有属性及其值fakeWindow,然后再创建了以fakeWindow为目标对象的Proxy实例。当我们在子应用中通过window['a']=1新增或修改属性时,会触发Proxy实例的handler.set方法执行,此时他会做以下操作:

  1. 修改fakeWindow"a"属性为 1
  2. 如果是修改System等一些在白名单属性里的值,则会先把Window中的['xx']的目前值备份一下,然后在把新的值覆盖到Window的属性中(当子应用销毁时,会把备份的值重置到原本属性中)。但大多数属性包括"a"都不在白名单属性中,因此是不会修改到Window的同名属性里的。

我们再来一段代码来理解一下沙箱的效果:

// 1. 主应用加载时定义window.app
window.app = "masterapp";

// 2. 子应用读取window.app的值
console.log(window.app); // 显示'master-app'

// 3. 子应用A更改window.app的值,然后在子应用A中读取是'micro-a',但如果在主应用中读取依旧是'master-app'
window.app = "micro-a";
console.log(window.app); // 'micro-a'

// 3. 切换到子应用B且读取window.app的值时,此时子应用A已销毁,window.app会从'micro-a'撤回为'masterapp'
console.log(window.user); // 'master-app'

总的来说,qiankun设置这种js沙箱是为了隔离子应用和主应用的window。但目前存在一个缺点:这种沙箱只能隔离Window的一级属性。因为Proxy只会捕获到一级属性的增删改,不能捕获到二级以上属性的变动,我们可以通过下图的控制台操作得出此结论:

不懂qiankun原理?这篇文章五张图片带你迅速通晓

4. 加载外部脚本资源(js),且进行包装、执行

开始对scripts列表中的外链进行资源请求,获取到的js代码,假设上述template里的"http://xxx/react-app/static/js/chunk1.js"的代码为:

function fn1() {
  window["micro-app"] = "dsn";
}

fn1();

然后对所有 请求获取的js代码 和 内联脚本 都进行包装以修改其作用域上的window,那上面的js代码做例子,包装后的代码如下所示:

;(function(window, self, globalThis){
  with(window){
    function fn1() {
      window["micro-app"] = "dsn";
    }
    fn1();
  }
  //# sourceURL=http://xxx/react-app/static/js/chunk1.js
).bind(window.proxy)(window.proxy, window.proxy, window.proxy);

其中window.proxy是上一步中生成的js沙箱,通过bind传入到匿名函数的作用域里,然后再通过with语句把window的指向从全局对象Window切换到该js沙箱。从而让子应用的js代码执行过程中,调用的window其实是一个目标对象为fakeWindowProxy实例,从而完成了应用间window的隔离。

最终上面包装后的代码会用eval执行。

流程图:

不懂qiankun原理?这篇文章五张图片带你迅速通晓

5. 从入口文件里获取生命周期钩子函数:bootstrapmountunmount,然后交给single-spa去调用执行

qiankun的官方教程中,告诉我们webpack设置中要加上以下配置:

const packageName = require("./package.json").name;

// 官方实例,仅适用于webpack4
module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: "umd",
    jsonpFunction: `webpackJsonp_${packageName}`,
  },
};

// 个人实践出的webpack5配置
module.exports = {
  output: {
    library: {
      // [name]代表入口文件名称
      name: `${packageName}-[name]`,
      type: "umd", //
    },
    chunkLoadingGlobal: `webpackJsonp_${packageName}`,
  },
};

我们来说一下library,它用于在执行环境中插入一个全局对象,该全局对象会包含入口文件中通过export导出的所有方法,例如如果我在名为react-ts-app子应用里打印window["react-ts-app-main"],则控制台会输出以下:

不懂qiankun原理?这篇文章五张图片带你迅速通晓

注意这个插入过程是对子应用的window进行的,也就是js沙箱中的proxy,因此如果在控制台里直接打印window["react-ts-app-main"]是不会输出以上结果的。

因此我们在入口文件在上一步骤中包装且eval执行后,qiankun会通过等同于Object.keys(window).pop()获取到最新插入的属性"react-ts-app-main",然后通过window["react-ts-app-main"]拿到这些生命周期钩子函数。最终这些生命周期钩子函数用作single-sparegisterApplicationapp的返回数据里。从而完成了从解析html到把入口文件中的生命周期钩子函数交给single-spa调用的整个过程。

关于library的更多详细用法可看webpack#outputlibraryname

流程图:

不懂qiankun原理?这篇文章五张图片带你迅速通晓


至此,子应用主要的加载过程已经描述完毕。

整个流程图如下所示:

不懂qiankun原理?这篇文章五张图片带你迅速通晓

当然其中还有很多其他细节方面的逻辑还没说详细,如:

  1. 步骤 5中会把beforeLoadbeforeMount等周期函数的执行逻辑安插在registerApplicationapp返回的声明周期函数中。

  2. 步骤 2中会把template<head></head>换成<qiankun-head></qiankun-head>。然后再用一个div标签包裹着template,如下所示:

    <div
      id="__qiankun_microapp_wrapper_for_${appName}"
      data-name="${appName}"
      data-version="${version}"
    >
      ${template}
    </div>
    

    然后会再次创建一个div元素appElement,通过appElement.innerHTML = template把对于上述字符串转化为DOM元素。

    接下来中会创建一个会根据sandbox里的设置分开做处理:

    • strictStyleIsolationtrue,则通过把appElement.attachShadow({ mode: 'open' })上述appElement转为shadowDOM
    • experimentalStyleIsolationtrue,则把步骤二中通过links外链请求获取到的所有css代码都加上div[data-qiankun="${appName}"]的前缀,于此同时也给appElement加上属性data-qiankun="${appName}"
  3. 步骤 5中会把GlobalState的初始化、卸载等逻辑安插在registerApplicationapp返回的声明周期函数中。

如果想查看更多可直接看qiankunregisterMicroApps源码

我们为什么需要js沙箱

js沙箱为我们隔离了应用之间的window,虽然说只是隔离了一级属性,但也已经非常给力。开发者们会疑惑 🤔:怎么给力了?我写开发代码是尽量不会往window添加或修改属性的。况且如果有这个需要,只要约定好每个应用使用的属性名不一样就行,不会互相影响呀?

的确我们在开发中通过代码规则约束能避免window污染,但我们的第三方依赖库就不一定能做到这点。如果有些应用都是用了同一个依赖库,而这个依赖库都往window里添加属性了,那如果没有js沙箱隔离就会导致应用之间彼此影响。例如我们常用的svg-sprite-loaderfont-awesome,他们都会把自身的svgComplier挂载到window上,如下所示:

不懂qiankun原理?这篇文章五张图片带你迅速通晓

如果应用都使用了这两个泛用的第三方库且没有隔离window,必然会造成影响。

后记

这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 👏👏👏。

转载自:https://juejin.cn/post/7202246519080304697
评论
请登录