微前端乾坤方案
了解乾坤
介绍
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) 导出 bootstrap
、mount
、unmount
、update
四个生命周期钩子,以供主应用在适当的时机调用。
具体操作可以参考下面示例
步骤一:在 src
目录新增 public-path.js
,用于修改运行时的 publicPath
。
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
备注
什么是运行时的 publicPath ?
运行时的publicPath
和构建时的publicPath
是不同的,两者不能等价替代。
步骤二:修改入口文件
主要改动:
- 引入
public-path.js
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'));
}
备注
注意点
容器名
- 修改
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)的配置说明
配置点说明
构建工具的配置主要有两个:
- 允许跨域
- 打包成
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
子应用的路由会发生跳转到对应路由。
生命周期
微应用需要在应用的入口文件导出 bootstrap
、mount
、unmount
、update
四个生命周期钩子,以供主应用在适当的时机调用。
注意
💡 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,