当前位置: 首页 > article >正文

微前端架构 qiankun

背景:随着业务功能的扩展,原有开发模式已无法满足需求。上线后出现问题时,排查过程变得异常复杂,新开发人员也难以迅速理解现有代码。同时,系统间界面风格和交互差异较大,导致跨系统办理业务时工作量增加。因此,引入微前端架构,以支持团队协作、实现独立部署,并提升开发效率。

微前端

微前端 qiankun:基于 single-spa 实现的微前端框架,允许多个子应用在一个主应用中独立运行且互不干扰,适用于大型应用或多团队协作场景。其优点包括:与技术栈无关,支持子应用独立开发和部署,提供开箱即用的 API,易于上手,且社区活跃,支持良好。

qiankun 的特点

  1. 技术栈无关:支持不同技术栈的主子应用(如 React、Vue、Angular 等),集成简单。
  2. 动态加载静态资源:通过 HTML
    解析动态加载子应用 JS 和 CSS,无需强耦合。
  3. 沙箱隔离:提供 Proxy 或快照沙箱,避免全局变量和样式污染。
  4. 独立部署:子应用可单独开发和部署。
  5. 应用间通信:支持props或全局状态管理initGlobalState,实现主子应用及子应用间数据交互。
  6. 动态加载:支持动态注册和加载子应用。 生命周期管理:提供bootstrap、mount、unmount等钩子。
  7. 多路由模式:兼容
    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 变化加载或卸载子应用。监听popstatehashchange事件,触发时加载或卸载子应用。重新方法和事件监听:
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))  // 执行事件回调
      }
    })
  }
}

http://www.kler.cn/a/418027.html

相关文章:

  • 用MATLAB符号工具建立机器人的动力学模型
  • 什么是内存对齐?为什么需要内存对齐?
  • Linux内核编译流程(Ubuntu24.04+Linux Kernel 6.8.12)
  • 项目切换Java21
  • 如何估算自然对流传热系数
  • 字节青训Marscode_5:寻找最大葫芦——最新题解
  • 在 Flutter app 中,通过视频 URL 下载视频到手机相册
  • 使用Feign远程调用丢失请求头问题
  • BGE-M3模型结合Milvus向量数据库强强联合实现混合检索
  • Tree搜索二叉树、map和set_数据结构
  • 1074 Reversing Linked List (25)
  • 【AI战略思考13】克服懒惰,保持专注,提升效率,不再焦虑
  • centos8:Could not resolve host: mirrorlist.centos.org
  • Springboot(四十四)Springboot集成Validation实现参数校验
  • 第六届国际科技创新(IAECST 2024)暨第四届物流系统与交通运输(LSTT 2024)
  • 【C++】优先队列(Priority Queue)全知道
  • Spring cache注解:缓存与业务解耦实战
  • 基于51单片机的电子秤设计
  • 网络安全系列 之 SQL注入学习总结
  • 21天掌握Java Web —— 第一天:Spring Boot入门
  • 面积等效原理
  • BUGKU printf
  • Electron builder打包配置
  • Adversarial Learning forSemi-Supervised Semantic Segmentation
  • 第二讲:C++基础语法与程序结构
  • 如何启动 Docker 服务:全面指南