微前端 qiankun@2.10.5 源码分析(一)

930 阅读7分钟

微前端 qiankun@2.10.5 源码分析(一)

前言

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends

微前端架构具备以下几个核心价值:

  • 技术栈无关 主框架不限制接入应用的技术栈,微应用具备完全自主权

  • 独立开发、独立部署 微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新

  • 增量升级

    在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略

  • 独立运行时 每个微应用之间状态隔离,运行时状态不共享

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。-- qiankun 官网

哈哈,其实目前我自己公司团队也存在上面说的一些问题,希望能够通过源码的分析研究从中得到一些灵感,对现有项目进行一些改造,打造符合自己的微前端生态。

安装

这里用的是 qiankun@2.10.5 版本。

执行以下命令安装 qiankun 源码:

$ git clone https://github.com/umijs/qiankun.git
$ cd qiankun

安装并运行:

$ yarn install
$ yarn examples:install
$ yarn examples:start

打开 http://localhost:7099 看效果:

example.gif

开始

第一步:初始化应用

找到 examples/main/index.js 文件的第 15 行:

/**
 * Step1 初始化应用(可选)
 */
render({loading: true});
const loader = (loading) => render({loading});

可以看到,调用了 render 方法,然后创建了一个 loader,我们重点看一下 render 方法。

找到 examples/main/render/VueRender.js 文件:

import Vue from 'vue/dist/vue.esm';

function vueRender({ loading }) {
  return new Vue({
    template: `
      <div id="subapp-container">
        <h4 v-if="loading" class="subapp-loading">Loading...</h4>
        <div id="subapp-viewport"> Vue 应用挂载节点 </div>
      </div>
    `,
    el: '#subapp-container',
    data() {
      return {
        loading,
      };
    },
  });
}

let app = null;

export default function render({ loading }) {
  if (!app) {
    app = vueRender({ loading });
  } else {
    app.loading = loading;
  }
}

可以看到,导出了一个 render 方法,在 render 方法中创建了一个 Vue 实例,这里有一个 id="subapp-viewport"div 节点,这个就是应用的挂载节点,后面会用到。

如果这个时候我们执行 render 方法的话,页面会是一个 loading 状态,我们可以试试看。

修改一下 examples/main/index.js 文件:

import 'zone.js'; // for angular subapp
import './index.less';
/**
 * 主应用 **可以使用任意技术栈**
 * 以下分别是 React 和 Vue 的示例,可切换尝试
 */
import render from './render/VueRender';
//
/**
 * Step1 初始化应用(可选)
 */
render({loading: true});
const loader = (loading) => render({loading});

保存看效果:

1-1.png

很简单,就不具体解释啦!

第二步:注册子应用

找到 examples/main/index.js 文件的第 23 行:

registerMicroApps(
  [
    {
      name: 'react16', // 应用名称
      entry: '//localhost:7100', // 应用入口文件
      container: '#subapp-viewport', // 应用挂载节点
      loader, // 应用加载器 
      activeRule: '/react16', // 应用路由匹配规则
    },
    {
      name: 'vue',
      entry: '//localhost:7101',
      container: '#subapp-viewport',
      loader,
      activeRule: '/vue',
    },
    ...
  ],
  {
    beforeLoad: [
      (app) => {
        console.log('[LifeCycle] before load %c%s', 'color: green;', app.name);
      },
    ],
    beforeMount: [
      (app) => {
        console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name);
      },
    ],
    afterUnmount: [
      (app) => {
        console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name);
      },
    ],
  },
);

可以看到,这里注册了很多个子应用,我们重点看一下这个 registerMicroApps 方法。

找到 src/apis.ts 文件的第 59 行:

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;
   // 注册应用(SPA)
    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,
    });
  });
}

ok,其实我们可以看到,在 registerMicroApps 方法中主要就是调用 registerApplication 方法去注册了每一个应用,而这里的 registerApplication 方法是 single-spa 库的方法,先上一张 single-spa 库的流程图(没了解过 single-spa 库也没关系,后面我们会详细分析它的源码的):

1-2.png

从上面流程图中我们可以知道,当 single-spa 匹配到路由信息后,会渲染对应的子应用,接着就会调用子应用的

app 方法对子应用进行渲染。

我们可以回到 src/apis.ts 文件的 registerApplication 方法:

// 注册应用
registerApplication({
  name,
  app: async () => {
    // 修改页面状态为 loading
    loader(true);
    // 等待 start 方法的调用
    await frameworkStartedDefer.promise;
    // 加载当前子应用,获取子应用的 mount 方法
    const { mount, ...otherMicroAppConfigs } = (
      // 调用 loadApp 加载子应用
      await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
    )();
    return {
      mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
      ...otherMicroAppConfigs,
    };
  },
  activeWhen: activeRule,
  customProps: props,
});

前面我们说了,当 single-spa 匹配到路由信息后,会渲染对应的子应用,接着就会调用子应用的

app 方法对子应用进行渲染。

可以看到,在 app 方法又调用了一个叫 loadApp 的方法,loadApp 很重要!!!我们后面用到的时候再具体分析。

第三步:设置默认进入的子应用

找到 examples/main/index.js 文件的第 103 行:

/**
 * Step3 设置默认进入的子应用
 */
setDefaultMountApp('/react16');

找到 src/effects.ts 文件的 setDefaultMountApp 方法:

export function setDefaultMountApp(defaultAppLink: string) {
  // 当调用 spa 的 start 方法后,如果没有匹配到任何子应用的话,会调用该事件
  window.addEventListener('single-spa:no-app-change', function listener() {
    // 获取 spa 的所有渲染过的应用
    const mountedApps = getMountedApps();
    // 如果从未渲染过任何子应用的话就将当前路径指向默认路径
    if (!mountedApps.length) {
      navigateToUrl(defaultAppLink);
    }

    window.removeEventListener('single-spa:no-app-change', listener);
  });
}

可以看到,如果从未渲染过任何子应用的话就将当前路径指向默认路径,我们这里传入的是 /react16,我们可以测试一下。

当我们访问 http://localhost:7099/ 地址的时候,qiankun 会自动的将我们的路径改为我们设置的默认路径 http://localhost:7099/react16

1-3.gif

ok,我们继续往下看!

第四步:启动应用

找到 examples/main/index.js 文件的第 108 行:

/**
 * Step4 启动应用
 */
start();

找到 src/apis.ts 文件中的 start 方法:

export function start(opts: FrameworkConfiguration = {}) {
  frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
  const { prefetch, urlRerouteOnly = defaultUrlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;
  // 预加载所有子应用(默认开启)
  if (prefetch) {
    doPrefetchStrategy(microApps, prefetch, importEntryOpts);
  }
  // 根据当前浏览器环境判断是否是需要降级
  frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);
  // 启动应用(urlRerouteOnly = true:仅路由发生变换的时候才触发自定义 popstate 事件)
  startSingleSpa({ urlRerouteOnly });
  // 已经调用了 started 标志
  started = true;
  // start 调用准备完毕回调
  frameworkStartedDefer.resolve();
}

可以看到,这里主要调用了 single-spa 库的 startSingleSpa 方法启动应用,最后一行有执行

准备完毕回调:

// start 调用准备完毕回调
frameworkStartedDefer.resolve();

ok,其实当我们调用了 single-spa 库的 startSingleSpa 方法的时候, single-spa 就会根据当前路由去匹配需要渲染的子应用,会调用子应用的 app 方法。

还记得我们在“第二步(注册子应用)”中的 registerMicroApps 方法?

找到 src/apis.ts 文件的第 59 行:

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;
   // 注册应用
    registerApplication({
      name,
      app: async () => {
        // 修改页面状态为 loading
        loader(true);
        // 等待 start 方法的调用
        await frameworkStartedDefer.promise;
        // 加载当前子应用,获取子应用的 mount 方法
        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,
    });
  });
}

可以看到,又回到了这里的 app 方法了,接着又调用了 loadApp 方法去加载子应用。

小伙伴们可以先停下来回顾一下 qiankun 的创建和启动步骤,下节见啦~