微前端框架qiankun剖析(上)

2,657 阅读9分钟

注意: 受篇幅限制,本文中所粘贴的代码都是经过作者删减梳理后的,只为讲述qiankun框架原理而展示,并非完整源码。如果需要阅读相关源码可以自行打开文中链接。

一、single-spa简介

要了解qiankun的实现机制,那我们不得不从其底层依赖的single-spa说起。随着微前端的发展,我们看到在这个领域之中出现了各式各样的工具包和框架来帮助我们方便快捷的实现自己的微前端应用。在发展早期,single-spa可以说是独树一帜,为我们提供了一种简便的微前端路由工具,大大降低了实现一个微前端应用的成本。我们来看一下一个典型single-spa微前端应用的架构及代码。

主应用(基座):

作为整个微前端应用中的项目调度中心,是用户进入该微前端应用时首先加载的部分。在主应用中,通过向single-spa提供的registerApplication函数传入指定的参数来注册子应用,这些参数包括子应用名称name、子应用如何加载app、子应用何时激活activeWhen、以及需要向子应用中传递的参数customProps等等。在完成整体注册后调用start函数启动整个微前端项目。

// single-spa-config.js
import { registerApplication, start } from 'single-spa';

// Config with more expressive API
registerApplication({
  name: 'app1',
  app: () => import('src/app1/main.js'),
  activeWhen: ['/myApp', (location) => location.pathname.startsWith('/some/other/path')],
  customProps: {
    some: 'value',
  }
});

start();

子应用:

子应用是实际展示内容的部分,最主要的工作是导出single-spa中所规定的生命周期函数,以便于主应用调度。其中,bootstrap在子应用第一次加载时调用,mount在子应用每次激活时调用,unmount在子应用被移出时调用。此外在这些生命周期函数中我们可以看到props参数被传入,这个参数中包含了子应用注册名称、singleSpa实例、用户自定义参数等信息,方便子应用的使用。

console.log("The registered application has been loaded!");

export async function bootstrap(props) {
  const {
    name, // The name of the application
    singleSpa, // The singleSpa instance
    mountParcel, // Function for manually mounting
    customProps, // Additional custom information
  } = props; // Props are given to every lifecycle
  return Promise.resolve();
}
export async function mount(props) {...}
export async function unmount(props) {...}

可以看到Single-spa作为一个微前端框架领域最为广泛使用的包,其为我们提供了良好的子应用路由机制。但是除此之外,single-spa也留下了很多需要用户自行解决的问题:

  1. 子应用究竟应该如何加载,从哪里加载?
  2. 子应用运行时会不会互相影响?
  3. 主应用与子应用、子应用之间具体可以通过customProps互相通信,但是怎样才能知道customProps发生了变化呢?

因此,市面上出现了很多基于single-spa二次封装的微前端框架。他们分别使用不同的方式,基于各自不同的侧重点包装出了更加完善的产品。对于这些产品,我们可以将single-spa在其中的作用类比位理解为react-router之于react项目的作用——single-spa作为一个没有框架、技术栈限制的微前端路由为它们提供了最底层的子应用间路由及生命周期管理的服务。在近几年微前端的发展壮大过程中,早期推出并经久不衰的阿里qiankun框架算的上是一枝独秀了。

二、qiankun简介

作为目前微前端领域首屈一指的框架,qiankun无论是从接入的方便程度还是从框架本身提供的易用性来说都是可圈可点的。qiankun基于single-spa进行了二次开发,不但为用户提供了简便的接入方式(包括减少侵入性,易于老项目的改造),还贴心的提供了沙箱隔离以及实现了基于发布订阅模式的应用间通信方式,大大降低了微前端的准入门槛,对于微前端工程化的推动作用是不可忽视的。

因为其基于single-spa二次开发, 所以qiankun微前端架构与第一章中所提及的并无二致,下面我们列出一个典型的qiankun应用的代码并类比其与single-spa的代码区别。

主应用:

这里qiankun将single-spa中的app改为了entry并对其功能进行了增强,用户只需要输入子应用的html入口路径即可,其余加载工作由qiankun内部完成,当然也可以自行列出所需加载的资源。此外加入了container选项,让用户显示指定并感知到子应用所挂载的容器,简化了多个子应用同时激活的场景。

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'react app', // app name registered
    entry: '//localhost:7100',
    container: '#yourContainer',
    activeRule: '/yourActiveRule',
  },
  {
    name: 'vue app',
    entry: { scripts: ['//localhost:7100/main.js'] },
    container: '#yourContainer2',
    activeRule: '/yourActiveRule2',
  },
]);

start();

子应用:

与single-spa基本一致,导出了三个生命周期函数。这里可以看到在mount中我们手动将react应用渲染到了页面上,反之在unmount中我们将其从页面上清除。

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log('react app bootstraped');
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  ReactDOM.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root'));
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount(props) {
  ReactDOM.unmountComponentAtNode(
    props.container ? props.container.querySelector('#root') : document.getElementById('root'),
  );
}

可以看到,由于其帮助我们完成了子应用的加载工作,所以用户的配置相比于single-spa更为简便了。但是,除了这个明面上的工作,qiankun还在暗处为我们的易用性做出了很多努力,接下来,我们会围绕着以下三个方面来深入剖析qiankun内部源码和相关实现原理:

  1. qiankun如何实现用户只需配置一个URL就可以加载相应子应用资源的;
  2. qiankun如何帮助用户做到子应用间独立运行的(包括JS互不影响和CSS互不污染);
  3. qiankun如何帮助用户实现更简便高效的应用间通信的;

三、子应用加载

qiankun的子应用注册方式非常简单,用户只需要调用registerMicroApps函数并将所需参数传入即可.前文中我们说到qiankun是基于single-spa二次封装的框架,因此qiankun中的路由监听和子应用生命周期管理实际上都是交给了single-spa来进行实现的。我们一起来看一下该方法的实现方式(部分截取,源代码地址github.com/umijs/qiank…

import { registerApplication } from 'single-spa';

let microApps: Array<RegistrableApp<Record<string, unknown>>> = [];

export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // 判断应用是否注册过,保证每个应用只注册一次
  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;

    // 调用single-spa的子应用注册函数,将用户输入的参数转换为single-spa所需的参数
    registerApplication({
      name,
      // 这里提供了single-spa所需的子应用加载方式函数
      app: async () => {
        loader(true);
        await frameworkStartedDefer.promise;
				// 调用转换函数loadApp将用户输入的url等解析转换运行,最终生成增强后的子应用生命周期函数(包括mount,unmount,bootstrap)
        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();
				// 返回值为loadApp生成的一系列生命周期函数,其中mount函数数组再次增强
        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

可以看到,qiankun在子应用加载上所做的工作就是将用户调用registerMicroApps时所提供的参数经过一系列处转换之后,改造成single-spa中registerApplication所需要的参数。下面,我们给出qiankun中实现该转换子的主要函数loadApp的部分实现代码(源代码地址github.com/umijs/qiank…

import { importEntry } from 'import-html-entry';

export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  const { entry, name: appName } = app;
  const {
    singular = false,
    sandbox = true,
    excludeAssetFilter,
    globalContext = window,
    ...importEntryOpts
  } = configuration;
  
  // 。。。。。。
  // 依赖了import-html-entry库中的方法解析了用户输入的url(entry参数),得到了template(HTML模版),execScripts(所依赖JS文件的执行函数)以及assetPublicPath(公共资源路径)
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
  
  // 。。。。。。
  // 在window沙箱中(global参数)执行entry依赖的js文件,得到相关生命周期( bootstrap, mount, unmount, update)
  // 这里可以忽略getLifecyclesFromExports函数,其返回与scriptExports一致,只是为了检查子应用是否导出了必须的生命周期
  const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
    scopedGlobalVariables: speedySandbox ? trustedGlobals : [],
  });
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
    scriptExports,
    appName,
    global,
    sandboxContainer?.instance?.latestSetProp,
  );
  
  // 。。。。。
  // 导出single-spa所需配置的getter方法(因为配置项与子应用挂在的container相关,默认为用户输入的container,后续用户可以手动加载子应用并指定其渲染位置)
  const initialContainer = 'container' in app ? app.container : undefined;
  const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
    const parcelConfig: ParcelConfigObject = {
      name: appInstanceId,
      bootstrap,
      // mount数组在子应用渲染时依次执行
      mount: [
        // 。。。。。。
        // 执行沙箱隔离
        mountSandbox,
        // 调用用户自定义mount生命周期,并传入setGlobalState/onGlobalStateChange的应用间通信方法函数
        async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
        // 。。。。。。
      ],
      // unmount数组在子应用卸载时依次执行
      unmount: [
        // 。。。。。。。
        // 调用用户自定义unmount生命周期
        async (props) => unmount({ ...props, container: appWrapperGetter() }),
        // 卸载隔离沙箱
        unmountSandbox,
        // 清理工作
        async () => {
          render({ element: null, loading: false, container: remountContainer }, 'unmounted');
          // 清理子应用对全局通信的订阅
          offGlobalStateChange(appInstanceId);
          // for gc
          appWrapperElement = null;
          syncAppWrapperElement2Sandbox(appWrapperElement);
        },
        // 。。。。。。。
      ],
    };
    return parcelConfig;
  }
	return parcelConfigGetter
}

可以看到,qiankun在其加载函数loadApp中做了一些额外的工作。

  1. 为了方便使用,qiankun提供了基于url入口来加载子应用的方式。为了获取用户提供的html文件(或者资源文件数组)并解析出其中所需的资源,qiankun依赖了import-html-entry库中的相关方法,执行并得到了子应用导出的用户自定义生命周期。
  2. 对用户自定义的生命周期进行增强(包括挂载/卸载应用间的隔离沙箱,初始化或传入应用间通信方法等等),返回框架增强后的生命周期函数数组并注册在single-spa中。

经过源码的分析我们可以看出,qiankun在子应用加载上就是作为中间层存在的,其主要作用就是简化用户对于子应用注册的输入,通过框架内部的方法转换并增强了用户的输入最终将其传入了single-spa之中,在后续的执行中真正负责子应用加载卸载的是single-spa。

(未完待续)