微前端架构 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. 异步请求系统树数据
js 复制代码
// 定义全局消息传递对象,存储主应用的状态和 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,并启动微前端框架。

js// 复制代码
registerMicroApps(apps)

// 第一个子应用加载完毕回调
runAfterFirstMounted(() => {})

// 启动微前端框架
start({
  sandbox: false,  // 关闭沙盒模式,确保子应用的 window 对象正常
  prefetch: 'all',  // 启用所有子应用的预加载
})

// 设置全局未捕获异常处理器
addGlobalUncaughtErrorHandler((event) => console.log(event))
3. 主应用渲染函数

主应用的渲染逻辑:只在首次渲染时创建 Vue 实例,从而避免不必要的重复创建。初始化函数 init(),在所有子应用成功注册后启动微前端框架。

js 复制代码
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。

js 复制代码
// 获取环境对应的应用入口 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 是否激活对应的子应用,适用于微前端架构中的子应用加载与路由控制。

js 复制代码
// 获取环境对应的应用入口 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 文件中,添加如下信息,确保子应用正确加载资源路径:

js 复制代码
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}

若当前运行在微前端环境中,webpack_public_path 会被动态设置为 qiankun 注入的公共路径,确保子应用在qiankun环境下能正确加载资源路径。

2. 入口文件

子应用的生命周期钩子函数分别为 bootstrap、mount 和 unmount。它们在子应用初始化、挂载、卸载时执行:

js 复制代码
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 格式,以支持微前端架构:

js 复制代码
const { name } = require('./package');

module.exports = {
  // 其他配置项...
  configureWebpack: {
    output: {
      library: `${name}`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};
4. 子应用路由配置

在子应用的 router.js 文件中引入 Vue 和 Vue Router,子应用在微前端环境下,则根据subAppCode作为路由前缀,否则使用默认的基础路径。

js 复制代码
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 路由守卫先触发,根据不同的条件来执行不同的操作,包括设置页面标题、判断用户是否已登录、加载用户权限、处理子系统菜单等,控制整个路由过程的流向。

javascript 复制代码
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)下,根据当前路径设置对应的子应用名称和菜单,更新子应用的相关信息:

js 复制代码
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. 处理未登录用户

未登录用根据白名单判断是否允许访问,否则重定向到登录页并带上当前页面的跳转路径。

js 复制代码
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函数异步加载子系统菜单,并根据页面配置加载对应的按钮权限。

js 复制代码
function handleLoggedInUser(to, next) {
  loadUserPermissions(to, next) // 加载用户权限
  loadMicroAppMenu(to, next) // 加载子系统菜单
  loadActionPermissions(to, next) // 获取按钮权限
}
3.1 加载用户权限

如果权限为空,请求并生成权限路由。

js 复制代码
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 加载子系统菜单

对于多子系统应用,每次路由跳转时,根据当前路径检查当前菜单是否已经加载。如果未加载则向后端请求菜单数据,加载对应的子系统菜单。

javascript 复制代码
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属性,加载并保存该页面按钮权限。若没有按钮权限的页面直接进入。

javascript 复制代码
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. 异常处理和重定向逻辑

当发生错误时(如请求失败或用户没有访问权限),统一处理错误并重定向到登录页面。

javascript 复制代码
// 统一处理错误提示
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 路径。

javascript 复制代码
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 用于在路由跳转完成后执行一些清理工作,如停止进度条。
javascript 复制代码
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. 未登录用户

未登录用户可以直接访问免登录白名单中的页面,否则会被重定向到登录页:

javascript 复制代码
function handleGuestUser(to, next) {
  if (whiteList.includes(to.name)) {
    next();
  } else {
    next({ path: '/passport', query: { redirect: to.fullPath } });
    NProgress.done();
  }
}
4. 已登录用户

已登录用户进行动态路由和按钮权限的初始化:

javascript 复制代码
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 加载动态路由

只有在权限菜单为空时,才会请求后端接口获取权限数据并生成路由,避免重复请求。

javascript 复制代码
/**
 * 确保动态路由已加载
 */
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 中,以便在页面中进行按钮权限的控制。

javascript 复制代码
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传递所需的数据或回调函数。
javascript 复制代码
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。
javascript 复制代码
export async function mount(props) {
  console.log('Props from main app:', props);
  const { userInfo, globalState, setGlobalState } = props;

  // 调用主应用方法
  setGlobalState({ theme: 'light' });
}

适用于主应用向子应用单向传递初始化数据,静态数据传递,简单高效,如:子应用初始化配置。

2. 全局状态管理

Qiankun 提供了initGlobalState 方法(全局状态管理工具),用于共享和同步主应用与子应用的状态。它支持双向通信,并且易于集成。实现步骤如下:

  • 主应用:初始化全局状态,设置监听器应子应用的状态变化。
javascript 复制代码
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 获取全局状态,并监听状态变化或更新全局状态。
javascript 复制代码
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个),如超过则卸载最早加载的子应用。
  • 检查子应用是否已加载,避免重复加载。
javascript 复制代码
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 提前加载这些子应用的资源,提升切换速度。

javascript 复制代码
// 获取并设置子应用权限菜单列表
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 同步,实现子应用的有序缓存与管理。

javascript 复制代码
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,确保主应用在缓存子应用时能够有效地管理子应用的加载状态。

javascript 复制代码
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(如开发、测试、生产等)动态配置。因此,主应用与子应用分别运行在本地开发环境中,主应用通过 子应用本地入口地址加载子应用。

优点:子应用支持独立运行,便于快速调试子应用逻辑,但本地可能同时启动多个服务会占用系统资源。

javascript 复制代码
registerMicroApps([
  {
    name: 'subApp1',
    entry: '//localhost:8081', // 子应用1的本地地址
    container: '#subApp1',
    activeRule: '/subapp1',
  },
  {
    name: 'subApp2',
    entry: '//localhost:8082', // 子应用2的本地地址
    container: '#subApp2',
    activeRule: '/subapp2',
  },
]);
2.2 子应用支持独立运行

在前面子应用路由配置中,子应用通过环境变量来动态设置 base 路径,实现子系统独立运行,快速验证功能。

javascript 复制代码
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 特性来实现页面或组件的缓存功能,从而优化页面性能和用户体验。

javascript 复制代码
// 子应用中
<keep-alive>
  <router-view v-if="$route.meta.keepAlive"/>
</keep-alive>
<router-view v-else></router-view>

子应用内的实现与主应用解耦,符合单一职责的设计理念,控制灵活,但子应用需要自己处理状态管理逻辑。

实现一个微前端框架

1. 核心原理

微前端支持不同框架的子应用,通过监听页面 URL 变化来切换不同的子应用。

  • 重写 pushState() 和 replaceState() 方法,根据 URL 变化加载或卸载子应用。监听popstatehashchange事件,触发时加载或卸载子应用。重新方法和事件监听:
javascript 复制代码
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 和子应用的触发规则加载或卸载子应用。
javascript 复制代码
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() 去加载子应用。
javascript 复制代码
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 内容。

javascript 复制代码
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 中

javascript 复制代码
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 加载样式和逻辑

将样式插入主应用页面,将脚本执行后加载子应用逻辑。

javascript 复制代码
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 节点。

javascript 复制代码
// 保存 HTML 代码
app.pageBody = doc.body.innerHTML

// 加载子应用前赋值给挂载的 DOM
app.container.innerHTML = app.pageBody
app.mount()

4. 沙箱机制

4.1 Proxy 代理 window

主应用和子应用共享一个 window 对象,导致属性互相覆盖。引入Proxy代理子应用的window对象,避免与父应用共享。

javascript 复制代码
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。

javascript 复制代码
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 存储了所有新增的属性,卸载时需要删除对应的属性。

javascript 复制代码
for (const key of injectKeySet) {
  Reflect.deleteProperty(microAppWindow, key);
}
4.2.2 清除事件和定时器

记录定时器和事件,卸载时清除:

  • 定时器:在 setTimeout 和 clearTimeout 中记录和清除定时器。
  • 事件监听器:记录事件并在卸载时移除。
javascript 复制代码
// 清除所有定时器
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 状态和事件。
javascript 复制代码
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 状态和事件。
javascript 复制代码
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。

javascript 复制代码
// 重写 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,避免污染全局。

javascript 复制代码
const re = /^(\s|,)?(body|html)\b/g;
cssText.replace(re, `#${app.container.id}`);

5. 子应用样式隔离

为了防止子应用的样式相互干扰,通过标识 DOM 元素、移除样式标签和修改 CSS 规则,实现样式的隔离与独立。

5.1 添加子应用标识

为创建的 DOM 元素添加 single-spa-name 属性,标识所属子应用。

javascript 复制代码
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 标签。

javascript 复制代码
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。
javascript 复制代码
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 事件,其他应用可以监听。
js 复制代码
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 实现应用间的事件订阅和发布功能,支持应用间的通知与交互。
js 复制代码
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))  // 执行事件回调
      }
    })
  }
}
相关推荐
疾风铸境8 分钟前
Qt5.14.2+mingw64编译OpenCV3.4.14一次成功记录
前端·webpack·node.js
晓风伴月12 分钟前
Css:overflow: hidden截断条件‌及如何避免截断
前端·css·overflow截断条件
最新资讯动态14 分钟前
使用“一次开发,多端部署”,实现Pura X阔折叠的全新设计
前端
爱泡脚的鸡腿29 分钟前
HTML CSS 第二次笔记
前端·css
灯火不休ᝰ1 小时前
前端处理pdf文件流,展示pdf
前端·pdf
智践行1 小时前
Trae开发实战之转盘小程序
前端·trae
最新资讯动态1 小时前
DialogHub上线OpenHarmony开源社区,高效开发鸿蒙应用弹窗
前端
lvbb661 小时前
框架修改思路
前端·javascript·vue.js
树上有只程序猿1 小时前
Java程序员需要掌握的技术
前端
从零开始学安卓1 小时前
Kotlin(三) 协程
前端