qiankun微前端学习分享

1,101 阅读5分钟

简介

qiankun是一个基于single-spa二次封装的微前端实现库,官网:qiankun.umijs.org/zh

single-spa:一个非常基础的微前端框架,主要做了两件事:监听路由变化加载微应用(具体怎么加载微应用还需用户提供),维护微应用状态。

微前端架构应该有以下几个特点

  • 技术栈无关,主框架不限制接入应用的技术栈,微应用具备完全自主权
  • 独立开发、独立部署
  • 微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 独立运行时,每个微应用之间状态隔离,运行时状态不共享

single-spa则几乎不满足以上特点,qiankun基于single-spa二次封装,解决了single-spa的一些痛点和不足,确保主应用微应用都能做到技术栈无关,确保微应用真正具备独立开发独立运行的能力。

qiankun特性

  • 📦 基于 single-spa 封装,提供了更加开箱即用的 API。
  • 📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
  • 🛡​ 样式隔离,确保微应用之间样式互相不干扰。
  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
  • 🔌 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。

demo示例

主应用Vue,微应用Vue2,Vue3,react

image.png

image.png

主应用main-app

使用vue脚手架创建main-app,安装qiankun:

yarn add qiankun # 或者 npm i qiankun -S

image.png

主应用需提供一个容器dom来展示微应用

image.png

然后需注册微应用并启动,本示例在src/main.js文件中完成注册与启动,

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/antd.css';
import { registerMicroApps, start, setDefaultMountApp, initGlobalState} from 'qiankun';
Vue.config.productionTip = false

Vue.use(Antd);

new Vue({
  router,
  render: h => h(App),
}).$mount('#app')

// 注册微应用
registerMicroApps([
  {
    name: 'sub-vue2', // app应用名称,必须确保唯一
    entry: 'http://localhost:7777', //微应用入口微应用的访问地址,通过fetch获取微应用的html页面做处理
    container: '#sub-container', // 挂载微应用的容器节点
    activeRule: '/sub-vue2', // 微应用激活规则
    props: {}, //主应用需要传递给微应用的数据。可选
    // loading 状态发生变化时会调用的方法。
    loader(loading) {
      console.log(loading)
    }
  },
  {
    name: 'sub-vue3',
    entry: 'http://localhost:8888',
    container: '#sub-container',
    activeRule: '/sub-vue3',
  },
  {
    name: 'sub-react',
    entry: 'http://localhost:3000',
    container: '#sub-container',
    activeRule: '/sub-react',
  },
],
// 全局的微应用生命周期钩子
{
  // app: 当前load,mount的微应用注册信息
    beforeLoad(app) {
      console.log(app, 'beforeLoad', app)
    },
    beforeMount(app) {
      console.log(app, 'beforeMount')
    },
    afterMount(app) {
      console.log(app, 'afterMount')
    },
    beforeUnmount(app) {
      console.log(app, 'beforeUnmount')
    },
    afterUnmount(app) {
      console.log(app, 'afterUnmount')
    },
});

const mainAppInfo = {
  user: {
    name: 'xiaoduo',
    age: 18
  },

  manApp: 'qiankun'
}

// 定义全局状态,并返回通信方法,建议在主应用使用,微应用通过 props 获取通信方法。
const { onGlobalStateChange, setGlobalState } = initGlobalState(mainAppInfo);

onGlobalStateChange((value,prev) => {
  console.log('我是主应用。。。',value, prev);
})

// 全局状态变成了{
//   user:'xiaoduo',
//   mainApp: 'qiankun',
//   sss: 111
// }
setGlobalState({
  sss: 111,
  user: 'xiaoduo'
})


// 启动
start({
  // 是否开启预加载,配置为 true 则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
  // 配置为 'all' 则主应用 start 后即开始预加载所有微应用静态资源
  // 配置为 string[] 则会在第一个微应用 mounted 后开始加载数组内的微应用资源
  // 自定义函数prefetchStrategy,const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
  // apps所有的微应用,返回criticalAppNames:关键微应用,需立即预加载,minorAppsName:普通微应用,第一个微应用挂载以后预加载
  prefetch: (apps) => {
    console.log('prefetch函数',apps)
    return {
      criticalAppNames: [],
      minorAppsName: []
    }
  },

  // 沙箱,还可以传对象{ strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }
  // strictStyleIsolation,严格沙箱模式,采用shadowDom实现
  // experimentalStyleIsolation,实验性样式隔离,改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围
  // 默认值为true,不能保证主应用与微应用样式隔离,或者多实例场景的子应用样式隔离
  sandbox: true,
  // 默认为true,开启单例模式
  singular: true,

});

// 设置主应用启动后默认进入的微应用。默认启动sub-vue2
setDefaultMountApp('/sub-vue2');

Vue2微应用

通过 vue-cli 创建的 vue demo 应用,然后对 vue.config.js 和 main.js 做了一些更改,通过修改vue.config.js将项目打包成umd格式,并且设置跨越,因为主应用需要通过 fetch 去获取微应用引入的静态资源,所以必须要求这些静态资源支持跨域。在main.js中导出生命周期函数,主应用可获取并在合适的时间执行微应用导出的生命周期函数。

src/main.js

import './public-path';
import Vue from 'vue'
import App from './App.vue'
import routes from './router';
import VueRouter from 'vue-router';

Vue.config.productionTip = false

Vue.use(VueRouter)

let instance =null
let router =null

function render (props = {}) {
  const { container } = props

  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/sub-vue2/' : '/',
    mode: 'history',
    routes,
  });

  // console.log('vue2App', App)

  instance = new Vue({
    router,
    render: (h) => h(App)
  }).$mount(container ? container.querySelector('#sub-vue2') : '#sub-vue2')
}

if (!window.__POWERED_BY_QIANKUN__) {
  // 不在主应用中运行直接调用render
  render()
}

function storeTest(props) {
  // 第二个参数为true表示立即执行一次
  props.onGlobalStateChange &&
    props.onGlobalStateChange(
      (value, prev) => console.log(`w我是子应用vue2[onGlobalStateChange - ${props.name}]:`, value,'prev', prev),
      true,
    );

  // 子应用只能修改已存在的一及属性
  props.setGlobalState &&
    props.setGlobalState({
      user: {
        name: props.name,
      },
    });
}

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

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  storeTest(props)
  render(props)
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  instance.$destroy()
  instance.$el.innerHTML = ''
  instance = null
}

public-path.js

if (window.__POWERED_BY_QIANKUN__) {
  // 将公共路径设置成微应用的入口entry,这样访问静态资源才不会出错
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
  }

vue.config.js

const { defineConfig } = require('@vue/cli-service')
const { name } = require('./package.json')
module.exports = defineConfig({
  transpileDependencies: true,
  runtimeCompiler: true,
  devServer: {
    port: 7777,
    headers: {
      'Access-Control-Allow-Origin': '*'
    }
  },
  configureWebpack: {
    output: {
      // 把子应用打包成 umd 库格式
      library: `${name}-[name]`,
      libraryTarget: 'umd',
    }
  }
})

为什么打包成umd格式,可以兼容CommonJS,AMD模块规范,并且可以获取导出的结果

foncteer ypref def ine M der ine. pod.png

react和vue3应用不一一分析了

可参考官网栗子🌰github.com/umijs/qiank…

源码分析

从index.ts可以看到导出的关键函数

image.png

registerMicroApps

export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // Each app only needs to be registered once
  // 过滤掉已注册的app,每个app只注册一次
  const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));

  // 所有的微应用
  microApps = [...microApps, ...unregisteredApps];

  // 注册每一个未注册的app
  unregisteredApps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    // 调用 single-spa 的 registerApplication 方法注册微应用
    // 即apps.push(每一个微应用),并内置了重要属性status来记录每个子应用当前的状态,single-spa就像是一个状态管理机去管理各个应用的状态
    registerApplication({
      name,
      app: async () => {
        // 调用app注册信息的loader函数
        loader(true);
        await frameworkStartedDefer.promise;

        const { mount, ...otherMicroAppConfigs } = (
          // loadApp核心函数,返回app的bootstrap,mount,unmount,update生命周期函数
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();

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

loadApp

export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  const { entry, name: appName } = app;
  // app实例id
  const appInstanceId = genAppInstanceIdByName(appName);

  // 生成一个标记名称,然后使用该名称在浏览器performance中设置一个时间戳,可以用来度量微应用的加载时间
  const markName = `[qiankun] App ${appInstanceId} Loading`;
  if (process.env.NODE_ENV === 'development') {
    performanceMark(markName);
  }

  // 调用start函数传递的参数
  const {
    singular = false,
    sandbox = true,
    excludeAssetFilter,
    globalContext = window,
    ...importEntryOpts
  } = configuration;

  // get the entry html content and script executor
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

  // as single-spa load and bootstrap new app parallel with other apps unmounting
  // (see https://github.com/CanopyTax/single-spa/blob/master/src/navigation/reroute.js#L74)
  // we need wait to load the app until all apps are finishing unmount in singular mode
  if (await validateSingularMode(singular, app)) {
    await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
  }

  // 通过importEntry获取html内容,getDefaultTplWrapper给html的head标签替换成qiankun-head标签
  // 给微应用的html包裹一个div,div上有个属性data-name="微应用名称",实现scoped css
  const appContent = getDefaultTplWrapper(appInstanceId)(template);

  const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;

  // qiankun3.0会移除掉strictStyleIsolation配置,可以使用experimentalStyleIsolation配置
  if (process.env.NODE_ENV === 'development' && strictStyleIsolation) {
    console.warn(
      "[qiankun] strictStyleIsolation configuration will be removed in 3.0, pls don't depend on it or use experimentalStyleIsolation instead!",
    );
  }

  // sandbox.experimentalStyleIsolation
  const scopedCSS = isEnableScopedCSS(sandbox);
  // 创建一个div包裹appContent,并且对微应用样式做处理
  let initialAppWrapperElement: HTMLElement | null = createElement(
    appContent,
    strictStyleIsolation,
    scopedCSS,
    appInstanceId,
  );

  const initialContainer = 'container' in app ? app.container : undefined;
  const legacyRender = 'render' in app ? app.render : undefined;

  const render = getRender(appInstanceId, appContent, legacyRender);

  // 第一次加载设置应用可见区域 dom 结构
  // 确保每次应用加载前容器 dom 结构已经设置完毕
  // 将element插入到container中渲染
  render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');

  // 获取appWrapperDom节点
  const initialAppWrapperGetter = getAppWrapperGetter(
    appInstanceId,
    !!legacyRender,
    strictStyleIsolation,
    scopedCSS,
    () => initialAppWrapperElement,
  );

  // 运行时沙箱
  let global = globalContext;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
  const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
  const speedySandbox = typeof sandbox === 'object' && !!sandbox.speedy;
  let sandboxContainer;
  if (sandbox) {
    /**
    * 生成运行时沙箱,这个沙箱其实由两部分组成 => JS 沙箱(执行上下文)、样式沙箱
    * 沙箱返回 window 的代理对象 proxy 和 mount、unmount 两个方法
    * unmount 方法会让微应用失活,恢复被增强的原生方法,并记录一堆 rebuild 函数,这个函数是微应用卸载时希望自己被重新挂载时要做的一些事情,比如动态样式表重建(卸载时会缓存)
    * mount 方法会执行一些一些 patch 动作,恢复原生方法的增强功能,并执行 rebuild 函数,将微应用恢复到卸载时的状态,当然从初始化状态进入挂载状态就没有恢复一说了
    */
    sandboxContainer = createSandboxContainer(
      appInstanceId,
      // FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
      initialAppWrapperGetter,
      scopedCSS,
      useLooseSandbox,
      excludeAssetFilter,
      global,
      speedySandbox,
    );
    // 用沙箱的代理对象作为接下来使用的全局对象
    global = sandboxContainer.instance.proxy as typeof window;
    mountSandbox = sandboxContainer.mount;
    unmountSandbox = sandboxContainer.unmount;
  }

  // 合并用户传入的生命周期对象和系统内置的一些生命周期对象
  const {
    beforeUnmount = [],
    afterUnmount = [],
    afterMount = [],
    beforeMount = [],
    beforeLoad = [],
    // 比如系统的global.__POWERED_BY_QIANKUN__和global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__的赋值都在生命周期函数里面进行
  } = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));

  // 开始调用生命周期钩子,执行beforeLoad
  await execHooksChain(toArray(beforeLoad), app, global);

  // get the lifecycle hooks from module exports
  const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
    scopedGlobalVariables: speedySandbox ? trustedGlobals : [],
  });
  // 获取微应用导出的app勾子
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
    scriptExports,
    appName,
    global,
    sandboxContainer?.instance?.latestSetProp,
  );

  const { onGlobalStateChange, setGlobalState, offGlobalStateChange }: Record<string, CallableFunction> =
    getMicroAppStateActions(appInstanceId);

  // FIXME temporary way
  const syncAppWrapperElement2Sandbox = (element: HTMLElement | null) => (initialAppWrapperElement = element);

  // 最终返回的函数
  const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
    let appWrapperElement: HTMLElement | null;
    let appWrapperGetter: ReturnType<typeof getAppWrapperGetter>;

    // 调用该函数返回的结果,调用微应用中用户导出的勾子函数
    const parcelConfig: ParcelConfigObject = {
      name: appInstanceId,
      bootstrap,
      mount: [
        // 第一个不用管,衡量appmount挂载效率
        async () => {
          if (process.env.NODE_ENV === 'development') {
            const marks = performanceGetEntriesByName(markName, 'mark');
            // mark length is zero means the app is remounting
            if (marks && !marks.length) {
              performanceMark(markName);
            }
          }
        },
        // 单例模式需要等微应用卸载完成以后才能执行挂载任务,promise 会在微应用卸载完以后 resolve
        async () => {
          if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
            return prevAppUnmountedDeferred.promise;
          }

          return undefined;
        },
        // initial wrapper element before app mount/remount
        async () => {
          appWrapperElement = initialAppWrapperElement;
          appWrapperGetter = getAppWrapperGetter(
            appInstanceId,
            !!legacyRender,
            strictStyleIsolation,
            scopedCSS,
            () => appWrapperElement,
          );
        },
        // 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
        async () => {
          const useNewContainer = remountContainer !== initialContainer;
          if (useNewContainer || !appWrapperElement) {
            // element will be destroyed after unmounted, we need to recreate it if it not exist
            // or we try to remount into a new container
            appWrapperElement = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId);
            syncAppWrapperElement2Sandbox(appWrapperElement);
          }

          // 调用render函数渲染微应用
          render({ element: appWrapperElement, loading: true, container: remountContainer }, 'mounting');
        },
        // 运行时沙箱导出的mount
        mountSandbox,
        // exec the chain after rendering to keep the behavior with beforeLoad
        async () => execHooksChain(toArray(beforeMount), app, global),
        // 调用用户导出的mount函数,并将props传递到函数中
        async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
        // finish loading after app mounted
        async () => render({ element: appWrapperElement, loading: false, container: remountContainer }, 'mounted'),
        // 执行afterMount生命周期勾子函数
        async () => execHooksChain(toArray(afterMount), app, global),
        // initialize the unmount defer after app mounted and resolve the defer after it unmounted
        // 如果是单例模式始化这个 promise,并且在微应用卸载以后 resolve 这个 promise
        async () => {
          if (await validateSingularMode(singular, app)) {
            prevAppUnmountedDeferred = new Deferred<void>();
          }
        },
        // 计算挂载性能
        async () => {
          if (process.env.NODE_ENV === 'development') {
            const measureName = `[qiankun] App ${appInstanceId} Loading Consuming`;
            performanceMeasure(measureName, markName);
          }
        },
      ],
      unmount: [
        // 执行beforeUnmount生命周期勾子函数
        async () => execHooksChain(toArray(beforeUnmount), app, global),
        // 调用微应用导出的unmount函数,并传值
        async (props) => unmount({ ...props, container: appWrapperGetter() }),
        // unmount沙箱恢复原始运行时环境
        unmountSandbox,
        // 调用afterUnmount生命周期钩子函数
        async () => execHooksChain(toArray(afterUnmount), app, global),
        // 显示 loading 状态、移除微应用的状态监听、置空 element
        async () => {
          render({ element: null, loading: false, container: remountContainer }, 'unmounted');
          offGlobalStateChange(appInstanceId);
          // for gc
          appWrapperElement = null;
          syncAppWrapperElement2Sandbox(appWrapperElement);
        },
        // 单例模式下,卸载完毕resolve,其他微应用才可以去挂载
        async () => {
          if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
            prevAppUnmountedDeferred.resolve();
          }
        },
      ],
    };

    if (typeof update === 'function') {
      parcelConfig.update = update;
    }

    return parcelConfig;
  };

  return parcelConfigGetter;
}

start

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);
  }

  frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);

  // 调用single-spa中的start,原理主要是每次切换路由时,根据app当前status值将应用分为四大类
  // ToUnload 要被移除
  // ToUnmount 要被卸载
  // ToLoad 要被加载
  // ToMount 要被挂载
  // 分为四类之后去执行对应app通过loadApp返回的bootstrap、mount、unmount 这个几个生命周期并且更新app目前的status
  // toUnload主要是框架自己处理删除app的生命周期属性并且将app status置为NOT_LOADED
  startSingleSpa({ urlRerouteOnly });
  started = true;

  frameworkStartedDefer.resolve();
}

doPrefetchStrategy

// 执行预加载策略prefetchStrategy,即调用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;
    }
  }
}


function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  // 监听第一次挂载世界
  window.addEventListener('single-spa:first-mount', function listener() {
    // 筛选出未加载app
    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);
    }

    // 调用prefetch函数,prefetch函数主要是调用import-html-entry中的importEntry方法去预加载微应用资源
    notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));

    // 移除第一次挂载事件
    window.removeEventListener('single-spa:first-mount', listener);
  });
}

// 直接去给每个app调用prefetch函数
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));
}