阅读 2892

解密微前端:从qiankun看子应用加载

在我之前的文章提到过,微前端的本质是分治的处理前端应用以及应用间的关系,那么更进一步,落地一个微前端框架,就会涉及到三点核心要素:

  • 子应用的加载;
  • 应用间运行时隔离;
  • 应用间通信
  • 路由劫持;

对于 qiankun 来说,路由劫持是在 single-spa 上去做的,而 qiankun 给我们提供的能力,主要便是子应用的加载和沙箱隔离。

我将分为三个 topic 去讲,这篇文章主要基于 qiankun 源码像大家讲一下微前端子应用的加载。

qiankun 是 single-spa 的一层封装,而 qiankun 中,真正去加载解析子应用的逻辑是在 import-html-entry 这个包中实现的。

html 解析

首先,当我们配置子应用的 entry 后,qiankun 会去通过 fetch 获取到子应用的 html 字符串(这就是为什么需要子应用资源允许跨域) 拿到 html 字符串后,会调用 processTpl 方法通过一大堆正则去匹配获取 html 中对应的 js(内联、外联)、css(内联、外联)、注释、入口脚本 entry 等等。processTpl 方法会返回我们加载子应用所需要的四个组成部分:

  • template:html 模板;
  • script:js 脚本(内联、外联);
  • styles:css 样式表(内联、外联);
  • entry:子应用入口 js 脚本文件,如果没有默认以解析后的最后一个 js 脚本代替;
export default function processTpl(tpl, baseURI) {
  // 省略详细代码,这里是对各种css、js等资源各种写法的预处理,用于规范后面对资源的统一处理

  return {
    template, // html 模板
    scripts, // js 脚本(内联、外联)
    styles, // css 样式表(内联、外联)
    entry: entry || scripts[scripts.length - 1], // 子应用入口 js 脚本文件,如果没有默认以解析后的最后一个 js 脚本代替;
  };
}
复制代码

css 处理

接下来在拿到子应用的依赖的各种资源关系后,会去通过 fetch 获取 css,并将 css 全部以内联形式嵌入 html 模板中,源码位置。到此对 css 的处理大致就完成了。

function getEmbedHTML(template, styles, opts = {}) {
  const { fetch = defaultFetch } = opts;
  let embedHTML = template;

  // 获取css资源
  // getExternalStyleSheets 同时处理了内联和外联css资源
  // 其中内联资源会获取css code,外联会先fetch 到css code然后处理
  return getExternalStyleSheets(styles, fetch).then((styleSheets) => {
    embedHTML = styles.reduce((html, styleSrc, i) => {
      // 内联处理全部的css资源
      html = html.replace(
        genLinkReplaceSymbol(styleSrc),
        `<style>/* ${styleSrc} */${styleSheets[i]}</style>`
      );
      return html;
    }, embedHTML);
    return embedHTML;
  });
}
复制代码

这里我以真实项目做了对比:

未嵌入父应用的子应用:

嵌入父应用的子应用:

js 处理

接下来是对 js 的处理,这里 qiankun 和 icestack 的处理模式就不同了。

首先简单说下 icestark,icestark 是在解析完 html 后拿到子应用的 js 依赖,通过动态创建 script 标签的形式去加载 js,因此在 icestark 是无视 js 跨域的(icestark 的 entry 模式和 url 模式均是如此,区别在于 entry 模式多了一步 fetch 拉 html 字符串并解析 js、css 依赖,而 url 模式只需要制定子应用的脚本和样式依赖即可)。

而 qiankun 则采用了另一种办法,首先同理会先通过 fetch 获取外联的 js 字符串。源码位置

export function getExternalScripts(
  scripts,
  fetch = defaultFetch,
  errorCallback = () => {}
) {
  const fetchScript = (scriptUrl) => {
    // 通过fetch 获取js资源,如果有缓存从缓存拿
    // 略
  };

  return Promise.all(
    scripts.map((script) => {
      if (typeof script === "string") {
        if (isInlineCode(script)) {
          // 获取内联的js code
          return getInlineCode(script);
        } else {
          // fetch 获取外联的js code
          return fetchScript(script);
        }
      } else {
        // 上面说过了,processTpl 方法会处理各种js css资源,其中对于需要异步执行的js资源会打上async标识
        // 打上async标识的js资源,会使用requestIdleCallback延迟执行
        const { src, async } = script;
        if (async) {
          return {
            src,
            async: true,
            content: new Promise((resolve, reject) =>
              requestIdleCallback(() => fetchScript(src).then(resolve, reject))
            ),
          };
        }

        return fetchScript(src);
      }
    })
  );
}
复制代码

接下来会创建一个匿名自执行函数包裹住获取到的 js 字符串,最后通过 eval 去创建一个执行上下文执行 js 代码。源码位置

function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
  const sourceUrl = isInlineCode(scriptSrc)
    ? ""
    : `//# sourceURL=${scriptSrc}\n`;

  window.proxy = proxy;
  return strictGlobal
    ? `;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
    : `;(function(window, self){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy);`;
}
复制代码

默认不会通过 with 劫持 window 对象的作用域,我们通过 webpack 打包后的 bundle 是会带着 with 劫持 window 对象的。为什么需要 with 劫持 window 的作用域,之后的的 sandbox 分析我会详细介绍,这里我先简单讲下,qiankun 的 sandbox 实现原理是通过 Proxy 代理劫持 window 去做的,那么就会出现一个问题,window.xxx 这样形式的属性会被劫持掉,但是直接声明的全局变量不会被劫持

那么,就需要用到 with 去劫持 window 的作用域了。

因为 qiankun 是创建自己的执行上下文执行子应用的 js,因此在加载后的子应用中是看不到 js 资源引用的,仅有一个资源被执行替换的标识。(icestark会有动态添加的script) 在 qiankun 中,通过调用 import-html-entry 导出的 execScript 函数,可以获取到子应用导出的生命周期勾子。

// get the lifecycle hooks from module exports
// 上面说过的eval包裹js代码返回可执行的bundle就是execScripts主要做的事
const scriptExports: any = await execScripts(global, !singular);
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
  scriptExports,
  appName,
  global
);
复制代码

总结

到此,子应用加载的核心逻辑就说完了,具体的实现以及一些其他细致逻辑的处理大家可以去看源码。下一片文章我将从 quankun 的源码像大家介绍微前端沙箱的实现。

推荐阅读

文章分类
前端
文章标签