微前端架构 qiankun
背景:随着业务功能的扩展,原有开发模式已无法满足需求。上线后出现问题时,排查过程变得异常复杂,新开发人员也难以迅速理解现有代码。同时,系统间界面风格和交互差异较大,导致跨系统办理业务时工作量增加。因此,引入微前端架构,以支持团队协作、实现独立部署,并提升开发效率。
微前端
微前端 qiankun:基于 single-spa 实现的微前端框架,允许多个子应用在一个主应用中独立运行且互不干扰,适用于大型应用或多团队协作场景。其优点包括:与技术栈无关,支持子应用独立开发和部署,提供开箱即用的 API,易于上手,且社区活跃,支持良好。
qiankun 的特点
- 技术栈无关:支持不同技术栈的主子应用(如 React、Vue、Angular 等),集成简单。
- 动态加载静态资源:通过 HTML
解析动态加载子应用 JS 和 CSS,无需强耦合。 - 沙箱隔离:提供 Proxy 或快照沙箱,避免全局变量和样式污染。
- 独立部署:子应用可单独开发和部署。
- 应用间通信:支持props或全局状态管理initGlobalState,实现主子应用及子应用间数据交互。
- 动态加载:支持动态注册和加载子应用。 生命周期管理:提供bootstrap、mount、unmount等钩子。
- 多路由模式:兼容
Hash 和 History 模式,支持嵌套路由配置。
微前端框架 qiankun,支持不同技术栈的子应用,提供沙箱隔离、独立部署、生命周期管理、应用间通信等功能,能够动态加载和注册子应用,兼容 Hash 和 History 路由模式,灵活且易于集成。
qiankun vs ifream
Qiankun 和 iframe 都可以用来实现微前端架构,但它们的实现方式和应用场景有所不同。
-
qiankun 是基于 JavaScript
的微前端框架,允许子应用共享主应用环境,并通过全局状态管理和路由共享实现协调和通信,增强灵活性和可维护性。 -
iframe 通过独立窗口隔离子应用,部署简单且子应用完全独立,互不影响,适合嵌套简单页面。由于隔离性强,导致路由刷新丢失、状态和 DOM 不共享,交互复杂。每次加载需重建上下文和资源,性能开销大。
实现方案
安装依赖:npm i qiankun -S
主应用基础配置
主应用入口文件中注册子应用信息。
1. 异步请求系统树数据
// 定义全局消息传递对象,存储主应用的状态和 Vuex
const msg = {
data: store.getters, // 从主应用仓库读取的数据
channelVueX: store, // 传递 Vuex 实例给子应用
}
async function fetchSystemTreeAndInit() {
try {
const res = await API.getSystemTree() // 异步请求系统树数据
// 动态生成子应用列表
const apps = appList.map((item) => ({
name: item.name,
entry: getAppEntry(item), // 获取子应用的入口 URL
render,
activeRule: genActiveRule(item.code), // 生成子应用激活规则
props: { ...msg, permissibleMenu: res.data.treeMenuList }, // 传递给子应用的属性
}))
// 注册子应用信息...
} catch (error) {
console.error('获取系统树失败:', error)
}
}
注意:在注册子应用后,上述的 props 字段,传递给子应用的属性。
2. 子应用的生命周期管理
执行子应用的注册 API,并启动微前端框架。
registerMicroApps(apps)
// 第一个子应用加载完毕回调
runAfterFirstMounted(() => {})
// 启动微前端框架
start({
sandbox: false, // 关闭沙盒模式,确保子应用的 window 对象正常
prefetch: 'all', // 启用所有子应用的预加载
})
// 设置全局未捕获异常处理器
addGlobalUncaughtErrorHandler((event) => console.log(event))
3. 主应用渲染函数
主应用的渲染逻辑:只在首次渲染时创建 Vue 实例,从而避免不必要的重复创建。初始化函数 init(),在所有子应用成功注册后启动微前端框架。
let app = null
// 创建并渲染 Vue 实例
function createVueApp() {
return new Vue({
el: '#container', // 挂载根元素
router,
store,
created: bootstrap, // 应用启动时执行
render: (h) => h(App), // 渲染根组件
})
}
// 主应用渲染函数
export function render() {
if (!app) {
app = createVueApp() // 只在首次渲染时创建 Vue 实例
}
}
// 初始化函数,获取系统树并初始化子应用
export async function init() {
await fetchSystemTreeAndInit() // 使用 await 等待系统树获取并初始化子应用
}
4. 环境配置与子应用入口 URL 获取
子应用的入口 URL 通常根据环境的不同(如开发、测试、生产等)动态配置。通过window.location 或 process.env 来判断当前的环境,从而选择正确的子应用 URL。
// 获取环境对应的应用入口 URL
const getAppEntry = (item) => {
const envUrls = {
local: item.devUrl,
test: item.testUrl,
uat: item.uatUrl,
prod: item.proUrl
}
const env = window.location.href.includes('localhost') || process.env.NODE_ENV === 'development' ? 'local' :
process.env.NODE_ENV === 'test' ? 'test' :
process.env.VUE_APP_IS_UAT ? 'uat' : 'prod'
return envUrls[env]
}
每个子应用有唯一的 code 值,genActiveRule 根据路由前缀判断当前 URL 是否激活对应的子应用,适用于微前端架构中的子应用加载与路由控制。
// 获取环境对应的应用入口 URL
const getAppEntry = (item) => {
const envUrls = {
local: item.devUrl,
test: item.testUrl,
uat: item.uatUrl,
prod: item.proUrl
}
const env = window.location.href.includes('localhost') || process.env.NODE_ENV === 'development' ? 'local' :
process.env.NODE_ENV === 'test' ? 'test' :
process.env.VUE_APP_IS_UAT ? 'uat' : 'prod'
return envUrls[env]
}
子应用基础配置
在微前端架构中,子应用需要做一些配置,与主应用进行良好的集成。子应用配置如下:
1. 配置信息
在 src/public-path.js 文件中,添加如下信息,确保子应用正确加载资源路径:
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
若当前运行在微前端环境中,webpack_public_path 会被动态设置为 qiankun 注入的公共路径,确保子应用在qiankun环境下能正确加载资源路径。
2. 入口文件
子应用的生命周期钩子函数分别为 bootstrap、mount 和 unmount。它们在子应用初始化、挂载、卸载时执行:
import './public-path'; // 引入 public-path.js
let instance = null
let router = null
// 初始化
export async function bootstrap(props) {
console.log(props)
}
// 挂载
export async function mount(props) {
// 这里可以进行子应用的初始化、路由配置等操作
}
// 卸载
export async function unmount() {
instance.$destroy()
instance = null
router = null
}
3. 打包配置
在 vue.config.js 中配置打包成 UMD 格式,以支持微前端架构:
const { name } = require('./package');
module.exports = {
// 其他配置项...
configureWebpack: {
output: {
library: `${name}`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${name}`,
},
},
};
4. 子应用路由配置
在子应用的 router.js 文件中引入 Vue 和 Vue Router,子应用在微前端环境下,则根据subAppCode作为路由前缀,否则使用默认的基础路径。
import Vue from 'vue'
import Router from 'vue-router'
import { constantRouterMap } from '@/router/router.config'
export default new Router({
// 根据子系统编码区分路径,动态设置 base 路径
base: window.__POWERED_BY_QIANKUN__ ? '/033' : process.env.BASE_URL,
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: constantRouterMap,
})
主应用路由守卫
在 Vue 项目中,路由守卫(router.beforeEach)是负责全局控制页面跳转、权限校验和用户状态管理等内容。主应用的路由守卫逻辑:包括子应用信息的设置、用户登录状态的判断、权限加载以及子系统菜单的动态加载。
每次路由跳转时,在beforeEach 路由守卫先触发,根据不同的条件来执行不同的操作,包括设置页面标题、判断用户是否已登录、加载用户权限、处理子系统菜单等,控制整个路由过程的流向。
router.beforeEach((to, from, next) => {
NProgress.start() // Start the progress bar
// 设置子应用信息
updateMicroAppInfo(to)
// 设置页面标题
if (to.meta?.title) {
setDocumentTitle(`${domTitle} - ${to.meta.title}`)
}
// 主页未设置市场,执行退出登录
if (to.name === 'home' && !store.getters.currentMarket) {
logoutAndRedirect(next, to)
return
}
// 检查用户是否已登录
const isLoggedIn = Cookies.get(ACCESS_TOKEN);
isLoggedIn ? handleLoggedInUser(to, next) : handleGuestUser(to, next);
})
router.afterEach(() => {
NProgress.done() // 结束进度条
})
1. 设置子应用信息
在每次路由跳转前,检查页面路由配置,确保在非登录页面(passport)下,根据当前路径设置对应的子应用名称和菜单,更新子应用的相关信息:
function updateMicroAppInfo(to) {
if (to.matched.length > 0 && to.name !== 'passport') {
// 更新子应用信息和活动标签
store.dispatch('SetMicroApp', { name: to.meta.title, url: to.fullPath })
store.dispatch('SetActiveTab', to.fullPath)
// 设置当前菜单
const menuKey = to.path === '/home' ? to.path : to.path.slice(0, -5);
store.commit('SET_MENU_KEY', menuKey);
}
}
2. 处理未登录用户
未登录用根据白名单判断是否允许访问,否则重定向到登录页并带上当前页面的跳转路径。
const whiteList = ['passport']
function handleGuestUser(to, next) {
if (whiteList.includes(to.name)) {
next() // 白名单页面直接进入
} else {
next({ path: '/passport', query: { redirect: to.fullPath } })
NProgress.done()
}
}
3. 处理已登录用户
对于已登录的用户,加载权限、子系统菜单和按钮权限等。下面通过loadMicroAppMenu函数异步加载子系统菜单,并根据页面配置加载对应的按钮权限。
function handleLoggedInUser(to, next) {
loadUserPermissions(to, next) // 加载用户权限
loadMicroAppMenu(to, next) // 加载子系统菜单
loadActionPermissions(to, next) // 获取按钮权限
}
3.1 加载用户权限
如果权限为空,请求并生成权限路由。
async function loadUserPermissions(to, next) {
store.dispatch('GetSystemMenu', store.state.passport.menuName); // 获取系统菜单
if (!store.getters.permissibleMenu.length) {
try {
const permissionMenu = await store.dispatch('GetPermission'); // 请求权限数据
await store.dispatch('GenerateRoutes', permissionMenu); // 生成路由
router.addRoutes(store.getters.addRouters); // 动态添加路由
} catch (err) {
handleError({ message: '错误', description: '请求用户信息失败,请重试' }, next, to);
}
}
}
3.2 加载子系统菜单
对于多子系统应用,每次路由跳转时,根据当前路径检查当前菜单是否已经加载。如果未加载则向后端请求菜单数据,加载对应的子系统菜单。
async function loadMicroAppMenu(to, next) {
if (fetchMenuFlag) return; // 防止重复请求
const app = findAppByPath(to.path); // 查找当前子应用
if (!store.getters.microAppMenuList.length) {
fetchMenuFlag = true; // 请求标识
try {
await store.dispatch('GetMicroAppMenuList'); // 请求子系统菜单列表
} catch (err) {
console.error('Failed to fetch menu list:', err);
handleError({
message: '错误',
description: '您暂未拥有此页面权限,或者此页面已关闭。',
})
fetchMenuFlag = false;
next({ path: '/home' })
return;
} finally {
fetchMenuFlag = false; // 重置请求标识
}
}
// 菜单加载后,检查应用并加载
if (app) {
try {
await store.dispatch('LoadedApp', { code: app.code, store });
} catch (err) {
console.error('Error loading app:', err);
}
}
}
function findAppByPath(path) {
const appCode = path.split('/')[1]; // 提取路径中的应用码
return store.getters.microAppMenuList.find((i) => i.code === appCode);
}
注意:查找当前子应用后,调用 store.dispatch 中的 LoadedApp 方法加载子应用。
3.3 获取按钮权限
根据页面的meta.action属性,加载并保存该页面按钮权限。若没有按钮权限的页面直接进入。
async function loadActionPermissions(to, next) {
if (to.meta?.action) {
try {
const data = await store.dispatch('GetAction', to.meta.menuId); // 获取按钮权限
const btnList = data.reduce((acc, item) => {
acc[item.menuCode] = true;
acc[item.menuName] = item.menuName;
acc['formId_' + item.menuCode] = item.formId;
return acc;
}, {});
store.commit('SET_BUTTON_LIST', btnList); // 更新按钮权限
next(); // 跳转
} catch (err) {
console.error('Failed to load action permissions:', err);
next(); // 继续跳转
}
} else {
next(); // 没有按钮权限控制直接跳转
}
}
4. 异常处理和重定向逻辑
当发生错误时(如请求失败或用户没有访问权限),统一处理错误并重定向到登录页面。
// 统一处理错误提示
function handleError({ message, description }, next, to) {
notification.error({ message, description })
next && logoutAndRedirect(next, to) // 调用统一的登出和重定向方法,传递 to 参数
}
// 统一登出并重定向
function logoutAndRedirect(next, to = null) {
store.dispatch('Logout').then(() => {
next({ path: '/passport', query: { redirect: to ? to.fullPath : '/' } })
NProgress.done()
}).catch(() => {
NProgress.done()
})
}
子应用路由守卫
1. 子应用 mount 挂载
子应用通过 mount
方法挂载,根据主应用权限菜单生成路由配置,并创建路由实例设置 base
路径。
export async function mount(props) {
const { container } = props;
let tempPermissibleMenu = props.permissibleMenu || []; // 获取权限菜单
// 根据子系统编码过滤菜单
if (props.channelVueX && props.channelVueX.getters.appCode === '118') {
tempPermissibleMenu = tempPermissibleMenu.filter(i => i.code === subAppCode)[0]?.list || [];
}
await store.dispatch('GenerateRoutes', tempPermissibleMenu); // 动态生成路由
// 配置路由
router = new Router({
base: window.__POWERED_BY_QIANKUN__ ? `/${subAppCode}` : process.env.BASE_URL,
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: window.__POWERED_BY_QIANKUN__ ? store.getters.addRouters : constantRouterMap,
});
setupRouterHooks(props); // 配置路由守卫
// 创建 Vue 实例并挂载
instance = new Vue({
router,
store,
created: bootstraps,
render: h => h(App),
}).$mount(container ? container.querySelector('#app') : '#app');
}
2. 配置路由守卫
配置 beforeEach 和 afterEach 守卫,处理权限校验、页面标题设置及进度条管理:
- beforeEach 用于在路由跳转之前进行权限校验和其他操作。
- afterEach 用于在路由跳转完成后执行一些清理工作,如停止进度条。
function setupRouterHooks(props) {
router.beforeEach((to, from, next) => {
NProgress.start();
// 设置页面标题
if (to.meta?.title) {
setDocumentTitle(`${domTitle} - ${to.meta.title}`)
}
if (Cookies.get(ACCESS_TOKEN)) {
handleLoggedInUser(to, next, props);
} else {
handleGuestUser(to, next);
}
});
router.afterEach(() => {
NProgress.done();
});
}
3. 未登录用户
未登录用户可以直接访问免登录白名单中的页面,否则会被重定向到登录页:
function handleGuestUser(to, next) {
if (whiteList.includes(to.name)) {
next();
} else {
next({ path: '/passport', query: { redirect: to.fullPath } });
NProgress.done();
}
}
4. 已登录用户
已登录用户进行动态路由和按钮权限的初始化:
async function handleLoggedInUser(to, next) {
try {
// 动态路由和权限初始化
await ensureDynamicRoutes();
// 按钮权限初始化
if (to.meta.action) {
await initializeButtonPermissions(to.meta.menuId);
}
} catch (err) {
console.error('权限处理出错:', err);
notification.error({
message: '错误',
description: '请求用户信息失败,请重试',
});
await store.dispatch('Logout');
redirectToLogin(to, next);
}
}
4.1 加载动态路由
只有在权限菜单为空时,才会请求后端接口获取权限数据并生成路由,避免重复请求。
/**
* 确保动态路由已加载
*/
async function ensureDynamicRoutes() {
// 如果没有权限菜单,加载权限并生成路由
if (store.getters.permissibleMenu.length === 0) {
const permissibleMenu = await store.dispatch('GetPermission');
if (!isCollaborationCenter()) {
await store.dispatch('GenerateRoutes', permissibleMenu);
router.addRoutes(store.getters.addRouters);
}
}
}
/**
* 判断是否为主应用
*/
function isCollaborationCenter() {
return props.channelVueX && props.channelVueX.getters.appCode === '118';
}
4.2 初始化按钮权限
加载指定菜单的按钮权限并将其保存到 Vuex store 中,以便在页面中进行按钮权限的控制。
async function initializeButtonPermissions(menuId) {
const actions = await store.dispatch('GetAction', menuId);
const btnList = actions.reduce((btns, { menuCode, menuName, formId }) => ({
...btns,
[menuCode]: true,
[menuName]: menuName,
[`formId_${menuCode}`]: formId, // 自定义表单兼容
}), {});
store.commit('SET_BUTTON_LIST', btnList);
}
应用通信
在 Qiankun 微前端框架中,Props 传递数据 和 全局状态管理 常用的应用间通信方式,用于主应用与子应用之间,或子应用之间的数据传递和事件触发,具备简单易用、与框架高度集成的特点。
1. Props 传递数据
在 Qiankun 框架中,将主应用传递的props注入子应用。子应用通过props获取主应用的数据和方法。Qiankun 支持主应用传递 props 注入子应用,在子应用的mount方法中接受并使用props,实现主应用与子应用之间的通信。实现步骤如下:
- 主应用: 在注册子应用时,通过props传递所需的数据或回调函数。
registerMicroApps([
{
name: 'childApp',
entry: '//localhost:8080',
container: '#container',
activeRule: '/child',
props: {
userInfo: { name: 'John Doe', role: 'admin' },
globalState: { theme: 'dark' },
setGlobalState: (state) => { console.log('Update state:', state); },
},
},
]);
- 子应用: 在 mount方法中接收并使用props。
export async function mount(props) {
console.log('Props from main app:', props);
const { userInfo, globalState, setGlobalState } = props;
// 调用主应用方法
setGlobalState({ theme: 'light' });
}
适用于主应用向子应用单向传递初始化数据,静态数据传递,简单高效,如:子应用初始化配置。
2. 全局状态管理
Qiankun 提供了initGlobalState 方法(全局状态管理工具),用于共享和同步主应用与子应用的状态。它支持双向通信,并且易于集成。实现步骤如下:
- 主应用:初始化全局状态,设置监听器应子应用的状态变化。
import { initGlobalState } from 'qiankun';
const actions = initGlobalState({ user: 'admin', theme: 'dark' });
// 监听全局状态变化
actions.onGlobalStateChange((state, prev) => {
console.log('Global state changed:', state, prev);
});
// 更新全局状态
actions.setGlobalState({ theme: 'light' });
// 获取全局状态
console.log(actions.getGlobalState());
- 子应用:通过 props 获取全局状态,并监听状态变化或更新全局状态。
export async function mount(props) {
const { onGlobalStateChange, setGlobalState } = props;
// 监听全局状态变化
onGlobalStateChange((state, prev) => {
console.log('State changed:', state, prev);
});
// 更新全局状态
setGlobalState({ user: 'guest' });
}
适用于多子应用共享状态,且支持双向同步,能够应对复杂的跨应用通信需求,如登录状态共享、子应用联动和跨应用动态更新。
3. 基于浏览器 localStorage 或 sessionStorage
主应用和子应用通过共享的 localStorage 或 sessionStorage 存储数据,但需要注意跨域限制。
待补充:单点登录。
主子应用通信频道
在基于微前端架构的开发中,主应用与子应用之间的通信、状态管理是核心问题之一。微前端架构需要主应用满足:
- 动态加载子应用。
- 管理子应用的权限菜单。
- 控制加载状态,提供缓存机制,避免重复加载。
- 保持已加载子应用数量在合理范围内,卸载超出部分的子应用以节省资源。
1. 加载子应用
通过 qiankun 提供的 loadMicroApp 动态加载子应用。加载逻辑如下:
- 在加载子应用之前,检查是否已达到缓存上限(5个),如超过则卸载最早加载的子应用。
- 检查子应用是否已加载,避免重复加载。
LoadedApp({ dispatch, commit, state }, param) {
const app = appList.find(i => i.code.slice(1) === param.code)
if (!app) return // 如果找不到子应用,直接返回
const microApp = {
name: app.name,
entry: getAppUrl(app),
container: `#${app.name}App`,
props: { channelVueX: param.store, permissibleMenu: state.microAppMenuList },
}
if (state.loadedAppsList.length >= 5) {
dispatch('UnLoadedApp') // 缓存满时卸载最早的子应用
}
if (!state.loadedAppsMap[microApp.name]) {
const appInstance = loadMicroApp(microApp)
commit('SET_LOADED_APPS_MAP', { appName: microApp.name, loadedApp: appInstance })
}
}
2. 预加载
根据用户权限菜单筛选出符合权限的子应用,通过qiankun使用 prefetchApps 提前加载这些子应用的资源,提升切换速度。
// 获取并设置子应用权限菜单列表
async GetMicroAppMenuList({ commit }) {
try {
const { data } = await API.getSystemTree() // 获取系统树数据
commit('SET_SUB_MENU', data.treeMenuList)
// 筛选出有权限的子应用进行预加载
const hasMenuCode = new Set(data.treeMenuList.map(item => item.code))
const appsToPrefetch = appList
.filter(item => hasMenuCode.has(item.code.slice(1)))
.map(item => ({
name: item.name,
entry: getAppUrl(item), // 根据环境选择子应用URL
}))
prefetchApps(appsToPrefetch) // 预加载子应用
} catch (error) {
console.error("获取子应用菜单失败:", error)
throw error // 重新抛出错误以便上层处理
}
},
3. 缓存与卸载优化
为了优化内存使用,主应用限制了最多保留 5 个子应用。卸载最先加载的子应用,同时更新状态,确保映射表 loadedAppsMap 和 缓存列表 loadedAppsList 同步,实现子应用的有序缓存与管理。
UnLoadedApp({ commit, state }) {
if (state.loadedAppsList.length > 0) {
const firstAppName = state.loadedAppsList[0]
const appInstance = state.loadedAppsMap[firstAppName]
appInstance && appInstance.unmount() // 卸载子应用
commit('SET_LOADED_APPS_MAP', { appName: firstAppName, loadedApp: null })
}
}
在 mutation 中,维护已加载子应用的映射 loadedAppsMap 和缓存列表 loadedAppsList,确保主应用在缓存子应用时能够有效地管理子应用的加载状态。
SET_LOADED_APPS_MAP: (state, { appName, loadedApp }) => {
state.loadedAppsMap[appName] = loadedApp
// 更新已加载子应用列表
if (loadedApp && !state.loadedAppsList.includes(appName)) {
state.loadedAppsList.push(appName)
} else if (!loadedApp && state.loadedAppsList.includes(appName)) {
// 只移除存在的子应用
state.loadedAppsList = state.loadedAppsList.filter(app => app !== appName)
}
},
- 添加未加载的子应用:子应用加载成功,且未加载过,则将其添加到 loadedAppsList 列表中。
- 移除卸载的子应用:子应用实例为 null,且子应用加载过, 从 loadedAppsList 中移除该子应用。
通过状态管理,确保子应用的动态加载和卸载过程能高效进行,同时管理子应用的缓存,避免内存溢出。
qiankun 使用问题集
1. 主应用与子应用路由选择
qiankun规定:若主应用history模式,则子应用可以是hash或history模式;若主应用hash模式,则子应用必须为hash模式。
- 主应用和子应用都设置为 history 模式,主应用通过动态路由前缀区分子应用。主子应用路由结构一致,URL 美观且规范,易于维护。
- 主应用和子应用都设置为 hash 模式,主应用通过activeRule配置匹配子应用的hash路由前缀。子应用可以独立运行,URL
可读性差,不利于 SEO。 - 主应用 history模式,子应用hash模式。主应用支持 SEO和URL 美观,子应用也保持独立运行,存在调试和维护稍复杂。
2. 如何调试多个子项目?
2.1 主子应用同时本地运行
前面我们通过当前的环境,加载对应子应用的入口 URL(如开发、测试、生产等)动态配置。因此,主应用与子应用分别运行在本地开发环境中,主应用通过 子应用本地入口地址加载子应用。
优点:子应用支持独立运行,便于快速调试子应用逻辑,但本地可能同时启动多个服务会占用系统资源。
registerMicroApps([
{
name: 'subApp1',
entry: '//localhost:8081', // 子应用1的本地地址
container: '#subApp1',
activeRule: '/subapp1',
},
{
name: 'subApp2',
entry: '//localhost:8082', // 子应用2的本地地址
container: '#subApp2',
activeRule: '/subapp2',
},
]);
2.2 子应用支持独立运行
在前面子应用路由配置中,子应用通过环境变量来动态设置 base 路径,实现子系统独立运行,快速验证功能。
export default new Router({
// 根据子系统编码区分路径,动态设置 base 路径
base: window.__POWERED_BY_QIANKUN__ ? '/033' : process.env.BASE_URL,
mode: 'history',
...
})
2.3 配置代理解决跨越
主应用通过代理(proxy)访问子应用本地服务,解决跨域问题。配置复杂,多个子应用需维护代理规则。
3. 如何实现 keep-alive 的需求吗?
3.1 子应用内部实现 keep-alive
在使用 qiankun 微前端框架时,子应用通过内部的 keep-alive 特性来实现页面或组件的缓存功能,从而优化页面性能和用户体验。
// 子应用中
<keep-alive>
<router-view v-if="$route.meta.keepAlive"/>
</keep-alive>
<router-view v-else></router-view>
子应用内的实现与主应用解耦,符合单一职责的设计理念,控制灵活,但子应用需要自己处理状态管理逻辑。
实现一个微前端框架
1. 核心原理
微前端支持不同框架的子应用,通过监听页面 URL 变化来切换不同的子应用。
- 重写 pushState() 和 replaceState() 方法,根据 URL 变化加载或卸载子应用。监听
popstate
和hashchange
事件,触发时加载或卸载子应用。重新方法和事件监听:
const originalPushState = window.history.pushState
window.history.pushState = function (state, title, url) {
const result = originalPushState.call(this, state, title, url)
loadApps() // 根据当前 url 加载或卸载 app
return result
}
window.addEventListener('popstate', () => loadApps(), true)
window.addEventListener('hashchange', () => loadApps(), true)
loadApps()
方法根据当前 URL 和子应用的触发规则加载或卸载子应用。
export async function loadApps() {
// 获取所有需要处理的子应用状态
const toUnMountApp = getAppsWithStatus(AppStatus.MOUNTED);
const toLoadApp = getAppsWithStatus(AppStatus.BEFORE_BOOTSTRAP);
const toMountApp = [
...getAppsWithStatus(AppStatus.BOOTSTRAPPED),
...getAppsWithStatus(AppStatus.UNMOUNTED)
];
// 执行卸载、初始化和加载子应用的操作
await Promise.all([
...toUnMountApp.map(unMountApp), // 卸载失活的子应用
...toLoadApp.map(bootstrapApp), // 初始化新注册的子应用
...toMountApp.map(mountApp) // 加载符合条件的子应用
]);
}
根据子应用状态,卸载、初始化和加载子应用,并通过 Promise.all() 并行执行,确保生命周期管理与 URL 变化同步。
2. 子应用的生命周期管理
- 子应用必须暴露
bootstrap()
、mount()
、unmount()
三个方法。bootstrap() 初始化,仅触发一次、mount() 每次加载时触发子应用渲染、unmount() 每次卸载时触发。 registerApplication()
用于注册子应用,start()
方法启动微前端框架,执行 loadApps() 去加载子应用。
let vueApp
// 注册 Vue 子应用
registerApplication({
name: 'vue',
loadApp() {
return Promise.resolve({
bootstrap() { console.log('vue bootstrap') }, // 初始化
mount() {
console.log('vue mount') // 挂载
vueApp = Vue.createApp({ data: () => ({ text: 'Vue App' }), render() { return Vue.h('div', this.text) } })
vueApp.mount('#app')
},
unmount() {
console.log('vue unmount') // 卸载
vueApp.unmount()
},
})
},
activeRule: (location) => location.hash === '#/vue', // 激活规则
})
3. 加载子应用
使用 entry 参数,配置子应用HTML入口,自动加载资源文件。解析 HTML 并提取
3.1 加载 HTML 内容
通过 AJAX 获取子应用入口文件的 HTML 内容。
export function loadSourceText(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 请求成功时解析响应内容
xhr.onload = (res: any) => { resolve(res.target.response) }
// 请求失败或中止时处理错误
xhr.onerror = () => reject(new Error('Network error'));
xhr.onabort = () => reject(new Error('Request aborted'));
// 初始化并发送请求
xhr.open('GET', url);
xhr.send();
});
}
3.2 提取资源
解析 HTML 中
export const globalLoadedURLs: string[] = [];
function extractScriptsAndStyles(node: Element, app: Application) {
if (!node.children.length) return { scripts: [], styles: [] };
const styles: Source[] = [];
const scripts: Source[] = [];
for (const child of Array.from(node.children)) {
const tagName = child.tagName;
const isGlobal = !!child.getAttribute('global');
const url = child.getAttribute(tagName === 'SCRIPT' ? 'src' : 'href') || '';
// 跳过重复加载的资源
if (url && (app.loadedURLs.includes(url) || globalLoadedURLs.includes(url))) continue;
if (tagName === 'STYLE') { // 提取 <style> 标签内容
styles.push({ isGlobal, value: child.textContent || '' });
} else if (tagName === 'SCRIPT') { // 提取 <script> 标签内容和属性
scripts.push({ isGlobal, type: child.getAttribute('type'), value: child.textContent || '', url: url || undefined });
} else if (tagName === 'LINK' && child.getAttribute('rel') === 'stylesheet' && url) {
// 提取 <link rel="stylesheet"> 标签
styles.push({ isGlobal, value: '', url });
} else {
// 递归处理子节点
const result = extractScriptsAndStyles(child, app);
scripts.push(...result.scripts);
styles.push(...result.styles);
}
// 更新已加载资源列表并移除节点
if (url) (isGlobal ? globalLoadedURLs : app.loadedURLs).push(url);
removeNode(child);
}
return { scripts, styles };
}
3.3 加载样式和逻辑
将样式插入主应用页面,将脚本执行后加载子应用逻辑。
export function addStyles(styles: (string | HTMLStyleElement)[]) {
styles.forEach(item => {
// 如果是字符串,则创建 <style> 标签;否则直接使用现有的 HTMLStyleElement
const node = typeof item === 'string'
? Object.assign(document.createElement('style'), { type: 'text/css', textContent: item })
: item;
// 将样式节点添加到 <head>
document.head.appendChild(node);
});
}
3.4 挂载子应用内容
保存子应用的 HTML 内容,并在挂载前赋值给容器。通过调用 mount()
渲染子应用,即:子应用的body内容渲染到指定的 DOM 节点。
// 保存 HTML 代码
app.pageBody = doc.body.innerHTML
// 加载子应用前赋值给挂载的 DOM
app.container.innerHTML = app.pageBody
app.mount()
4. 沙箱机制
4.1 Proxy 代理 window
主应用和子应用共享一个 window 对象,导致属性互相覆盖。引入Proxy代理子应用的window对象,避免与父应用共享。
app.window = new Proxy({}, {
get(target, key) {
// 如果代理对象有该属性,直接返回
if (Reflect.has(target, key)) return Reflect.get(target, key);
const result = originalWindow[key]; // 否则从父应用的 window 获取
return (isFunction(result) && needToBindOriginalWindow(result))
? result.bind(window) // 如果是函数,绑定 this 到 window
: result;
},
set: (target, key, value) => {
this.injectKeySet.add(key); // 记录修改的属性
return Reflect.set(target, key, value); // 修改代理对象的属性
}
});
通过Proxy代理拦截对子应用 window 对象的操作,实现子应用与父应用的作用域隔离,避免子应用对父应用的 window 产生影响。
让子应用代码读取和修改 window 时,访问的是子应用的代理window对象,而不是父应用的window。前面说到微前端框架通过 entry 拉取子应用 JS 资源并执行,在执行之前,使用 with 语句包裹子应用的代码,将全局 window 指向代理 window。
export function executeScripts(scripts: string[], app: Application) {
try {
scripts.forEach(code => {
if (isFunction(app.loader)) {
code = app.loader(code); // 处理代码
}
// 使用 with 语句将 window 指向代理 window
const warpCode = `
;(function(proxyWindow){
with (proxyWindow) {
(function(window){${code}\n}).call(proxyWindow, proxyWindow)
}
})(this);
`;
new Function(warpCode).call(app.sandbox.proxyWindow); // 执行包裹后的代码
});
} catch (error) {
throw error;
}
}
4.2 卸载时清除子应用
在子应用卸载时,需要清除其 window 代理对象、绑定的全局事件和定时器,防止数据残留影响下一次加载。
4.2.1 清除 window 对象
injectKeySet 存储了所有新增的属性,卸载时需要删除对应的属性。
for (const key of injectKeySet) {
Reflect.deleteProperty(microAppWindow, key);
}
4.2.2 清除事件和定时器
记录定时器和事件,卸载时清除:
- 定时器:在 setTimeout 和 clearTimeout 中记录和清除定时器。
- 事件监听器:记录事件并在卸载时移除。
// 清除所有定时器
for (const timer of timeoutSet) {
originalWindow.clearTimeout(timer);
}
// 移除所有事件监听
for (const [type, arr] of windowEventMap) {
for (const item of arr) {
originalWindowRemoveEventListener.call(originalWindow, type, item.listener, item.options);
}
}
4.3 缓存子应用快照
在微前端中,子应用的 JS 文件只加载一次,mount() 方法每次执行前的初始化逻辑不会重复。为解决这个问题,可以通过快照机制记录和恢复子应用的状态。
- 生成快照:在卸载子应用时,保存其 window 状态和事件。
const { windowSnapshot, microAppWindow } = this
const recordAttrs = windowSnapshot.get('attrs')!
const recordWindowEvents = windowSnapshot.get('windowEvents')!
// 保存 window 属性
this.injectKeySet.forEach(key => {
windowSnapshot.get('attrs')!.set(key, deepCopy(microAppWindow[key]))
})
// 保存 window 事件
this.windowEventMap.forEach((arr, type) => {
windowSnapshot.get('windowEvents')!.set(type, deepCopy(arr))
})
- 恢复快照:在子应用重新加载时,还原之前记录的 window 状态和事件。
const { windowSnapshot, injectKeySet, microAppWindow, windowEventMap } = this;
// 恢复 window 属性
windowSnapshot.get('attrs')?.forEach((value, key) => {
injectKeySet.add(key); // 记录属性到 injectKeySet
microAppWindow[key] = deepCopy(value); // 恢复属性到代理的 window 对象
});
// 恢复 window 事件
windowSnapshot.get('windowEvents')?.forEach((events, type) => {
windowEventMap.set(type, deepCopy(events)); // 更新事件到当前的事件映射
events.forEach(({ listener, options }) => {
// 绑定事件到原生 window
originalWindowAddEventListener.call(originalWindow, type, listener, options);
});
});
4.4 隔离子应用元素作用域
避免查询到子应用范围外的 DOM 元素,重写查询 DOM API,将查询范围限制在子应用的挂载容器内,确保只在子应用容器内查询 DOM。
// 重写 querySelector,将查询范围限制在子应用容器内
Document.prototype.querySelector = function(selector) {
const app = getCurrentApp();
if (!app || !selector || isUniqueElement(selector)) {
return originalQuerySelector.call(this, selector);
}
return app.container.querySelector(selector); // 限制查询范围
}
// 恢复原始 querySelector API
Document.prototype.querySelector = originalQuerySelector;
Document.prototype.querySelectorAll = originalQuerySelectorAll;
同时限制样式作用域,将子应用样式限制在子应用挂载容器内,调整样式作用域,将body改为子应用容器 ID,避免污染全局。
const re = /^(\s|,)?(body|html)\b/g;
cssText.replace(re, `#${app.container.id}`);
5. 子应用样式隔离
为了防止子应用的样式相互干扰,通过标识 DOM 元素、移除样式标签和修改 CSS 规则,实现样式的隔离与独立。
5.1 添加子应用标识
为创建的 DOM 元素添加 single-spa-name 属性,标识所属子应用。
Document.prototype.createElement = function (tagName, options) {
const appName = getCurrentAppName(); // 获取当前子应用名称
const element = originalCreateElement.call(this, tagName, options); // 调用原生 createElement 方法创建元素
if (appName) element.setAttribute('single-spa-name', appName); // 如果有子应用名称,则添加 'single-spa-name' 属性
return element; // 返回创建的元素
};
5.2 卸载时移除样式
在子应用卸载时,移除对应的 style 标签。
export function removeStyles(name) {
document
.querySelectorAll(`style[single-spa-name=${name}]`) // 查询所有对应子应用名称的 style 标签
.forEach(style => removeNode(style)); // 遍历并移除这些 style 标签
}
5.3 样式作用域隔离
将样式选择器添加子应用标识,限定样式作用范围。
- 原始样式:div { color: red; }
- 隔离后:div[single-spa-name=vue] { color: red; }
5.4 核心代码
- 遍历 CSS 规则 (cssRules)。
- 替换选择器,添加 [single-spa-name=子应用名]。
- 替换 body 和 html 为子应用挂载容器 ID。
function handleCSSRules(cssRules, app) {
// 获取子应用容器的 ID,如果没有则生成一个唯一 ID
const id = app.container.id || `single-spa-id-${count++}`;
app.container.id = id; // 设置容器的 ID
// 遍历 CSS 规则并为每个选择器添加作用域
return Array.from(cssRules).reduce((result, cssRule) => {
const { selectorText } = cssRule; // 获取当前 CSS 规则的选择器文本
// 将选择器添加子应用名称作为属性,并替换 body 和 html
const scopedSelector = selectorText
.split(',') // 分割多个选择器
.map(text => `${text.trim()}[single-spa-name=${app.name}]`) // 给每个选择器加上单独的作用域
.join(',') // 合并多个选择器
.replace(/^(\s|,)?(body|html)\b/g, `#${id}`); // 替换 body 和 html 为子应用容器的 ID
return result + cssRule.cssText.replace(selectorText, scopedSelector); // 将修改后的选择器替换回原 CSS 规则
}, '');
}
6. 各应用间通信
通过window.spaGlobalState允许多个应用共享,监听和修改全局状态,同时支持事件订阅/发布。
- 全局状态共享:通过 window.spaGlobalState,各应用共享和修改数据。修改时触发 change 事件,其他应用可以监听。
export default class GlobalState extends EventBus {
private state: AnyObject = {} // 存储全局状态的对象
private stateChangeCallbacksMap: Map<string, Array<Callback>> = new Map() // 存储每个应用的状态变更回调函数
// 设置全局状态,并触发状态变更
set(key: string, value: any) {
this.state[key] = value
this.emitChange('set', key) // 触发状态变更事件
}
// 获取全局状态
get(key: string) {
return this.state[key]
}
// 注册状态变化回调,监听状态变更
onChange(callback: Callback) {
const appName = getCurrentAppName() // 获取当前应用名称
if (!appName) return // 如果没有获取到应用名,退出
// 如果当前应用没有对应的回调列表,初始化
if (!this.stateChangeCallbacksMap.get(appName)) {
this.stateChangeCallbacksMap.set(appName, [])
}
// 将回调添加到当前应用的回调列表中
this.stateChangeCallbacksMap.get(appName)?.push(callback)
}
// 触发状态变更事件,通知所有应用
emitChange(operator: string, key?: string) {
// 遍历所有注册的应用,调用其回调函数
this.stateChangeCallbacksMap.forEach((callbacks, appName) => {
const app = getApp(appName) as Application
if (isActive(app) && app.status === AppStatus.MOUNTED) {
// 仅在应用已挂载时触发回调
callbacks.forEach(callback => callback(this.state, operator, key))
}
})
}
}
- 事件通信:通过 EventBus 实现应用间的事件订阅和发布功能,支持应用间的通知与交互。
export default class EventBus {
private eventsMap: Map<string, Record<string, Array<Callback>>> = new Map() // 存储各应用的事件及回调
// 注册事件回调
on(event: string, callback: Callback) {
if (!isFunction(callback)) { // 确保回调是函数
throw Error(`The second param ${typeof callback} is not a function`)
}
const appName = getCurrentAppName() || 'parent' // 获取当前应用名,默认为父应用
// 如果当前应用没有事件列表,初始化
const events = this.eventsMap.get(appName) || {}
this.eventsMap.set(appName, {...events, [event]: [...(events[event] || []), callback]})
}
// 触发事件
emit(event: string, ...args: any) {
// 遍历所有应用的事件,调用相应的回调
this.eventsMap.forEach((events, appName) => {
const app = getApp(appName) as Application
// 仅在应用已挂载或为父应用时触发事件
if (appName === 'parent' || (isActive(app) && app.status === AppStatus.MOUNTED)) {
events[event]?.forEach(callback => callback(...args)) // 执行事件回调
}
})
}
}