Skip to content

微前端

哪些应用需要拆分成微前端?

  • 技术:技术⽼旧,应用随着项目迭代越来越庞大,耦合度升高,以致缺乏灵活性,难以维护
  • 团队协作:解决组织和团队间协作带来的工程问题
  • 业务:用户喜欢聚合、一体化的应用,可以在既不重写原有系统的基础之下,又可以抽出人力来开发新的业务

❌ 不适合拆分为微前端的场景:

  1. 简单小型项目:业务逻辑简单、模块少的项目(如单页应用),拆分微前端会增加架构复杂度和维护成本,得不偿失。
  2. 强耦合的功能模块:模块间存在高频数据交互或共享复杂状态(如实时协同编辑功能),拆分后会导致通信成本过高,影响性能。
  3. 对性能敏感的场景:微前端涉及资源加载、渲染调度等额外开销,对性能要求极高的场景(如高频交互的实时数据面板)需谨慎评估。
  4. 团队规模小或技术能力不足:微前端需要团队掌握路由劫持、沙箱隔离、跨框架通信等技术,若团队经验不足,可能导致开发效率下降。

优点

  • 技术栈⽆关
  • 便于业务拆解
  • 子应用可独立测试、部署,不影响主应用稳定性

缺点

  • 架构复杂,维护成本⾼
  • 性能开销与加载延迟
  • 样式与交互统一成本高
    • 挑战:
      • 不同技术栈的组件库(如 Vue 的 Element UI 与 React 的 Ant Design)样式难以统一。
      • 全局样式管理(如主题、字体)需跨应用协调,容易出现碎片化。
    • 解决方案:
      • 使用跨框架组件库(如 Element Plus、Ant Design)或统一设计系统(如 Figma 规范)。
      • 通过 CSS 变量或 CSS-in-JS 统一主题配置。
  • 状态管理与通信复杂性
    • 挑战:
      • 子应用与主应用、子应用之间的通信需制定标准化协议(如事件总线、全局状态),否则易导致数据不一致。
      • 避免强依赖主应用的状态管理库(如 Vuex),需保持子应用独立性。
    • 最佳实践:
      • 定义明确的通信接口(如 onLogin、onLogout 事件)。
      • 使用轻量级状态管理工具(如 Redux Toolkit、Pinia)或浏览器 API(localStorage、CustomEvent)。
      • 定义标准化通信接口(如 parent.postMessage),避免直接引用子应用内部实现。
  • 开发调试体验不好
  • 版本,依赖的管理复杂

微前端带来的问题

  1. 跨框架通信障碍
    • 问题:
      • 不同技术栈(如 Vue 与 React)间的事件传递、状态共享需特殊处理。
      • 子应用间通信可能导致强耦合,违反微前端隔离原则。
    • 解决方案:
      • 使用发布订阅,绑定在 window 上。
      • 定义标准化通信接口(如 parent.postMessage),避免直接引用子应用内部实现。
  2. 加载性能下降
    • 问题:
      • 微前端架构需额外加载框架代码、路由配置等,导致首屏加载时间增加。
      • 子应用资源分散,HTTP 请求数增多(尤其在未合并资源时)。
    • 解决方案:
      • 按需加载子应用(如访问时才加载),避免一次性加载所有资源。
      • 使用 CDN 加速静态资源,并配置 HTTP/2 减少请求开销。
  3. 样式冲突与覆盖
    • 问题:
      • 不同子应用的 CSS 类名可能冲突(如都定义了 .button-primary)。
      • 全局样式(如 body 样式)难以统一管理,可能被子应用意外修改。
    • 解决方案:
      • 使用 CSS Modules、Shadow DOM 或 CSS-in-JS 实现样式隔离。
      • 通过命名空间约定(如 .app1-*、.app2-*)避免类名冲突。

为什么选择微前端,微前端的缺点(微前端性能一定就要比 spa 好吗),在性能上会有损失怎么做的

🤔 为什么选择 qiankun 微前端

项目公司的项目是个后台管理,在之前是多个独立部分的后台系统,后面要求整合起来,并且大部分采用的是 antd pro 进行开发的,所以项目采用 qiankun 进行微前端合并。

🤔 微前端的缺点(是否一定比 SPA 性能差)?

微前端在某些场景下可能会比单体 SPA 的性能略差,但在特定业务需求下其优势更为重要。以下是关键点:

性能劣势

  • 加载延迟:子应用资源需要远程加载,首次渲染可能存在延迟。
  • 通信开销:跨应用通信可能带来额外性能损耗。
  • 重复依赖:不同子应用可能引入相同的库(如 React 或 Vue),导致资源冗余。
  • 复杂性增加:沙箱机制、路由劫持等会增加运行时开销。

性能优化手段

  • 按需加载:通过 activeRule 控制仅加载当前需要的子应用。
  • 模块联邦(Module Federation):共享公共依赖(如 React、Vue),减少重复加载。
  • CDN 加速:利用 CDN 缓存静态资源,缩短加载时间。
  • 懒加载与缓存策略:对非核心功能进行懒加载,结合浏览器缓存或 Service Worker 提升二次加载速度。
  • CSS 模块化与隔离:使用 CSS Modules、Shadow DOM 避免样式冲突,提升渲染效率。
  • 轻量级通信机制:采用 CustomEvent、postMessage 等方式实现高效通信。

🤔 在性能上会有损失怎么做的

虽然微前端存在一定的性能开销,但可以通过以下策略来缓解:

问题解决方案
子应用加载慢使用懒加载、按需加载,结合 Webpack 动态导入
JS/CSS 冲突引入沙箱机制(JS 沙箱、CSS-in-JS)、命名空间约定
资源重复利用 Module Federation 共享基础库
通信复杂使用标准化事件总线(如全局事件中心)或 CustomEvent
首屏加载慢增加预加载策略(如 prefetch、preload),配合骨架屏

微前端业务遇到哪些问题怎么解决

1. 主子应用样式污染

qiankun 提供的样式隔离方案不好用,react、vue 有自己的样式隔离方案

在子应用构建时,使用 postcss 插件,给子应用添加上特有的前缀(可以启动两个子应用项目,用来做样式对照,一个是单独运行的,一个是添加前缀运行到 qiankun 中的)

2. 应用间通信

  1. localStorage/sessionStorage
  2. 通过路由参数共享
  3. 官方提供的 props
  4. 官方提供的 actions(const actions = initGlobalState({})
  5. 主应用使用 vuex 或 redux 管理状态,通过 props 传递给子应用
  6. 使用发布订阅,将发布订阅绑定在 window

3. 路由跳转

在子应用中是没有办法通过 <router-link> 或者用 router.push/router.replace 直接跳转的,因为这个 router 是子应用的路由,所有的跳转都会基于子应用的 base 。当然了写 <a> 链接可以跳转过去,但是会刷新页面,用户体验并不好。

这里可以采用以下两种方式:

  • 将主应用的路由实例通过 props 传给子应用,子应用用这个路由实例跳转
  • 路由模式为 history 模式时,通过 history.pushState() 方式跳转

4. 权限处理

由主应用去控制,当用户登录成功后,返回动态路由表,并存入 store 中,然后将 store 通过 props 传给子应用,子应用根据 store 中的数据进行权限控制。

参考文章:https://juejin.cn/post/7403192588347490314

弱网环境下是懒加载好呢还是一次性加载好

在弱网环境下,优先推荐使用懒加载,并结合以下优化手段提升体验:

  • 使用骨架屏提升首屏感知性能。
  • 在空闲时段进行子应用预加载(如 prefetch)。
  • 合理划分模块,控制每个懒加载 chunk 的体积。
  • 使用 CDN 和 HTTP/2 加速静态资源加载。

qiankun

qiankun 的子应用只会根据 activeRule 匹配的 URL 渲染,只会加载一个子应用。并且子应用需要构建为 UMD 格式。

优点

  • 监听路由自动的加载、卸载当前路由对应的子应用
  • 完备的沙箱方案
    • js 沙箱做了三套渐进增强方案:
      1. 快照沙箱SnapshotSandbox
      2. 支持单应用的代理沙箱LegacySandbox
      3. 支持多应用的代理沙箱ProxySandbox
    • css 沙箱做了两套:
      1. shadow DOM 方式strictStyleIsolation
      2. css scope 方式experimentalStyleIsolation
  • 路由保持,浏览器刷新、前进、后退,都可以作用到子应用
  • 应用间通信简单,全局注入

缺点

  • 基于路由匹配,无法同时激活多个子应用,也不支持子应用保活
  • 改造成本较大,从 webpack、代码、路由等等都要做一系列的适配
  • css 沙箱无法绝对的隔离,js 沙箱在某些场景下执行性能下降严重
  • 无法支持 vite 等 ESM 脚本运行

样式污染怎么解决

可以不使用 qiankun 的样式隔离,使用 css-in-js 或者 css-modules 来解决。

原理

  1. qiankun 主要是监听路由变化,来加载对应的子应用。
md
- hash: window.onhashchange
- history: window.onpopstate
  - history.go, history.back, history.forward 使用 popstate 事件监听
  - pushState, replaceState 需要通过函数重写的方式进行劫持
  1. 子应用是通过 fetch 去请求子应用的 entry,然后通过 import-html-entry 然后正则匹配得到内部样式表、外部样式表、内部脚本、外部脚本,组合之前为样式添加属性选择器(data-微应用名称);将组合好的样式通过 style 标签添加到 head 中。(外部样式表也会被读取到内容后写入<style>中)
  2. 创建 js 沙盒:不支持 Proxy 的用 SnapshotSandbox(通过遍历 window 对象进行 diff 操作来激活和还原全局环境),支持 Proxy 且只需要单例的用 LegcySandbox(通过代理来明确哪些对象被修改和新增以便于卸载时还原环境),支持 Proxy 且需要同时存在多个微应用的用 ProxySandbox(创建了一个 window 的拷贝对象,对这个拷贝对象进行代理,所有的修改都不会在 rawWindow 上进行而是在这个拷贝对象上),最后将这个 proxy 对象挂到 window 上面
  3. 执行脚本:将上下文环境绑定到 proxy 对象上,然后 eval 执行

注册采用的是 single-spa start 启动也是 single-spa 的方法,相比之下,qiankun 新增了下面的功能:

  • 预加载功能:利用 requestIdleCallback 进行加载
  • 沙箱功能:js 沙箱(创建 sandbox,让 execScript 方法运行在 sandbox 中) 样式隔离(影子 DOM,scoped css)
  • 获取导出的接入协议(在沙箱中执行的),并进行扩展,然后放入 single-spa 的接入协议中

沙箱的作用

  • JS 沙箱:防止子应用修改全局变量、污染全局 window 对象。
  • CSS 沙箱(样式隔离):防止不同子应用之间的样式冲突或互相覆盖。

qiankun JS 沙箱 和 CSS 沙箱

JS 沙箱

  1. 快照沙箱(SnapshotSandbox)需要遍历 window 上的所有属性,性能较差。
  2. ES6 新增的 Proxy,产生了新的沙箱支持单应用的代理沙箱(LegacySandbox),它可以实现和快照沙箱一样的功能,但是性能更好,但是缺点是:也会和快照沙箱一样,污染全局的 window,因此它仅允许页面同时运行一个微应用。
  3. 而多应用的代理沙箱(ProxySandbox),可以允许页面同时运行多个微应用,并且不会污染全局的 window。

qiankun 默认使用 ProxySandbox,可以通过 start.singular 来修改,如果发现浏览器不支持 Proxy 时,会自动优雅降级使用 SnapshotSandbox

SnapshotSandbox 快照沙箱

快照沙箱原理:A 应用启动前先保留 Window 属性,应用卸载掉的时候,把 A 修改的属性保存下来。等会新应用 B 进来我就把之前保留的原始未修改的 Window 给 B,应用 A 回来我就把 A 之前修改的属性还原回来。

这种方法比较浪费性能:因为需要给 Window 拍照,后续需要和 Window 进行对比。

简单实现:

js
class SnapshotSandbox {
  constructor() {
    this.windowSnapshot = {}
    this.modifiedPropsMap = {}
  }
  active() {
    // 当激活的时候,给 window 拍个快照
    this.windowSnapshot = {}
    Object.keys(window).forEach((key) => {
      this.windowSnapshot[key] = window[key]
    })
    // 如果是重新调用  active 的时候,表示又一次激活,需要将保存的属性还原回来
    Object.keys(this.modifiedPropsMap).forEach((key) => {
      window[key] = this.modifiedPropsMap[key]
    })
  }
  inactive() {
    // 需要记录全局哪些属性被修改了
    this.modifiedPropsMap = {}
    // 如果失活,需要和快照的 window 进行对比,将变化的内容存入modifiedPropsMap
    Object.keys(window).forEach((key) => {
      if (window[key] !== this.windowSnapshot[key]) {
        // 记录修改的属性
        this.modifiedPropsMap[key] = window[key]
        // 将 window 属性还原成最初样子
        window[key] = this.windowSnapshot[key]
      }
    })
  }
}

const sandbox = new SnapshotSandbox()
sandbox.active()
window.a = 100
window.b = 200
sandbox.inactive()
console.log(window.a, window.b) // undefined undefinded
sandbox.active()
console.log(window.a, window.b) // 100 200
LegacySandbox 单应用的代理沙箱

LegacySandbox 原理:只存储修改或添加的属性,不用给 window 拍照。需要存储新增的属性(addPropsMap),修改前的属性(modifiedPropsMap)和记录所有修改后、新增的属性(currentPropsMap)。后续的操作是通过 Proxy 代理空对象进行处理的,例如:sandbox.proxy.a 是读取的 window 上的属性,sandbox.proxy.a = 100 是需要记录修改或新增的值,并且修改 window 上的属性。

这种方法的优点:性能比快照沙箱好(不用去监听整个 window)。缺点:Proxy 的兼容性不好,如果两个应用同时运行,window 只有一个,可能会有冲突。

modifiedPropsMapaddPropsMap 是对失活的时候,进行属性还原的

currentPropsMap 是用在激活时候,进行属性还原的

js
class LegacySandbox {
  constructor() {
    /**
     * modifiedPropsMap,addPropsMap是对失活的时候,进行属性还原的
     */
    // 修改后,需要记录window上该属性的原值
    this.modifiedPropsMap = {}
    // 需要记录新增的内容
    this.addPropsMap = {}
    /**
     * currentPropsMap是用在激活时候,进行属性还原的
     */
    // 需要记录所有(不管修改还是新增),对于window上属性的删除就是修改
    this.currentPropsMap = {}

    // 创建了一个假 Window 对象,fakeWindow => {}
    const fakeWindow = Object.create(null)
    const proxy = new Proxy(fakeWindow, {
      get: (target, key, receiver) => {
        // 当取值的时候,直接从 window 上取值
        return window[key]
      },
      set: (target, key, value, receiver) => {
        if (!window.hasOwnProperty(key)) {
          // 如果 window 上没有这个属性,则记录新增的属性
          this.addPropsMap[key] = value
        } else if (!this.modifiedPropsMap.hasOwnProperty(key)) {
          // 如果 window 上有这个属性,并且之前没有修改过,需要记录原 window 上的值
          this.modifiedPropsMap[key] = window[key]
        }
        // 无论新增还是修改,都记录一份(变化后的值)
        this.currentPropsMap[key] = value
        // 修改window上的属性,修改成最新的内容
        window[key] = value
      }
    })
    this.proxy = proxy
  }
  active() {
    // 激活时,恢复之前的内容
    Object.keys(this.currentPropsMap).forEach((key) => {
      this.setWindowProps(key, this.currentPropsMap[key])
    })
  }
  inactive() {
    // 失活的时候,需要把修改的属性还原回去
    Object.keys(this.modifiedPropsMap).forEach((key) => {
      this.setWindowProps(key, this.modifiedPropsMap[key])
    })
    // 如果新增了属性,在失活时需要移除
    Object.keys(this.addPropsMap).forEach((key) => {
      this.setWindowProps(key, undefined)
    })
  }
  // 设置window上的属性
  setWindowProps(key, value) {
    if (value === undefined) {
      // 移除后面新增的属性
      delete window[key]
    } else {
      // 变回原来的初始值
      window[key] = value
    }
  }
}

const sandbox = new LegacySandbox()
// 需要去修改代理对象,代理对象setter后会去修改window上的属性
sandbox.proxy.a = 100
console.log(window.a, sandbox.proxy.a) // 100 100
sandbox.inactive()
console.log(window.a, sandbox.proxy.a) // undefined undefined
sandbox.active()
console.log(window.a, sandbox.proxy.a) // 100 100
ProxySandbox 多应用的代理沙箱

ProxySandbox 原理:产生各自的代理对象,读取的时候从代理对象上取值,没有再去读 window。修改的时候,设置代理对象,只需要修改代理对象即可,不会去操作 window。

优点:不会污染全局的 window。

js
class ProxySandbox {
  constructor() {
    // 控制激活和失活
    this.running = false
    const fakeWindow = Object.create(null)
    this.proxy = new Proxy(fakeWindow, {
      get: (target, key) => {
        // 获取的时候,先从 fakeWindow 上取值,如果取不到,再从 window 上取值
        return key in target ? target[key] : window[key]
      },
      set: (target, key, value) => {
        if (this.running) {
          // 激活才去设置
          target[key] = value
        }
        return true
      }
    })
  }
  active() {
    if (!this.running) this.running = true
  }
  inactive() {
    this.running = false
  }
}

const sandbox1 = new ProxySandbox()
const sandbox2 = new ProxySandbox()
sandbox1.active()
sandbox2.active()
// 修改不会影响window,不用去还原window
sandbox1.proxy.a = 100
sandbox2.proxy.a = 200
console.log(sandbox1.proxy.a, sandbox2.proxy.a) // 100 200
sandbox1.inactive()
sandbox2.inactive()
sandbox1.proxy.a = 200
sandbox2.proxy.a = 400
console.log(sandbox1.proxy.a, sandbox2.proxy.a) // 100 200

子应用在使用时:把 sandbox1.proxy 当作 window 参数传递给子应用,子应用调用 window 上的属性时,会去 sandbox1.proxy 上取值。(LegacySandbox 同理)

js
;(function (window) {
  console.log(window.a) // 100
})(sandbox1.proxy)

CSS 沙箱

qiankun 中提供了两种方案,一种是放入 Shadow DOM 中,具有完全的样式隔离;另一种是 CSS Scope 样式隔离,通过在容器节点添加 data-qiankun="子应用名" 属性,实现样式隔离。

html
<style>
  div[data-qiankun='子应用名'] #app {
    /* 子应用样式 */
  }
</style>

除了 qiankun,还有哪些方案以及优劣?

🏆 single-spa

qiankun 和 single-spa 属于是“路由分发+资源处理”,就是通过监听路由变化,然后加载对应子应用资源。

采用 SystemJS 进行资源加载

缺点

  • 学习成本高(systemjs)
  • 无沙箱机制,需要自己实现 js 沙箱和 css 隔离
  • 需要对原有的应用进行改造
  • 子应用间相同资源重复加载的问题

🏆 原生 iframe

优点

  • 使用简单
  • 完全隔离(天然沙箱和样式隔离)
  • 兼容性良好,组合灵活

缺点

  • dom 割裂严重的问题(例如:弹窗只能在 iframe 内部展示,无法覆盖全局)
  • 子应用间通信麻烦
  • 额外的性能开销,白屏时间长
  • 路由状态丢失:刷新一下,iframe 的 url 状态就丢失了

🏆 无界(iframe + ShadowRoot)

无界微前端是一款基于 基于 WebComponent 容器(ShadowRoot) + iframe 沙箱的微前端框架。

原理

无界采用 webcomponent 来实现页面的样式隔离,无界会创建一个 wujie 自定义元素,然后将子应用的完整结构渲染在内部。子应用的实例 instance 在 iframe 内运行,dom 在主应用容器下的 webcomponent 内,通过代理 iframe 的 document 到 webcomponent ,可以实现两者的互联。(也是通过和 qiankun 类似的 import-html-entry 进行分离)

无界对 iframe 的 window,document,location 进行了代理

优点

  1. 解决了 iframe dom 割裂严重的问题
  2. 解决了路由状态丢失的问题,浏览器的前进后退可以天然的作用到 iframe 上,此时监听 iframe 的路由变化并同步到主应用,如果刷新浏览器,就可以从 url 读回保存的路由
  3. 解决了通信非常困难的问题,iframe 和主应用是同域的,天然的共享内存通信

上面 3 点,解决了 iframe 的缺点

  1. 可以同时激活多个应用

缺点

  1. 内存占用较高,为了降低子应用的白屏时间,将未激活子应用的 shadowRoot 和 iframe 常驻内存并且保活模式下每张页面都需要独占一个 wujie 实例,内存开销较大。
  2. 兼容性一般,目前用到了浏览器的 shadowRoot 和 proxy 能力
  3. iframe 劫持 document 到 shadowRoot 时,某些第三方库可能无法兼容导致穿透。

🏆 Micro App

MicroApp 是京东开源的一款微前端框架,通过 web components + js 沙箱实现(Proxy 沙箱)。

优点:接入成本低

缺点:兼容性不好,没有降级方案

🏆 Module Federation

Module Federation(以下简称 MF) 是 Webpack5 的新特性,借助于 MF, 我们可以在多个独立构建的应用之间,动态地调用彼此的模块。

和上面的几种方案都不同,这种方案主要是远程模块资源共享。

优点:去中心化:没有“基座”概念,任何一个容器可以是远程(remote)也可以是主机(host)。

缺点:没有沙箱机制,无法做到隔离。