微前端乾坤方案
了解乾坤
官方文档
介绍
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 registeredentry: '//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 设置。
// webpackconst { 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模式则不需要设置baseroutes,});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)的配置说明
配置点说明
构建工具的配置主要有两个:
- 允许跨域
- 打包成
umd
格式
为什么要打包成 umd
格式呢?是为了让 qiankun
拿到其 export
的生命周期函数。我们可以看下其打包后的 app.js
就知道了:
配置示例
微应用的打包工具需要增加如下配置:
react
修改 webpack
配置
安装插件 @rescripts/cli
,当然也可以选择其他的插件,例如 react-app-rewired
。
npm i -D @rescripts/cli
根目录新增 .rescriptsrc.js
:
// webpackconst { name } = require('./package');module.exports = {webpack: (config) => {config.output.library = `${name}-[name]`;config.output.libraryTarget = 'umd';// webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobalconfig.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.jsconst { 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 替换成 chunkLoadingGlobaljsonpFunction: `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 替换成 chunkLoadingGlobaljsonpFunction: `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.tsconst 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
,并通过 RegistrableApp
的 props
传给子应用
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
生命周期的 props
的 onGlobalStateChange
和 setGlobalState
export function mount(props) {props.onGlobalStateChange((state, prev) => {// state: 变更后的状态; prev 变更前的状态console.log(state, prev);});props.setGlobalState(state);
}
eventBus 通信
第一步:在主应用中创建一个 EventBus
// src/eventBus/index.tsimport mitt from 'mitt';
const eventBus = mitt();
export default eventBus;
第二步:在 main.js
文件中引入这个 eventBus
,并通过 RegistrableApp
的 props
传给子应用(类似 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
中,同时依次调用微应用暴露出的生命周期钩子。
- 加载应用,创建子应用实例,渲染。
- 切到其他子应用后切回,会重新创建新的子应用实例并渲染。
- 之前的子应用实例直接不要了,即使你没有手动销毁实例。
- 采用这种模式的话 一定要在子应用暴露的
unmount
钩子里手动销毁实例,不然会导致内存泄漏。
注意
💡 由于
registerMicroApps
的特性,会导致路由的keepAlive
失效
自动注册演示
// 主应用/scr/main.js
import { registerMicroApps, start } from 'qiankun';// 1. 获取微应用配置
const MICRO_CONFIG = [{name: 'vue app', // 应用的名字 必填entry: '//localhost:7100', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetchcontainer: '#yourContainer', // 挂载具体容器 ID// 3. 根据路由匹配,激活的子应用activeRule: '/yourActiveRule',props: {xxxx: '/', // 下发给子应用},},
];// 2. 注册微应用
registerMicroApps(MICRO_CONFIG);start(); // 启动微服务
手动注册:使用 loadMicroApp 手动注册微应用
如果微应用不是直接跟路由关联的时候,你可以选择手动加载微应用,使用会更加灵活。
- 每个子应用都有一个的实例 ID,reload 时会复用之前的实例
- 如果需要卸载则需要手动卸载
xxxMicroApp.unmount()
手动注册演示
// 任意页面都可以注册import { loadMicroApp } from 'qiankun';// 获取应用配置并手动注册,注册后返回注册对象
this.microApp = loadMicroApp({name: 'vue app', // 应用的名字 必填entry: '//localhost:7100', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetchcontainer: '#yourContainer', // 挂载具体容器 IDactiveRule: '/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.jsexport const MICRO_CONFIG = [{name: 'you app name', // 应用的名字entry: '//localhost:7286/', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetchcontainer: '#yuo-container-container', // 容器idactiveRule: '/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,});// 关键代码 startif (window.__POWERED_BY_QIANKUN__ && window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__) {const cachedInstance = window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__;// 从最初的Vue实例上获得_vnodeconst 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');}// 关键代码 endelse {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');// 关键代码 startconst 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],};// 关键代码 endinstance.$destroy();router = null;instance.$router.apps = [];
}
方案三:子应用不要销毁实例
实现起来也比较简单: 子系统卸载不清空容器里的 dom 也不卸载 vue 实例,用 display: none 来隐藏。子系统加载时先判断下容器有无内容,已存在就不重新插入子系统的 HTML。
主要分 4 个步骤:
- 修改子项目的 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');}
}
- 修改子项目的 unmount 生命周期,子项目 unmount 时不卸载 vue 实例
export async function unmount() {// instance.$destroy();// instance = null;// router = null;
}
- 修改主项目中子项目的注册及容器,每个子项目单独放一个容器(当然你也可以放到一个容器,处理起来麻烦点)。然后就是切换子系统隐藏其他的
<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 中,手动注册子应用会绕过 qiankun 框架的子应用生命周期管理,因此子应用的
-
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
,才能复用已有的公共依赖。
-
子应用配置 externals
-
子应用通过 index.html 外链CDN静态资源,并且在 index.html 中公共依赖的
script
和link
标签加上 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
时
按需引入公共依赖,有两个层面:
- 没有使用到的依赖不加载
- 大插件只加载需要的部分,例如 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.VueRouteconst 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);
}
解决方案三:修改主应用和子应用的依赖名称
主应用和子应用复用的依赖改个名称,这样就不会影响其他不复用依赖的子应用。具体改的有:
- 修改子应用和主应用的
externals
配置,修改依赖的名称,不使用 Vue
externals: {'vue': 'Vue2' , // 这个的意思是告诉 webpack 去把 winodw.Vue2 当做 vue 这个模块
}
- 在主应用导入外链 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 实例【推荐】
- 存在的问题:
由于子项目本身也是一个 qiankun 项目,所以独立运行时 window.__POWERED_BY_QIANKUN__
为 true,被集成时,还是 true。
因此,子项目无法根据已有信息判断是独立运行还是被集成:
if (!window.__POWERED_BY_QIANKUN__) {render();
}
解决办法:
在父应用的入口文件定义一个全局变量 window.__POWERED_BY_QIANKUN_PARENT__ = true;
,使用这个变量让子应用可以区分是被集成还是独立运行
// 父应用 main.jsimport { 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();
- 子项目入口文件的修改
主要有以下几点注意的地方:
- 切换子项目时,避免重复注册孙子项目,
- 由于子项目会被注入一个前缀,那么孙子项目的路由也要加上这个前缀
- 注意容器的冲突,子项目和孙子项目使用不同的容器
// 子应用 index.jsimport { 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.jsconst { 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: '/',
};
- 孙项目入口文件的修改
// 孙应用 main.jslet 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;
}
- 孙项目打包配置的修改
以上操作完成后,在主项目中可以把这个 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
- 可选,全局的微应用生命周期钩子
- apps -
-
类型
-
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>
- 可选
- beforeLoad -
-
-
用法
注册微应用的基础配置信息。当浏览器 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
可选
- opts -
-
类型
-
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
- 必选
- appLink -
-
用法
设置主应用启动后默认进入的微应用。
-
示例
import { setDefaultMountApp } from 'qiankun';setDefaultMountApp('/homeApp');
runAfterFirstMounted(effect)
-
参数
- effect -
() => void
- 必选
- effect -
-
用法
第一个微应用 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
- 可选,初始化时需要传递给微应用的数据。
- name -
-
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 - 可选 - 加载配置
- apps -
-
类型
AppMetadata
- name -
string
- 必选 - 应用名 - entry -
string | { scripts?: string[]; styles?: string[]; html?: string }
- 必选,微应用的 entry 地址
- name -
-
用法
手动预加载指定的微应用静态资源。仅手动加载微应用场景需要,基于路由自动激活场景直接配置
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
- 必选
- handler -
-
用法
添加全局的未捕获异常处理器。
-
示例
import { addGlobalUncaughtErrorHandler } from 'qiankun';addGlobalUncaughtErrorHandler((event) => console.log(event));
removeGlobalUncaughtErrorHandler(handler)
-
参数
- handler -
(...args: any[]) => void
- 必选
- handler -
-
用法
移除全局的未捕获异常处理器。
-
示例
import { removeGlobalUncaughtErrorHandler } from 'qiankun';removeGlobalUncaughtErrorHandler(handler);
通讯
initGlobalState(state)
-
参数
- state -
Record<string, any>
- 必选
- state -
-
用法
定义全局状态,并返回通信方法,建议在主应用使用,微应用通过 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.jsexport async function bootstrap() {console.log('app bootstraped');
}
mount
应用每次进入都会调用 mount
方法,通常我们在这里触发应用的渲染方法。
// index.jsexport async function mount(props) {// ReactDOM.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root'));console.log('app mount');
}
unmount
应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例。
// index.jsexport async function unmount(props) {// ReactDOM.unmountComponentAtNode(props.container ? props.container.querySelector('#root') : document.getElementById('root'));console.log('app unmount');
}
update
可选生命周期钩子,仅使用 loadMicroApp
方式加载微应用时生效。
// index.jsexport 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 沙箱还有个思路就是将子项目的样式局限到子项目的容器范围内生效,这样只需要给不同的子项目不同的容器就可以了。但是这样也会有新的问题,子项目中 append
到 body
的弹窗,样式就无法生效。所以说样式污染还需要制定规范才行,约定 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 加载微应用的
简易流程:
- qiankun 会用 原生
fetch
方法,请求微应用的entry
获取微应用资源,然后通过response.text
把获取内容转为字符串。 - 将 HTML 字符串传入
processTpl
函数,进行 HTML 模板解析,通过正则匹配 HTML 中对应的 javaScript(内联、外联)、css(内联、外联)、代码注释、entry、ignore 收集并替换,去除html/head/body
等标签,其他资源保持原样 - 将收集的 styles 外链 URL 对象通过 fetch 获取 css,并将 css 内容以
<style>
的方式替换到原来 link 标签的位置 - 收集 script 外链对象,对于异步执行的 JavaScript 资源会打上
async
标识 ,会使用requestIdleCallback
方法延迟执行。 - 接下来会创建一个匿名自执行函数包裹住获取到的 js 字符串,后通过 eval 去创建一个执行上下文执行 js 代码,通过传入
proxy
改变 window 指向,完成 JavaScript 沙箱隔离。源码位置。 - 由于 qiankun 是自执行函数执行微应用的 JavaScript,因此在加载后的微应用中是看不到 JavaScript 资源引用的,只有一个资源被执行替换的标识。
- 当一切准备就绪的时候,执行微应用的 JavaScript 代码,渲染出微应用。
关键字说明
umd
umd
全称是 UniversalModuleDefinition
,是一种通用模块定义格式,通常用于前端模块化开发中。
由于不同的模块化规范定义不同,为了让各种规范的模块可以通用,在不同的环境下都可以正常运行,就出现了 umd 这个通用格式。
特点
umd
格式是一种既可以在浏览器环境下使用,也可以在 node 环境下使用的格式。它将 CommonJS、AMD 以及普通的全局定义模块三种模块模式进行了整合。
(function (global, factory) {// CommonJStypeof exports === 'object' && typeof module !== 'undefined'? factory(exports): // AMDtypeof 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模式
的步骤:
- new Router 时设置
mode
为history
- webpack 打包的配置( vue.config.js ) :
publicPach: './'; // hash模式使用 publicPach: '/'; // history模式使用
- 一些资源会报 404,相对路径改为绝对路径:
<img src="./img/logo.jpg">
改为<img src="/img/logo.jpg">
即可
路由模式如何选择?
首先,业务情况分两种:
(1)子应用已经存在了,也并不清楚各子应用使用了何种路由模式的;
(2)子应用当前不存在,需要和主应用一起开发的。这种情况下,可以任意选择。
那么就以子应用已经存在的这种自由度不大的情况先阐述:
主应用使用 history
模式,activeRule
使用 location.pathname
来区分子应用【推荐】
此时,子应用可以是 hash
和 history
模式。
主应用路由模式:✅ history
+ location.pathname
子应用路由模式:✅ history
| ✅ hash
主应用的改动点:
主应用在注册子应用时 activeRule
这样写即可:
registerMicroApps([{name: 'app',entry: 'http://localhost:8080',container: '#container',activeRule: '/app',},
]);
子应用的改动点:
-
当子应用是
history
模式时,设置路由base
即可 -
当子应用是
hash
模式时,vue-router 和 react-router 两种路由的表现不一致路由 主应用跳转/app/#/about 特殊配置 vue-router 响应 about 路由 无 react-router 不响应 about 路由 无
优缺点:
优点:
- 子应用可以使用
history
模式,也可以使用hash
模式 。这样旧项目就都可以直接接入,兼容性强。 - 如果子应用是
hash
模式,不需要做任何修改。
缺点:
- 如果子应用是
history
模式,则需要设置base
- 子应用之间的跳转需要使用父应用的 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',// 关键代码 startactiveRule: 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,// 关键代码 startchildren: [// 其他的路由都写到这里],// 关键代码 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
就是第二种。
好的路由模式就是主应用、子应用都统一模式,可以减少不同模式之间的兼容工作
主模式 | 子模式 | 推荐 | 接入影响 | 解决方案 | 备注 |
---|---|---|---|---|---|
hash | hash | 强烈推荐 | 无 | ||
hash | history | 不推荐 | 有 | history.pushState | 改造成本大 |
history | history | 强烈推荐 | 无 | ||
history | hash | 推荐 | 无 |
路由跳转问题
在子项目里面如何跳转到另一个子项目/主项目页面呢,直接写 <router-link>
或者用 router.push
/ router.replace
是不行的,原因是这个 router
是子项目的路由,所有的跳转都会基于子项目的 base
。而 <a>链接
可以跳转过去,但是会刷新页面,用户体验不好。
解决方案:
- 在子项目注册时将主项目的路由实例
router
对象传过去,子项目挂载到全局,用父项目的这个router
跳转就可以了。但是有一丢丢不完美,这样只能通过 js 来跳转,跳转的链接无法使用浏览器自带的右键菜单。 - 使用通讯的方式,父应用封装router跳转的方法,向子应用传递,子应用使用该方法,传入需要跳转到的路由信息,也可以实现跳转。这种则不传递父应用的router对象,而是传递封装过的方法,安全性更高。
- 路由模式为
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 = urlif (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 和端口)
如果服务器数量有限,或不能跨域等原因,需要把主应用和微应用部署到一起。
通常的做法是主应用部署在一级目录,微应用部署在二/三级目录。
微应用部署在非根目录,在微应用打包之前需要做两件事:
必须配置
webpack
构建时的publicPath
为目录名称,更多信息请看 webpack 官方说明 和 vue-cli3 的官方说明
history
路由的微应用需要设置base
,值为目录名称,用于独立访问时使用。
部署之后注意三点:
activeRule
不能和微应用的真实访问路径一样,否则在主应用页面刷新会直接变成微应用页面。- 微应用的真实访问路径就是微应用的
entry
,entry
可以为相对路径。- 微应用的
entry
路径最后面的/
不可省略,否则publicPath
会设置错误,例如子项的访问路径是http://localhost:8080/app1
,那么entry
就是http://localhost:8080/app1/
。
具体的部署有以下两种方式,选择其一即可。
方案 1:微应用都放在在一个特殊名称(不会和微应用重名)的文件夹下(建议使用)
假设我们有一个主应用和 6 个微应用(分别为 vue-hash
、vue-history
、react-hash
、react-history
、angular-hash
、angular-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文件夹
此时需要设置微应用构建时的 publicPath
和 history
模式的路由 base
,然后才能打包放到对应的目录里。
项目 | 路由 base | publicPath | 真实访问路径 |
---|---|---|---|
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/",
那么此时的注册函数是这样的(需要保证 activeRule
和 entry
不同):
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-history
、react-history
、angular-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 项目要想部署到二级目录,必须配置 publicPath
,vue-cli3 官网描述
然后需要注意的点就是,注册子项目时 入口地址 entry
的填写。
假设子项目部署在 app-vue-hash
目录下,entry
直接写 http://localhost/app-vue-hash
会导致 qiankun
取 publicPath
错误。子项目入口地址 http://localhost/app-vue-hash
的相对路径是 http://localhost
,而我们希望的子项目相对路径是 http://localhost/app-vue-hash
解决方案:
只需要写成 http://localhost/app-vue-hash/
即可,最后面的 /
不可省略。
qiankun 取 publicPath 源码:
function defaultGetPublicPath(url) {try {// URL 构造函数不支持使用 // 前缀的 urlconst { 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/app
和 http://localhost/app/
两个不同路径的 server
, 同一个 html
,然后在 html
里引入一个相对路径的资源。浏览器解析的地址分别为:
说明 qiankun
取 publicPath
的处理是正确的。
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>
标签的 async
和 defer
属性:
- defer : 等价于将外链的 js 放在了页面底部
- 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 周期里面注册子项目并启动。
其他
更多问题查阅地址:
总结
-
qiankun 的
js沙箱
并不能解决所有的 js 污染。例如:用 onclick 或 addEventListener 给<body>
添加了一个点击事件,js沙箱
并不能消除它的影响,所以说,还得靠代码规范和自己自觉 -
qiankun 不太好实现
keep-alive
需求,因为解决 css/js 污染的办法就是删除子项目插入的 css 标签和劫持 window 对象,卸载时还原成子项目加载前的样子,这与keep-alive
相悖:keep-alive
要求保留这些,仅仅是样式上的隐藏。
qiankun 比较致命缺点:
localStorage
、sessionStorage
、cookie
等,没有沙箱隔离。子应用较多的情况下,使用了localStorage / sessionStorage
存储数据容易出问题。- 拓展升级问题:当前暂不支持 vite
参考
- https://juejin.cn/post/6844904185910018062#heading-0
- https://juejin.cn/post/6856569463950639117#heading-0
- https://zhuanlan.zhihu.com/p/691530332