乾坤之大,一锅炖不下

简介: qiankun 源码学习什么是乾坤乾为天,坤为地,乾坤代表天地。不得不说这个名字:qiankun 是基于 single-spa 的微前端解决方案。什么是微前端?什么是 single-spa?前端喜欢不断的造轮子这件事儿是毋庸置疑的,但轮子都有被造出来的原因,比如 single-spa,他是最早的微前端框架。微前端产生的原因很多:拆分巨石应用/集成其他应用(可能技术栈不同)/各前端模块独立开发部署.

qiankun 源码学习

什么是乾坤

乾为天,坤为地,乾坤代表天地。

不得不说这个名字:

qiankun 是基于 single-spa 的微前端解决方案。

什么是微前端?什么是 single-spa?

前端喜欢不断的造轮子这件事儿是毋庸置疑的,但轮子都有被造出来的原因,比如 single-spa,他是最早的微前端框架。

微前端产生的原因很多:拆分巨石应用/集成其他应用(可能技术栈不同)/各前端模块独立开发部署......

而 single-spa 是第一个微前端框架,它的特性:

为什么标题不是 single-spa 呢

显然我们并没有采用 signle-spa 来做微前端技术选型,为什么呢?将前端大型项目比作做饭,那 single-spa 只提供了火力旺盛的灶台,你需要找到适合自己的刀具、锅、锅铲等工具,然后才能开始做饭,所以你想靠它从零开始,可能要耗费不少精力。

single-spa 本身到底做了什么?

  • 注册
import { registerApplication, start } from 'single-spa';

// Config with more expressive API
registerApplication({
  name: 'app1',
  app: () => import('src/app1/main.js'),
  activeWhen: '/app1',
  customProps: {
    some: 'value',
  }
);
// 记住这里
start();

在主应用中,你要配置什么时候加载对应子应用,是通过路由 activeWhen来决定的,子应用的入口文件则是 app,也可以给子应用传递自定义参数 customProps。之后执行 start 开始执行 mount 逻辑。

  • 子应用
export async function bootstrap(props) {...}
export async function mount(props) {...}
export async function unmount(props) {...}

对于子应用,需要在入口文件实现以上三个钩子函数,当 activeWhen 匹配该子应用的路由时,则会执行一次 bootstrap钩子,之后不会再执行,然后会执行 mount进行 DOM 挂载,当路由切换时,执行 unmount将该应用从 body中移除。

基本上 single-spa 就这了这些,你可能会问,那它怎么实现微前端间样式和全局变量隔离?怎么加载不同框架的代码?如何单独部署呢?如何更新微前端资源?这些都需要你自己实现,或者找工具,或者造轮子。(最开始的 single-spa 是这样,后来社区上出现了很多主流框架的微前端实现,像 single-spa-react/single-spa-vue/single-spa-angular等,但也不是实现了所有特性)

qiankun 的特点

qiankun 基本帮你实现了以上 single-spa 未实现的点

深入乾坤

qiankun 的重点在主应用,也就是 主loader ,子应用只要满足 single-spa 规定的几个钩子方法就可以被 qiankun 正常加载,所以 single-spa 支持的应用,它都支持(废话!)。

qiankun 是这样使用的:

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'reactApp',
    entry: '//localhost:3000',
    container: '#container',
    activeRule: '/app-react',
  },
  {
    name: 'vueApp',
    entry: '//localhost:8080',
    container: '#container',
    activeRule: '/app-vue',
  },
  {
    name: 'angularApp',
    entry: '//localhost:4200',
    container: '#container',
    activeRule: '/app-angular',
  },
]);
// 启动 qiankun
start();

这里和 single-spa 最大的区别就是 entry 返回 html 文件。

registerMicroApps 注册

import { registerApplication } from 'single-spa';

export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // Each app only needs to be registered once
  const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));

  microApps = [...microApps, ...unregisteredApps];

  unregisteredApps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    registerApplication({
      name,
      app: async () => {
        loader(true);
        await frameworkStartedDefer.promise;

        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();

        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

以上其实就是用 single-spa 的 registerApplication 来注册各个微前端。

如果有个微前端在注册之后直接被激活了,则会执行对应 app内部逻辑,

app 函数 返回 Promise,走到 await frameworkStartedDefer.promise 时会 pending住,不会再走下面的逻辑,为什么会这样,下文告诉你。

start 初始化

注册完成之后用户就执行 start()进入主逻辑了

import {start as startSingleSpa } from 'single-spa';

export function start(opts: FrameworkConfiguration = {}) {
  frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
  const {
    prefetch,
    sandbox,
    singular,
    urlRerouteOnly = defaultUrlRerouteOnly,
    ...importEntryOpts
  } = frameworkConfiguration;
  // 预加载
  if (prefetch) {
    doPrefetchStrategy(microApps, prefetch, importEntryOpts);
  }
  // 兼容性提示,当下你可以忽略它
  if (sandbox) {
    if (!window.Proxy) {
      console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
      frameworkConfiguration.sandbox = typeof sandbox === 'object' ? { ...sandbox, loose: true } : { loose: true };
      if (!singular) {
        console.warn(
          '[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy',
        );
      }
    }
  }
  // 调用qiankun的 start
  startSingleSpa({ urlRerouteOnly });
  started = true;

  frameworkStartedDefer.resolve();
}

start 时,先是做了预加载逻辑 doPrefetchStrategy,之后直接调用 single-spa的 start来初始化。之后将 frameworkStartedDefer放开,这样注册阶段的 loadApp逻辑就可以往下走了。

这里有两个疑问点:

  • 为什么 single-spa 不在注册之后直接  start ?而是暴露  start api 让用户手动执行?
  • 为什么 qiankun 要设置  frameworkStartedDefer 卡点, start  完成后在  load 子应用?

其实这两个问题是同一个问题,当某个微前端一开始就被激活时:

对于 single-spa 来说,要经历这个步骤:

注册 -> 激活对应微前端 -> load 资源 -> 初始化(start) -> 挂载

只有 start 时,才可以挂载微前端到主应用上,这样做可以让用户对挂载的操作更有把控力,比如 ajax 请求用户权限后,再决定是否挂载微前端。

对于qiankun来说,要经历这个步骤:

注册 -> 激活对应微前端 -> 初始化(start) -> load 资源 -> 挂载

可以理解为,如果没执行 start ,那么连 load 资源都没必要了。也就是保证微前端在 start 之后再进行加载,比single-spa 更激进。

但这里我存疑,如果qiankun 这样做的原因和 single-spa一样,是为了避免没必要的加载?那么当该子应用真正需要被挂载的情况下,load 资源的时机不就滞后了吗?

预加载 和 直接加载

我们可以通过配置 start的 prefetch参数开启预加载:

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 () => {
      // critical rendering apps would be prefetch as earlier as possible
      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;
    }
  }
}

这里的逻辑比较简单,主要是对参数类型的判断, 从而做不同的处理,处理分为两个函数:prefetchAfterFirstMounted(首屏之后) 和 prefetchImmediately(直接加载)。

function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  window.addEventListener('single-spa:first-mount', function listener() {
    // getAppStatus single-spa 内部方法
    const notLoadedApps = apps.filter((app) => getAppStatus(app.name) === NOT_LOADED);

    if (process.env.NODE_ENV === 'development') {
      const mountedApps = getMountedApps();
      console.log(`[qiankun] prefetch starting after ${mountedApps} mounted...`, notLoadedApps);
    }

    notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));

    window.removeEventListener('single-spa:first-mount', listener);
  });
}

export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  if (process.env.NODE_ENV === 'development') {
    console.log('[qiankun] prefetch starting for apps...', apps);
  }

  apps.forEach(({ entry }) => prefetch(entry, opts));
}

prefetchAfterFirstMounted 会监听 single-spa:first-mount的自定义事件,然后遍历 prefetch传入微前端列表中未加载的应用。

prefetchImmediately 则直接遍历 直接预加载,你认为首屏需要展示的应用就可以放到这里。

const isSlowNetwork = navigator.connection
  ? navigator.connection.saveData ||
    (navigator.connection.type !== 'wifi' &&
      navigator.connection.type !== 'ethernet' &&
      /(2|3)g/.test(navigator.connection.effectiveType))
  : false;
/**
 * prefetch assets, do nothing while in mobile network
 * @param entry
 * @param opts
 */
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
  // 离线和网络慢的情况下不做预加载
  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);
  });
}

这里prefetch内部用了 importEntry 来做加载,我们先不进入这个库,会在下文讲它,你只要记住 getExternalScripts加载脚本,getExternalStyleSheets加载样式就行。

让我们先来看看几个有意思的点。

1,什么时候不做预加载?

可以看到离线的状态或者 isSlowNetwork状态,主要是利用 navigator 来做一些弱网判断,这几个值分别是:

navigator.connection.saveData

是否开启浏览器的精简模式,可参考:https://support.google.com/chrome/answer/2392284?hl=en&co=GENIE.Platform%3DAndroid

navigator.connection.type(移动端支持)

网络类型:

  • wifi 
  • ethernet 以太网(局域网)
  • bluetooth 蓝牙
  • cellular 蜂窝网络
  • wimax 无线城域网

navigator.connection.effectiveType

'2g' '3g' 

也就是说,弱网环境不会做预加载操作,这也给了我们一些思路,比如在项目开发的时候,判断网络环境去加载对应资源:在弱网环境下可以牺牲图片的分辨率换取加载速度的提升。

2,requestIdleCallback 是啥?

requestAnimationFrame我们可能听说过,requestIdleCallback又是啥?

其实他两个是同一个维度上的东西,前者是在浏览器当前帧绘制之前调用,后者是在绘制之后的空闲时间调用。前者经常被我们用来做面试题 ^v^:如何实现流畅的动画?而后者就可以用来做优先级不高的事件避免其影响用户体验,比如这里的预加载,比如我们的打点上报时机。

加载模版之 import-html-entry 

qiankun做了最重要的两件大事儿,其中一件就是支持配置模版来加载微前端资源,import-html-entry这个库就是干这件事儿的。

大多数 SPA 应用都会将资源打包成一个 html 模版和若干资源,当这些应用的开发者有天想升级为微前端架构时,直接将微前端入口配置为模版(而不是去配置入口js和各种样式文件)的成本是很低的,这点我们在项目中应该也深有体会。所以呀,当我们做产品的时候得帮用户“偷懒”,帮用户多省点事儿,用户就会爱上我们的产品~

我们不去深究 import-html-entry 库,只需要知道其功能就行。

import { importEntry } from 'import-html-entry';
async function getTemplate() {
  const { 
    execScripts, 
    template, 
    assetPublicPath, 
    getExternalScripts, 
    getExternalStyleSheets 
  } =
    await importEntry('http://127.0.0.1:5500/src/pages/home/index.html');
  
  console.log(template);
  console.log(assetPublicPath);
  const styles = await getExternalStyleSheets();
  console.log(styles);
  const script = await getExternalScripts();
  console.log(script);
}

importEntry接受一个模版链接,返回值有以下:

  • template 将模版中的 css 和 js 资源标签注释,并且将 css 的内容插入到模版中
  • assetPublicPath 资源公共路径
  • getExternalStyleSheets 返回数组,包括所有css内容
  • getExternalScripts 发起js 脚本请求,返回数组,包括所有 js 代码字符串
  • execScripts(proxy, strictGlobal) 发起 js 脚本请求,接收一个 proxy 对象,返回入口脚本中 proxy 或者 window 的最后一个非 property 属性

其他方法都好说,这最后一个方法的功能有点奇怪,我举个例子:

假设模版的入口脚本内容是:

((global) => {
  global['purehtml'] = {
    bootstrap: () => {
      console.log('purehtml bootstrap');
      return Promise.resolve();
    },
    mount: () => {
      console.log('purehtml mount');
      return render($);
    },
    unmount: () => {
      console.log('purehtml unmount');
      return Promise.resolve();
    },
  };
})(window);

那执行 execScripts 之后会返回 purehtml 对象:

async function getTemplate() {
  const { execScripts } =
    await importEntry('http://url/index.html');
  const runScript = await execScripts();
  console.log(runScript);
  
}
getTemplate();

这下你应该能猜到它的主要作用了吧,这里我们就不深入了,先记住这几个 API,后面再见到它们就更亲切了。

loadApp 之沙箱

终于到了核心的加载逻辑了,qiankun 干的另一件大事儿就是实现了沙箱,也会在这里展开:假设初始化路由匹配到 /app1,用户执行了 start,接下来就到了加载 /app1 的微前端资源了。

模版渲染和样式隔离

export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  const { entry, name: appName } = app;
 
  const appInstanceId = `${appName}_${+new Date()}_${Math.floor(Math.random() * 1000)}`;

  const markName = `[qiankun] App ${appInstanceId} Loading`;
  if (process.env.NODE_ENV === 'development') {
    performanceMark(markName);
  }

  const { singular = false, sandbox = true, excludeAssetFilter, ...importEntryOpts } = configuration;
  
  // 上文我们认识的几个方法
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

  // 用 div 包装 html 内容,并赋予唯一id
  const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);

  const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
  const scopedCSS = isEnableScopedCSS(sandbox);
  // 再创建一个div,包装 html,主要是对样式隔离的处理,strictStyleIsolation是 ShadowDom scoped 是增加className style
  let initialAppWrapperElement: HTMLElement | null = createElement(
    appContent,
    strictStyleIsolation,
    scopedCSS,
    appName,
  );

  const initialContainer = 'container' in app ? app.container : undefined;
  const legacyRender = 'render' in app ? app.render : undefined;
  // 返回一个 render 函数,故名思义,调用它把 html dom 填充到指定 DOM 上
  const render = getRender(appName, appContent, legacyRender);

  // 第一次加载设置应用可见区域 dom 结构
  // 确保每次应用加载前容器 dom 结构已经设置完毕, 把 initialContainer 塞进 element,如果有的话就不塞了
  render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
  // 获取 app className dom
  const initialAppWrapperGetter = getAppWrapperGetter(
    appName,
    appInstanceId,
    !!legacyRender,
    strictStyleIsolation,
    scopedCSS,
    () => initialAppWrapperElement,
  );

  // stop ,看到这里,我需要把下文的代码抽出来单独看,因为到了另一个核心功能 沙箱了。

  /**被抢走的代码 ^-^ ***/
}

以上其实就做了render html 和 css 样式隔离这两个主要功能,css 样式隔离有两种,一种是添加 scope 命名空间,一种是 shadow dom,两种方式都有各自的弊端,我们项目中都没开启,这里就不展开讲了。

基于样式冲突的处理可以看我们项目中用的权哥的方案:https://yuque.antfin-inc.com/junshun.mq/rhz9sm/wig7le#Y1S5n

沙箱

loadApp 的下半部分代码主要是包装了生命周期钩子,然后返回给 single-spa 的 registerApplication 方法。

这里的核心逻辑是,如何执行微前端的 entry 脚本?这就引出沙箱的概念。

沙箱是什么?

沙箱是一个隔离的运行环境,沙箱和宿主环境是隔离的,沙箱间也是隔离的。

为什么要有沙箱?

如果没有沙箱的话,微前端 A 定义了一个 全局变量 window.a = 1,微前端 B也有一个全局变量 window.a = 2。

那之后对变量 a 的操作预期就不明确了,如果引发了缺陷可能让你浪费巨多的时间去排查。或者,被卸载掉的微前端对window对象的操作会一直留在内存里,这也是种性能浪费嘛。

可以没有沙箱

既然担心全局变量污染,那就不去写全局变量的代码不就行了,可以在 CR 流程中加一环节,以代码规范规避这个事情。我看网上有的团队确实是这样干的,用规范来舍弃沙箱。但.......这有个前提,微前端都是由团队内开发的。而我们要常常接第三方平台,比如大禹接 星轨 接政务中台的自动化测试工具......里面的代码都是不可控的。所以对我们而言:沙箱,得有!

沙箱方案?

  • 其实 iframe 就是一个隔离的沙箱,可以构造 iframe 中的 window 分配给微前端, const iframe = document.createElement( 'iframe' ); 然后用这个 window 对象执行  eval 微前端代码,这个方案被阿里云的微前端框架  https://github.com/aliyun/alibabacloud-alfa 采纳。缺点嘛,通信有点麻烦,得通过 postMessage 来做。
  • 而 qiankun 走了另外的路:
  • 单例沙箱:通过代理 window 对象,当微前端 A mounted 时,A 对 window 的操作会被记录在全局变量中;当 A unmounted 时,会根据这个全局变量 delete A 对 window 的修改;当 A 再次 mounted 时,则把全局变量中的属性再赋给 window。可以显而易见的,该模式只能同时渲染一个微前端。所以这个沙箱基本处于废弃状态。
  • 多例沙箱:因为单例的局限性,所以有多例沙箱,它为每一个微前端构造了新代理对象 fakeWindow,微前端为 window 的操作都会被代理到 fakeWindow,接下来我们重点将这个沙箱是怎么被创建出来的。

/* loader的下半部分代码 **/
  let global = window;
  const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
  let sandboxContainer;
  // 核心方法
  if (sandbox) {
    sandboxContainer = createSandboxContainer(
      appName,
      // FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
      initialAppWrapperGetter,
      scopedCSS,
      useLooseSandbox,
      excludeAssetFilter,
    );
    // 用沙箱的代理对象作为接下来使用的全局对象
    global = sandboxContainer.instance.proxy as typeof window;
    mountSandbox = sandboxContainer.mount;
    unmountSandbox = sandboxContainer.unmount;
  }


  // execScripts 用 createSandboxContainer 生成的代理对象来执行微前端代码
  const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox);
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
    scriptExports,
    appName,
    global,
    sandboxContainer?.instance?.latestSetProp,
  );
 

这里我省去了其他不重要的逻辑,createSandboxContainer生成了 fakeWindow 代理对象,并且用这个对象 execScripts微前端代码,这样在微前端内部的对 window的操作便指向了 fakeWindow。接下来我们看看 这个 fakeWindow 代理对象是怎么产生的。

function createFakeWindow(global: Window) {
  // map always has the fastest performance in has check scenario
  // see https://jsperf.com/array-indexof-vs-set-has/23
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

  /*
   copy the non-configurable property of global to fakeWindow
   see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
   > A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
   */
  Object.getOwnPropertyNames(global)
    .filter((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      return !descriptor?.configurable;
    })
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      if (descriptor) {
        const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');

        /*
         make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
         see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
         > The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property.
         */
        if (
          p === 'top' ||
          p === 'parent' ||
          p === 'self' ||
          p === 'window' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          descriptor.configurable = true;
          /*
           The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was
           Example:
            Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
            Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
           */
          if (!hasGetter) {
            descriptor.writable = true;
          }
        }

        if (hasGetter) propertiesWithGetter.set(p, true);

        // freeze the descriptor to avoid being modified by zone.js
        // see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

既然核心用了 Proxy,肯定要有一个代理对象。代理对象是要满足几点要求:

A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.

一个属性不能被称为 不可配置的,如果它不存在于目标对象的私有属性中 或者 它作为一个 可配置的属性存在于目标对象中

基于此点,所以要遍历 window 对象中的不可配置属性,并通过 Object.defineProperty 配置到 fekeWindow 的私有属性中。

top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.

而对这几个属性的 可配置属性设置为 true,则是为了避开这条:当一个属性是不可配置的,但其值和 目标对象的值不相等则会抛出错误。所以这里需要修改为 configurable =  true

其他的则是对浏览器兼容性以及 angular 做的兼容。

有了目标对象,就可以实现代理了。这里主要实现了 proxy 的几个 traps 函数,就不展开了。

    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        ...
      },

      get(target: FakeWindow, p: PropertyKey): any {
        ...
      },

      // trap in operator
      // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
      has(target: FakeWindow, p: string | number | symbol): boolean {
        return p in unscopables || p in target || p in rawWindow;
      },

      getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
        ...
      },

      // trap to support iterator with sandbox
      ownKeys(target: FakeWindow): ArrayLike<string | symbol> {
        return uniq(Reflect.ownKeys(rawWindow).concat(Reflect.ownKeys(target)));
      },

      defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
        ...
      },

      deleteProperty(target: FakeWindow, p: string | number | symbol): boolean {
       ...
      },

      // makes sure `window instanceof Window` returns truthy in micro app
      getPrototypeOf() {
        ...
      },
    });

用 fakeWindow 执行完微前端代码后,生命周期钩子被传入到 single-spa 中,之后就交给 single-spa 来调度了。

补充

简要流程

https://yuque.antfin.com/wenxian.dh/lry0tb/gfkzcv

是否要开启预加载

预加载听起来很美好,但是否要开启预加载你得知道预加载发生在什么时机,假设当前主应用是 A,微前端 1 是 B,微前端 2 是 C。

  • A: a.umi.js
  • B: b.umi.js + b.vendor.js
  • C: c.umi.js + c.vendor.js

假设我们给 C 开启了预加载,当前激活的页面是 B 应用,那加载逻辑应该是这样的:

a.umi.js -> b.umi.js  -> c.umi.js -> b.vendor.js

可以得出,微前端 C 的预加载脚本影响到了微前端 B 的 vendor 渲染。

这里用我们业务来举例:当前是 alita-frontend的页面,此时 alita-frontend的 vendor在 collboration umi.js 后加载,这无疑影响到 alita-frontend 的打开速度。

所以,开启预加载可能会拖慢非预加载项目的首屏渲染速度。要慎重!

你可能注意到,微前端的加载是依赖主应用 umi.js 的加载的,因为这个行为是在主应用运行时触发的

所以要想微前端加载的快,就得优化主应用加载的时间,这个收益比较大。有两种方式,我认为这两种方式取其一即可:

  • 主应用不要做其他事情,比如大禹的主应用现在承担着渲染侧导航栏的责任,把这块业务逻辑抽出去可以节省不少加载时间,这点是我们可以去优化的方向。
  • 子应用的加载能否提前?和主应用的 umi 并发加载,但这里要解决 qiankun 的加载时序问题,这个方向有待探索。

是否要开启样式隔离

不,现有的两个官方方案都有各自的问题,样式冲突主要体现在 全局样式和 antd 上。

对于全局样式我们有统一的 theme 包,在模版引入,为了兼容开发环境,需要在开发环境引入主题包

对于antd,一是开启 antdStyleClear 参数即可,它可以帮我们删掉微前端中的 antd 样式依赖,以使用模版中引入的 antd.css。二是 antd: false删掉umi帮我们自动引入的 antd

// 兼容开发环境
'style': isProduction ? [] : [require('@ali-dayu/{主题名}/lib/link/index').default.prod],
  
'gts-base': {
  // 是否清理掉源码 import 的 antd 样式
  // 因为使用的是 babel-loader 方式处理的所有 node_modules 里面的文件
  // 所以编译时长有一定可能会增加(也有可能降低,这个主要取决于编译 antd less 和 babel-loader 处理 node_modules 谁耗时长)
  // 默认:false
  // 【重要】:如果开启了这个属性,代表你的工程不会再引入任何 antd/xx/less,所以此时 第三步 的插件也不再有用;
  antdStyleClear: true,
},
'antd: false

微前端的优化

Q:第一招,对于四大项:react、react-dom、moment、moment-locale

A:已经在模版中渲染了,直接通过配置 external 掉就不会被打包了。如果没去掉,可能写法上有问题,要使用 import Moment from 'moment' 而不是 import Utils from 'moment/es/xxx'

'gts-base': {
    // 是否 external 掉四大项,react、react-dom、moment、moment-locale
    // 默认:true
    externalsDefault: true,
}

Q:第二招,对于 antd 

A:模版中已经引入了,直接配置 

externals: {
  antd: 'antd',
},
antd: false

Q:第三招,对于第三方包 如:g2dayuEditor

A:比如g2如果一个微前端用到,则动态导入用到的图表,否则通过script或者 runtimeImport加载享受缓存的快感。

externals: {
  g2: 'g2',
},
scripts: ['https://gw.alipayobjects.com/os/lib/antv/g2/4.1.19/dist/g2.min.js']

Q:第四招,主应用已经加载过的库,子应用就不用加载了

A:比如去掉 coreJs

// .env 文件
BABEL_POLYFILL=none

Q:第五招,针对 xapi 的优化

A:  参考:https://code.dayu.work/gts-xapis/transform-xapi-import

Q:第六招,关掉沙箱或者升级 qiankun 版本

A:沙箱的逻辑比较复杂,社区也对其做不断的优化,非常推荐你读这篇文章:https://yuque.antfin.com/zexuan.dzx/qhwim4/huxpfp,对于我们而言,在项目中保持 qiankun 的最新版本的收益是很大的。

相关文章
|
Web App开发 编解码 缓存
乾坤之大,一锅炖不下
qiankun 源码学习什么是乾坤乾为天,坤为地,乾坤代表天地。不得不说这个名字:qiankun 是基于 single-spa 的微前端解决方案。什么是微前端?什么是 single-spa?前端喜欢不断的造轮子这件事儿是毋庸置疑的,但轮子都有被造出来的原因,比如 single-spa,他是最早的微前端框架。微前端产生的原因很多:拆分巨石应用/集成其他应用(可能技术栈不同)/各前端模块独立开发部署.
364 0
乾坤之大,一锅炖不下
|
机器学习/深度学习 人工智能 缓存
程序员年薪50万有多难?背后真相曝光,溢价程度超乎你想象
最近在四面阶段,人工智能方向,面试了一个20年毕业的小伙,在这里提一嘴,主要是溢价程度确实超过了我的想象。
285 0
|
小程序
小程序十大利剑,“割”新餐饮业
阿里云小程序运营干货分享
1119 0