前端之微前端

468 阅读20分钟

1. 谈谈你对微前端的理解,它解决了什么问题?

  • 定义:一种类似于微服务的架构,将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。
  • 为什么需要微前端?解决了什么问题?
    • 技术栈无关:主框架不限制接入应用的技术栈,子应用可自主选择。
    • 独立开发、独立部署:各团队之间可以独立开发、测试、部署自己的应用。
    • 增量升级:当需要对老旧系统进行升级时,可以用微前端的方式,用新的技术栈逐步替换。
    • 团队自治:各团队可以更专注于自己的业务领域。

2. 实现微前端有哪些主流方案?它们各有什么优缺点?

实现微前端的主流方案,本质上是在解决两个核心问题:1. 应用加载2. 应用隔离。主要有以下几种:

方案优点缺点
1. iframe隔离性最强:天然的 JS、CSS 沙箱,几乎没有样式和全局变量污染。实现简单。体验糟糕:URL 不同步,浏览器前进后退键失灵;弹窗和遮罩层只能在 iframe 内部,无法覆盖全局;通信复杂,只能用 postMessage,性能开销大。现在基本被淘汰。
2. Web ComponentsW3C 标准:无框架依赖,任何框架都能用。通过 Shadow DOM 实现样式和脚本的隔离。兼容性问题:需要 Polyfills 兼容旧浏览器。改造有成本:需要将现有应用改造成 Web Component 规范。
3. single-spa (路由分发式)优秀的生命周期管理:它是一个元框架,能统一管理所有子应用的 bootstrap, mount, unmount 生命周期。技术栈无关:支持任何前端框架。不够灵活:它只是个“调度员”,本身不解决 JS 和 CSS 的隔离问题,需要自己配合其他方案(如 CSS Modules, BEM)来解决。依赖加载方案:需要配合 SystemJS 或 Module Federation 等来加载远程模块。
4. Module Federation (模块联邦)真正的运行时共享:Webpack 5 的革命性功能。它允许一个应用在运行时动态加载另一个独立部署应用的模块,并且可以智能地共享依赖(如 React, Vue),避免重复加载,性能好。去中心化:每个应用都可以是“主应用”,也可以是“子应用”,非常灵活。生态绑定:目前主要和 Webpack 生态强绑定,虽然其他构建工具也在跟进,但成熟度还不够。配置稍复杂:上手需要一定的学习成本。

总结对比:

  • iframe 是“物理隔离”,简单粗暴但体验差。
  • single-spa 是“路由总管”,负责调度,但不负责装载和隔离。
  • Module Federation 是“模块快递员”,负责把远程模块安全、高效地送过来,并解决共享问题。

在现代项目中,“single-spa + Module Federation” 或者直接使用 Module Federation 是最推荐、最主流的组合拳。

3. 微前端架构下,你是如何解决以下问题的?

a) 应用间通信: 通信方案的选择取决于应用间的耦合关系,我通常会组合使用:

  • URL/路由传递:对于不敏感、简单的数据,通过 URL 的 pathquery 参数传递,简单直观,能被用户分享。
  • 自定义事件 (CustomEvent):基于 window 对象派发和监听事件。这是一种非常解耦的方式,一个应用派发事件,其他应用监听即可。dispatchEventaddEventListener
  • 全局状态管理:如果多个应用需要共享复杂的状态,可以把 Redux StorePinia Store 实例暴露到全局 window 上,或者由主应用创建后通过 props 注入。缺点是会增加耦合度。
  • props 传递:主应用可以直接通过 props 向子应用传递数据和回调函数,这是最简单的方式,适用于父子关系明确的场景。

b) 样式隔离: 样式污染是微前端的头号敌人,必须严格处理:

  • CSS-in-JS:如 styled-components,天生就能生成唯一的类名,作用域被限定在组件内,不会泄露。
  • Scoped CSS:主要在 Vue 中使用,核心原理是通过给 DOM 元素和 CSS 规则添加唯一的自定义属性来限定作用域,比如,一个 .title 的类,在 scoped 模式下会变成 .title[data-v-12345],它的优点是使用非常简单。
  • CSS Modules:在 React 中更常见,核心原理是在编译时将 CSS 类名本身程序化、哈希化,使其在全局变得独一无二。比如 .title 会变成一个唯一的哈希,像 .Component_title__aBcDeF。你必须在 JS 中 import 样式文件,然后像使用 JS 对象(styles.title)一样来使用类名。它的优点是隔离性最强,是完全的;缺点是写法上比原生 CSS 繁琐。
  • Shadow DOM:Web Components 的原生隔离方案,将样式完全封装在内部。
  • BEM 命名规范 或 为 CSS 选择器加前缀:这是一种约定式的弱隔离方案,依赖开发者的自觉性。比如,用户中心的 CSS 都以 .user-center- 开头,但有维护成本和冲突风险。

c) 公共依赖: 避免每个子应用都打包一份 React、Vue、Ant Design,导致页面加载性能雪崩:

  • Webpack Externals:在构建时不打包某些库,而是假设它们由外部提供(如通过 <script> 标签在 index.html 中引入)。这是一种传统但有效的方案。
  • Import Maps:一个新兴的浏览器标准,它允许你控制浏览器在解析 import 语句时如何查找模块。你可以指定 import 'react' 时,去加载 CDN 上的某个特定版本的 React。single-spa 经常配合 import-map-overrides 来实现。
  • Module Federation 的 shared 配置:这是最完美的方案。你可以在 shared 数组里声明需要共享的依赖(如 react),Webpack 会在运行时智能判断:如果主应用已经加载了 React 18,其他子应用就不会再加载,而是直接复用主应用的实例。

4. 如果让你从零开始设计一个微前端架构,你的技术选型和设计思路是什么?

我的设计会遵循以下步骤:

  1. 确定主应用(或称基座应用):我会选择一个轻量级的技术栈(比如 React 或 Vue)来搭建主应用。它的职责非常单一:只负责应用注册、路由分发、全局状态管理和公共资源加载,不包含任何业务逻辑。
  2. 技术选型定案
    • 框架选型:我会选择 single-spa 作为上层路由和生命周期管理的“总调度室”。
    • 模块加载:我会选择 Webpack Module Federation 作为底层子应用的加载和依赖共享方案。这个组合兼具了 single-spa 清晰的生命周期管理和 Module Federation 高效的运行时共享能力。
  3. 制定规范和协议
    • 通信协议:定义清晰的全局事件名或共享状态的读写规范。
    • 样式方案:强制所有子应用使用 CSS Modules 或 CSS-in-JS 来避免样式冲突。
    • 公共组件库:建立一个独立的、由所有团队共同维护的共享组件库,发布为 npm 包,确保所有子应用的 UI/UX 体验一致。
  4. 设计部署流程
    • 每个子应用都有自己独立的 Git 仓库和 CI/CD 流水线,可以随时独立部署上线,不依赖其他应用。
    • 主应用也独立部署。子应用的部署只更新自己的 JS 文件,主应用通过路由配置动态拉取即可,无需重新部署主应用。
  5. 设计沙箱和隔离
    • JS 沙箱:为了防止子应用修改全局 window 对象或 document 事件互相污染,我会引入一个简易的 JS 沙箱。在子应用挂载时,通过 Proxy 劫持 window,使得对全局对象的操作被隔离在沙箱内部;卸载时恢复。像 qiankun 框架就内置了完善的沙箱机制。
    • CSS 沙箱:除了之前提到的样式隔离方案,也可以在子应用加载时,动态地为所有样式规则加上特定的属性选择器(如 div[data-app-name="app1"] p { ... }),并在卸载时移除,实现更严格的隔离。

5. qiankun 的核心原理是什么?(从加载、沙箱、隔离三个方面回答)

  1. 应用加载机制:基于 import-html-entry 这个库。当匹配到子应用路由时,qiankun 会通过 fetch 获取子应用的 index.html 文件。然后,它会解析这个 HTML 文件,提取出其中的 JS 和 CSS 资源链接。最后,它会加载这些资源,并按顺序执行 JS 脚本,从而完成子应用的渲染。

  2. JS 沙箱:为了防止不同子应用间的全局变量冲突,qiankun 实现了 JS 沙箱。它主要通过 Proxy 来创建一个代理的 window 对象(fakeWindow)。当子应用访问或修改全局变量时(如 window.a = 1),所有操作都会被拦截并作用在这个 fakeWindow上,而不会影响到真实的 window 对象,从而实现了完美的隔离。对于不支持 Proxy 的旧版浏览器,它会降级为使用快照沙箱。

  3. CSS 隔离:为了避免全局样式污染,qiankun 提供了两种方案。最常用的一种是实验性样式隔离,它会在子应用加载时,通过 PostCSS 等工具遍历其所有 CSS 规则,并为每个选择器自动添加一个特殊的属性前缀,如 div[data-qiankun="app-name"] .title { ... },同时为主应用中承载子应用的容器节点也加上这个属性,从而确保样式只在子应用内部生效。另一种是基于 Shadow DOM 的严格隔离方案,但因其完全隔离的特性,可能会导致一些弹窗类组件样式失效,使用较少(两个方案都存在弹窗样式问题)。

6. qiankun 是如何实现 JS 沙箱的?

qiankun 实现 JS 沙箱的核心是隔离不同子应用之间的全局变量,避免互相污染。

  • SnapshotSandbox (快照沙箱)

    • 原理:在子应用挂载 (mount) 前,它会遍历当前的 window 对象,用一个 Map 记录下所有属性的快照。在子应用卸载 (unmount) 时,它会再次遍历 window 对象,与之前的快照进行对比,将所有被修改或新增的属性恢复到原始状态。
    • 区别/缺点:这种方式决定了它无法同时支持多个子应用实例共存。因为只有一个全局 window,恢复快照会影响到所有应用,所以同一时间只能有一个子应用处于激活状态。它主要用于兼容不支持 Proxy 的老旧浏览器。
  • LegacySandbox (单例代理沙箱)

    • 原理:SnapshotSandbox 的升级版。在支持 Proxy 的浏览器中,但只运行单个子应用实例的场景下启用。它也使用 Proxy 拦截,但所有子应用实例共享同一个 Proxy 沙箱,失活时需要遍历还原 window 上的属性。
    • 缺点:同样只支持单实例运行。是 ProxySandbox 之前的一个过渡形态。
  • ProxySandbox (代理沙箱)

    • 原理:利用 ES6 的 Proxy 特性,为每个子应用创建一个“伪造”的 window 对象(fakeWindow)。当子应用内部执行 window.a = 1 或读取 window.a 时,Proxy 会捕获这些操作,并将它们代理到 fakeWindow 对象上。子应用读取属性时,会先从 fakeWindow 找,找不到再从真实的 window 上找。
    • 区别/优点:由于每个子应用都有自己独立的 fakeWindow,它们之间的全局变量是完全隔离的,因此可以完美支持多个子应用同时运行且互不干扰。这是在现代浏览器中的默认和推荐方案。

7. qiankun 是如何实现样式隔离的?有哪几种方式,优缺点是什么?

qiankun 提供了两种 CSS 隔离方案:

  1. strictStyleIsolation (严格样式隔离)

    • 方式:通过 Shadow DOM 实现。qiankun 会将子应用完全渲染在一个 Shadow DOM 内部,Shadow DOM 提供的原生能力可以确保内部的样式和外部的样式、内部的 DOM 和外部的 DOM 完全隔离。
    • 优点:提供了最严格的隔离,原生实现,无需任何 CSS 预处理。
    • 缺点:一些依赖于挂载到 document.body 的 UI 组件(如 Modal、Drawer)会因为无法穿透 Shadow DOM 的样式边界而导致样式丢失。
  2. experimentalStyleIsolation (实验性样式隔离)

    • 方式:通过动态修改 CSS 选择器范围实现。它会为子应用的所有 CSS 规则自动添加一个特殊的属性选择器作为前缀,例如 [data-qiankun="app-name"]
    • 优点:兼容性好,没有 Shadow DOM 的弹窗样式问题,是目前最常用和推荐的方案。
    • 缺点:隔离性不如 Shadow DOM 严格,如果主应用中有一些非常通用的标签选择器(如 body, html)且权重很高,理论上仍有污染的可能性

8. 主应用和子应用之间是如何通信的?GlobalState 的原理是什么?

主子应用通信主要有两种方式:

  1. 基于 props 的单向通信:在主应用注册子应用时,可以通过 props 属性将数据或函数传递给子应用。子应用在 mount生命周期中可以接收到这些 props。这种方式简单直接,但通常用于主应用向子应用单向传递信息。

  2. 基于 initGlobalState 的双向通信:这是 qiankun 官方推荐的方式。

    • 原理:它的底层实现是一个经典的发布-订阅模式
      • initGlobalState(initialState):在主应用中调用,它会创建一个全局的状态存储对象,并返回 onGlobalStateChangesetGlobalState 两个方法。
      • setGlobalState(state):相当于发布者。无论是主应用还是子应用调用它,都会更新全局状态,并通知所有订阅者。
      • onGlobalStateChange((state, prev) => ...):相当于订阅者。主应用和子应用都可以通过它来注册一个回调函数,监听全局状态的变化,一旦状态被 setGlobalState 修改,所有注册的回调函数就会被执行。

9. 你在使用 qiankun 的过程中如何项目改造?遇到过哪些坑?是怎么解决的?

  1. 项目改造

    • 主应用
      • 安装 qiankun
      • 使用 registerMicroApps(apps, lifeCycles) 注册子应用列表。
      • 使用 start(opts) 启动 qiankun
      • 设置容器 DOM。
    • 子应用
      • 在入口文件 (main.js/main.ts) 中导出 bootstrap, mount, unmount 三个生命周期函数。
      • 修改 webpack 打包配置:
        • output.library: 应用名,需与主应用注册时一致。
        • output.libraryTarget: umd
        • output.jsonpFunction: webpackJsonp_${packageName}
      • 配置路由的 base 属性,以匹配主应用中激活的规则。
  2. 常见问题 (踩坑指南)

    • 静态资源加载失败 (404)
      • 问题:图片、字体等资源相对路径错误。
      • 解决:利用 webpackpublicPath 动态设置资源前缀。qiankun 会向子应用注入 window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
    • 路由问题
      • 问题:子应用路由无法正常工作或跳转到了主应用。
      • 解决:为子应用的 react-routervue-router 设置正确的 basename
    • 跨域问题
      • 问题:开发时主应用 fetch 子应用资源时出现跨域。
      • 解决:子应用 webpack-dev-server 配置 headers: { 'Access-Control-Allow-Origin': '*' }
    • Keep-Alive 状态保持
      • qiankun 自身不直接支持,但可以结合 React.lazy / Vuekeep-aliveqiankunloadMicroApp 手动管理子应用实例,或者利用 umi-plugin-qiankun 等社区方案。
    • 弹窗挂载问题
      • 问题:Ant DesignModal 等组件默认挂载到 document.body,在 Shadow DOM 隔离模式下样式会丢失。
      • 解决:通过组件库提供的 getContainer API,将弹窗挂载到子应用内部的 DOM 节点上。

10. qiankunWebpack Module Federation 的区别是什么,你会怎么选型?

  • 核心区别

    • qiankun 是“应用级”的运行时方案:它将每个子系统视为一个完整的应用,在运行时通过路由来加载和管理这些应用。它的核心是应用隔离生命周期管理
    • Module Federation 是“模块级”的编译时方案:它允许一个 Webpack 构建产物在运行时去动态加载另一个构建产物暴露出来的模块(如组件、函数)。它的核心是模块共享
  • 如何选型

    • 当你的目标是整合多个独立、完整、异构的应用时,特别是处理包含不同技术栈的历史项目时,qiankun 是更好的选择。它提供了开箱即用的沙箱和隔离,让应用整合变得简单。
    • 当你的目标是在多个应用之间共享组件库、工具函数等代码片段,而不是整个应用时,Module Federation 更合适。它避免了重复打包和加载公共依赖的问题,粒度更细。
    • 两者也可以结合使用:例如,使用 qiankun 作为顶层的应用治理框架来组织不同的业务应用,而在这些应用之间,可以使用 Module Federation 来共享通用的组件库,实现优势互补。

11. Wujie(无界)框架的核心原理

1. JavaScript 沙箱:iframe 幽灵 + Proxy 代理

这是 Wujie 最巧妙、最核心的部分。传统的 JS 沙箱要么用 Proxy 硬扛(有兼容性问题和性能损耗),要么用 with + eval(有安全风险和性能问题)。Wujie 另辟蹊径:

  1. 创建“幽灵” iframe

    • 当加载一个子应用时,Wujie 会在背后创建一个不可见的、存在于内存中iframe。这个 iframe 并不用来显示任何东西。
    • 它的唯一目的,是提供一个纯净、隔离的 window 对象。我们称之为 shadowWindow。这个 window 与主应用的 window 是完全隔离的,拥有自己独立的 document, location, history 等一整套全局变量。
  2. 建立 Proxy 代理

    • Wujie 将子应用的所有代码都运行在这个 shadowWindow 的环境中。
    • 同时,它使用 Proxy 拦截了子应用对 shadowWindow 的所有操作(比如 window.location = '...' 或者 document.createElement(...))。
  3. 重定向操作

    • 对于变量读写:当子应用读写全局变量(如 window.a = 1)时,Proxy 会确保这些操作只发生在隔离的 shadowWindow 上,绝对不会污染主应用的 window
    • 对于 DOM 操作:当子应用调用 document.createElementdocument.body.appendChild 等 DOM API 时,Proxy 会拦截这些操作,然后将它们转发到主应用的 document 上去执行

一句话总结:子应用的 JS 以为自己活在一个独立的 iframe 世界里(变量隔离),但它创造的“肉身”(DOM 元素)却被 Wujie 悄悄地搬运到了主应用的世界里(UI 呈现)。

这种方式既利用了 iframe 的原生隔离性,又避免了 iframe 的所有 UI 和体验问题。


2. CSS 隔离:Web Components + Shadow DOM

Wujie 的 CSS 隔离方案则完全拥抱了 Web 标准:

  1. 创建自定义元素:Wujie 会将承载子应用的容器 DOM 节点,通过 customElements.define 定义成一个 Web Component。
  2. 启用 Shadow DOM:然后为这个 Web Component 开启 Shadow DOM(影子 DOM)。
  3. 样式注入:子应用的所有 style 标签和 link 标签都会被移动到这个 Shadow DOM 内部。

效果Shadow DOM 就像一个结界,它内部的样式绝对不会泄露出去影响主应用或其他子应用;同时,外部的样式也绝对不会渗透进来影响子应用。这是浏览器原生提供的、最可靠的样式隔离方案。


3. 路由同步:劫持 + 同步

为了解决 iframe 中 URL 不同步、浏览器前进后退键失灵的问题,Wujie 做了:

  1. 劫持子应用路由:在 JS 沙箱内部,Wujie 劫持了 shadowWindowhistory.pushStatehistory.replaceState 等方法。
  2. 同步到主应用:当子应用调用这些方法尝试改变 URL 时,Wujie 会捕获这个行为,然后调用主应用的路由实例去改变浏览器地址栏的真实 URL(通常是加上特定的前缀或参数来区分)。

这样,无论子应用内部如何跳转,最终都会反映在浏览器顶层的地址栏上,用户体验就像一个单体应用一样流畅。

12. micro-app 框架的核心原理

1. 组件化封装:一切皆为 Custom Element

这是 micro-app 架构的基石。它将整个子应用看作是一个“组件”。

  1. 定义自定义元素micro-app 通过 window.customElements.define 定义了一个名为 <micro-app> 的 HTML 标签。
  2. 实例化子应用:当你在主应用中使用 <micro-app name='xx' url='...'></micro-app> 时,实际上就是在实例化这个自定义元素。micro-app 会监听这个元素的生命周期(如 connectedCallbackdisconnectedCallback),在合适的时机去加载、渲染和卸载子应用。
  3. 优点
    • 极简接入:使用方式和普通的 divspan 几乎没有区别,非常符合前端开发者的直觉。
    • 技术栈无关:因为是 W3C 标准,所以无论主应用是 React、Vue 还是 Angular,都可以轻松使用。

2. JavaScript 沙箱:with + Proxy 的巧妙结合

为了隔离 JS 环境,防止全局变量污染,micro-app 实现了一套非常高效的沙箱机制。

  1. 创建代理 window:当加载子应用时,micro-app 不会使用 iframe,而是用 Proxy 创建一个全新的、纯净的 window 代理对象(我们称之为 proxyWindow)。
  2. with 绑定执行上下文micro-app 会将子应用的所有 JS 代码包裹在一个 with(proxyWindow) { ... } 的代码块中执行。
  3. 变量的读写规则
    • 写入操作:当子应用执行 window.a = 1 时,由于 with 的作用,这个 a 属性会被直接设置到 proxyWindow 上,而不会影响到真实的 window
    • 读取操作:当子应用读取 window.a 时,它会首先在 proxyWindow 上寻找。如果找不到,Proxy 的机制会让它向上穿透,去真实的 window 对象上寻找。

一句话总结:写操作只在自己的沙箱内生效,读操作可以读取沙箱和主应用的值。

这种设计非常精妙,它既保证了子应用之间的隔离,又允许子应用能够获取到主应用环境提供的一些全局能力(比如 documentlocation 等),避免了像 Wujie 那样需要手动转发大量 window 属性的复杂性。

3. CSS 隔离:Scoped CSS 的自动化

micro-app 并没有使用 Shadow DOM 来做样式隔离,而是实现了一套类似 Vue 中 scoped 样式的方案。

  1. 加载和转换:当加载子应用的 CSS 文件时,micro-app 会在运行时(in-memory)解析 CSS 规则。
  2. 添加动态属性:它会为子应用的所有 DOM 元素动态添加一个唯一的自定义属性,例如 <div data-micro-app="app1"></div>
  3. 重写 CSS 选择器:同时,它会重写所有的 CSS 规则,在每个选择器后面都加上这个属性选择器。例如,div { color: red; } 会被改写成 div[data-micro-app="app1"] { color: red; }

效果:这样一来,子应用的所有样式都只会精确地应用在带有特定属性的 DOM 元素上,从而实现了严格的样式隔离,避免了对全局环境的污染。