美文网首页饥人谷技术博客
【微前端】qiankun 到底是个什么鬼

【微前端】qiankun 到底是个什么鬼

作者: 写代码的海怪 | 来源:发表于2021-06-11 02:07 被阅读0次

    前言

    在上一篇文章【微前端】single-spa 到底是个什么鬼 聊到了 single-spa 这个框架仅仅实现了子应用的生命周期的调度以及 url 变化的监听。微前端的一个特点都没有实现,严格来说算不上微前端框架。

    今天就来聊一个真正的微前端框架:qiankun。同样地,本文不会教大家怎么实现一个 Demo,因为官方的 Github 已经有一个很好的 Demo 了,如果你觉得官网的 Demo 太复杂了,也可以看我自己实现的小 Demo

    qiankun 到底做了什么

    首先,qiankun 并不是单一个框架,它在 single-spa 基础上添加更多的功能。以下是 qiankun 提供的特性:

    • 实现了子应用的加载,在原有 single-spa 的 JS Entry 基础上再提供了 HTML Entry
    • 样式和 JS 隔离
    • 更多的生命周期:beforeMount, afterMount, beforeUnmount, afterUnmount
    • 子应用预加载
    • 全局状态管理
    • 全局错误处理

    接下来不会一个特性一个特性地讲,因为这样会很无聊,讲完你也只能知道这是个啥,不能深入了解是怎么来的。所以我更愿意聊一下这些特性是怎么来的,它们是怎么被想到的。

    多入口

    先复习一下 single-spa 是怎么注册子应用的:

    singleSpa.registerApplication(
      'appName',
      () => System.import('appName'),
      location => location.pathname.startsWith('appName'),
    );
    

    可以看到 single-spa 采用 JS Entry 的方式接入微应用,也即:输出一个 JS,然后 bootstrap, mount, unmount 函数。

    但是事件并没有这么简单:我们项目一般都会将静态资源放到 CDN 上来加速。为了不受缓存的影响,我们还会将 JS 文件命名成 contenthash 的乱码文件名: jlkasjfdlkj.jalkjdsflk.js。这样一来,每次子应用一发布,入口 JS 文件名肯定又要改了,导致主应用引入的 JS url 又得改了。麻烦!

    打包成单个 JS 文件的另一个问题就是打包的优化都没了:按需加载、首屏资源加载优化、css 独立打包等优化措施全 🈚️。

    很多时候,子应用一般都已经是线上的应用了,比如 https://abcd.com。微前端融合多个子应用本质上不就是融合多个 HTML 嘛?那为什么不给你子应用的 HTML,主应用就自动接入收工了呢?操作起来应该和在 <iframe/> 和插入 src 是一样的才对味。

    这种通过提供 HTML 入口来接入子应用的方式就叫 HTML Entry。 qiankun 的一大亮点就是提供了 HTML Entry,在调用 qiankun 的注册子应用函数时可以这么写:

    registerMicroApps([
      {
        name: 'react app', // 子应用名
        entry: '//localhost:7100', // 子应用 html 或网址
        container: '#yourContainer', // 挂载容器选择器
        activeRule: '/yourActiveRule', // 激活路由
      },
    ]);
    
    start(); // Go
    

    用起来毫不费力,只需要在 JS 入口加上 single-spa 的生命周期钩子,再发布就可以直接接入了。

    import-html-entry

    然而,HTML Entry 并不是给个 HTML 的 url 就可以直接接入整个子应用这么简单了。子应用的 HTML 文件就是一堆乱七八糟的标签文本。<link>, <style>, <script> 得处理吧?要写正则表达式吧?头要秃了吧?

    所以 qiankun 的作者自己也写了一个专门处理 HTML Entry 这种需求的 NPM 包:import-html-entry。用法如下:

    import importHTML from 'import-html-entry';
    
    importHTML('./subApp/index.html')
      .then(res => {
        console.log(res.template); // 拿到 HTML 模板
    
        res.execScripts().then(exports => { // 执行 JS 脚本
          const mobx = exports; // 获取 JS 的输出内容
          // 下面就是拿到 JS 入口的内容,并用来做一些事
          const { observable } = mobx;
          observable({
            name: 'kuitos'
          })    
        })
    });
    

    当然,qiankun 已经将 import-html-entry 与子应用加载函数完美地结合起来,大家只需要知道这个库是用来获取 HTML 模板内容,Style 样式和 JS 脚本内容就可以了。

    有了上面的了解后,相信大家对于如何加载子应用就有思路了,伪代码如下:

    // 解析 HTML,获取 html,js,css 文本
    const {htmlText, jsText, cssText} = importHTMLEntry('https://xxxx.com')
    
    // 创建容器
    const $= document.querySelector(container)
    $container.innerHTML = htmlText
    
    // 创建 style 和 js 标签
    const $style = createElement('style', cssText)
    const $script = createElement('script', jsText)
    
    $container.appendChild([$style, $script])
    

    在第三步,我们不禁有个疑问:当前这个应用完美地插入了 style 和 script 标签,那下一个应用 mount 时就会被前面的 style 和 script 污染了呀。

    为了解决这两个问题,不得不做好应用之间的样式和 JS 的隔离。

    样式隔离

    qiankun 实现 single-spa 推荐的两种样式隔离方案:ShadowDOM 和 Scoped CSS。

    先来说说 ShadowDOM,qiankun 的源码实现也很简单,只是添加一个 Shadow DOM 节点,伪代码如下:

      if (strictStyleIsolation) {
        if (!supportShadowDOM) {
          // 报错
          // ...
        } else {
          // 清除原有的内容
          const { innerHTML } = appElement;
          appElement.innerHTML = '';
    
          let shadow: ShadowRoot;
    
          if (appElement.attachShadow) {
            // 添加 shadow DOM 节点
            shadow = appElement.attachShadow({ mode: 'open' });
          } else {
            // deprecated 的操作
            // ...
          }
          // 在 shadow DOM 节点添加内容
          shadow.innerHTML = innerHTML;
        }
      }
    

    通过 Shadow DOM 的天然的隔离特性来实现子应用间的样式隔离。

    另一个方案就是 Scoped CSS 了,说白了就是通过修改 CSS 选择器来实现子应用间的样式隔离。 比如,你有这样的 CSS 代码:

    .container {
      background: red;
    }
    
    div {
      color: red;
    }
    

    qiankun 会扫描给定的 CSS 文本,通过正则匹配在选择器前加上子应用的名字,如果遇到元素选择器,就加一个爸爸类名给它,比如:

    .subApp.container {
      background: red;
    }
    
    .subApp div {
      color: red;
    }
    

    JS 隔离

    第一步要隔离的是对全局对象 window 上的变量进行隔离。不能 A 子应用 window.setTimeout = undefined 之后, B 子应用用 setTimeout 的时候就凉了。

    所以 JS 隔离深一层本质就是记录当前 window 对象以前的值,在 A 子应用进来时一顿乱搞之后,要将所有值都恢复过来(恢复现场)。这就是 SnapshotSandbox 的做法,伪代码如下:

    class SnapshotSandbox {
      ...
    
      active() {
        // 记录当前快照
        this.windowSnapshot = {} as Window;
        getKeys(window).forEach(key => {
          this.windowSnapshot[key] = window[key];
        })
    
        // 恢复之前的变更
        getKeys(this.modifyPropsMap).forEach((key) => {
          window[key] = this.modifyPropsMap[key];
        });
    
        this.sandboxRunning = true;
      }
    
      inactive() {
        this.modifyPropsMap = {};
    
        // 记录变更,恢复环境
        getKeys(window).forEach((key) => {
          if (window[key] !== this.windowSnapshot[key]) {
            this.modifyPropsMap[key] = window[key];
            window[key] = this.windowSnapshot[key];
          }
        });
    
        this.sandboxRunning = false;
      }
    }
    

    除了 SnapShotSandbox,qiankun 还提供了一种使用 ES 6 Proxy 实现的沙箱:

    class SingularProxySandbox {
      /** 沙箱期间新增的全局变量 */
      private addedPropsMapInSandbox = new Map<PropertyKey, any>();
    
      /** 沙箱期间更新的全局变量 */
      private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
    
      /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
      private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
    
      active() {
        if (!this.sandboxRunning) {
          // 恢复子应用修改过的值
          this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
        }
    
        this.sandboxRunning = true;
      }
    
      inactive() {
        // 恢复加载子应用前的 window 值
        this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
        // 删掉子应用期间新加的 window 值 
        this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
    
        this.sandboxRunning = false;
      }
    
      constructor(name: string) {
        this.name = name;
        this.type = SandBoxType.LegacyProxy;
        const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
    
        const rawWindow = window;
        const fakeWindow = Object.create(null) as Window;
    
        const proxy = new Proxy(fakeWindow, {
          set: (_: Window, key: PropertyKey, value: any): boolean => {
            if (this.sandboxRunning) {
              if (!rawWindow[key]) {
                addedPropsMapInSandbox.set(key, value); // 将沙箱期间新加的值记录下来
              } else if (!modifiedPropsOriginalValueMapInSandbox.has(key)) {
                modifiedPropsOriginalValueMapInSandbox.set(key, rawWindow[key]); // 记录沙箱前的值
              }
    
              currentUpdatedPropsValueMap.set(key, value); // 记录沙箱后的值
    
              // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
              (rawWindow as any)[key] = value;
            }
          },
    
          get(_: Window, key: PropertyKey): any {
            return rawWindow[key]
          },
        }
      }
    }
    

    两者差不太多,那怎么不直接用 Proxy 高级方案呢,因为在一些低版本的浏览器下是没有 Proxy 对象的,所以 SnapshotSandbox 其实是 SingularProxySandbox 的降级方案。

    然而,问题还是没有解决完。上面这种情况仅适用于一个页面只有一个子应用的情况,这种情况也被称为单例(singular mode)。 如果一个页面有多个子应用那一个 SingluarProxySandbox 明显不够的。为了解决这个问题,qiankun 提供了 ProxySandbox,伪代码如下:

    class ProxySandbox {
      ...
    
      active() { // +1 废话
        if (!this.sandboxRunning) activeSandboxCount++;
        this.sandboxRunning = true;
      }
    
      inactive() { // -1 废话
        if (--activeSandboxCount === 0) {
          variableWhiteList.forEach((p) => {
            if (this.proxy.hasOwnProperty(p)) {
              delete window[p]; // 删除白名单里子应用添加的值
            }
          });
        }
    
        this.sandboxRunning = false;
      }
    
      constructor(name: string) {
        ...
        const rawWindow = window; // 原 window 对象
        const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow); // 将真 window 上的 key-value 复制到假 window 对象上
    
        const proxy = new Proxy(fakeWindow, { // 代理复制出来的 window
          set: (target: FakeWindow, key: PropertyKey, value: any): boolean => {
            if (this.sandboxRunning) {
              target[key] = value // 修改 fakeWindow 上的值
    
              if (variableWhiteList.indexOf(key) !== -1) {
                rawWindow[key] = value; // 白名单的话,修改真 window 上的值
              }
    
              updatedValueSet.add(p); // 记录修改的值
            }
          },
    
          get(target: FakeWindow, key: PropertyKey): any {
            return target[key] || rawWindow[key] // 在 fakeWindow 上找,找不到从直 window 上找
          },
        }
      }
    }
    

    从上面可以看到,在 activeinactive 里并没有太多在恢复现场操作,因为只要子应用 unmount,把 fakeWindow 一扔掉就完事了。

    等等,说了这么多上面还只是讨论 window 对象的隔离呀,格局是不是小了点?是小了。

    沙箱

    现在我们再来审视一下沙箱这个玩意,其实无论沙箱也好 JS 隔离也好,最终要实现的是给子应用一个独立的环境,这也意味着我们有成百上千的东西要做补丁来打造终极的类 <iframe> 硬隔离。

    然而,qiankun 也不是万能的,它只对某些重要的函数和监听器进行打补丁。

    其中最重要的补丁就是 insertBefore, appendChildremoveChild 的补丁了。

    当我们加载子应用的时候,免不了遇到动态添加/移除 CSS 和 JS 脚本的情况。这时 <head><body> 都有可能调用 insertBefore, appendChild, removeChild 这三个函数来插入或者删除 <style>, <link> 或者 <script> 元素。

    所以,这三个函数在被 <head><body> 调用时,就要用上补丁,主要目的是别插入到主应用的 <head><body> 上,要插在子应用里。打补丁伪代码如下:

    // patch(element)
    switch (element.tagName) {
      case LINK_TAG_NAME:  // <link> 标签
      case STYLE_TAG_NAME: { // <style> 标签
        if (scopedCSS) { // 使用 Scoped CSS
          if (element.href;) { // 处理如 <link rel="icon" href="favicon.ico"> 的玩意
            stylesheetElement = convertLinkAsStyle( // 获取 <link> 里的 CSS 文本,并使用 css.process 添加前缀
              element,
              (styleElement) => css.process(mountDOM, styleElement, appName), // 添加前缀回调
              fetch,
            );
            dynamicLinkAttachedInlineStyleMap.set(element, stylesheetElement); // 缓存,下次加载沙箱时直接吐出来
          } else { // 处理如 <style>.container { background: red }</style> 的玩意
            css.process(mountDOM, stylesheetElement, appName);
          }
        }
    
        return rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode); // 插入到挂载容器上
      }
    
      case SCRIPT_TAG_NAME: {
        const { src, text } = element as HTMLScriptElement;
    
        if (element.src) { // 处理外链 JS
          execScripts(null, [src], proxy, { // 获取并执行 JS
            fetch,
            strictGlobal,
          });
    
          return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode); // 插入到挂载容器上
        }
    
        // 处理内联 JS
        execScripts(null, [`<script>${text}</script>`], proxy, { strictGlobal });
        return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
      }
    
      default:
        break;
    }
    

    当在创建沙箱时打完补丁后,在处理样式和 JS 脚本时就可以针对当前子应用来应用样式和 JS 了。上面我们还注意到 CSS 样式文本是被保存的,所以当子应用 remount 的时候,这些样式也可以作为缓存直接一波补上,不需要再做处理了。

    剩下的补丁都是给 historyListeners, setInterval, addEventListeners, removeEventListeners 做的补丁,无非就是 mount 时记录 listeners 以及一些添加的值,在 unmount 的时候再一次性执行掉或者删除掉,不再赘述。

    更多的生命周期

    如果当前项目迁移成子应用,在入口的 JS 就不得不配合 qiankun 来做一些改动,而这些改动有可能影响子应用的独立运行。比如,接入了微前端后,可能就不得不在本地先起一个主应用,再起一个子应用,然后才能做开发和调试,那这也太蛋疼了。

    为了解决子应用也能独立运行的问题,qiankun 注入了一些变量,来告诉子应用说:喂,你现在是儿子,要用子应用的渲染方式。而当子应用获取不到这些注入的变量时,它就知道:哦,我现在要独立运行了,用回原来的渲染方式就可以了,比如:

    if (window. __POWERED_BY_QIANKUN__) {
      console.log('微前端场景')
      renderAsSubApp()
    } else {
      console.log('单体场景')
      previousRenderApp()
    }
    

    怎么注入就是个问题了,不能简单的 window.__POWERED_BY_QIANKUN__ = true 就完事了,因为子应用会在编译时就要这个变量了。所以,qiankun 在 single-spa 提供的生命周期 load, mount, unmount 做了变量的注入,伪代码如下:

    // getAddOn
    export default function getAddOn(global: Window): FrameworkLifeCycles<any> {
      return {
        async beforeLoad() {
          // eslint-disable-next-line no-param-reassign
          global.__POWERED_BY_QIANKUN__ = true;
        },
    
        async beforeMount() {
          // eslint-disable-next-line no-param-reassign
          global.__POWERED_BY_QIANKUN__ = true;
        },
    
        async beforeUnmount() {
          // eslint-disable-next-line no-param-reassign
          delete global.__POWERED_BY_QIANKUN__;
        },
      };
    }
    
    // loadApp
    const addOnLifeCycles = getAddOn(window)
    
    return {
      load: [addOnLifeCycles.beforeLoad, subApp.load],
      mount: [addOnLifeCycles.mount, subApp.mount],
      unmount: [addOnLifeCycles.unmount, subApp.unmount]
    }
    

    总结一下,新增的生命周期有:

    • beforeLoad
    • beforeMount
    • afterMount
    • beforeUnmount
    • afterUnmount

    loadApp

    好了,上面就是加载一个子应用的所有步骤了,这里先做个小总结:

    • import-html-entry 解析 html,获取 JavaScript, CSS, HTML
    • 创建容器 container,同时加上 css 样式隔离:在 container 上添加 Shadow DOM 或者对 CSS 文本 添加前缀实现 Scoped CSS
    • 创建沙箱,监听 window 的变化,并对一些函数打上补丁
    • 提供更多的生命周期,在 beforeXXX 里注入一些 qiankun 提供的变量
    • 返回带有 bootstrap, mount, unmount 属性的对象

    预加载

    从上面可以看到加载一个子应用的时候需要很多的步骤,我们不禁想到:如果在 mount 第一个子应用空闲时候,可以预先加载别的子应用,那之后切换子应用就可以更快了,也即子应用预加载。

    在空闲的时候干一些事,可以使用浏览器提供的 requestIdleCallback。OK,那我们再来定义一下“预加载”是什么,其实就是把 CSS 和 JS 下载下来就完事了,所以 qiankun 的源码也是很简单的:

    requestIdleCallback(async () => {
      const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
      requestIdleCallback(getExternalStyleSheets);
      requestIdleCallback(getExternalScripts);
    });
    

    现在,我们再来脑洞大开一下:难道一下子就要所有子应用都要预加载么?不见得吧?有可能一些子应用要预加载,一些不需要。

    所以 qiankun 提供了三种预加载策略:

    • 全部子应用都立马预加载
    • 全部子应用都在第一个子应用加载后才预加载
    • criticalAppNames 数组里的子应用要立马预加载,在 minorAppsName 数组里的子应用在第一个子应用加载后才预加载

    源码实现如下:

    export function doPrefetchStrategy(
      apps: AppMetadata[],
      prefetchStrategy: PrefetchStrategy,
      importEntryOpts?: ImportEntryOpts,
    ) {
      const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter((app) => names.includes(app.name));
    
      if (Array.isArray(prefetchStrategy)) {
        // 全部都在第一个子应用加载后才预加载
        prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
      } else if (isFunction(prefetchStrategy)) {
        (async () => {
          // 一半一半
          const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
          prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
          prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
        })();
      } else {
        switch (prefetchStrategy) {
          case true: // 全部都在第一个子应用加载后才预加载
            prefetchAfterFirstMounted(apps, importEntryOpts);
            break;
    
          case 'all': // 全部子应用都立马预加载
            prefetchImmediately(apps, importEntryOpts);
            break;
    
          default:
            break;
        }
      }
    }
    

    全局状态管理

    全局状态很有可能出现在微前端的场景中,比如主应用提供可以一些初始化好的 SDK。刚开始先传个未初始好的 SDK,等主应用把 SDK 初始化好了,再通过回调通知子应用:醒醒,SDK 准备好了。

    这种思路和 Redux, Event Bus 一模一样。 状态都存在 window 的 gloablState 全局对象里,再添加一个 onGlobalStateChange 回调就完事了,实现伪代码如下:

    let gloablState = {}
    let deps = {}
    
    // 触发全局监听
    function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
      Object.keys(deps).forEach((id: string) => {
        if (deps[id] instanceof Function) {
          deps[id](cloneDeep(state), cloneDeep(prevState));
        }
      });
    }
    
    // 添加全局状态变化的监听器
    function onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
      deps[id] = callback;
      if (fireImmediately) {
        const cloneState = cloneDeep(globalState);
        callback(cloneState, cloneState);
      }
    }
    
    // 更新 globalState
    function setGlobalState(state: Record<string, any> = {}) {
      const prevState = globalState
      globalState = {...cloneDeep(globalState), ...state}
      emitGlobal(globalState, prevState);
    }
    
    // 注销该应用下的依赖
    function offGlobalStateChange() {
      delete deps[id];
    }
    

    onGlobalStateChange 添加监听器,当调用 setGlobalState 更新值,值改了,调用 emitGlobal,执行所有对应的监听器。调用 offGlobalStateChange 删掉监听器。Easy ~

    全局错误处理

    主要监听了 errorunhandledrejection 两个错误事件:

    export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
      window.addEventListener('error', errorHandler);
      window.addEventListener('unhandledrejection', errorHandler);
    }
    
    export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
      window.removeEventListener('error', errorHandler);
      window.removeEventListener('unhandledrejection', errorHandler);
    }
    

    使用的时候添加监听器,不要的时候移除监听器,不废话。

    总结

    再次总结一下 qiankun 做了什么事情:

    • 实现 loadApp 函数,是最关键、重要的一步
      • 实现 CSS 样式隔离,主要有 Shadow DOM 和 Scoped CSS 两种方案
      • 实现沙箱,JS 隔离,主要对 window 对象、各种 listeners 和方法进行隔离
      • 提供很多生命周期,并在一些 beforeXXX 的钩子里注入 qiankun 提供的变量
    • 提供预加载,提前下载 HTML、CSS、JS,并有三种策略
      • 全部立马预加载
      • 全部在第一个加载后预加载
      • 一些立马预加载,一些在第一个加载后预加载
    • 提供全局状态管理,类似 Redux,Event Bus
    • 提供全局错误处理,主要监听 error 和 unhandledrejection 两个事件

    最后

    虽然阿里说:“可能是你见过最完善的微前端解决方案🧐”。但是从上面对源码的解读也可以看出来,qiankun 也有一些事情没有做的。比如没有对 localStorage 进行隔离,如果多个子应用都用到 localStorage 就有可能冲突了,除此之外,还有 cookie, indexedDB 的共享等。再比如如果单个页面下多个子应用都依赖了前端路由怎么办呢?当然这里的质疑也仅是我个人的猜想。

    另一件事想说的是:微前端的难点并不是 single-spa 的生命周期、路由挟持。而是如何加载好一个子应用。从上面可以看到,有很多 hacky 的编码,比如在选择器前面加前缀,将子应用的 <link>, <script> 加载到子应用上,监听 window 的变化,恢复现场等等,都是台上一句话,台下想秃头的操作。如果不是真见过,估计想破头都想不出来。

    也正是这些 hacky 代码,在搭建微前端的时候会遇到非常多的问题,而且微前端的目的是要将多个💩山聚合起来,所以微前端的解决方案是注定没有银弹的,且行且珍惜吧。

    相关文章

      网友评论

        本文标题:【微前端】qiankun 到底是个什么鬼

        本文链接:https://www.haomeiwen.com/subject/nmcyeltx.html