微前端教程

253 阅读24分钟

引言

image.png

为什么不考虑 iframe

如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

微前端架构

在开始介绍 qiankun 之前,我们需要先了解微前端架构如何划分子应用。

在微前端架构中,应该按业务划分出对应的子应用,而不是通过功能模块划分子应用,这么做的原因有两个:

  1. 在微前端架构中,子应用并不是一个模块,而是一个独立的应用,我们将子应用按业务划分可以拥有更好的可维护性和解耦性。
  2. 子应用应该具备独立运行的能力,应用间频繁的通信会增加应用的复杂度和耦合度。

综上所述,我们应该从业务的角度出发划分各个子应用,尽可能减少应用间的通信,从而简化整个应用,使得我们的微前端架构可以更加灵活可控。

为什么要使用qiankun

使用 qiankun 的大背景基本也就是如下几点:

  • 老项目耦合和很多条业务线,变得越来越庞大,发布版本 build 需要很久
  • 老项目中存在很多的问题,但是又没有时间对他进行重构
  • 老项目中的框架版本过低,或UI库的版本过低,导致很多功能无法使用
  • 想使用更新的技术栈来开发,跟上时代变更的步伐

基于上述种种原因,qiankun 无非就成了拆分业务架构最合适的选择

qiankun 的优势有哪些

这里引用官网对他的描述:

  • 技术栈无关:主框架不限制接入应用的技术栈,微应用具备完全自主权
  • 独立开发,独立部署:微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 增量升级:在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
  • 独立运行时:每个微应用之间状态隔离,运行时状态不共享

qiankun官网

基本使用

juejin.cn/post/684490…

介绍如何使用 qiankun 如何搭建主应用基座,然后接入不同技术栈的微应用,完成微前端架构的从 0 到 1。

本教程中,接入了多技术栈 微应用 的 主应用 最终效果图如下:

micro-app

主应用基座

主应用在很多地方又叫做基座,顾名思义,如果拿盖房子来举例的话,他更像是地基,可以说,他的好坏从一定程度上也可以决定上层建筑

主应用中应该只存放全局配置相关的信息,比如:

  • 菜单页,可以控制页面跳转到不同的系统
  • 权限获取,主应用中获取权限信息,然后传递给子应用
  • 全局共享的数据和方法(这个公共的 utils 也可以单独打包然后发布到 npm )
  • 登录功能和角色信息,一般用户的角色信息是不会经常变动的,只获取一次,之后存起来就好

构建主应用基座

使用 vue-cli 生成一个 Vue 的项目,初始化主应用。

将普通的项目改造成 qiankun 主应用基座,需要进行三步操作:

  1. 创建微应用容器 - 用于承载微应用,渲染显示微应用
  2. 注册微应用 - 设置微应用激活条件,微应用地址等等
  3. 启动 qiankun

创建微应用容器

先在主应用中创建微应用的承载容器,这个容器规定了微应用的显示区域,微应用将在该容器内渲染并显示。

先设置主应用路由,路由文件规定了主应用自身的路由匹配规则,代码实现如下:

复制代码
// micro-app-main/src/routes/index.ts
import Home from "@/pages/home/index.vue";

const routes = [
  {
    /**
     * path: 路径为 / 时触发该路由规则
     * name: 路由的 name 为 Home
     * component: 触发路由时加载 `Home` 组件
     */
    path: "/",
    name: "Home",
    component: Home,
  },
];

export default routes;

// micro-app-main/src/main.ts
//...
import Vue from "vue";
import VueRouter from "vue-router";

import routes from "./routes";

/**
 * 注册路由实例
 * 即将开始监听 location 变化,触发路由规则
 */
const router = new VueRouter({
  mode: "history",
  routes,
});

// 创建 Vue 实例
// 该实例将挂载/渲染在 id 为 main-app 的节点上
new Vue({
  router,
  render: (h) => h(App),
}).$mount("#main-app");

接下来设置主应用的布局,会有一个菜单和显示区域,代码实现如下:

复制代码
// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
  ];
}

上面的代码是菜单配置的实现,还需要实现基座和微应用的显示区域(如下图)

micro-app

分析一下上面的代码:

  • 第 5 行:主应用菜单,用于渲染菜单
  • 第 9 行:主应用渲染区。在触发主应用路由规则时(由路由配置表的 $route.name 判断),将渲染主应用的组件
  • 第 10 行:微应用渲染区。在未触发主应用路由规则时(由路由配置表的 $route.name 判断),将渲染微应用节点

从上面的分析可以看出,我们使用了在路由表配置的 name 字段进行判断,判断当前路由是否为主应用路由,最后决定渲染主应用组件或是微应用节点。

由于篇幅原因,样式实现代码就不贴出来了,最后主应用的实现效果如下图所示:

micro-app

从上图可以看出,我们主应用的组件和微应用是显示在同一片内容区域,根据路由规则决定渲染规则。

注册微应用

在构建好了主框架后,需要使用 qiankunregisterMicroApps 方法注册微应用,代码实现如下:

复制代码
// micro-app-main/src/micro/apps.ts
// 此时我们还没有微应用,所以 apps 为空
const apps = [];

export default apps;

// micro-app-main/src/micro/index.ts
// 一个进度条插件
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import { message } from "ant-design-vue";
import {
  registerMicroApps,
  addGlobalUncaughtErrorHandler,
  start,
} from "qiankun";

// 微应用注册信息
import apps from "./apps";

/**
 * 注册微应用
 * 第一个参数 - 微应用的注册信息
 * 第二个参数 - 全局生命周期钩子
 */
registerMicroApps(apps, {
  // qiankun 生命周期钩子 - 微应用加载前
  beforeLoad: (app: any) => {
    // 加载微应用前,加载进度条
    NProgress.start();
    console.log("before load", app.name);
    return Promise.resolve();
  },
  // qiankun 生命周期钩子 - 微应用挂载后
  afterMount: (app: any) => {
    // 加载微应用前,进度条加载完成
    NProgress.done();
    console.log("after mount", app.name);
    return Promise.resolve();
  },
});

/**
 * 添加全局的未捕获异常处理器
 */
addGlobalUncaughtErrorHandler((event: Event | string) => {
  console.error(event);
  const { message: msg } = event as any;
  // 加载失败时提示
  if (msg && msg.includes("died in status LOADING_SOURCE_CODE")) {
    message.error("微应用加载失败,请检查应用是否可运行");
  }
});

// 导出 qiankun 的启动函数
export default start;

从上面可以看出,微应用注册信息在 apps 数组中(此时为空,后面接入微应用时会添加微应用注册信息),然后使用 qiankunregisterMicroApps 方法注册微应用,最后导出了 start 函数,注册微应用的工作就完成啦!

启动主应用

注册好了微应用,导出 start 函数后,需要在合适的地方调用 start 启动主应用,一般是在入口文件启动 qiankun 主应用,代码实现如下:

复制代码
// micro-app-main/src/main.ts
//...
import startQiankun from "./micro";

startQiankun();

最后,启动主应用,效果图如下:

micro-app

因为还没有注册任何微应用,所以这里的效果图和上面的效果图是一样的。

到这一步,主应用基座就创建好啦!

接入微应用

现在主应用基座只有一个主页,需要我们接入微应用。

qiankun 内部通过 import-entry-html 加载微应用,要求微应用需要导出生命周期钩子函数(见下图):

micro-app

从上图可以看出,qiankun 内部会校验微应用的生命周期钩子函数,如果微应用没有导出这三个生命周期钩子函数,则微应用会加载失败。

如果使用了脚手架搭建微应用的话,我们可以通过 webpack 配置在入口文件处导出这三个生命周期钩子函数。如果没有使用脚手架的话,也可以直接在微应用的 window 上挂载这三个生命周期钩子函数。

下面来接入各个技术栈微应用吧!

接入 Vue 微应用

在主应用的同级目录(micro-app-main 同级目录),使用 vue-cli 先创建一个 Vue 的项目,在命令行运行如下命令:

复制代码
vue create micro-app-vue

本文的 vue-cli 选项如下图所示,你也可以根据自己的喜好选择配置。

micro-app

在新建项目完成后,创建几个路由页面再加上一些样式,最后效果如下:

micro-app

micro-app

注册微应用

在创建好了 Vue 微应用后,我们可以开始接入工作了。首先需要在主应用中注册该微应用的信息,代码实现如下:

复制代码
// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "VueMicroApp",
    entry: "//localhost:10200",
    container: "#frame",
    activeRule: "/vue",
  },
];

export default apps;

通过上面的代码,我们在主应用中注册了 Vue 微应用,进入 /vue 路由时将加载我们的 Vue 微应用。

在菜单配置处也加入 Vue 微应用的快捷入口,代码实现如下:

复制代码
// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
    {
      key: "VueMicroApp",
      title: "Vue 主页",
      path: "/vue",
    },
    {
      key: "VueMicroAppList",
      title: "Vue 列表页",
      path: "/vue/list",
    },
  ];
}

菜单配置完成后,主应用基座效果图如下:

micro-app

配置微应用

在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。首先,我们在 Vue 的入口文件 main.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:

micro-app

从上图来分析:

  • 第 6 行webpack 默认的 publicPath"" 空字符串,会基于当前路径来加载资源。我们在主应用中加载微应用时需要重新设置 publicPath,这样才能正确加载微应用的相关资源。(public-path.js 具体实现在后面)
  • 第 21 行:微应用的挂载函数,在主应用中运行时将在 mount 生命周期钩子函数中调用,可以保证在沙箱内运行。
  • 第 38 行:微应用独立运行时,直接执行 render 函数挂载微应用。
  • 第 46 行:微应用导出的生命周期钩子函数 - bootstrap
  • 第 53 行:微应用导出的生命周期钩子函数 - mount
  • 第 61 行:微应用导出的生命周期钩子函数 - unmount

完整代码实现如下:

复制代码
// micro-app-vue/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// micro-app-vue/src/main.js
import Vue from "vue";
import VueRouter from "vue-router";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/antd.css";

import "./public-path";
import App from "./App.vue";
import routes from "./routes";

Vue.use(VueRouter);
Vue.use(Antd);
Vue.config.productionTip = false;

let instance = null;
let router = null;

/**
 * 渲染函数
 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
 */
function render() {
  // 在 render 中创建 VueRouter,可以保证在卸载微应用时,移除 location 事件监听,防止事件污染
  router = new VueRouter({
    // 运行在主应用中时,添加路由命名空间 /vue
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "history",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

// 独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

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

/**
 * 应用每次进入都会调用 mount 方法,通常在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log("VueMicroApp mount", props);
  render(props);
}

/**
 * 应用每次切出/卸载会调用 unmount 方法,通常在这里卸载微应用的应用实例
 */
export async function unmount() {
  console.log("VueMicroApp unmount");
  instance.$destroy();
  instance = null;
  router = null;
}

在配置好了入口文件 main.js 后,还需要配置 webpack,使 main.js 导出的生命周期钩子函数可以被 qiankun 识别获取。

直接配置 vue.config.js 即可,代码实现如下:

复制代码
// micro-app-vue/vue.config.js
const path = require("path");

module.exports = {
  devServer: {
    // 监听端口
    port: 10200,
    // 关闭主机检查,使微应用可以被 fetch
    disableHostCheck: true,
    // 配置跨域请求头,解决开发环境的跨域问题
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  configureWebpack: {
    resolve: {
      alias: {
        "@": path.resolve(__dirname, "src"),
      },
    },
    output: {
      // 微应用的包名,这里与主应用中注册的微应用名称一致
      library: "VueMicroApp",
      // 将你的 library 暴露为所有的模块定义下都可运行的方式
      libraryTarget: "umd",
      // 按需加载相关,设置为 webpackJsonp_VueMicroApp 即可
      jsonpFunction: `webpackJsonp_VueMicroApp`,
    },
  },
};

需要重点关注一下 output 选项,当我们把 libraryTarget 设置为 umd 后,我们的 library 就暴露为所有的模块定义下都可运行的方式了,主应用就可以获取到微应用的生命周期钩子函数了。

vue.config.js 修改完成后,重新启动 Vue 微应用,然后打开主应用基座 http://localhost:9999。点击左侧菜单切换到微应用,此时我们的 Vue 微应用被正确加载啦!(见下图)

micro-app

打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)

micro-app

到这里,Vue 微应用就接入成功了!

接入 React 微应用

在主应用的同级目录(micro-app-main 同级目录),使用 create-react-app 先创建一个 React 的项目,在命令行运行如下命令:

复制代码
npx create-react-app micro-app-react

在项目创建完成后,我们在根目录下添加 .env 文件,设置项目监听的端口,代码实现如下:

复制代码
# micro-app-react/.env
PORT=10100
BROWSER=none

然后,我们创建几个路由页面再加上一些样式,最后效果如下:

micro-app

micro-app

注册微应用

在创建好了 React 微应用后,我们可以开始我们的接入工作了。首先需要在主应用中注册该微应用的信息,代码实现如下:

复制代码
// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "ReactMicroApp",
    entry: "//localhost:10100",
    container: "#frame",
    activeRule: "/react",
  },
];

export default apps;

通过上面的代码,我们在主应用中注册了 React 微应用,进入 /react 路由时将加载我们的 React 微应用。

在菜单配置处也加入 React 微应用的快捷入口,代码实现如下:

复制代码
// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
    {
      key: "ReactMicroApp",
      title: "React 主页",
      path: "/react",
    },
    {
      key: "ReactMicroAppList",
      title: "React 列表页",
      path: "/react/list",
    },
  ];
}

菜单配置完成后,主应用基座效果图如下:

micro-app

配置微应用

在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。首先,我们在 React 的入口文件 index.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:

micro-app

从上图来分析:

  • 第 5 行webpack 默认的 publicPath"" 空字符串,会基于当前路径来加载资源。我们在主应用中加载微应用时需要重新设置 publicPath,这样才能正确加载微应用的相关资源。(public-path.js 具体实现在后面)
  • 第 12 行:微应用的挂载函数,在主应用中运行时将在 mount 生命周期钩子函数中调用,可以保证在沙箱内运行。
  • 第 17 行:微应用独立运行时,直接执行 render 函数挂载微应用。
  • 第 25 行:微应用导出的生命周期钩子函数 - bootstrap
  • 第 32 行:微应用导出的生命周期钩子函数 - mount
  • 第 40 行:微应用导出的生命周期钩子函数 - unmount

完整代码实现如下:

复制代码
// micro-app-react/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// micro-app-react/src/index.js
import React from "react";
import ReactDOM from "react-dom";
import "antd/dist/antd.css";

import "./public-path";
import App from "./App.jsx";

/**
 * 渲染函数
 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
 */
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}

// 独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

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

/**
 * 应用每次进入都会调用 mount 方法,通常在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log("ReactMicroApp mount", props);
  render(props);
}

/**
 * 应用每次切出/卸载会调用 unmount 方法,通常在这里卸载微应用的应用实例
 */
export async function unmount() {
  console.log("ReactMicroApp unmount");
  ReactDOM.unmountComponentAtNode(document.getElementById("root"));
}

在配置好了入口文件 index.js 后,还需要配置路由命名空间,以确保主应用可以正确加载微应用,代码实现如下:

复制代码
// micro-app-react/src/App.jsx
const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/react" : "";
const App = () => {
  //...

  return (
    // 设置路由命名空间
    <Router basename={BASE_NAME}>{/* ... */}</Router>
  );
};

接下来还需要配置 webpack,使 index.js 导出的生命周期钩子函数可以被 qiankun 识别获取。

我们需要借助 react-app-rewired 来帮助我们修改 webpack 的配置,我们直接安装该插件:

复制代码
npm install react-app-rewired -D

react-app-rewired 安装完成后,我们还需要修改 package.jsonscripts 选项,修改为由 react-app-rewired 启动应用,就像下面这样

复制代码
// micro-app-react/package.json

//...
"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  "test": "react-app-rewired test",
  "eject": "react-app-rewired eject"
}

react-app-rewired 配置完成后,我们新建 config-overrides.js 文件来配置 webpack,代码实现如下:

复制代码
const path = require("path");

module.exports = {
  webpack: (config) => {
    // 微应用的包名,这里与主应用中注册的微应用名称一致
    config.output.library = `ReactMicroApp`;
    // 将你的 library 暴露为所有的模块定义下都可运行的方式
    config.output.libraryTarget = "umd";
    // 按需加载相关,设置为 webpackJsonp_VueMicroApp 即可
    config.output.jsonpFunction = `webpackJsonp_ReactMicroApp`;

    config.resolve.alias = {
      ...config.resolve.alias,
      "@": path.resolve(__dirname, "src"),
    };
    return config;
  },

  devServer: function (configFunction) {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      // 关闭主机检查,使微应用可以被 fetch
      config.disableHostCheck = true;
      // 配置跨域请求头,解决开发环境的跨域问题
      config.headers = {
        "Access-Control-Allow-Origin": "*",
      };
      // 配置 history 模式
      config.historyApiFallback = true;

      return config;
    };
  },
};

需要重点关注一下 output 选项,当我们把 libraryTarget 设置为 umd 后,我们的 library 就暴露为所有的模块定义下都可运行的方式了,主应用就可以获取到微应用的生命周期钩子函数了。

config-overrides.js 修改完成后,我们重新启动 React 微应用,然后打开主应用基座 http://localhost:9999。点击左侧菜单切换到微应用,此时我们的 React 微应用被正确加载啦!(见下图)

micro-app

打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)

micro-app

到这里,React 微应用就接入成功了!

接入 Jquery、xxx... 微应用

这里的 Jquery、xxx... 微应用指的是没有使用脚手架,直接采用 html + css + js 三剑客开发的应用。

本案例使用了一些高级 ES 语法,请使用谷歌浏览器运行查看效果。

我们以 实战案例 - feature-inject-sub-apps 分支 为例,我们在主应用的同级目录(micro-app-main 同级目录),手动创建目录 micro-app-static

使用 express 作为服务器加载静态 html,我们先编辑 package.json,设置启动命令和相关依赖。

复制代码
// micro-app-static/package.json
{
  "name": "micro-app-jquery",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "nodemon index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1",
    "cors": "^2.8.5"
  },
  "devDependencies": {
    "nodemon": "^2.0.2"
  }
}

然后添加入口文件 index.js,代码实现如下:

复制代码
// micro-app-static/index.js
const express = require("express");
const cors = require("cors");

const app = express();
// 解决跨域问题
app.use(cors());
app.use('/', express.static('static'));

// 监听端口
app.listen(10400, () => {
  console.log("server is listening in http://localhost:10400")
});

使用 npm install 安装相关依赖后,我们使用 npm start 启动应用。

新建 static 文件夹,在文件夹内新增一个静态页面 index.html(代码在后面会贴出),加上一些样式后,打开浏览器,最后效果如下:

micro-app

注册微应用

在创建好了 Static 微应用后,可以开始我们的接入工作了。首先我们需要在主应用中注册该微应用的信息,代码实现如下:

复制代码
// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "StaticMicroApp",
    entry: "//localhost:10400",
    container: "#frame",
    activeRule: "/static"
  },
];

export default apps;

通过上面的代码,我们在主应用中注册了 Static 微应用,进入 /static 路由时将加载我们的 Static 微应用。

在菜单配置处也加入 Static 微应用的快捷入口,代码实现如下:

复制代码
// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/"
    },
    {
      key: "StaticMicroApp",
      title: "Static 微应用",
      path: "/static"
    }
  ];
}

菜单配置完成后,主应用基座效果图如下:

micro-app

配置微应用

在主应用注册好了微应用后,我们还需要直接写微应用 index.html 的代码即可,代码实现如下:

micro-app

从上图来分析:

  • 第 70 行:微应用的挂载函数,在主应用中运行时将在 mount 生命周期钩子函数中调用,可以保证在沙箱内运行。
  • 第 77 行:微应用独立运行时,直接执行 render 函数挂载微应用。
  • 第 88 行:微应用注册的生命周期钩子函数 - bootstrap
  • 第 95 行:微应用注册的生命周期钩子函数 - mount
  • 第 102 行:微应用注册的生命周期钩子函数 - unmount

完整代码实现如下:

复制代码
<!-- micro-app-static/static/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <!-- 引入 bootstrap -->
    <link
      href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.4.1/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <title>Jquery App</title>
  </head>

  <body>
    <section
      id="jquery-app-container"
      style="padding: 20px; color: blue;"
    ></section>
  </body>
  <!-- 引入 jquery -->
  <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
  <script>
    /**
     * 请求接口数据,构建 HTML
     */
    async function buildHTML() {
      const result = await fetch("http://dev-api.jt-gmall.com/mall", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        // graphql 的查询风格
        body: JSON.stringify({
          query: `{ vegetableList (page: 1, pageSize: 20) { page, pageSize, total, items { _id, name, poster, price } } }`,
        }),
      }).then((res) => res.json());
      const list = result.data.vegetableList.items;
      const html = `<table class="table">
  <thead>
    <tr>
      <th scope="col">菜名</th>
      <th scope="col">图片</th>
      <th scope="col">报价</th>
    </tr>
  </thead>
  <tbody>
    ${list
      .map(
        (item) => `
    <tr>
      <td>
        <img style="width: 40px; height: 40px; border-radius: 100%;" src="${item.poster}"></img>
      </td>
      <td>${item.name}</td>
      <td>¥ ${item.price}</td>
    </tr>
      `
      )
      .join("")}
  </tbody>
</table>`;
      return html;
    }

    /**
     * 渲染函数
     * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
     */
    const render = async ($) => {
      const html = await buildHTML();
      $("#jquery-app-container").html(html);
      return Promise.resolve();
    };

    // 独立运行时,直接挂载应用
    if (!window.__POWERED_BY_QIANKUN__) {
      render($);
    }

    ((global) => {
      /**
       * 注册微应用生命周期钩子函数
       * global[appName] 中的 appName 与主应用中注册的微应用名称一致
       */
      global["StaticMicroApp"] = {
        /**
         * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
         * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
         */
        bootstrap: () => {
          console.log("MicroJqueryApp bootstraped");
          return Promise.resolve();
        },
        /**
         * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
         */
        mount: () => {
          console.log("MicroJqueryApp mount");
          return render($);
        },
        /**
         * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
         */
        unmount: () => {
          console.log("MicroJqueryApp unmount");
          return Promise.resolve();
        },
      };
    })(window);
  </script>
</html>

在构建好了 Static 微应用后,打开主应用基座 http://localhost:9999。点击左侧菜单切换到微应用,此时可以看到,我们的 Static 微应用被正确加载啦!(见下图)

micro-app

打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)

micro-app

到这里,Static 微应用就接入成功了!

扩展阅读

如果在 Static 微应用的 html 中注入 SPA 路由功能的话,将演变成单页应用,只需要在主应用中注册一次。

如果是多个 html 的多页应用 - MPA,则需要在服务器(或反向代理服务器)中通过 referer 头返回对应的 html 文件,或者在主应用中注册多个微应用(不推荐)。

注意点

  • 设置 publicPath

webpack 默认的 publicPath 为 "" 空字符串,会基于当前路径来加载资源。在主应用中加载微应用时需要重新设置 publicPath,这样才能正确加载微应用的相关资源。

  • history 路由的微应用需要设置 base

主应用中运行 / 微应用单独启动时的路由是不同的,需要在 main.js 中配置

  • 配置 webpack

使 main.js 导出的生命周期钩子函数可以被 qiankun 识别获取

小结

最后,所有微应用都注册在主应用和主应用的菜单中,效果图如下:

micro-app

从上图可以看出,我们把不同技术栈 Vue、React、Angular、Jquery... 的微应用都已经接入到主应用基座中啦!

应用通信

juejin.cn/post/684490…

主要介绍两种通信方式:

  1. 第一种是 qiankun 官方提供的通信方式 Actions 通信,适合业务划分清晰,比较简单的微前端应用,一般来说使用第一种方案就可以满足大部分的应用场景需求。
  2. 第二种是基于 vuex / redux 实现的通信方式 Shared 通信,适合需要跟踪通信状态,子应用具备独立运行能力,较为复杂的微前端应用。

Actions 通信

通信原理

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

  • setGlobalState:设置 globalState

设置新的值时,内部将执行 浅检查,如果检查到 globalState 发生改变则触发通知,通知到所有的 观察者 函数。

  • onGlobalStateChange:注册 观察者 函数

响应 globalState 变化,在 globalState 发生改变时触发该 观察者 函数。

  • offGlobalStateChange:取消 观察者 函数

该实例不再响应 globalState 变化。

画一张图来帮助理解(见下图)

micro-app

从上图可以看出,我们可以先注册 观察者 到观察者池中,然后通过修改 globalState 可以触发所有的 观察者 函数,从而达到组件间通信的效果。

实战教程

主应用的工作

首先在主应用中注册一个 MicroAppStateActions 实例并导出,代码实现如下:

复制代码
// micro-app-main/src/shared/actions.ts
import { initGlobalState, MicroAppStateActions } from "qiankun";

const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);

export default actions;

在注册 MicroAppStateActions 实例后,我们在需要通信的组件中使用该实例,并注册 观察者 函数,这里以登录功能为例,实现如下:

复制代码
// micro-app-main/src/pages/login/index.vue
import actions from "@/shared/actions";
import { ApiLoginQuickly } from "@/apis";

@Component
export default class Login extends Vue {
  $router!: VueRouter;

  // `mounted` 是 Vue 的生命周期钩子函数,在组件挂载时执行
  mounted() {
    // 注册一个观察者函数
    actions.onGlobalStateChange((state, prevState) => {
      // state: 变更后的状态; prevState: 变更前的状态
      console.log("主应用观察者:token 改变前的值为 ", prevState.token);
      console.log("主应用观察者:登录状态发生改变,改变后的 token 的值为 ", state.token);
    });
  }
  
  async login() {
    // ApiLoginQuickly 是一个远程登录函数,用于获取 token,详见 Demo
    const result = await ApiLoginQuickly();
    const { token } = result.data.loginQuickly;

    // 登录成功后,设置 token
    actions.setGlobalState({ token });
  }
}

在上面的代码中,我们在 Vue 组件mounted 生命周期钩子函数中注册了一个 观察者 函数,然后定义了一个 login 方法,最后将 login 方法绑定在下图的按钮中(见下图):

micro-app

此时点击 2 次按钮,将触发我们在主应用设置的 观察者 函数(如下图):

micro-app

从上图中我们可以看出,我们的 globalState 成功更新了:

  • 第一次点击:原 token 值为 undefined,新 token 值为最新设置的值
  • 第二次点击:原 token 值为上次设置的值,新 token 值为最新设置的值

最后在 login 方法最后加上一行代码,在登录后跳转到主页,代码实现如下:

复制代码
async login() {
  //...

  this.$router.push("/");
}

子应用的工作

我们已经完成了主应用的登录功能,将 token 信息记录在了 globalState 中。现在,我们进入子应用,使用 token 获取用户信息并展示在页面中。

疑问:主应用initGlobalState的返回值,不需要传递给子应用吗?

这个在qiankun内部,已经封装好了,直接使用就好

接下来改造 Vue 子应用,设置一个 Actions 实例,代码实现如下:

复制代码
// micro-app-vue/src/shared/actions.js
function emptyAction() {
  // 警告:提示当前使用的是空 Action
  console.warn("Current execute action is empty!");
}

class Actions {
  // 默认值为空 Action
  actions = {
    setGlobalState: emptyAction,
    onGlobalStateChange: emptyAction,
    offGlobalStateChange,
  };
  
  /**
   * 设置 actions
   */
  setActions(actions) {
    this.actions = actions;
  }
  
  /**
   * 映射 setGlobalState
   */
  setGlobalState(...args) {
    return this.actions.setGlobalState(...args);
  },

  /**
   * 映射 onGlobalStateChange
   */
  onGlobalStateChange(...args) {
    return this.actions.onGlobalStateChange(...args);
  }
  
  /**
   * 映射 offGlobalStateChange
   */
  offGlobalStateChange(...args) {
    return this.actions.offGlobalStateChange(...args) 
  }
}

const actions = new Actions();
export default actions;

创建 actions 实例后,需要为其注入真实 Actions。我们在入口文件 main.jsrender 函数中注入,代码实现如下:

复制代码
// micro-app-vue/src/main.js
//...

/**
 * 渲染函数
 * 主应用生命周期钩子中运行/子应用单独启动时运行
 */
function render(props) {
  /** 
  * 这里的 setGlobalState,onGlobalStateChange,offGlobalStateChange 
  * 是qiankun自动帮我们注入的,所以直接使用即可 
  */
  if (props) {
    // 注入 actions 实例
    // props 中包含 setGlobalState 等对象
    actions.setActions(props);
  }

  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "history",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

从上面的代码可以看出,挂载子应用时将会调用 render 方法,我们在 render 方法中将主应用的 actions 实例注入即可。

最后我们在子应用的 通讯页 获取 globalState 中的 token,使用 token 来获取用户信息,最后在页面中显示用户信息。代码实现如下:

复制代码
// micro-app-vue/src/pages/communication/index.vue
// 引入 actions 实例
import actions from "@/shared/actions";
import { ApiGetUserInfo } from "@/apis";

export default {
  name: "Communication",

  data() {
    return {
      userInfo: {}
    };
  },

  mounted() {
    // 注册观察者函数
    // onGlobalStateChange 第二个参数为 true,表示立即执行一次观察者函数
    actions.onGlobalStateChange(state => {
      const { token } = state;
      // 未登录 - 返回主页
      if (!token) {
        this.$message.error("未检测到登录信息!");
        return this.$router.push("/");
      }

      // 获取用户信息
      this.getUserInfo(token);
    }, true);
  },

  methods: {
    async getUserInfo(token) {
      // ApiGetUserInfo 是用于获取用户信息的函数
      const result = await ApiGetUserInfo(token);
      this.userInfo = result.data.getUserInfo;
    }
  }
};

从上面的代码可以看到,我们在组件挂载时注册了一个 观察者 函数并立即执行,从 globalState/state 中获取 token,然后使用 token 获取用户信息,最终渲染在页面中。

最后看看实际效果。我们从登录页面点击 Login 按钮后,通过菜单进入 Vue 通讯页,就可以看到效果啦!(见下图)

micro-app

React 子应用的实现也是类似的,实现代码可以参照 完整 Demo - feature-communication 分支,实现效果如下(见下图)

micro-app

小结

到这里,qiankun 基础通信 就完成了!

我们在主应用中实现了登录功能,登录拿到 token 后存入 globalState 状态池中。在进入子应用时,我们使用 actions 获取 token,再使用 token 获取到用户信息,完成页面数据渲染!

最后我们画一张图帮助大家理解这个流程(见下图)。

micro-app

Shared 通信

由于 Shared 方案实现起来会较为复杂,所以当 Actions 通信方案满足需求时,使用 Actions 通信方案可以得到更好的官方支持。

官方提供的 Actions 通信方案是通过全局状态池和观察者函数进行应用间通信,该通信方式适合大部分的场景。

Actions 通信方案也存在一些优缺点

优点如下:

  1. 使用简单
  2. 官方支持性高
  3. 适合通信较少的业务场景

缺点如下:

  1. 子应用独立运行时,需要额外配置无 Actions 时的逻辑
  2. 子应用需要先了解状态池的细节,再进行通信
  3. 由于状态池无法跟踪,通信场景较多时,容易出现状态混乱、维护困难等问题

如果你的应用通信场景较多,希望子应用具备完全独立运行能力,希望主应用能够更好的管理子应用,那么可以考虑 Shared 通信方案。

通信原理

Shared 通信方案的原理:主应用基于 redux / vuex 维护一个状态池,通过 shared 实例暴露一些方法给子应用使用。同时,子应用需要单独维护一份 shared 实例,在独立运行时使用自身的 shared 实例,在嵌入主应用时使用主应用的 shared 实例,这样就可以保证在使用和表现上的一致性。

Shared 通信方案需要自行维护状态池,这样会增加项目的复杂度。好处是可以使用市面上比较成熟的状态管理工具,如 reduxmobx,可以有更好的状态管理追踪和一些工具集。

Shared 通信方案要求父子应用都各自维护一份属于自己的 shared 实例,同样会增加项目的复杂度。好处是子应用可以完全独立于父应用运行(不依赖状态池),子应用也能以最小的改动被嵌入到其他 第三方应用 中。

Shared 通信方案也可以帮助主应用更好的管控子应用。子应用只可以通过 shared 实例来操作状态池,可以避免子应用对状态池随意操作引发的一系列问题。主应用的 Shared 相对于子应用来说是一个黑箱,子应用只需要了解 Shared 所暴露的 API 而无需关心实现细节。

实战教程

主应用的工作

首先。需要在主应用中创建 store 用于管理全局状态池,这里我们使用 redux 来实现,代码实现如下:

复制代码
// micro-app-main/src/shared/store.ts
import { createStore } from "redux";

export type State = {
  token?: string;
};

type Action = {
  type: string;
  payload: any;
};

const reducer = (state: State = {}, action: Action): State => {
  switch (action.type) {
    default:
      return state;
    // 设置 Token
    case "SET_TOKEN":
      return {
        ...state,
        token: action.payload,
      };
  }
};

const store = createStore<State, Action, unknown, unknown>(reducer);

export default store;

从上面可以看出,我们使用 redux 创建了一个全局状态池,并设置了一个 reducer 用于修改 token 的值。接下来我们需要实现主应用的 shared 实例,代码实现如下:

复制代码
// micro-app-main/src/shared/index.ts
import store from "./store";

class Shared {
  /**
   * 获取 Token
   */
  public getToken(): string {
    const state = store.getState();
    return state.token || "";
  }

  /**
   * 设置 Token
   */
  public setToken(token: string): void {
    // 将 token 的值记录在 store 中
    store.dispatch({
      type: "SET_TOKEN",
      payload: token
    });
  }
}

const shared = new Shared();
export default shared;

从上面实现可以看出,我们的 shared 实现非常简单,shared 实例包括两个方法 getTokensetToken 分别用于获取 token 和设置 token。接下来我们还需要对我们的 登录组件 进行改造,将 login 方法修改一下,修改如下:

复制代码
// micro-app-main/src/pages/login/index.vue
// ...
async login() {
  // ApiLoginQuickly 是一个远程登录函数,用于获取 token,详见 Demo
  const result = await ApiLoginQuickly();
  const { token } = result.data.loginQuickly;

  // 使用 shared 的 setToken 方法记录 token
  shared.setToken(token);
  this.$router.push("/");
}

从上面可以看出,登录成功后将通过 shared.setToken 方法将 token 记录在 store 中。

最后,我们需要将 shared 实例通过 props 传递给子应用,代码实现如下:

复制代码
// micro-app-main/src/micro/apps.ts
import shared from "@/shared";

const apps = [
  {
    name: "ReactMicroApp",
    entry: "//localhost:10100",
    container: "#frame",
    activeRule: "/react",
    // 通过 props 将 shared 传递给子应用
    props: { shared },
  },
  {
    name: "VueMicroApp",
    entry: "//localhost:10200",
    container: "#frame",
    activeRule: "/vue",
    // 通过 props 将 shared 传递给子应用
    props: { shared },
  },
];

export default apps;

子应用的工作

现在,我们来处理子应用需要做的工作。我们刚才提到,希望子应用有独立运行的能力,所以子应用也应该实现 shared,以便在独立运行时可以拥有兼容处理能力。代码实现如下:

复制代码
// micro-app-vue/src/shared/index.js
class Shared {
  /**
   * 获取 Token
   */
  getToken() {
    // 子应用独立运行时,在 localStorage 中获取 token
    return localStorage.getItem("token") || "";
  }

  /**
   * 设置 Token
   */
  setToken(token) {
    // 子应用独立运行时,在 localStorage 中设置 token
    localStorage.setItem("token", token);
  }
}

class SharedModule {
  static shared = new Shared();

  /**
   * 重载 shared
   */
  static overloadShared(shared) {
    SharedModule.shared = shared;
  }

  /**
   * 获取 shared 实例
   */
  static getShared() {
    return SharedModule.shared;
  }
}

export default SharedModule;

从上面我们可以看到两个类,我们来分析一下其用处:

  • Shared:子应用自身的 shared,子应用独立运行时将使用该 shared,子应用的 shared 使用 localStorage 来操作 token
  • SharedModule:用于管理 shared,例如重载 shared 实例、获取 shared 实例等等;

实现子应用的 shared 后,需要在入口文件处注入 shared,代码实现如下:

复制代码
// micro-app-vue/src/main.js
//...

/**
 * 渲染函数
 * 主应用生命周期钩子中运行/子应用单独启动时运行
 */
function render(props = {}) {
  // 当传入的 shared 为空时,使用子应用自身的 shared
  // 当传入的 shared 不为空时,主应用传入的 shared 将会重载子应用的 shared
  const { shared = SharedModule.getShared() } = props;
  SharedModule.overloadShared(shared);

  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "history",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

从上面可以看出,在 propsshared 字段不为空时,将会使用传入的 shared 重载子应用自身的 shared。这样做的话,主应用的 shared 和子应用的 shared 在使用时的表现是一致的。

然后修改子应用的 通讯页,使用 shared 实例获取 token,代码实现如下:

复制代码
// micro-app-vue/src/pages/communication/index.vue
// 引入 SharedModule
import SharedModule from "@/shared";
import { ApiGetUserInfo } from "@/apis";

export default {
  name: "Communication",

  data() {
    return {
      userInfo: {}
    };
  },

  mounted() {
    const shared = SharedModule.getShared();
    // 使用 shared 获取 token
    const token = shared.getToken();

    // 未登录 - 返回主页
    if (!token) {
      this.$message.error("未检测到登录信息!");
      return this.$router.push("/");
    }

    this.getUserInfo(token);
  },

  methods: {
    async getUserInfo(token) {
      // ApiGetUserInfo 是用于获取用户信息的函数
      const result = await ApiGetUserInfo(token);
      this.userInfo = result.data.getUserInfo;
    }
  }
};

最后我们打开页面,看看在主应用中运行和独立运行时的表现吧!(见下图)

micro-app

micro-app

上图 1 可以看出,在主应用中运行子应用时,shared 实例被主应用重载,登录后可以在状态池中获取到 token,并且使用 token 成功获取了用户信息。

上图 2 可以看出,独立运行子应用时,shared 实例是子应用自身的 shared,在 localStorage 中无法获取到 token,被拦截返回到主页。

这样一来,我们就完成了 Shared 通信啦!

小结

从上面案例也可以看出 Shared 通信方案的优缺点,这里也做一些简单的分析:

优点有这些:

  • 可以自由选择状态管理库,更好的开发体验。 比如 redux 有专门配套的开发工具可以跟踪状态的变化。
  • 子应用无需了解主应用的状态池实现细节,只需要了解 shared 的函数抽象,实现一套自身的 shared 甚至空 shared 即可,可以更好的规范子应用开发。
  • 子应用无法随意污染主应用的状态池,只能通过主应用暴露的 shared 实例的特定方法操作状态池,从而避免状态池污染产生的问题。
  • 子应用将具备独立运行的能力,Shared 通信使得父子应用有了更好的解耦性。

缺点也有两个:

  • 主应用需要单独维护一套状态池,会增加维护成本和项目复杂度;
  • 子应用需要单独维护一份 shared 实例,会增加维护成本;

Shared 通信方式也是有利有弊,更高的维护成本带来的是应用的健壮性和可维护性。

最后画一张图对 shared 通信的原理和流程进行解析(见下图)

micro-app

总结

到这里,两种 qiankun 应用间通信方案就分享完啦!

两种通信方案都有合适的使用场景,大家可以结合自己的需要选择即可。

还有一个方法,直接在主应用的 main.js 中,将方法挂载到 window 上面,这样子应用就可以通过 window 拿到想要的属性和方法。

微前端进阶

手动加载子应用

主应用加载子应用有两种模式,一种是自动加载,另一种是手动加载

使用场景如下:

  • 主动加载:通过监听浏览器 URL 的变化,自动的加载其所对应的子应用

更适用于当前业务系统可单独抽离出来,比如在一个销售类的 CRM 管理系统中,可以将物流模块单独抽离出来。

只能加载一个子应用

不需要手动销毁

  • 手动加载:在主应用中触发相应的操作,从而加载子应用

手动加载的个性化定制更强,因为我们可以在触发主应用中某一个操作,或者更改某一个数据的时候,加载子系统。

可以同时加载多个子应用

需要手动销毁

自动加载

首先准备一个 Vue3 版本的主应用,执行安装 qiankun 的命令npm install qiankun -S,然后在主应用中加入如下配置:

重点字段及其含义在注释中标明

js
复制代码
// main.js中
import { registerMicroApps, start } from 'qiankun'

registerMicroApps([ // registerMicroApps, 注册微应用
  {
    name: 'qiankun-vue3', // 微应用的名称
    entry: '//localhost:5000', // 微应用的地址
    container: '#sub-app', // 主应用中挂载微应用的 Dom 节点
    activeRule: "vue3", // 当路由匹配到activeRule的时候,自动加载微应用
    props: {} // 向微应用中传递的参数,稍后会有介绍
  },
  {
    name: 'qiankun-vue2',
    entry: '//localhost:4000',
    container: '#sub-app',
    activeRule: "vue2"
  }
])

// 启动 qiankun
start()

其中的 container 字段比较重要,这个字段所指的是挂载子应用的 dom 节点,这个节点不要跟随主应用中页面的变化而消失,一般放在 App.vue 或者菜单的右侧可视区域中。

以菜单为例,假设菜单有如下 HTML 结构:

html
复制代码
// Menu.vue
<section>
  <aside>
    <ul>
      <li>菜单1</li>
      <li>菜单2</li>
    </ul>
  </aside>
  <main>
    <!-- 此处为重点 -->
    <div id="sub-app"></div>
    <router-view></router-view>
  </main>
</section>

在上述代码中,将 Vue 的内置组件 <router-view /> 放在了 <main> 标签中,即页面变化的时候,只在 main 标签下进行更改。同样,当子应用加载的时候,就会以 idsub-app 的标签作为容器

手动加载

手动加载就涉及到了一些特定的逻辑。假设有如下逻辑:

当路由是以 /vue2 开头的时候,加载 vue2 的子系统;

当路由是以 /vue3 开头的时候,加载 vue3 的子系统;

当路由不是以 /vue2/vue3 开头的时候,分别卸载其子系统

在此,不考虑 /vue2/pageOne/vue2/pageTwo 的子应用重复卸载在加载的影响,大致实现如下:

js
复制代码
// 定义当前的主应用
let activeMicroApp: MicroApp | null = null

// 子应用加载逻辑
router.beforeEach((to, from, next) => {
  // 如果当前主应用存在即卸载
  if (activeMicroApp) {
    activeMicroApp.unmount()
  }
  
  // 当路由以/vue3开头的时候
  if (to.path.startsWith("/vue3")) {
    activeMicroApp = loadMicroApp({
      name: 'qiankun-vue3',
      entry: '//localhost:5000',
      container: '#sub-app',
      props: {}
    })
  }

  if (to.path.startsWith("/vue2")) {
    activeMicroApp = loadMicroApp({
      name: 'qiankun-vue2', // app name registered
      entry: '//localhost:4000',
      container: '#sub-app'
    })
  }
  next()
})

总结

自动加载和手动加载的参数配置,就差在了字段 activeRule 上,因为在自动加载中,应用是通过监听 URL 的变化,从而加载子应用;而手动加载则不需要,因为手动加载可以适应其他更复杂的业务逻辑

最后基于手动加载的特性,可以实现同时加载多个子系统的需求,这时子应用的注册信息中,只需要将 container 保持不一致即可。

跨系统页面的跳转

经过 qiankun 进行改造过的系统,可能会存在多个系统,在这儿暂且称为主系统A子系统B子系统C,他们可能存在如下几种路由跳转的情况:

  • 主系统A 跳转到 子系统B,也就是主系统跳转到子系统
  • 子系统B跳回主系统A,也就是子系统跳回主系统
  • 子系统B跳转到子系统C,也就是子系统之间的跳转
  • 主系统A的page1跳转到主系统A的page2,或者子系统B的page1跳转到子系统B的page2,也就是系统内部的跳转

综上所述的几种情况,既然存在了跨系统的调用,那么我们具体该怎么实现呢?

主系统到子系统

在需要跳转到子系统的时候,带上加载子系统时所配置的 activeRule 即可

子系统到主系统

子系统跳转到主系统,如果使用的是子系统的 router,我们会发现,不管怎么书写代码,一定跳不出子系统的 router,因为这个 router 是子应用的路由,所有的跳转都会基于子应用的 activeRule

这里可以采用以下两种方式:

  • 将主应用的路由实例通过 props 传给子应用,子应用通过这个路由实例跳转
  • 路由模式为 history 模式时,通过 history.pushState() 方式跳转

当然使用 <a> 链接可以跳转过去,但是会刷新页面,用户体验并不好。

这里封装了一个常用方法:

js
复制代码
/**
 * 微前端子应用路由跳转
 * @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)
}

子系统到子系统

子系统到子系统的路由跳转,也可以使用同子系统到主系统的路由跳转方式

系统内部路由跳转

子系统内部的跳转就比较容易了,直接使用子系统的 router 即可。

当然如果不嫌麻烦,也可以使用主系统的 router 或者 history.pushState()

js
复制代码
// 主系统跳转到子系统
// vue3
import { useRouter } from "vue-router"
const router = useRouter()
const goVue2 = () => {
  router.push({
    path: "/vue2/pageOne",
    query: {
      name: "张三"
    }
  })
}

// vue2
this.$router.push({
  path: "/vue2/pageOne"
})

-----------------------
// 子系统到主系统
import actions from "@/qiankun/actions.js"

// 方案一: 使用主应用的router
/**
* actions.parentRouter 是主应用中传递过来的主应用的 router
**/
actions.parentRouter.push({
  path: "/"
})

// 方案二:使用 history API
history.pushState("", "", "/") // PS: 具体用法可参考 MDN

------------------------
// 子系统到子系统
// 方案一、使用主应用的 router
actions.parentRouter.push({
  path: "/"
})

// 方案二、使用 history API
history.pushState("", "", "/") 

------------------------
// 系统内部的跳转:使用当前系统 router 的 API
// vue2
this.$router.push({ path: "/page1" })

// vue3
const router = useRouter()
router.push({ path: "/page1" })

子系统的 keep-alive

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

这里指的是子系统的 keep-alive,而不是主系统或者主系统和子系统的 keep-alive,原因如下:

  • 主系统其实是始终不会卸载的,他会一直存在于页面中;但是,子系统却不太一样,如果是通过自动加载的方式实现的微前端,那么子系统的加载逻辑会跟页面的路径有关,即当页面路径跳到其他子系统的时候,当前子系统就会卸载,那么他所缓存的页面dom也会随之消失
  • 如果在你的系统中,不存在跨系统的页面跳转,那你一定不会出现 keep-alive 这个问题,因为你的子系统,从始至终就没有被卸载
  • 如果你的系统存在在页面跳转时,需要跨系统的场景,但是这种场景并不多的话,其实,你可以将数据存入主系统中,子系统挂载的时候,再进行初始化数据,当然这种方案比较麻烦,需要手动存数据,所以他只适用于跨系统页面跳转较少的情况
  • 如果你的系统存在大量跨系统的交互时,或许你可以使用手动加载的方式来实现,因为手动加载需要主动卸载当前子应用如果你不卸载当前子应用的话,当前子应用就会一直存在,继而不会触发 keep-alive 丢失的场景

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

具体解决方案可以看 qiankun issues 里所给出的

微前端常见问题

主子应用样式相互影响

各个应用样式隔离,这个问题乾坤框架做了一定的处理,在运行时有一个 sandbox 的参数,默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。

如果要解决主应用和子应用的样式问题,目前有2种方式:

  • qiankun 中配置 { strictStyleIsolation: true } 表示开启严格的样式隔离模式。

这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。

但是基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来,这个在 qiankun 的 issue 里面有一些讨论和使用经验。

  • 人为用 css 前缀来隔离开主应用和子应用,在组件层面用 css scoped 进行组件层面的样式区分,在 css 框架层面可以给 css 组件库加上不同的前缀

elementPlus 自定义命名空间

element-plus.gitee.io/zh-CN/guide…

比如文档中的 antd 例子: 配置 webpack 修改 less 变量

js
复制代码
{
  loader: 'less-loader',
+ options: {
+   modifyVars: {
+     '@ant-prefix': 'yourPrefix',
+   },
+   javascriptEnabled: true,
+ },
}

配置 antd ConfigProvider

js
复制代码
import { ConfigProvider } from 'antd';
   
export const MyApp = () => (
  <ConfigProvider prefixCls="yourPrefix">
    <App />
  </ConfigProvider>
);

应用间通信

  1. 官方提供的 actions
  2. 官方提供的 props
  3. 通过路由参数共享
  4. localStorage/sessionStorage
  5. 使用 vuex/redux 管理状态,通过 shared 分享

具体实现参考这篇文章 qiankun的五种通信方式

适配 vue-pdf 报错

找到 vue-pdf 的依赖包下的 vuePdfNoSss.vue

vue
复制代码
//找到vue-pdf的依赖包下的vuePdfNoSss.vue
<style src="./annotationLayer.css"></style>
<script>
	import componentFactory from './componentFactory.js'
	if ( process.env.VUE_ENV !== 'server' ) {
		var pdfjsWrapper = require('./pdfjsWrapper.js').default;
		var PDFJS = require('pdfjs-dist/es5/build/pdf.js');
		if ( typeof window !== 'undefined' && 'Worker' in window && navigator.appVersion.indexOf('MSIE 10') === -1 ) {
      // 注释原本的引入方法
			// var PdfjsWorker = require('worker-loader!pdfjs-dist/es5/build/pdf.worker.js');
			  var PdfjsWorker=require('pdfjs-dist/es5/build/pdf.worker.js');
			PDFJS.GlobalWorkerOptions.workerPort = new PdfjsWorker();
		}
		var component = componentFactory(pdfjsWrapper(PDFJS));
	} else {
		var component = componentFactory({});
	}
	export default component;
</script>

修改项目的配置文件 vue.config.js

js
复制代码
chainWebpack: (config) => {
  config.module
    .rule('worker')
    .test(/.worker.js$/)
    .use('worker-loader').loader('worker-loader')
    .options({
      inline: true,
      fallback: false
    }).end();
}

qiankun 在子应用中引入百度地图时报错解决

因为 qiankun 会把静态资源的加载拦截,改用 fetch 方式获取资源,所以要求这些资源支持跨域,这里我们使用 qiankun 提供的 excludeAssetFilter 将其加入白名单放行。

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

修改主应用 start 方法

js
复制代码
// 启动微前端
if (!window.qiankunStarted) {
  window.qiankunStarted = true
  start({
    singular: false,
    excludeAssetFilter: (assetUrl) => {
      // 过滤baidu
      const wihiteWords = ['baidu']
      if (wihiteWords.includes(assetUrl)) {
        return true
      }
      return wihiteWords.some(w => {
        return assetUrl.includes(w)
      })
    }
  })
}

其他一些常见问题可见于 qiankun官网