引言
为什么不考虑 iframe
如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。
微前端架构
在开始介绍 qiankun 之前,我们需要先了解微前端架构如何划分子应用。
在微前端架构中,应该按业务划分出对应的子应用,而不是通过功能模块划分子应用,这么做的原因有两个:
- 在微前端架构中,子应用并不是一个模块,而是一个独立的应用,我们将子应用按业务划分可以拥有更好的可维护性和解耦性。
- 子应用应该具备独立运行的能力,应用间频繁的通信会增加应用的复杂度和耦合度。
综上所述,我们应该从业务的角度出发划分各个子应用,尽可能减少应用间的通信,从而简化整个应用,使得我们的微前端架构可以更加灵活可控。
为什么要使用qiankun
使用 qiankun 的大背景基本也就是如下几点:
- 老项目耦合和很多条业务线,变得越来越庞大,发布版本 build 需要很久
- 老项目中存在很多的问题,但是又没有时间对他进行重构
- 老项目中的框架版本过低,或UI库的版本过低,导致很多功能无法使用
- 想使用更新的技术栈来开发,跟上时代变更的步伐
基于上述种种原因,qiankun 无非就成了拆分业务架构最合适的选择
qiankun 的优势有哪些
这里引用官网对他的描述:
- 技术栈无关:主框架不限制接入应用的技术栈,微应用具备完全自主权
- 独立开发,独立部署:微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
- 增量升级:在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
- 独立运行时:每个微应用之间状态隔离,运行时状态不共享
基本使用
介绍如何使用 qiankun 如何搭建主应用基座,然后接入不同技术栈的微应用,完成微前端架构的从 0 到 1。
本教程中,接入了多技术栈 微应用 的 主应用 最终效果图如下:
主应用基座
主应用在很多地方又叫做基座,顾名思义,如果拿盖房子来举例的话,他更像是地基,可以说,他的好坏从一定程度上也可以决定上层建筑。
主应用中应该只存放全局配置相关的信息,比如:
- 菜单页,可以控制页面跳转到不同的系统
- 权限获取,主应用中获取权限信息,然后传递给子应用
- 全局共享的数据和方法(这个公共的 utils 也可以单独打包然后发布到 npm )
- 登录功能和角色信息,一般用户的角色信息是不会经常变动的,只获取一次,之后存起来就好
构建主应用基座
使用 vue-cli 生成一个 Vue 的项目,初始化主应用。
将普通的项目改造成 qiankun 主应用基座,需要进行三步操作:
- 创建微应用容器 - 用于承载微应用,渲染显示微应用
- 注册微应用 - 设置微应用激活条件,微应用地址等等
- 启动
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: "/",
},
];
}
上面的代码是菜单配置的实现,还需要实现基座和微应用的显示区域(如下图)
分析一下上面的代码:
第 5 行:主应用菜单,用于渲染菜单第 9 行:主应用渲染区。在触发主应用路由规则时(由路由配置表的$route.name判断),将渲染主应用的组件第 10 行:微应用渲染区。在未触发主应用路由规则时(由路由配置表的$route.name判断),将渲染微应用节点
从上面的分析可以看出,我们使用了在路由表配置的 name 字段进行判断,判断当前路由是否为主应用路由,最后决定渲染主应用组件或是微应用节点。
由于篇幅原因,样式实现代码就不贴出来了,最后主应用的实现效果如下图所示:
从上图可以看出,我们主应用的组件和微应用是显示在同一片内容区域,根据路由规则决定渲染规则。
注册微应用
在构建好了主框架后,需要使用 qiankun 的 registerMicroApps 方法注册微应用,代码实现如下:
复制代码
// 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 数组中(此时为空,后面接入微应用时会添加微应用注册信息),然后使用 qiankun 的 registerMicroApps 方法注册微应用,最后导出了 start 函数,注册微应用的工作就完成啦!
启动主应用
注册好了微应用,导出 start 函数后,需要在合适的地方调用 start 启动主应用,一般是在入口文件启动 qiankun 主应用,代码实现如下:
复制代码
// micro-app-main/src/main.ts
//...
import startQiankun from "./micro";
startQiankun();
最后,启动主应用,效果图如下:
因为还没有注册任何微应用,所以这里的效果图和上面的效果图是一样的。
到这一步,主应用基座就创建好啦!
接入微应用
现在主应用基座只有一个主页,需要我们接入微应用。
qiankun 内部通过 import-entry-html 加载微应用,要求微应用需要导出生命周期钩子函数(见下图):
从上图可以看出,qiankun 内部会校验微应用的生命周期钩子函数,如果微应用没有导出这三个生命周期钩子函数,则微应用会加载失败。
如果使用了脚手架搭建微应用的话,我们可以通过 webpack 配置在入口文件处导出这三个生命周期钩子函数。如果没有使用脚手架的话,也可以直接在微应用的 window 上挂载这三个生命周期钩子函数。
下面来接入各个技术栈微应用吧!
接入 Vue 微应用
在主应用的同级目录(micro-app-main 同级目录),使用 vue-cli 先创建一个 Vue 的项目,在命令行运行如下命令:
复制代码
vue create micro-app-vue
本文的 vue-cli 选项如下图所示,你也可以根据自己的喜好选择配置。
在新建项目完成后,创建几个路由页面再加上一些样式,最后效果如下:
注册微应用
在创建好了 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",
},
];
}
菜单配置完成后,主应用基座效果图如下:
配置微应用
在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。首先,我们在 Vue 的入口文件 main.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:
从上图来分析:
第 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 微应用被正确加载啦!(见下图)
打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)
到这里,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
然后,我们创建几个路由页面再加上一些样式,最后效果如下:
注册微应用
在创建好了 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",
},
];
}
菜单配置完成后,主应用基座效果图如下:
配置微应用
在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。首先,我们在 React 的入口文件 index.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:
从上图来分析:
第 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.json 的 scripts 选项,修改为由 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 微应用被正确加载啦!(见下图)
打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)
到这里,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(代码在后面会贴出),加上一些样式后,打开浏览器,最后效果如下:
注册微应用
在创建好了 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"
}
];
}
菜单配置完成后,主应用基座效果图如下:
配置微应用
在主应用注册好了微应用后,我们还需要直接写微应用 index.html 的代码即可,代码实现如下:
从上图来分析:
第 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 微应用被正确加载啦!(见下图)
打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)
到这里,Static 微应用就接入成功了!
扩展阅读
如果在 Static 微应用的 html 中注入 SPA 路由功能的话,将演变成单页应用,只需要在主应用中注册一次。
如果是多个 html 的多页应用 - MPA,则需要在服务器(或反向代理服务器)中通过 referer 头返回对应的 html 文件,或者在主应用中注册多个微应用(不推荐)。
注意点
- 设置
publicPath
webpack 默认的 publicPath 为 "" 空字符串,会基于当前路径来加载资源。在主应用中加载微应用时需要重新设置 publicPath,这样才能正确加载微应用的相关资源。
history路由的微应用需要设置base
主应用中运行 / 微应用单独启动时的路由是不同的,需要在 main.js 中配置
- 配置
webpack
使 main.js 导出的生命周期钩子函数可以被 qiankun 识别获取
小结
最后,所有微应用都注册在主应用和主应用的菜单中,效果图如下:
从上图可以看出,我们把不同技术栈 Vue、React、Angular、Jquery... 的微应用都已经接入到主应用基座中啦!
应用通信
主要介绍两种通信方式:
- 第一种是
qiankun官方提供的通信方式Actions通信,适合业务划分清晰,比较简单的微前端应用,一般来说使用第一种方案就可以满足大部分的应用场景需求。 - 第二种是基于
vuex / redux实现的通信方式Shared通信,适合需要跟踪通信状态,子应用具备独立运行能力,较为复杂的微前端应用。
Actions 通信
通信原理
qiankun 内部提供了 initGlobalState 方法用于注册 MicroAppStateActions 实例用于通信,该实例有三个方法,分别是:
setGlobalState:设置globalState
设置新的值时,内部将执行
浅检查,如果检查到globalState发生改变则触发通知,通知到所有的观察者函数。
onGlobalStateChange:注册观察者函数
响应
globalState变化,在globalState发生改变时触发该观察者函数。
offGlobalStateChange:取消观察者函数
该实例不再响应
globalState变化。
画一张图来帮助理解(见下图)
从上图可以看出,我们可以先注册 观察者 到观察者池中,然后通过修改 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 方法绑定在下图的按钮中(见下图):
此时点击 2 次按钮,将触发我们在主应用设置的 观察者 函数(如下图):
从上图中我们可以看出,我们的 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.js 的 render 函数中注入,代码实现如下:
复制代码
// 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 通讯页,就可以看到效果啦!(见下图)
React 子应用的实现也是类似的,实现代码可以参照 完整 Demo - feature-communication 分支,实现效果如下(见下图)
小结
到这里,qiankun 基础通信 就完成了!
我们在主应用中实现了登录功能,登录拿到 token 后存入 globalState 状态池中。在进入子应用时,我们使用 actions 获取 token,再使用 token 获取到用户信息,完成页面数据渲染!
最后我们画一张图帮助大家理解这个流程(见下图)。
Shared 通信
由于
Shared方案实现起来会较为复杂,所以当Actions通信方案满足需求时,使用Actions通信方案可以得到更好的官方支持。
官方提供的 Actions 通信方案是通过全局状态池和观察者函数进行应用间通信,该通信方式适合大部分的场景。
Actions 通信方案也存在一些优缺点
优点如下:
- 使用简单
- 官方支持性高
- 适合通信较少的业务场景
缺点如下:
- 子应用独立运行时,需要额外配置无
Actions时的逻辑 - 子应用需要先了解状态池的细节,再进行通信
- 由于状态池无法跟踪,通信场景较多时,容易出现状态混乱、维护困难等问题
如果你的应用通信场景较多,希望子应用具备完全独立运行能力,希望主应用能够更好的管理子应用,那么可以考虑 Shared 通信方案。
通信原理
Shared 通信方案的原理:主应用基于 redux / vuex 维护一个状态池,通过 shared 实例暴露一些方法给子应用使用。同时,子应用需要单独维护一份 shared 实例,在独立运行时使用自身的 shared 实例,在嵌入主应用时使用主应用的 shared 实例,这样就可以保证在使用和表现上的一致性。
Shared 通信方案需要自行维护状态池,这样会增加项目的复杂度。好处是可以使用市面上比较成熟的状态管理工具,如 redux、mobx,可以有更好的状态管理追踪和一些工具集。
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 实例包括两个方法 getToken 和 setToken 分别用于获取 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");
}
从上面可以看出,在 props 的 shared 字段不为空时,将会使用传入的 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;
}
}
};
最后我们打开页面,看看在主应用中运行和独立运行时的表现吧!(见下图)
从 上图 1 可以看出,在主应用中运行子应用时,shared 实例被主应用重载,登录后可以在状态池中获取到 token,并且使用 token 成功获取了用户信息。
从 上图 2 可以看出,独立运行子应用时,shared 实例是子应用自身的 shared,在 localStorage 中无法获取到 token,被拦截返回到主页。
这样一来,我们就完成了 Shared 通信啦!
小结
从上面案例也可以看出 Shared 通信方案的优缺点,这里也做一些简单的分析:
优点有这些:
- 可以自由选择状态管理库,更好的开发体验。 比如
redux有专门配套的开发工具可以跟踪状态的变化。 - 子应用无需了解主应用的状态池实现细节,只需要了解
shared的函数抽象,实现一套自身的shared甚至空shared即可,可以更好的规范子应用开发。 - 子应用无法随意污染主应用的状态池,只能通过主应用暴露的
shared实例的特定方法操作状态池,从而避免状态池污染产生的问题。 - 子应用将具备独立运行的能力,
Shared通信使得父子应用有了更好的解耦性。
缺点也有两个:
- 主应用需要单独维护一套状态池,会增加维护成本和项目复杂度;
- 子应用需要单独维护一份
shared实例,会增加维护成本;
Shared 通信方式也是有利有弊,更高的维护成本带来的是应用的健壮性和可维护性。
最后画一张图对 shared 通信的原理和流程进行解析(见下图)
总结
到这里,两种 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 标签下进行更改。同样,当子应用加载的时候,就会以 id 为 sub-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 自定义命名空间
比如文档中的 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>
);
应用间通信
- 官方提供的 actions
- 官方提供的 props
- 通过路由参数共享
- localStorage/sessionStorage
- 使用 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官网