微前端乾坤方案

微前端乾坤方案


了解乾坤

官方文档

介绍

qiankun 是一个基于 single-spa微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

qiankun 的核心设计理念

  • 🥄 简单

    由于主应用微应用都能做到技术栈无关,qiankun 对于用户而言只是一个类似 jQuery 的库,你需要调用几个 qiankun 的 API 即可完成应用的微前端改造。同时由于 qiankun 的 HTML entry 及沙箱的设计,使得微应用的接入像使用 iframe 一样简单。

  • 🍡 解耦/技术栈无关

    微前端的核心目标是将巨石应用拆解成若干可以自治的松耦合微应用,而 qiankun 的诸多设计均是秉持这一原则,如 HTML entry、沙箱、应用间通信等。这样才能确保微应用真正具备 独立开发、独立运行 的能力。

特性

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

qiankun 在 single-spa 的基础上都增加了啥?

以下是 qiankun 提供的特性:

  • 实现了子应用的加载,在原有 single-spa 的 JS Entry 基础上再提供了 HTML Entry
  • 样式和 JS 隔离
  • 更多的生命周期:beforeMount, afterMount, beforeUnmount, afterUnmount
  • 子应用预加载
  • umi 插件
  • 全局状态管理
  • 全局错误处理



qiankun 的使用

主应用

0、安装 qiankun
$ yarn add qiankun # 或者 npm i qiankun -S
1、引入
import { registerMicroApps, start } from 'qiankun';
2-1、注册子应用,并启动
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',
  },
]);

// 启动 qiankun
start();

备注

自动注册模式:当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。
主应用不限技术栈,只需要提供一个容器 DOM,然后注册微应用并 start 即可。

2-2、手动加载子应用

如果微应用不是直接跟路由关联的时候,你也可以选择手动加载微应用。

import { loadMicroApp } from 'qiankun';

loadMicroApp({
  name: 'app',
  entry: '//localhost:7100',
  container: '#yourContainer',
});



子应用改造

微应用不需要额外安装任何其他依赖即可接入到 qiankun 主应用。

1、前提

子应用的资源和接口的请求都在主域名发起,所以会有跨域问题,子应用必须做cors 设置

// webpack

const { name } = require('./package');

module.exports = {
  devServer: (config) => {
    // 因为内部请求都是 fetch 来请求资源,所以子应用必须允许跨域
    config.headers = {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Credentials': true,
      'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type',
      'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS',
      'Content-Type': 'application/json; charset=utf-8',
    };
    return config;
  },
};

2、生命周期改造

微应用需要在应用的入口文件 (通常就是你配置的 webpack 的 entry js) 导出 bootstrapmountunmountupdate 四个生命周期钩子,以供主应用在适当的时机调用。

具体操作可以参考下面示例
步骤一:在 src 目录新增 public-path.js,用于修改运行时的 publicPath
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

备注

什么是运行时的 publicPath ?
运行时的 publicPath 和构建时的 publicPath 是不同的,两者不能等价替代。

步骤二:修改入口文件

主要改动:

  1. 引入 public-path.js
  2. export 生命周期函数
    • 将子应用路由的创建、实例的创建渲染挂载到mount函数上
    • 将实例的销毁挂载到unmount
// vue 2

// 入口文件 `main.js` 修改,为了避免根 id `#app` 与其他的 DOM 冲突,需要限制查找范围。

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

Vue.config.productionTip = false;

let router = null;
let instance = null;

function render(props = {}) {
  const { container } = props;
  router = new VueRouter({
    // histroy模式的路由需要设置base。app-vue 根据项目名称来定
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
    mode: 'history',
    // hash模式则不需要设置base
    routes,
  });

  instance = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

export async function mount(props) {
  console.log('[vue] props from main framework', props);
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
// react 16

// 以 `create react app` 生成的 `react 16` 项目为例,搭配 `react-router-dom` 5.x。
// 入口文件 `index.js` 修改,为了避免根 id `#root` 与其他的 DOM 冲突,需要限制查找范围。
// 这里需要特别注意的是,通过 ReactDOM.render 挂载子应用时,需要保证每次子应用加载都应使用一个新的路由实例。

import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

function render(props) {
  const { container } = props;
  ReactDOM.render(<App />, container ? container.querySelector('#root') : document.querySelector('#root'));
}

if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

export async function bootstrap() {
  console.log('[react16] react app bootstraped');
}

export async function mount(props) {
  console.log('[react16] props from main framework', props);
  render(props);
}

export async function unmount(props) {
  const { container } = props;
  ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}

备注

  • qiankun 基于 single-spa,所以你可以在这里找到更多关于微应用生命周期相关的文档说明。
  • 无 webpack 等构建工具的应用接入方式请见这里

注意点

  • 容器名

    • 修改 index.html 中项目初始化容器的根 id,不要使用 #app#root,避免与其他的项目冲突,建议换成 项目name 的驼峰写法
  • 微应用建议使用 history 路由模式。

  • 路由模式为 history 时,同时需要设置路由 base,值和它的 activeRule 是一样的:

    • vue:

      router = new VueRouter({
        mode: 'history',
        base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
      });
      
    • react:

      <BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>
      

3、构建工具配置

微应用分为有 webpack 构建和无 webpack 构建项目。

注意
qiankun v2 及以下版本只支持 webpack,并不支持 vite!!!

下面以 webpack 为构建工具的微应用为例(主要是指 Vue、React)的配置说明

配置点说明

构建工具的配置主要有两个:

  1. 允许跨域
  2. 打包成 umd 格式

为什么要打包成 umd 格式呢?是为了让 qiankun 拿到其 export 的生命周期函数。我们可以看下其打包后的 app.js 就知道了:
在这里插入图片描述

配置示例

微应用的打包工具需要增加如下配置:

react

修改 webpack 配置

安装插件 @rescripts/cli,当然也可以选择其他的插件,例如 react-app-rewired

npm i -D @rescripts/cli

根目录新增 .rescriptsrc.js

// webpack

const { name } = require('./package');

module.exports = {
  webpack: (config) => {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    // webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    config.output.globalObject = 'window';

    return config;
  },

  devServer: (_) => {
    const config = _;

    config.headers = {
      'Access-Control-Allow-Origin': '*',
    };
    config.historyApiFallback = true;
    config.hot = false;
    config.watchContentBase = false;
    config.liveReload = false;

    return config;
  },
};

修改 package.json

-   "start": "react-scripts start",
+   "start": "rescripts start",
-   "build": "react-scripts build",
+   "build": "rescripts build",
-   "test": "react-scripts test",
+   "test": "rescripts test",
-   "eject": "react-scripts eject"
vue
// vue.config.js

const { name } = require('./package');

module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Credentials': true,
      'Access-Control-Allow-Origin': req.headers.origin || '*',
      'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type',
      'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS',
      'Content-Type': 'application/json; charset=utf-8',
    },
  },
  // 自定义webpack配置
  configureWebpack: {
    output: {
      library: `${name}-[name]`, // 微应用的包名,这里与主应用中注册的微应用名称一致
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式,可以在 AMD 或 CommonJS 的 require 访问。
      // webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
      jsonpFunction: `webpackJsonp_${name}`, // webpack 用来异步加载 chunk 的 JSONP 函数。
    },
  },
};

注意

这个 name 默认从 package.json 获取,可以自定义,只要和父项目注册时的 name 保持一致即可。qiankun 拿这三个生命周期,是根据注册应用时,你给的 name 值,name 不一致则会导致拿不到生命周期函数。

webpack 不同版本的配置

webpack v5:

const packageName = require('./package.json').name;

module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: 'umd',
    chunkLoadingGlobal: `webpackJsonp_${packageName}`,
  },
};

webpack v4:

const packageName = require('./package.json').name;

module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: 'umd',
    // webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
    jsonpFunction: `webpackJsonp_${packageName}`,
  },
};

备注

更多相关配置介绍可以查看 webpack 相关文档





子项目开发的一些注意事项

js 相关
给 body 、 document 等绑定的事件,请在 unmount 周期清除

js 沙箱只劫持了 window.addEventListener,而使用了 document.body.addEventListener 或者 document.body.onClick 添加的事件并不会被沙箱移除,会对其他的页面产生影响,因此请在 unmount 生命周期清除绑定的事件。


css 相关
避免 css 污染

组件内样式的 css-scoped 是必须的。

对于一些插入到 body 的弹窗,无法使用 scoped,请不要直接使用原 class 修改样式,请添加自己的 class,来修改样式。

.el-dialog {
  /* 不推荐使用组件原有的class */
}

.my-el-dialog {
  /* 推荐使用自定义组件的class */
}
谨慎使用 position:fixed

在父项目中,浮动定位未必准确,应尽量避免使用,确有相对于浏览器窗口定位需求,可以用 position: sticky,但是会有兼容性问题(IE 不支持)。如果定位使用的是 bottom 和 right,则问题不大。

还有个办法,位置可以写成动态绑定 style 的形式:

<div :style="{ top: isQiankun ? '10px' : '0'}"></div>

静态资源 相关
所有的资源(图片/音视频等)都应该放到 src 目录下,不要放在 public 或者 static

资源放 src 目录,会经过 webpack 处理,能统一注入 publicPath。否则在主项目中会 404。

参考:vue-cli3 的官方文档介绍:何时使用-public-文件夹


第三方库 相关
请给 axios 实例添加拦截器,而不是 axios 对象

后续会考虑子项目共享公共插件,这时就需要避免公共插件的污染

// 正确做法:给 axios 实例添加拦截器
const instance = axios.create();
instance.interceptors.request.use(function () {
  // ...
});

// 错误用法:直接给 axios 对象添加拦截器
axios.interceptors.request.use(function () {
  // ...
});




qiankun 功能介绍

运行模式

乾坤只有一种运行模式:单例模式

在微前端框架中,子应用会随着主应用页面的打开和关闭反复的激活和销毁(单例模式:生命周期模式)。

单例模式

子应用页面如果切走,会调用 unmount 销毁子应用当前实例,子应用页面如果切换回来,会调用 mount 渲染子应用新的实例。

在单例式下,改变 url 子应用的路由会发生跳转到对应路由。



生命周期

微应用需要在应用的入口文件导出 bootstrapmountunmountupdate 四个生命周期钩子,以供主应用在适当的时机调用。

注意

💡 qiankun 生命周期函数都必须要 export 暴露
💡 qiankun 生命周期函数都必须是 Promise函数,使用 async 会返回一个 Promise 对象

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

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

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

/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update(props) {
  console.log('update props', props);
}


通讯方式

项目之间的不要有太多的数据依赖,毕竟项目还是要独立运行的。通信操作需要判断是否 qiankun 模式,做兼容处理。

props 通信

如果父子应用都是vue项目,通过 props 传递父项目的 Vuex store,可以实现响应式;但假如子项目是不同的技术栈(jQuery/react/angular),这是就不能很好的监听到数据的变化了。

注意

vue 项目之间数据传递还是使用共享父组件的 Vuex 比较方便,与其他技术栈的项目之间的通信使用 qiankun 提供的 initGlobalState

通过 vuex/pinia 实现通信

第一步:在主应用中创建一个 store

// src/store/index.ts

const personModule = {
  state: {
    id: 0,
    name: '',
    age: 0,
  },
  mutations: {
    setId(state: Object, id: number) {
      state.id = id;
    },
    setName(state: Object, name: string) {
      state.name = name;
    },
    setAge(state: Object, age: number) {
      state.age = age;
    },
  },
  actions: {
    setId(context: Object, id: number) {
      context.commit('setId', id);
    },
    setName(context: Object, name: string) {
      context.commit('setName', name);
    },
    setAge(context: Object, age: number) {
      context.commit('setAge', age);
    },
  },
};

export default new Vuex.Store({
  state: {},
  getters: {},
  mutations: {},
  actions: {},
  modules: {
    personModule,
  },
});

第二步:在 main.js 文件中引入这个 store,并通过 RegistrableAppprops 传给子应用

import store from './store/index';
import { registerMicroApps, start, initGlobalState } from 'qiankun';

registerMicroApps([
  {
    name: 'vue3-element-admin',
    entry: '//localhost:3001',
    container: '#container',
    activeRule: '/#/vue3-element-admin',
    props: {
      store,
    },
  },
]);

第三步:在子应用的 main.js 文件中接收这个 store,并通过 createApp()的第二个参数 props,将主应用的这个 store 传递到子应用的根组件 App.vue 中。

renderWithQiankun({
  mount(props) {
    appMount(props);
  },
  //、、、
});

function appMount(props) {
  app = createApp(App, {
    parentStore: props.store,
  });
}

第四步:在子应用的根组件 App.vue 文件中,通过 props 接收这个 store,并通过 provite()将这个 store 注入到所有子组件中

export default defineComponent({
  props: {
    parentStore: Object,
  },
  setup(props) {
    const { lang } = useLang();
    provide('parentStore', props.parentStore);
    //、、、
  },
});

第五步:在需要的子组件中,通过 inject()接收这个 store,并根据需要操作这个 store

<script setup>
  import { onMounted, onUnmounted, onActivated, onDeactivated, inject } from 'vue';
  const parentStore = inject('parentStore');
  const personModule = parentStore.state.personModule;
  const changeParentStore = () => {
    parentStore?.dispatch('setId', '11');
    parentStore?.dispatch('setName', 'lily');
    parentStore?.dispatch('setAge', 18);
  };
</script>
通过 vuex 创建的 store 也可以在 react 子应用中使用

子项目 Create-React-App + webpack

第一步,在入口处接收 store, 然后传递给根组件

let root;
function render(props) {
  const rootProps = {
    parentStore: props.store,
  };
  root = ReactDOM.createRoot(props?.container ? props.container.querySelector('#root') : document.querySelector('#root'));
  root.render(
    <React.StrictMode>
      {/* 将store传递给根组件 */}
      <App {...rootProps} />
    </React.StrictMode>
  );
}

export async function mount(props) {
  render(props);
}

第二步:在根组件或其他子组件中使用 store

function App(props) {
  const parentStore = props?.parentStore;
  const personModule = props?.parentStore?.state?.personModule;
  const changeParentStore = () => {
    parentStore.commit('setId', 11);
    parentStore.commit('setName', 'Lucy');
    parentStore.commit('setAge', 18);
    forceRemount();
  };

  const [key, setKey] = useState(0);
  // 当需要强制重新渲染时,调用这个函数
  const forceRemount = () => {
    setKey((currentKey) => currentKey + 1);
  };
  return (
    <div>
      <div>展示主应用vue传过来的store:{personModule?.id}</div>
      <div>展示主应用vue传过来的store:{personModule?.name}</div>
      <div>展示主应用vue传过来的store:{personModule?.age}</div>
      <div>
        <Button type="primary" onClick={changeParentStore}>
          修改主应用的store
        </Button>
      </div>
    </div>
  );
}

actions 通信(initGlobalState)

这种通信方式比较适合业务划分清晰,应用间通信较少的微前端应用场景。一般来说这种通讯就可以满足大部分的应用场景需求。

qiakun 提供了一个 api initGlobalState 来共享数据。主项目初始化之后,子项目可以监听到这个数据的变化,也能提交这个数据。

actions 通信原理

qiankun 内部提供了 initGlobalState 方法用于注册 MicroAppStateActions 实例用于通信,该实例有三个方法,分别是:

  • onGlobalStateChange:注册 观察者 函数 - 响应 globalState 变化,在 globalState 发生改变时触发该 观察者 函数。
  • offGlobalStateChange:取消 观察者 函数 - 该实例不再响应 globalState 变化。
  • setGlobalState:设置 globalState - 设置新的值时,内部将执行 浅检查,如果检查到 globalState 发生改变则触发通知,通知到所有的 观察者 函数。

我们来画一张图来帮助大家理解(见下图)
在这里插入图片描述我们从上图可以看出,我们可以先注册 观察者 到观察者池中,然后通过修改 globalState 可以触发所有的 观察者 函数,从而达到通信的效果。

actions 通信示例
主应用

父子应用通过onGlobalStateChange这个方法进行通信,这其实是一个发布-订阅的设计模式。

// main/src/main.js
import { initGlobalState } from 'qiankun';
// 初始化 state
const initialState = {
  user: {} // 用户信息
};
const actions = initGlobalState(initialState);
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
子应用

子应用直接使用 mount 生命周期的 propsonGlobalStateChangesetGlobalState

export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
  });
  props.setGlobalState(state);
}

eventBus 通信

第一步:在主应用中创建一个 EventBus

// src/eventBus/index.ts

import mitt from 'mitt';
const eventBus = mitt();
export default eventBus;

第二步:在 main.js 文件中引入这个 eventBus,并通过 RegistrableAppprops 传给子应用(类似 Vuex)

import eventBus from './eventBus/index';

registerMicroApps([
  {
    name: 'vue3-element-admin',
    entry: '//localhost:3001',
    container: '#container',
    activeRule: '/#/vue3-element-admin',
    props: {
      store,
      eventBus,
    },
  },
]);

第三步:在子应用的 main.js 文件中接收这个 store,并通过 createApp()的第二个参数 props,将主应用的这个 eventBus 传递到子应用的根组件 App.vue 中。

renderWithQiankun({
  mount(props) {
    appMount(props);
  },
});

let app;
function appMount(props) {
  app = createApp(App, {
    parentStore: props?.store,
    parentEventBus: props?.eventBus,
  });
  //...
}

第四步:在子应用的根组件 App.vue 文件中,通过 props 接收这个 eventBus,并通过 provite()将这个 eventBus 注入到所有子组件中

setup(props) {
  provide('parentEventBus', props.parentEventBus)
}

第五步:在主应用的 App.vue 文件中,引入 eventBus,订阅一个 setUserName 事件

import eventBus from './eventBus/index';

const userName: any = ref('');
eventBus.on('setUserName', (name) => {
  userName.value = name;
});

第六步:在子应用需要的子组件中,触发 setUserName 事件

const userName = ref('');
const parentEventBus = inject('parentEventBus');
const changeUserName = () => {
  parentEventBus?.emit('setUserName', userName.value);
};

注意:如果订阅事件的组件销毁了,则通信失败



注册方式

自动注册:使用 registerMicroApps + start 自动注册微应用,路由变化加载微应用

当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑。所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。

  1. 加载应用,创建子应用实例,渲染。
  2. 切到其他子应用后切回,会重新创建新的子应用实例并渲染。
  3. 之前的子应用实例直接不要了,即使你没有手动销毁实例。
  4. 采用这种模式的话 一定要在子应用暴露的 unmount 钩子里手动销毁实例,不然会导致内存泄漏。

注意

💡 由于 registerMicroApps 的特性,会导致路由的 keepAlive 失效

自动注册演示
// 主应用/scr/main.js
import { registerMicroApps, start } from 'qiankun';

// 1. 获取微应用配置
const MICRO_CONFIG = [
  {
    name: 'vue app', // 应用的名字 必填
    entry: '//localhost:7100', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
    container: '#yourContainer', // 挂载具体容器 ID
    // 3. 根据路由匹配,激活的子应用
    activeRule: '/yourActiveRule',
    props: {
      xxxx: '/', // 下发给子应用
    },
  },
];

// 2. 注册微应用
registerMicroApps(MICRO_CONFIG);

start(); // 启动微服务
手动注册:使用 loadMicroApp 手动注册微应用

如果微应用不是直接跟路由关联的时候,你可以选择手动加载微应用,使用会更加灵活。

  1. 每个子应用都有一个的实例 ID,reload 时会复用之前的实例
  2. 如果需要卸载则需要手动卸载 xxxMicroApp.unmount()
手动注册演示
// 任意页面都可以注册

import { loadMicroApp } from 'qiankun';

// 获取应用配置并手动注册,注册后返回注册对象
this.microApp = loadMicroApp({
  name: 'vue app', // 应用的名字 必填
  entry: '//localhost:7100', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
  container: '#yourContainer', // 挂载具体容器 ID
  activeRule: '/yourActiveRule', // 根据路由 激活的子应用
  props: {
    xxxx: '/', // 下发给子应用
  },
});

this.microApp.unmount(); // 手动销毁~


预加载

预先请求子应用的 HTML、JS、CSS 等静态资源,等切换子应用时,可以直接从缓存中读取这些静态资源,从而加快渲染子应用。

自动注册下的预加载(registerMicroApps + start)

主应用使用 api start,并传入prefetch属性,通过不同的值控制需要预加载的子应用。

API 已经解析得很清楚了:

  • prefetch - boolean | 'all' | string[] | (( apps: RegistrableApp[] ) => { criticalAppNames: string[]; minorAppsName: string[] }) - 可选,是否开启预加载,默认为 true。
    • 配置为 true 则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
    • 配置为 'all' 则主应用 start 后即开始预加载所有微应用静态资源
    • 配置为 string[] 则会在第一个微应用 mounted 后开始加载数组内的微应用资源
    • 配置为 function 则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)

示例:

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([...AppsConfig]);

start({ prefetch: 'all' }); // 配置预加载

注意

当注册的子应用比较多的时候,使用'all'就要注意了。因为当所有子应用一起进行预加载的时候,首次加载时会出现较长的白屏时间。如果确实需要同时预加载多个子应用,建议使用function自定义模式。

手动注册下的预加载(loadMicroApps)

原生使用:

import { prefetchApps } from 'qiankun';

export const MICRO_PREFETCH_APPS = [
  { name: 'vue-child', entry: '//localhost:7101/' },
  { name: 'vue-app', entry: '//localhost:8081/' },
];

prefetchApps(MICRO_PREFETCH_APPS);

改进:

为了日后维护的便携性,新增 isPreload 字段是否开启预加载,这样微应用的配置信息都可以统一维护。

// 基座/src/const/micro/application-list.js

export const MICRO_CONFIG = [
  {
    name: 'you app name', // 应用的名字
    entry: '//localhost:7286/', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
    container: '#yuo-container-container', // 容器id
    activeRule: '/your-prefix', // 根据路由激活的路径
    isPreload: true, // !! 是否开启预加载 !!
  },
];
import { prefetchApps } from 'qiankun';
import { MICRO_CONFIG } from '@/const/micro/application-list.js';

// 获取配置的 isPreload 字段,并生成加载对应的格式
const MICRO_PREFETCH_APPS = MICRO_CONFIG.reduce((total, { isPreload, name, entry }) => (isPreload ? [...total, { name, entry }] : total), []);
// 预加载应用
prefetchApps(MICRO_PREFETCH_APPS);




功能拓展

keep-alive 需求

子项目 keep-alive 其实就是想在子项目切换时不卸载掉,仅仅是样式上的隐藏(display: none),这样下次打开就会更快。

keep-alive 需要谨慎使用,同时加载并运行多个子项目,这会增加 js/css 污染的风险。

虽然 qiankun 的 proxy 方式的 js 沙箱可以完美支持多项目运行,但是别忘了 IE11 这个毒瘤,IE11 下沙箱使用的是 diff 方法,这会让多个项目共用一个沙箱,这等于没有沙箱。路由之间也可能存在冲突。

多项目运行的 css沙箱 也没有特别好的处理方式,目前比较靠谱的是 class 命名空间 + css-scoped

实现 keep-alive 需求有多种方式,推荐使用方案一。

方案一:借助 loadMicroApp 函数【推荐】

尝试使用其已有 API 来实现 keep-alive 需求:借助 loadMicroApp 函数来实现手动加载和卸载子项目。
一般有 keep-alive 需求的就是 tab 页,新增一个 tab 页时就加载这个子项目,关闭 tab 页时卸载这个子项目。

主项目 APP.vue 文件:

<template>
  <div id="app">
    <header>
      <router-link to="/app-vue-hash/">app-vue-hash</router-link>
      <router-link to="/app-vue-history/">app-vue-history</router-link>
      <router-link to="/about">about</router-link>
    </header>
    <div id="appContainer1" v-show="$route.path.startsWith('/app-vue-hash/')"></div>
    <div id="appContainer2" v-show="$route.path.startsWith('/app-vue-history/')"></div>
    <router-view></router-view>
  </div>
</template>

<script>
  import { loadMicroApp } from 'qiankun';

  const apps = [
    {
      name: 'app-vue-hash',
      entry: 'http://localhost:1111',
      container: '#appContainer1',
      props: { data: { store, router } },
    },
    {
      name: 'app-vue-history',
      entry: 'http://localhost:2222',
      container: '#appContainer2',
      props: { data: store },
    },
  ];

  export default {
    mounted() {
      // 优先加载当前的子项目
      const path = this.$route.path;
      const currentAppIndex = apps.findIndex((item) => path.includes(item.name));
      if (currentAppIndex !== -1) {
        const currApp = apps.splice(currentAppIndex, 1)[0];
        apps.unshift(currApp);
      }
      // loadMicroApp 返回值是 app 的生命周期函数数组
      const loadApps = apps.map((item) => loadMicroApp(item));

      // 当 tab 页关闭时,调用 loadApps 中 app 的 unmount 函数即可
    },
  };
</script>

切换子项目,子项目的 DOM 没有被清空:
在这里插入图片描述

方案二:缓存子项目的 dom【子应用接入成本增加】

方案来源:qiankun 仓库的 issue

这个方案比较麻烦,大致原理就是缓存 vue 实例的 dom ,子项目的入口文件修改:

let instance = null;
let router = null;

function render() {
  // 这里必须要new一个新的路由实例,否则无法响应URL的变化。
  router = new VueRouter({
    mode: 'hash',
    base: process.env.BASE_URL,
    routes,
  });

  // 关键代码 start
  if (window.__POWERED_BY_QIANKUN__ && window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__) {
    const cachedInstance = window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__;

    // 从最初的Vue实例上获得_vnode
    const cachedNode =
      // (cachedInstance.cachedInstance && cachedInstance.cachedInstance._vnode) ||
      cachedInstance._vnode;

    // 让当前路由在最初的Vue实例上可用
    router.apps.push(...cachedInstance.catchRoute.apps);

    instance = new Vue({
      router,
      store,
      render: () => cachedNode,
    });

    // 缓存最初的Vue实例
    instance.cachedInstance = cachedInstance;

    router.onReady(() => {
      const { path } = router.currentRoute;
      const { path: oldPath } = cachedInstance.$router.currentRoute;
      // 当前路由和上一次卸载时不一致,则切换至新路由
      if (path !== oldPath) {
        cachedInstance.$router.push(path);
      }
    });
    instance.$mount('#appVueHash');
  }
  // 关键代码 end
  else {
    console.log('正常实例化');
    // 正常实例化
    instance = new Vue({
      router,
      store,
      render: (h) => h(App),
    }).$mount('#appVueHash');
  }
}

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

export async function mount(props) {
  console.log('[vue] props from main framework', props);
  render();
}

export async function unmount() {
  console.log('[vue] vue app unmount');
  // 关键代码 start
  const cachedInstance = instance.cachedInstance || instance;
  window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__ = cachedInstance;
  const cachedNode = cachedInstance._vnode;
  if (!cachedNode.data.keepAlive) cachedNode.data.keepAlive = true;
  cachedInstance.catchRoute = {
    apps: [...instance.$router.apps],
  };
  // 关键代码 end
  instance.$destroy();
  router = null;
  instance.$router.apps = [];
}
方案三:子应用不要销毁实例

实现起来也比较简单: 子系统卸载不清空容器里的 dom 也不卸载 vue 实例,用 display: none 来隐藏。子系统加载时先判断下容器有无内容,已存在就不重新插入子系统的 HTML。

主要分 4 个步骤:

  1. 修改子项目的 render 函数,不重复实例化 vue
function render() {
  if (!instance) {
    router = new VueRouter({
      base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history' : '/',
      mode: 'history',
      routes,
    });
    instance = new Vue({
      router,
      store,
      render: (h) => h(App),
    }).$mount('#appVueHistory');
  }
}
  1. 修改子项目的 unmount 生命周期,子项目 unmount 时不卸载 vue 实例
export async function unmount() {
  // instance.$destroy();
  // instance = null;
  // router = null;
}
  1. 修改主项目中子项目的注册及容器,每个子项目单独放一个容器(当然你也可以放到一个容器,处理起来麻烦点)。然后就是切换子系统隐藏其他的
<div id="appContainer1" v-show="$route.path && $route.path.startsWith('/app-vue-hash')"></div>
<div id="appContainer2" v-show="$route.path && $route.path.startsWith('/app-vue-history')"></div>
registerMicroApps([
  {
    name: 'app-vue-hash',
    entry: 'http://localhost:1111',
    container: '#appContainer1',
    activeRule: '/app-vue-hash',
    props: {
      data: {
        store,
        router,
      },
    },
  },
  {
    name: 'app-vue-history',
    entry: 'http://localhost:2222',
    container: '#appContainer2',
    activeRule: '/app-vue-history',
    props: {
      data: store,
    },
  },
]);
疑问
  • 使用 loadMicroApp 手动注册子应用,子应用的 unmount 生命周期还会不会执行?

    • 不会。在 qiankun 中,手动注册子应用会绕过 qiankun 框架的子应用生命周期管理,因此子应用的 unmount 生命周期也不会被触发。手动注册子应用时,需要手动调用子应用的 mount 和 unmount 方法。
  • qiankun 实现应用保活,可不可以在父应用实现,而不用修改子应用?

    • 使用 loadMicroApp 手动注册子应用,再在需要卸载子应用的地方,在父应用中 unmount()
小结

在qiankun中,使用 loadMicroApp 手动注册子应用,相当于是一种保活模式,子应用卸载时,需要父应用手动卸载。


应用共享依赖

假如主应用和子应用都用到了同一个版本的 Vue / Vuex / Vue-Router 等,主应用加载一遍之后,子应用又加载一遍,比较浪费资源。

依赖复用有两种情况:

  • 子应用之间的复用依赖
  • 子应用复用主应用的依赖
子应用之间的依赖复用【不推荐,子应用藕合了】

这个很好办,你只需要保证依赖的 url 一致即可。比如说子应用 A 使用了 vue,子应用 B 也使用了同版本的 vue,如果两个应用使用了同一份 CND 文件,加载时会先从缓存读取:

const fetchScript = (scriptUrl) => scriptCache[scriptUrl] || (scriptCache[scriptUrl] = fetch(scriptUrl).then((response) => response.text()));
子应用复用主应用的依赖

子应用要想复用公共依赖,前提条件是子应用必须配置 externals ,这样依赖就不会打包进 chunk-vendors.js ,才能复用已有的公共依赖。

  1. 子应用配置 externals

  2. 子应用通过 index.html 外链CDN静态资源,并且在 index.html 中公共依赖的 scriptlink 标签加上 ignore 属性。这样 qiankun 便不会再去加载这个 js/css,而子应用独立运行,这些 js/css 仍能被加载。

    <link ignore rel="stylesheet" href="//cnd.com/antd.css" />
    <script ignore src="//cnd.com/antd.js"></script>
    

需要注意的是:主应用使用 externals 后,子应用可以复用它的依赖,但是不复用依赖的子应用会报错。
qiankun 官网,有写这个问题:Vue Router 报错 Uncaught TypeError: Cannot redefine property: $router

按需引用公共依赖

巨无霸应用的公共依赖和公共函数被太多的页面使用,导致升级和改动困难,使用微前端可以让各个子应用独立拥有自己的依赖,互不干扰。而我们想要复用公共依赖,这与微前端的理念是相悖的。

解决方案:父应用提供公共依赖,子应用可以自由选择用或者不用。

子应用配置了 externals

按需引入公共依赖,有两个层面:

  1. 没有使用到的依赖不加载
  2. 大插件只加载需要的部分,例如 UI 组件库的按需加载、echarts / lodash 的按需加载。

webpack 的 externals 是支持大插件的按需引入的:

subtract: {
  root: ['math', 'subtract'];
}

subtract 可以通过全局 math 对象下的属性 subtract 访问(例如 window[‘math’][‘subtract’])。

子应用不配置 externals 时的报错,并对应的解决方案

子应用不配置 externals 时,应用中的 Vue 是全局变量,但是 不属于 window ,所以子应用独立运行时这个 if 判断不生效:

if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter);
}

而 qiankun 在运行这个子应用时,先找子应用的 window,再找父应用的 window,然后在 window 上找到了 vue。if 判断会生效,然后 window 上父应用的 Vue 安装了 VueRouter,子应用自己的全局的 vue 没有安装,导致报错。

解决方案一:加载子应用之前处理下全局变量

假设 app-vue-hash 子应用复用主应用依赖,app-vue-history 子应用不复用主应用依赖。

在主应用中注册子应用时新增如下代码解决:

registerMicroApps(apps, {
  beforeLoad(app) {
    if (app.name === 'app-vue-hash') {
      // 如果直接在 app-vue-hash 子应用刷新页面,此时 window.Vue2 是 undefined
      // 所以先判断下 window.Vue2 是否存在
      if (window.Vue2) {
        window.Vue = window.Vue2;
        window.Vue2 = undefined;
      }
    } else if (app.name === 'app-vue-history') {
      window.Vue2 = window.Vue;
      window.Vue = undefined;
    }
  },
});
解决方案二:通过 props 传递依赖

上面的兼容性问题,可以考虑 主应用通过 props 把依赖传给子应用,不配置 externals 来解决。

主应用注册时,将依赖传递给子应用(省略了一些不必要的代码):

import VueRouter from 'vue-router';
registerMicroApps([
  {
    name: 'app-vue-hash',
    entry: 'http://localhost:1111',
    container: '#appContainer',
    activeRule: '/app-vue-hash',
    props: { data: { VueRouter } },
  },
]);

子应用配置 externals 并且外链依赖加上 ignore 属性:

function render(parent = {}) {
  if (!instance) {
    // 当它独立运行时,使用自己的外链依赖 window.VueRoute
    const VueRouter = parent.VueRouter || window.VueRouter;
    Vue.use(VueRouter);
    router = new VueRouter({
      routes,
    });
    instance = new Vue({
      router,
      store,
      render: (h) => h(App),
    }).$mount('#appVueHash');
  }
}

export async function mount(props) {
  render(props.data);
}
解决方案三:修改主应用和子应用的依赖名称

主应用和子应用复用的依赖改个名称,这样就不会影响其他不复用依赖的子应用。具体改的有:

  1. 修改子应用和主应用的 externals 配置,修改依赖的名称,不使用 Vue
externals: {
  'vue': 'Vue2' , // 这个的意思是告诉 webpack 去把 winodw.Vue2 当做 vue 这个模块
}
  1. 在主应用导入外链 vue.js 之后,将名称改成 Vue2
<script src="https://unpkg.com/vue@2.5.16/dist/vue.runtime.min.js"></script>
<script>
  window.Vue2 = winow.Vue;
  window.Vue = undefined;
</script>

应用嵌套

应用嵌套即是存在父子孙的关系,子和孙存在父子关系,父和子又存在父子关系,这样的微前端嵌套结构(父 --> 子 --> 孙)

当前以父子孙应用路由都是使用的 history 模式为例说明

方案一:子项目自己运行一个 qiankun 实例【推荐】
  1. 存在的问题:

由于子项目本身也是一个 qiankun 项目,所以独立运行时 window.__POWERED_BY_QIANKUN__ 为 true,被集成时,还是 true。

因此,子项目无法根据已有信息判断是独立运行还是被集成:

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

解决办法:

在父应用的入口文件定义一个全局变量 window.__POWERED_BY_QIANKUN_PARENT__ = true;,使用这个变量让子应用可以区分是被集成还是独立运行

// 父应用 main.js

import { registerMicroApps, start, initGlobalState } from 'qiankun';

/************ 关键代码 start ************/
// 由于本身有window.__POWERED_BY_QIANKUN__参数,sub应用无法判断自己在第几层
// 设置一个全局参数,让sub应用检测到该参数则说明自己作为孙子应用运行
window.__POWERED_BY_QIANKUN_PARENT__ = true;
/************ 关键代码 end ************/

// 子应用相关配置
//路由规则匹配activeRule,当匹配到该路由则会加载对应微应用到对应的container, 并依次调用微应用暴露出的生命周期钩子
const apps = [
  {
    name: 'qiankun-main',
    entry: '//localhost:10001',
    container: '#main',
    activeRule: '/main',
  },
];

// 注册应用
registerMicroApps(apps);

// 启动
start();
  1. 子项目入口文件的修改

主要有以下几点注意的地方:

  • 切换子项目时,避免重复注册孙子项目,
  • 由于子项目会被注入一个前缀,那么孙子项目的路由也要加上这个前缀
  • 注意容器的冲突,子项目和孙子项目使用不同的容器
// 子应用 index.js

import { registerMicroApps, start } from 'qiankun';

let router = null;
let instance = null;
let flag = false;

function render(props = {}) {
  // 判断是否作为微应用运行,若是,则要加上前缀
  router = new VueRouter({
    mode: 'history',
    /************ 关键代码 start ************/
    base: window.__POWERED_BY_QIANKUN_PARENT__ ? '/main' : '/',
    /************ 关键代码 end ************/
    routes,
  });
  
  /*
  const childRoute = ['/app-vue-hash', '/app-vue-history'];
  const isChildRoute = (path) => childRoute.some((item) => path.startsWith(item));
  const rawAppendChild = HTMLHeadElement.prototype.appendChild;
  const rawAddEventListener = window.addEventListener;
  router.beforeEach((to, from, next) => {
    // 从子项目跳转到主项目
    if (isChildRoute(from.path) && !isChildRoute(to.path)) {
      HTMLHeadElement.prototype.appendChild = rawAppendChild;
      window.addEventListener = rawAddEventListener;
    }
    next();
  });
  */

  instance = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount('#appQiankun');

  if (!flag) {
    registerMicroApps([
      {
        name: 'app-vue-hash',
        entry: 'http://localhost:1111',
        container: '#appContainer',
        /************ 关键代码 start ************/
        // 因为main作为子项目会被注入一个前缀,所以孙子应用sub也要加上这个前缀
        activeRule: window.__POWERED_BY_QIANKUN_PARENT__ ? '/main/app-vue-hash' : '/app-vue-hash',
        /************ 关键代码 end ************/
        props: { data: { store, router } },
      },
      {
        name: 'app-vue-history',
        entry: 'http://localhost:2222',
        container: '#appContainer',
        activeRule: window.__POWERED_BY_QIANKUN_PARENT__ ? '/main/app-vue-history' : '/app-vue-history',
        props: { data: store },
      },
    ]);

    start();
    
    flag = true;
  }
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN_PARENT__) {
  render();
}

// 解决加载资源是404的问题
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

export async function bootstrap() {
  console.log('vue app bootstraped');
}

export async function mount(props) {
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
// vue.config.js

const { name } = require('./package');

module.exports = {
  devServer: {
    port: 10001,
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      // 把子应用打包成 umd 库格式
      library: `${name}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
  publicPath: '/',
};
  1. 孙项目入口文件的修改
// 孙应用 main.js

let instance = null;
let router = null;

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

  router = new VueRouter({
    mode: 'history',
    /************ 关键代码 start ************/
    // 根据全局参数判断自己是作为第几层应用运行,加上对应的前缀,其实这里的前缀也可以在上层应用配置对应的全局参数来传递
    base: window.__POWERED_BY_QIANKUN_PARENT__ ? '/main/app-vue-history': (window.__POWERED_BY_QIANKUN__ ? '/app-vue-history' : '/'),
    /************ 关键代码 end ************/
    routes,
  });

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

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}
// 解决加载资源是404的问题
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  render(props);
}
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
  1. 孙项目打包配置的修改

以上操作完成后,在主项目中可以把这个 qiankun 子项目加载出来,但是点击其孙子项目,报错,生命周期找不到。

修改一下孙子项目的打包配置:

- library: `${name}-[name]`,
+ library: `${name}`,

然后重启就可以了。

原因是 qiankun 取子项目的生命周期,优先取子项目运行时最后一个挂载到 window 上的变量,如果这个不是生命周期函数,再根据 appName 取。让 webpack 的 library 值对应 appName 即可。
在这里插入图片描述

方案二:主项目将 qiankun 的注册函数传递给子项目

基本步骤同上,但是这里有 bug:孙子项目不加载。路由/main 加载 qiankun 子项目,孙子项目注册为主项目的子项目/main/app-vue-hash,但是其 qiankun 子项目可以正常加载,孙子项目不加载也不报错,感觉这是 qiankun 的一个 bug,两个项目共用了一部分路由前缀,路径长的一个不加载。

如果孙子项目不和 qiankun 子项目共用路由前缀,则可以正常加载,所以这个实用场景趋向于:将嵌套的子项目都注册为同级子项目,直接用主项目的容器,共用了主项目的注册函数,这些孙子项目本身就是主项目的子项目。

qiankun 子项目注册子项目时的代码如下:

if (!flag) {
  let registerMicroApps = parentData.registerMicroApps;
  let start = parentData.start;
  if (!window.__POWERED_BY_QIANKUN_PARENT__) {
    const model = await import('qiankun');
    registerMicroApps = model.registerMicroApps;
    start = model.start;
  }
  registerMicroApps([
    {
      name: 'app-vue-hash',
      entry: 'http://localhost:1111',
      container: window.__POWERED_BY_QIANKUN_PARENT__ ? '#appContainerParent' : '#appContainer',
      activeRule: '/app-vue-hash',
      props: { data: { store, router } },
    },
    {
      name: 'app-vue-history',
      entry: 'http://localhost:2222',
      container: window.__POWERED_BY_QIANKUN_PARENT__ ? '#appContainerParent' : '#appContainer',
      activeRule: '/app-vue-history',
      props: { data: store },
    },
  ]);
  start();
  flag = true;
}



API 说明

主应用

基于路由配置的

适用于 route-based 场景。

通过将微应用关联到一些 url 规则的方式,实现当浏览器 url 发生变化时,自动加载相应的微应用的功能。

registerMicroApps(apps, lifeCycles?)
  • 参数

    • apps - Array<RegistrableApp> - 必选,微应用的一些注册信息
    • lifeCycles - LifeCycles - 可选,全局的微应用生命周期钩子
  • 类型

    • RegistrableApp

      • name - string - 必选,微应用的名称,微应用之间必须确保唯一。

      • entry - string | { scripts?: string[]; styles?: string[]; html?: string } - 必选,微应用的入口。

        • 配置为字符串时,表示微应用的访问地址,例如 https://qiankun.umijs.org/guide/
        • 配置为对象时,html 的值是微应用的 html 内容字符串,而不是微应用的访问地址。微应用的 publicPath 将会被设置为 /
      • container - string | HTMLElement - 必选,微应用的容器节点的选择器或者 Element 实例。如container: '#root'container: document.querySelector('#root')

      • activeRule - string | (location: Location) => boolean | Array<string | (location: Location) => boolean> - 必选,微应用的激活规则。

        • 支持直接配置字符串或字符串数组,如 activeRule: '/app1'activeRule: ['/app1', '/app2'],当配置为字符串时会直接跟 url 中的路径部分做前缀匹配,匹配成功表明当前应用会被激活。
        • 支持配置一个 active function 函数或一组 active function。函数会传入当前 location 作为参数,函数返回 true 时表明当前微应用会被激活。如 location => location.pathname.startsWith('/app1')

        规则示例:

        '/app1'

        • ✅ https://app.com/app1
        • ✅ https://app.com/app1/anything/everything
        • 🚫 https://app.com/app2

        '/users/:userId/profile'

        • ✅ https://app.com/users/123/profile
        • ✅ https://app.com/users/123/profile/sub-profile/
        • 🚫 https://app.com/users//profile/sub-profile/
        • 🚫 https://app.com/users/profile/sub-profile/

        '/pathname/#/hash'

        • ✅ https://app.com/pathname/#/hash
        • ✅ https://app.com/pathname/#/hash/route/nested
        • 🚫 https://app.com/pathname#/hash/route/nested
        • 🚫 https://app.com/pathname#/another-hash

        ['/pathname/#/hash', '/app1']

        • ✅ https://app.com/pathname/#/hash/route/nested
        • ✅ https://app.com/app1/anything/everything
        • 🚫 https://app.com/pathname/app1
        • 🚫 https://app.com/app2

        浏览器 url 发生变化会调用 activeRule 里的规则,activeRule 任意一个返回 true 时表明该微应用需要被激活。

      • loader - (loading: boolean) => void - 可选,loading 状态发生变化时会调用的方法。

      • props - object - 可选,主应用需要传递给微应用的数据。

    • LifeCycles

      type Lifecycle = (app: RegistrableApp) => Promise<any>;
      
      • beforeLoad - Lifecycle | Array<Lifecycle> - 可选
      • beforeMount - Lifecycle | Array<Lifecycle> - 可选
      • afterMount - Lifecycle | Array<Lifecycle> - 可选
      • beforeUnmount - Lifecycle | Array<Lifecycle> - 可选
      • afterUnmount - Lifecycle | Array<Lifecycle> - 可选
  • 用法

    注册微应用的基础配置信息。当浏览器 url 发生变化时,会自动检查每一个微应用注册的 activeRule 规则,符合规则的应用将会被自动激活。

  • 示例

    import { registerMicroApps } from 'qiankun';
    
    registerMicroApps(
      [
        {
          name: 'app1',
          entry: '//localhost:8080',
          container: '#container',
          activeRule: '/react',
          props: {
            name: 'kuitos',
          },
        },
      ],
      {
        beforeLoad: (app) => console.log('before load', app.name),
        beforeMount: [(app) => console.log('before mount', app.name)],
      }
    );
    
start(opts?)
  • 参数

    • opts - Options 可选
  • 类型

    • Options

      • prefetch - boolean | 'all' | string[] | (( apps: RegistrableApp[] ) => { criticalAppNames: string[]; minorAppsName: string[] }) - 可选,是否开启预加载,默认为 true

        配置为 true 则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源

        配置为 'all' 则主应用 start 后即开始预加载所有微应用静态资源

        配置为 string[] 则会在第一个微应用 mounted 后开始加载数组内的微应用资源

        配置为 function 则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)

      • sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean } - 可选,是否开启沙箱,默认为 true

        默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。当配置为 { strictStyleIsolation: true } 时表示开启严格的样式隔离模式。这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。

        基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来(比如 react 场景下需要解决这些 问题,使用者需要清楚开启了 strictStyleIsolation 意味着什么。后续 qiankun 会提供更多官方实践文档帮助用户能快速的将应用改造成可以运行在 ShadowDOM 环境的微应用。

        除此以外,qiankun 还提供了一个实验性的样式隔离特性,当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,因此改写后的代码会表达类似为如下结构:

        // 假设应用名是 react16
        .app-main {
          font-size: 14px;
        }
        
        div[data-qiankun-react16] .app-main {
          font-size: 14px;
        }
        

        注意: @keyframes, @font-face, @import, @page 将不被支持 (i.e. 不会被改写)

      • singular - boolean | ((app: RegistrableApp<any>) => Promise<boolean>); - 可选,是否为单实例场景,单实例指的是同一时间只会渲染一个微应用。默认为 true

      • fetch - Function - 可选,自定义的 fetch 方法。

      • getPublicPath - (entry: Entry) => string - 可选,参数是微应用的 entry 值。

      • getTemplate - (tpl: string) => string - 可选。

      • excludeAssetFilter - (assetUrl: string) => boolean - 可选,指定部分特殊的动态加载的微应用资源(css/js) 不被 qiankun 劫持处理。

  • 用法

    启动 qiankun。

  • 示例

    import { start } from 'qiankun';
    
    start();
    
setDefaultMountApp(appLink)
  • 参数

    • appLink - string - 必选
  • 用法

    设置主应用启动后默认进入的微应用。

  • 示例

    import { setDefaultMountApp } from 'qiankun';
    
    setDefaultMountApp('/homeApp');
    
runAfterFirstMounted(effect)
  • 参数

    • effect - () => void - 必选
  • 用法

    第一个微应用 mount 后需要调用的方法,比如开启一些监控或者埋点脚本。

  • 示例

    import { runAfterFirstMounted } from 'qiankun';
    
    runAfterFirstMounted(() => startMonitor());
    

手动加载微应用

适用于需要手动 加载/卸载 一个微应用的场景。

通常这种场景下微应用是一个不带路由的可独立运行的业务组件。
微应用不宜拆分过细,建议按照业务域来做拆分。业务关联紧密的功能单元应该做成一个微应用,反之关联不紧密的可以考虑拆分成多个微应用。
一个判断业务关联是否紧密的标准:看这个微应用与其他微应用是否有频繁的通信需求。如果有可能说明这两个微应用本身就是服务于同一个业务场景,合并成一个微应用可能会更合适。

loadMicroApp(app, configuration?)
  • 参数

    • app - LoadableApp - 必选,微应用的基础信息

      • name - string - 必选,微应用的名称,微应用之间必须确保唯一。
      • entry - string | { scripts?: string[]; styles?: string[]; html?: string } - 必选,微应用的入口(详细说明同上)。
      • container - string | HTMLElement - 必选,微应用的容器节点的选择器或者 Element 实例。如container: '#root'container: document.querySelector('#root')
      • props - object - 可选,初始化时需要传递给微应用的数据。
    • configuration - Configuration - 可选,微应用的配置信息

      • sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean } - 可选,是否开启沙箱,默认为 true

        默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。当配置为 { strictStyleIsolation: true } 时表示开启严格的样式隔离模式。这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。

        基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来(比如 react 场景下需要解决这些 问题,使用者需要清楚开启了 strictStyleIsolation 意味着什么。后续 qiankun 会提供更多官方实践文档帮助用户能快速的将应用改造成可以运行在 ShadowDOM 环境的微应用。

        除此以外,qiankun 还提供了一个实验性的样式隔离特性,当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,因此改写后的代码会表达类似为如下结构:

        // 假设应用名是 react16
        .app-main {
          font-size: 14px;
        }
        
        div[data-qiankun-react16] .app-main {
          font-size: 14px;
        }
        

        注意事项: 目前 @keyframes, @font-face, @import, @page 等规则不会支持 (i.e. 不会被改写)

      • singular - boolean | ((app: RegistrableApp<any>) => Promise<boolean>); - 可选,是否为单实例场景,单实例指的是同一时间只会渲染一个微应用。默认为 false

      • fetch - Function - 可选,自定义的 fetch 方法。

      • getPublicPath - (entry: Entry) => string - 可选,参数是微应用的 entry 值。

      • getTemplate - (tpl: string) => string - 可选

      • excludeAssetFilter - (assetUrl: string) => boolean - 可选,指定部分特殊的动态加载的微应用资源(css/js) 不被 qiankun 劫持处理

  • 返回值 - MicroApp - 微应用实例

    • mount(): Promise<null>;
    • unmount(): Promise<null>;
    • update(customProps: object): Promise<any>;
    • getStatus(): | “NOT_LOADED” | “LOADING_SOURCE_CODE” | “NOT_BOOTSTRAPPED” | “BOOTSTRAPPING” | “NOT_MOUNTED” | “MOUNTING” | “MOUNTED” | “UPDATING” | “UNMOUNTING” | “UNLOADING” | “SKIP_BECAUSE_BROKEN” | “LOAD_ERROR”;
    • loadPromise: Promise<null>;
    • bootstrapPromise: Promise<null>;
    • mountPromise: Promise<null>;
    • unmountPromise: Promise<null>;
  • 用法

    手动加载一个微应用。

    如果需要能支持主应用手动 update 微应用,需要微应用 entry 再多导出一个 update 钩子:

    export async function mount(props) {
      renderApp(props);
    }
    
    // 增加 update 钩子以便主应用手动更新微应用
    export async function update(props) {
      renderPatch(props);
    }
    
  • 示例

    import { loadMicroApp } from 'qiankun';
    import React from 'react';
    
    class App extends React.Component {
      containerRef = React.createRef();
      microApp = null;
    
      componentDidMount() {
        this.microApp = loadMicroApp({
          name: 'app1',
          entry: '//localhost:1234',
          container: this.containerRef.current,
          props: { brand: 'qiankun' },
        });
      }
    
      componentWillUnmount() {
        this.microApp.unmount();
      }
    
      componentDidUpdate() {
        this.microApp.update({ name: 'kuitos' });
      }
    
      render() {
        return <div ref={this.containerRef}></div>;
      }
    }
    
prefetchApps(apps, importEntryOpts?)
  • 参数

    • apps - AppMetadata[] - 必选 - 预加载的应用列表
    • importEntryOpts - 可选 - 加载配置
  • 类型

    • AppMetadata
      • name - string - 必选 - 应用名
      • entry - string | { scripts?: string[]; styles?: string[]; html?: string } - 必选,微应用的 entry 地址
  • 用法

    手动预加载指定的微应用静态资源。仅手动加载微应用场景需要,基于路由自动激活场景直接配置 prefetch 属性即可。

  • 示例

    import { prefetchApps } from 'qiankun';
    
    prefetchApps([
      { name: 'app1', entry: '//localhost:7001' },
      { name: 'app2', entry: '//localhost:7002' },
    ]);
    

全局监听
addErrorHandler/removeErrorHandler

基于 single-spa 的 addErrorHandler/removeErrorHandler

addGlobalUncaughtErrorHandler(handler)
  • 参数

    • handler - (...args: any[]) => void - 必选
  • 用法

    添加全局的未捕获异常处理器。

  • 示例

    import { addGlobalUncaughtErrorHandler } from 'qiankun';
    
    addGlobalUncaughtErrorHandler((event) => console.log(event));
    
removeGlobalUncaughtErrorHandler(handler)
  • 参数

    • handler - (...args: any[]) => void - 必选
  • 用法

    移除全局的未捕获异常处理器。

  • 示例

    import { removeGlobalUncaughtErrorHandler } from 'qiankun';
    
    removeGlobalUncaughtErrorHandler(handler);
    
通讯
initGlobalState(state)
  • 参数

    • state - Record<string, any> - 必选
  • 用法

    定义全局状态,并返回通信方法,建议在主应用使用,微应用通过 props 获取通信方法。

  • 返回

    • MicroAppStateActions

      • onGlobalStateChange: (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void, 在当前应用监听全局状态,有变更触发 callback,fireImmediately = true 立即触发 callback

      • setGlobalState: (state: Record<string, any>) => boolean, 按一级属性设置全局状态,微应用中只能修改已存在的一级属性

      • offGlobalStateChange: () => boolean,移除当前应用的状态监听,微应用 umount 时会默认调用

  • 示例

    主应用:

    import { initGlobalState, MicroAppStateActions } from 'qiankun';
    
    // 初始化 state
    const actions: MicroAppStateActions = initGlobalState(state);
    
    actions.onGlobalStateChange((state, prev) => {
      // state: 变更后的状态; prev 变更前的状态
      console.log(state, prev);
    });
    actions.setGlobalState(state);
    actions.offGlobalStateChange();
    

    微应用:

    // 从生命周期 mount 中获取通信方法,使用方式和 master 一致
    export function mount(props) {
      props.onGlobalStateChange((state, prev) => {
        // state: 变更后的状态; prev 变更前的状态
        console.log(state, prev);
      });
    
      props.setGlobalState(state);
    }
    



子应用

全局变量

qiankun 会在子应用的window对象中注入一些全局变量:

declare global {
  interface Window {
    // 是否存在qiankun
    __POWERED_BY_QIANKUN__?: boolean;
    // 子应用公共加载路径
    __INJECTED_PUBLIC_PATH_BY_QIANKUN__?: string;
    // 是否开发环境
    __QIANKUN_DEVELOPMENT__?: boolean;
  }
}
生命周期

注意:子应用在声明生命周期时都必须要进行 export 导出。

bootstrap

bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap

通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。

// index.js

export async function bootstrap() {
  console.log('app bootstraped');
}
mount

应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法。

// index.js

export async function mount(props) {
  // ReactDOM.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root'));
  console.log('app mount');
}
unmount

应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例。

// index.js

export async function unmount(props) {
  // ReactDOM.unmountComponentAtNode(props.container ? props.container.querySelector('#root') : document.getElementById('root'));
  console.log('app unmount');
}
update

可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效。

// index.js

export async function update(props) {
  console.log('update props', props);
}




原理

沙箱机制

js 沙箱

js/css 污染是无法避免的,并且是一个可大可小的问题。就像一颗定时炸弹,不知道什么时候会出问题,排查也麻烦。作为一个基础框架,解决这两个污染非常重要,不能仅凭“规范”开发。

js 沙箱的原理是子项目加载之前,对 window 对象做一个快照,子项目卸载时恢复这个快照,如图:
在这里插入图片描述
那么如何监测 window 对象的变化呢,直接将 window 对象进行一下深拷贝,然后深度对比各个属性显然可行性不高,qiankun 框架采用的是 ES6 新特性,proxy 代理方法。
但是 proxy 是不兼容 IE11 的,为了兼容,低版本 IE 采用了 diff 方法:浅拷贝 window 对象,然后对比每一个属性。

qiankun 多应用同时运行 js 沙箱的处理

两个子应用同时存在, 又添加了两个全局变量 window.a, 如何保证这两个能同时运行但互不干扰?

采用了 proxy 代理之后,所有子应用的全局变量变更都是在闭包中产生的,不会真正回写到 window 上,这样就能避免多实例之间的污染了。

css 沙箱

qiankun 的 css沙箱的原理是重写 HTMLHeadElement.prototype.appendChild 事件,记录子项目运行时新增的 style/link 标签,卸载子项目时移除这些标签。

single-spa 方案中我用了换肤的思路来解决 css 污染:首先 css-scoped 解决大部分的污染,对于一些全局样式,在子项目给 body/html 加一个唯一的 id/class(正常开发部署用),然后这个全局的样式前面加上这个 id/class,而 single-spa 模式则在 mount 周期给 body/html 加上这个唯一的 id/class,在 unmount 周期去掉,这样就可以保证这个全局 css 只对这个项目生效了。

这两个方案的致命点都在于无法解决多个子项目同时运行时的 css 污染,以及子项目对主项目的 css 污染。

虽然说两个项目同时运行常见并不常见,但是如果想实现 keep-alive ,就需要使用 display: none 将子项目隐藏起来,子项目不需要卸载,这时候就会存在两个子项目同时运行,只不过其中一个对用户不可见。

css 沙箱还有个思路就是将子项目的样式局限到子项目的容器范围内生效,这样只需要给不同的子项目不同的容器就可以了。但是这样也会有新的问题,子项目中 appendbody 的弹窗,样式就无法生效。所以说样式污染还需要制定规范才行,约定 class 命名前缀。

更多

沙箱机制的详细原理参考以下这 2 篇文章:

  • https://zhuanlan.zhihu.com/p/379744976
  • https://mp.weixin.qq.com/s/mC-u3pNH6dbtl8tePdWFbw


html entry

single-spa 和 qiankun 最大的不同,大概就是 qiankun 实现了 html entry,而 single-spa 只能是 js entry
通过 import-html-entry,我就能像 iframe 一样加载一个子应用,只需要知道其 html 的 url 就能加载到主应用中。

获取子应用资源 - import-html-entry

importHTML 几个核心方法:

首先 importHTML 的参数为需要加载的页面 url,拿到后会先通过 fetch 方法读取页面内容。

import importHTML from 'import-html-entry';

importHTML('./subApp/index.html').then((res) => {
  console.log(res.template);

  res.execScripts().then((exports) => {
    const mobx = exports;
    const { observable } = mobx;
    observable({
      name: 'kuitos',
    });
  });
});
返回值
  • template - string - 处理过的 HTML 模板。
  • assetPublicPath - string - 资源的公共途径。
  • getExternalScripts - Promise<string[]> - 来自模板的脚本 URL。
  • getExternalStyleSheets - Promise<string[]> - 来自模板的 StyleSheets URL。
  • execScripts - (sandbox?: object, strictGlobal?: boolean, execScriptsHooks?: ExecScriptsHooks): - Promise - the return value is the last property on window or proxy window which set by the entry - script.
    • sandbox - optional, Window or proxy window.
    • strictGlobal - optional, Strictly enforce the sandbox.
processTpl

它会解析 html 的内容并且删除注释,获取 style 样式及 script 代码。通过大量的正则 + replace,每一个步骤都做了很多适配,比如获取 script 脚本,需要区分该 script 是不是 entry script,type 是 JavaScript 还是 module,是行内 script 还是外链 script,是相对路径还是绝对路径,是否需要处理协议等等。
processTpl 的返回值有 template,script,style,entry。


qiankun 是如何通过 import-html-entry 加载微应用的

在这里插入图片描述

简易流程:

  1. qiankun 会用 原生 fetch 方法,请求微应用的 entry 获取微应用资源,然后通过 response.text 把获取内容转为字符串。
  2. 将 HTML 字符串传入 processTpl 函数,进行 HTML 模板解析,通过正则匹配 HTML 中对应的 javaScript(内联、外联)、css(内联、外联)、代码注释、entry、ignore 收集并替换,去除 html/head/body 等标签,其他资源保持原样
  3. 将收集的 styles 外链 URL 对象通过 fetch 获取 css,并将 css 内容以 <style> 的方式替换到原来 link 标签的位置
  4. 收集 script 外链对象,对于异步执行的 JavaScript 资源会打上 async 标识 ,会使用 requestIdleCallback 方法延迟执行。
  5. 接下来会创建一个匿名自执行函数包裹住获取到的 js 字符串,后通过 eval 去创建一个执行上下文执行 js 代码,通过传入 proxy 改变 window 指向,完成 JavaScript 沙箱隔离。源码位置。
  6. 由于 qiankun 是自执行函数执行微应用的 JavaScript,因此在加载后的微应用中是看不到 JavaScript 资源引用的,只有一个资源被执行替换的标识。
  7. 当一切准备就绪的时候,执行微应用的 JavaScript 代码,渲染出微应用。




关键字说明

umd

umd 全称是 UniversalModuleDefinition,是一种通用模块定义格式,通常用于前端模块化开发中。
由于不同的模块化规范定义不同,为了让各种规范的模块可以通用,在不同的环境下都可以正常运行,就出现了 umd 这个通用格式。

特点

umd 格式是一种既可以在浏览器环境下使用,也可以在 node 环境下使用的格式。它将 CommonJS、AMD 以及普通的全局定义模块三种模块模式进行了整合。

(function (global, factory) {
  // CommonJS
  typeof exports === 'object' && typeof module !== 'undefined'
    ? factory(exports)
    : // AMD
    typeof define === 'function' && define.amd
    ? define(['exports'], factory)
    : // Window
      ((global = typeof globalThis !== 'undefined' ? globalThis : global || self), factory((global.qiankun = {})));
})(this, function (exports) {
  // 应用代码
});
为什么 qiankun 要求子应用打包为 umd 库格式呢?

主要是为了主应用能够拿到子应用在入口文件导出的生命钩子函数,这也是主应用和子应用之间通信的关键。

  • bootstrap
  • mount
  • unmount
  • update




常见问题及解决方案

同时存在多个子应用时

如果一个页面同时展示多个子应用,需要使用 loadMicroApp 来加载。

如果这些子应用都有路由跳转的需求,要保证这些路由能互不干扰,需要使用 momery 路由。

  • react-router 使用 memory history 模式;
  • vue-router 使用 abstract 模式;
  • angular-router 不支持。

vue 项目如何将 hash模式 改成 history模式

vue 项目 hash模式 history模式 的步骤:

  1. new Router 时设置 modehistory
  2. webpack 打包的配置( vue.config.js ) :
    publicPach: './'; // hash模式使用
    publicPach: '/'; // history模式使用
    
  3. 一些资源会报 404,相对路径改为绝对路径:<img src="./img/logo.jpg"> 改为 <img src="/img/logo.jpg"> 即可

路由模式如何选择?

首先,业务情况分两种:
(1)子应用已经存在了,也并不清楚各子应用使用了何种路由模式的;
(2)子应用当前不存在,需要和主应用一起开发的。这种情况下,可以任意选择。

那么就以子应用已经存在的这种自由度不大的情况先阐述:

主应用使用 history 模式,activeRule 使用 location.pathname 来区分子应用【推荐】

此时,子应用可以是 hashhistory 模式。

主应用路由模式:✅ history + location.pathname
子应用路由模式:✅ history | ✅ hash

主应用的改动点:

主应用在注册子应用时 activeRule 这样写即可:

registerMicroApps([
  {
    name: 'app',
    entry: 'http://localhost:8080',
    container: '#container',
    activeRule: '/app',
  },
]);
子应用的改动点:
  1. 当子应用是 history 模式时,设置路由 base 即可

  2. 当子应用是 hash 模式时,vue-router 和 react-router 两种路由的表现不一致

    路由主应用跳转/app/#/about特殊配置
    vue-router响应 about 路由
    react-router不响应 about 路由
优缺点:

优点:

  1. 子应用可以使用 history 模式,也可以使用 hash 模式 。这样旧项目就都可以直接接入,兼容性强。
  2. 如果子应用是 hash 模式,不需要做任何修改。

缺点:

  1. 如果子应用是 history 模式,则需要设置 base
  2. 子应用之间的跳转需要使用父应用的 router 对象(不用 <a>链接 直接跳转的原因是 <a>链接 会刷新页面)。
    其实不传递 router 对象,用原生的 history 对象跳转也行: history.pushState(null, 'name', '/app-vue-hash/#/about'),同样不会刷新页面。
    不管是父应用的 router 对象,还是原生的 history 对象,跳转都是 js 的方式。这里有一个小小的用户体验问题:标签(<router-link><a>)形式的跳转是支持浏览器默认的右键菜单的,js 方式则没有。
主应用使用 history | hash 模式,activeRule使用 location.hash 区分子应用

此时,子应用只能是 hash 模式!!

主应用路由模式:✅ hash | ✅ history + location.hash
子应用路由模式:✅ hash

这种情况主应用和子应用会共同接管路由,举个栗子:

  • /#/vue/home: 会加载 vue 子应用的 home 页面。但是其实,单独访问这个子应用的 home 页面的完整路由就是/#/vue/home
  • /#/react/about: 会加载 react 子应用的 about 页面。同样,单独访问这个子应用的 about 页面的完整路由就是/#/react/about
  • /vue/#/home: 会加载 vue 子应用的 home 页面。
  • /#/about: 会加载主应用的 about 页面
主应用的改动点:

由于主应用和子应用会共同接管路由,主应用在注册子应用时需要自定义 activeRule

// 关键代码 start
const getActiveRule = (hash) => (location) => location.hash.startsWith(hash);
// 关键代码 end
registerMicroApps([
  {
    name: 'app-hash',
    entry: 'http://localhost:8080',
    container: '#container',
    // 关键代码 start
    activeRule: getActiveRule('#/app-hash'),
    // 这里也可以直接写 activeRule: '#/app-hash',但是如果主应用是 history 模式或者主应用部署在非根目录,这样写不会生效。
    // 关键代码 end
  },
]);
子应用的改动点:

子应用则需要在所有路由前加上这个前缀或者将子应用的根路由设置为这个前缀

而 vue-router 和 react-router 两种路由的改造实现不一致:

  • react-router
    子应用需要设置 activeRule 的值为路由的 base ,写法同 history 模式。

  • vue-router
    不支持设置路由的 base,需要额外新建一个空的路由页面,将其他所有路由都作为它的 children

    const routes = [
      {
        path: '/app-hash',
        name: 'Home',
        component: Home,
        // 关键代码 start
        children: [
          // 其他的路由都写到这里
        ],
        // 关键代码 end
      },
    ];
    
优缺点:

优点:

  • 所有应用之间的跳转都可以直接使用自己的 router 对象或者 <router-link>,不需要借助父应用的路由对象或者原生的 history 对象

缺点:

  • 对子应用是入侵式修改,如果子应用都是新应用还好,如果是旧应用,则影响比较大。
  • 子应用里面的路由跳转(<router-link>router.push()router.repace())。如果使用的是 path 跳转,则需要加上前缀;如果使用的是 name 跳转,则无需改动(router.push({ name: 'user'}))。
小结

主应用路由的 hash 与 history 模式都可以使用,主要还是看 activeRule 使用了 location.pathname,还是使用了 location.hash
使用 location.pathname 就是第一种情况,使用 location.hash 就是第二种。

好的路由模式就是主应用、子应用都统一模式,可以减少不同模式之间的兼容工作

主模式子模式推荐接入影响解决方案备注
hashhash强烈推荐
hashhistory不推荐history.pushState改造成本大
historyhistory强烈推荐
historyhash推荐

路由跳转问题

在子项目里面如何跳转到另一个子项目/主项目页面呢,直接写 <router-link> 或者用 router.push / router.replace 是不行的,原因是这个 router 是子项目的路由,所有的跳转都会基于子项目的 base 。而 <a>链接 可以跳转过去,但是会刷新页面,用户体验不好。

解决方案:

  1. 在子项目注册时将主项目的路由实例router对象传过去,子项目挂载到全局,用父项目的这个 router 跳转就可以了。但是有一丢丢不完美,这样只能通过 js 来跳转,跳转的链接无法使用浏览器自带的右键菜单。
  2. 使用通讯的方式,父应用封装router跳转的方法,向子应用传递,子应用使用该方法,传入需要跳转到的路由信息,也可以实现跳转。这种则不传递父应用的router对象,而是传递封装过的方法,安全性更高。
  3. 路由模式为 history模式 时,通过 history.pushState() 方式跳转,方法封装如下:
    /**
     * 微前端子应用路由跳转
     * @param {String} url 路由
     * @param {Object} mainRouter 主应用路由实例
     * @param {*} params 状态对象:传给目标路由的信息,可为空
     */
    
    const qiankunJump = (url, mainRouter, params) => {
      if (mainRouter) {
        // 使用主应用路由实例跳转
        mainRouter.push({ path: url, query: params })
        return
      }
      // 未传递主应用路由实例,传统方式跳转
      let searchParams = '?'
      let targetUrl = url
      if (typeOf(params) === 'object' && Object.keys(params).length) {
        Object.keys(params).forEach(item => {
          searchParams += `${item}=${params[item]}&`
        })
        targetUrl = targetUrl + searchParams.slice(0, searchParams.length - 1)
      }
      window.history.pushState(null, '', targetUrl)
    }
    

如何部署

建议:主应用和微应用都是独立开发和部署,即它们都属于不同的仓库和服务。

场景 1:主应用和微应用部署在不同的服务器,使用 Nginx 代理访问

一般这么做是因为不允许主应用跨域访问微应用,做法就是将主应用服务器上一个特殊路径的请求全部转发到微应用的服务器上,即通过代理实现“微应用部署在主应用服务器上”的效果。

例如,主应用在 A 服务器,微应用在 B 服务器,使用路径 /app1 来区分微应用,即 A 服务器上所有 /app1 开头的请求都转发到 B 服务器上。

此时主应用的 Nginx 代理配置为:

/app1/ {
  proxy_pass http://www.b.com/app1/;
  proxy_set_header Host $host:$server_port;
}

主应用注册微应用时,entry 可以为相对路径,activeRule 不可以和 entry 一样(否则主应用页面刷新就变成微应用):

registerMicroApps([
  {
    name: 'app1',
    entry: '/app1/', // http://localhost:8080/app1/
    container: '#container',
    activeRule: '/child-app1',
  },
],

对于 webpack 构建的微应用,微应用的 webpack 打包的 publicPath 需要配置成 /app1/,否则微应用的 index.html 能正确请求,但是微应用 index.html 里面的 js/css 路径不会带上 /app1/

module.exports = {
  output: {
    publicPath: `/app1/`,
  },
};

微应用打包的 publicPath 加上 /app1/ 之后,必须部署在 /app1 目录,否则无法独立访问。

另外,如果不想微应用通过代理路径被独立访问,可以根据请求的一些信息判断下,主应用中请求微应用是用 fetch 请求的,可以带参数和 cookie。例如通过请求头参数判断:

if ($http_custom_referer != "main") {
  rewrite /index /404.html;
}
场景 2:主应用和微应用部署到同一个服务器(同一个 IP 和端口)

如果服务器数量有限,或不能跨域等原因,需要把主应用和微应用部署到一起。

通常的做法是主应用部署在一级目录,微应用部署在二/三级目录。

微应用部署在非根目录,在微应用打包之前需要做两件事:

  1. 必须配置 webpack 构建时的 publicPath目录名称,更多信息请看 webpack 官方说明vue-cli3 的官方说明

  2. history 路由的微应用需要设置 base ,值为目录名称,用于独立访问时使用。

部署之后注意三点:

  1. activeRule 不能和微应用的真实访问路径一样,否则在主应用页面刷新会直接变成微应用页面。
  2. 微应用的真实访问路径就是微应用的 entryentry 可以为相对路径。
  3. 微应用的 entry 路径最后面的 / 不可省略,否则 publicPath 会设置错误,例如子项的访问路径是 http://localhost:8080/app1,那么 entry 就是 http://localhost:8080/app1/

具体的部署有以下两种方式,选择其一即可。

方案 1:微应用都放在在一个特殊名称(不会和微应用重名)的文件夹下(建议使用

假设我们有一个主应用和 6 个微应用(分别为 vue-hashvue-historyreact-hashreact-historyangular-hashangular-history ),打包后如下放置:

└── html/                     # 根文件夹
    |
    ├── child/                # 存放所有微应用的文件夹
    |   ├── vue-hash/         # 存放微应用 vue-hash 的文件夹
    |   ├── vue-history/      # 存放微应用 vue-history 的文件夹
    |   ├── react-hash/       # 存放微应用 react-hash 的文件夹
    |   ├── react-history/    # 存放微应用 react-history 的文件夹
    |   ├── angular-hash/     # 存放微应用 angular-hash 的文件夹
    |   ├── angular-history/  # 存放微应用 angular-history 的文件夹
    ├── index.html            # 主应用的index.html
    ├── css/                  # 主应用的css文件夹
    ├── js/                   # 主应用的js文件夹

此时需要设置微应用构建时的 publicPathhistory 模式的路由 base,然后才能打包放到对应的目录里。

项目路由 basepublicPath真实访问路径
vue-hash/child/vue-hash/http://localhost:8080/child/vue-hash/
vue-history/child/vue-history//child/vue-history/http://localhost:8080/child/vue-history/
react-hash/child/react-hash/http://localhost:8080/child/react-hash/
react-history/child/react-history//child/react-history/http://localhost:8080/child/react-history/
angular-hash/child/angular-hash/http://localhost:8080/child/angular-hash/
angular-history/child/angular-history//child/angular-history/http://localhost:8080/child/angular-history/
  • vue-history 微应用

    路由设置:

    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history/' : '/child/vue-history/',
    

    webpack 打包 publicPath 配置(vue.config.js):

    module.exports = {
      publicPath: '/child/vue-history/',
    };
    
  • react-history 微应用

    路由设置:

    <BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react-history' : '/child/react-history/'}>
    

    webpack 打包 publicPath 配置:

    module.exports = {
      output: {
        publicPath: '/child/react-history/',
      },
    };
    
  • angular-history 微应用

    路由设置:

    providers: [
      {
        provide: APP_BASE_HREF,
        useValue: window.__POWERED_BY_QIANKUN__ ? '/app-angular-history/' : '/child/angular-history/',
      },
    ];
    

    webpack 打包的 publicPath 通过 deploy-url 来修改,修改 package.json

    - "build": "ng build",
    + "build": "ng build --deploy-url /child/angular-history/",
    

那么此时的注册函数是这样的(需要保证 activeRuleentry 不同):

registerMicroApps([
  {
    name: 'app-vue-hash',
    entry: '/child/vue-hash/', // http://localhost:8080/child/vue-hash/
    container: '#container',
    activeRule: '/app-vue-hash',
  },
  {
    name: 'app-vue-history',
    entry: '/child/vue-history/', // http://localhost:8080/child/vue-history/
    container: '#container',
    activeRule: '/app-vue-history',
  },
  // angular 和 react 同上
],

至此主应用已经和微应用都能跑起来了,但是主应用和 vue-historyreact-historyangular-history 微应用是 history 路由,需要解决刷新 404 的问题,nginx 还需要配置一下:

server {
  listen       8080;
  server_name  localhost;

  location / {
    root   html;
    index  index.html index.htm;
    try_files $uri $uri/ /index.html;
  }

  location /child/vue-history {
    root   html;
    index  index.html index.htm;
    try_files $uri $uri/ /child/vue-history/index.html;
  }
  # angular 和 react 的history 配置同上
}
方案 2:微应用直接放在二级目录,但是设置特殊的 activeRule
└── html/                     # 根文件夹
    |
    ├── vue-hash/             # 存放微应用 vue-hash 的文件夹
    ├── vue-history/          # 存放微应用 vue-history 的文件夹
    ├── react-hash/           # 存放微应用 react-hash 的文件夹
    ├── react-history/        # 存放微应用 react-history 的文件夹
    ├── angular-hash/         # 存放微应用 angular-hash 的文件夹
    ├── angular-history/      # 存放微应用 angular-history 的文件夹
    ├── index.html            # 主应用的index.html
    ├── css/                  # 主应用的css文件夹
    ├── js/                   # 主应用的js文件夹

基本操作和上面是一样的,只要保证 activeRule 和微应用的存放路径名不一样即可。



子项目部署在二级目录

首先,一个 vue 项目要想部署到二级目录,必须配置 publicPathvue-cli3 官网描述

然后需要注意的点就是,注册子项目时 入口地址 entry 的填写。

假设子项目部署在 app-vue-hash 目录下,entry 直接写 http://localhost/app-vue-hash 会导致 qiankunpublicPath 错误。子项目入口地址 http://localhost/app-vue-hash 的相对路径是 http://localhost,而我们希望的子项目相对路径是 http://localhost/app-vue-hash

解决方案:
只需要写成 http://localhost/app-vue-hash/ 即可,最后面的 / 不可省略

qiankun 取 publicPath 源码:

function defaultGetPublicPath(url) {
  try {
    // URL 构造函数不支持使用 // 前缀的 url
    const { origin, pathname } = new URL(url.startsWith('//') ? `${location.protocol}${url}` : url, location.href);
    const paths = pathname.split('/');
    // 移除最后一个元素
    paths.pop();
    return `${origin}${paths.join('/')}/`;
  } catch (e) {
    console.warn(e);
    return '';
  }
}

通过测试我们可以发现 http://localhost/apphttp://localhost/app/ 两个不同路径的 server, 同一个 html,然后在 html 里引入一个相对路径的资源。浏览器解析的地址分别为:
在这里插入图片描述在这里插入图片描述

说明 qiankunpublicPath 的处理是正确的。


vue 子项目内存泄露问题

也就是说,即使卸载子项目时,子项目占用的内存没有被释放。

这个问题挺难发现的,是在 qiankun 的 issue 区看到的: github.com/umijs/qiankun/issues/674 ,解决方案也挺简单。

解决方案:子项目销毁时清空 dom 即可。

export async function unmount() {
  instance.$destroy();
+ instance.$el.innerHTML = ""; // 新增这一行清空dom的代码
  instance = null;
  router = null;
}

子项目字体文件加载失败

qiankun 对于子项目的 js/css 的处理

qiankun 请求到子项目的 index.html 之后,会先用正则匹配到其中的 js/css 相关标签,然后替换掉,它需要自己加载 js/css 并运行,接着去掉 html/head/body 等标签,剩下的内容原样插入到子项目的容器中 :
在这里插入图片描述

对于 js(<script>标签)的处理

内联 js 的内容会直接记录到一个对象中,外链 js 则会使用 fetch 请到到内容(字符串),然后记录到这个对象中。

if (isInlineCode(script)) {
  return getInlineCode(script);
} else {
  return fetchScript(script);
}

const fetchScript = (scriptUrl) => scriptCache[scriptUrl] || (scriptCache[scriptUrl] = fetch(scriptUrl).then((response) => response.text()));

运行子项目时,执行这些 js 即可:

//内联js
eval(`;(function(window){;${inlineScript}\n}).bind(window.proxy)(window.proxy);`)
//外链js
eval(`;(function(window){;${downloadedScriptText}\n}).bind(window.proxy)(window.proxy);`))

加载并运行外链 js 这里有一个难点就是,如何保证 js 的正确执行顺序?

<script> 标签的 asyncdefer 属性:

  1. defer : 等价于将外链的 js 放在了页面底部
  2. async : 相对于页面的其余部分异步地执行,加载好了就执行。常用于 Google Analytics

所以说外链 js 只要区分有无 async,有 async<script> 使用 promise 异步加载,加载完再执行即可,无 async 属性的按顺序执行。

假设 HTML 中有如下几个 js 标签:

<script src="./a.js" onload="console.log('a load')"></script>
<script src="./b.js" onload="console.log('b load')"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<script src="./c.js" onload="console.log('c load')"></script>

浏览器正常的加载及执行逻辑是并行加载,但是按顺序执行,只要第一个加载好了,就会立马执行第一个。如果第三个没加载完成,第四个即使加载完成,也不会先执行。

qiankun 则是并行加载,但是等所有的 js 都加载完成了,再按顺序执行。与浏览器的原生加载执行顺序有一点点出入,但是效果一样。

这里有一点优化的空间:只要它前面的 js 都加载执行完了,那么它加载好了就可以立即执行,而不用等它后面的 js 加载完成。

对于 css(<style><link> 标签)的处理

加载逻辑还是一样的:内联 css ( <style> 标签)的内容会直接记录到一个对象中,外链 css ( <link> 标签)则会使用 fetch 请求到内容(字符串),然后记录到这个对象中。

但是执行时,也和 js “类似的”:内容放到 <style> 标签,然后插入到页面,子项目卸载移除这些 <style> 标签。

这样会把外链的 css 变成内联 css ,好处就是切换子系统,不用重复请求,直接应用 css 样式,让子项目加载得更快。

但是会带来一个隐藏的坑,css 中如果使用了字体文件,并且是相对路径,原本是 link 外链样式,相对路径就是相对于这个外链 css 的路径,现在变成了内联样式,相对路径则变成了相对于 index.html 的路径,就会导致字体文件 404。

更坑的是开发模式没有这个问题,开发模式下这个路径会被注入 publicPath,打包之后会有这个问题。

如何解决子项目字体文件加载失败的问题

虽然说,是由于 qiankun 将子项目的 <link> 改成 <style> 执行 ,才导致了这个问题,但是它这么做似乎也没问题,并且是合理的。

其根本原因是:字体文件虽然经过了 webpack 处理,但是没有被注入路径前缀。

解决方案:
修改 webpack 的配置,让字体文件经过 url-loader 的处理,打包成 base64 ,就可以解决这个问题了。

子项目的 webpack 配置中加上如下内容即可:

module.exports = {
  chainWebpack: (config) => {
    config.module
      .rule('fonts')
      .test(/.(ttf|otf|eot|woff|woff2)$/)
      .use('url-loader')
      .loader('url-loader')
      .options({})
      .end();
  },
};

备注

查看 vue-cli4 源码发现,也是这样处理的,但是它限制了 4kb 以内的字体打包成 base64。


出现最多的问题: 偶现刷新页面报错,容器找不到。

解决方案 1:在组件 mounted 周期注册并启动 qiankun

解决方案 2:new Vue() 之后,等 DOM 加载好了再注册并启动 qiankun

const vueApp = new Vue({
  router,
  store,
  render: (h) => h(App),
}).$mount('#app');
vueApp.$nextTick(() => {
  //在这里注册并启动 qiankun
});




常见异常

子项目未 export 需要的生命周期函数

先检查下子项目的入口文件有没有 export 生命周期函数,再检查下子项目的打包,最后看看请求到的子项目的文件对不对。
在这里插入图片描述

子项目加载时,容器未渲染好

在这里插入图片描述
检查容器 div 是否是写在了某个路由里面,路由没匹配到所有未加载。如果只在某个路由页面加载子项目,可以在页面的 mounted 周期里面注册子项目并启动。

其他

更多问题查阅地址:





总结

  1. qiankun 的 js沙箱 并不能解决所有的 js 污染。例如:用 onclick 或 addEventListener 给 <body> 添加了一个点击事件,js沙箱并不能消除它的影响,所以说,还得靠代码规范和自己自觉

  2. qiankun 不太好实现 keep-alive 需求,因为解决 css/js 污染的办法就是删除子项目插入的 css 标签和劫持 window 对象,卸载时还原成子项目加载前的样子,这与 keep-alive 相悖: keep-alive 要求保留这些,仅仅是样式上的隐藏。

qiankun 比较致命缺点:

  • localStoragesessionStoragecookie等,没有沙箱隔离。子应用较多的情况下,使用了 localStorage / sessionStorage 存储数据容易出问题。
  • 拓展升级问题:当前暂不支持 vite

参考

  • https://juejin.cn/post/6844904185910018062#heading-0
  • https://juejin.cn/post/6856569463950639117#heading-0
  • https://zhuanlan.zhihu.com/p/691530332
  • 13
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值