57 KiB
@micro-zoe/micro-app 微前端面试题
基于 Vue 3 + Vite 主应用搭建实战经验整理,涵盖从项目初始化、配置、到 bug 排查的全流程。
一、基础概念
Q1:什么是微前端?micro-app 相比其他方案有什么优势?
答: 微前端是一种将多个独立的前端应用组合成一个统一应用的架构模式。每个子应用可以独立开发、独立部署、使用不同的技术栈。
@micro-zoe/micro-app(京东出品)的核心优势:
- 零依赖:不依赖 single-spa,完全自研
- 类 WebComponent:使用
<micro-app>标签加载子应用,对开发者透明 - 双沙箱模式:同时支持 with 沙箱和 iframe 沙箱
- 不限制技术栈:主应用和子应用可以任意组合(Vue 2/3、React、原生 HTML 等)
- 兼容 Vite:通过 iframe 模式完美支持 Vite 构建的子应用
| 对比维度 | micro-app | qiankun |
|---|---|---|
| 接入成本 | 低(类 WebComponent) | 中(需改造子应用入口) |
| Vite 支持 | 原生支持(iframe 模式) | 需额外配置 |
| 沙箱方案 | with + iframe 双模式 | 基于 Proxy 的沙箱 |
| 包体积 | ~300KB | ~100KB |
Q2:micro-app 主应用和子应用之间是如何隔离的?
答: micro-app 提供三层隔离体系,各管各的维度,可以组合使用:
┌──────────────────────────────────────────────────────────────┐
│ micro-app 隔离体系 │
│ │
│ ① CSS 样式隔离(默认开启,disableScopecss: false) │
│ └── 方式:CSS 前缀改写,不是 Shadow DOM! │
│ 子应用写了 h2 { color: red } │
│ micro-app 提取 CSS 后自动改成 │
│ micro-app[name=xxx] h2 { color: red } │
│ │
│ ② JS 沙箱(默认开启,disableSandbox: false) │
│ ├── with 沙箱:new Function() + Proxy(window) │
│ └── iframe 沙箱:浏览器原生 iframe 隔离 │
│ │
│ ③ 元素隔离(默认关闭,shadowDOM: false) │
│ └── 浏览器原生 Shadow DOM,DOM 树级别隔离 │
│ │
└──────────────────────────────────────────────────────────────┘
一、CSS 样式隔离 — CSS 前缀改写(默认方案)
工作原理:
/* 子应用源码 */
h2 { color: orange; }
/* micro-app 自动改写为 ↓ */
micro-app[name=mock-app] h2 { color: orange; }
micro-app 在加载子应用时,会提取所有 <style> 标签内容,给每个 CSS 规则自动加上 micro-app[name=xxx] 属性选择器前缀,限制样式只作用于子应用容器内部。
⚠️ 关键局限:只能阻止「子→主」,不能阻止「主→子」
子应用样式泄漏到主应用(子→主):✅ CSS 前缀改写可以阻止
──────────────────────────────────────────────
子应用: h2 { color: orange }
→ 改写后: micro-app[name=mock-app] h2 { color: orange }
→ 主应用的 h2 没有 micro-app[name=mock-app] 父级 → 不匹配 ✅
主应用样式泄漏到子应用(主→子):❌ CSS 前缀改写无法阻止
──────────────────────────────────────────────
主应用: .app-main h2 { border-bottom: 2px solid purple; }
→ 子应用 h2 也在 .app-main 内部 → 匹配 ❌
→ 需要额外手段::not(.child-app-wrapper) / iframe / shadowDOM
这就是我们在 App.vue 中遇到的问题 — 加了 .app-main 前缀还不够,因为子应用 DOM 就嵌在 .app-main 里面。
二、JS 沙箱 — with 沙箱 vs iframe 沙箱
| 维度 | with 沙箱 (iframe: false) |
iframe 沙箱 (iframe: true) |
|---|---|---|
| 原理 | with(window.proxy) + new Function() |
浏览器原生 iframe |
| 子应用 window | Proxy 代理的 microWindow | 真实的 iframe.contentWindow |
| ES Module | ❌ 不支持 import/export |
✅ 原生支持 |
| 适用 | Webpack UMD 子应用 | Vite 子应用 |
| 性能 | 较快 | 略慢(多一层 iframe 渲染) |
| 共享内存 | ✅ 同一 document,可传引用 | ❌ 跨 document,必须 postMessage 序列化 |
with 沙箱的执行过程:
// micro-app 内部简化逻辑
const code = await fetchJs() // 拉取子应用 JS 文本
const fn = new Function('window', `
with(window) {
${code} // 子应用代码拼接在这里
}
`)
fn(microWindow) // microWindow = new Proxy(realWindow, handler)
子应用代码里的 window.xxx / document.xxx 等全局操作,都会被 with(microWindow) 劫持到 Proxy 代理上,实现对全局变量的拦截和隔离。
为什么 Vite 子应用必须用 iframe? Vite 开发环境输出 <script type="module"> 格式的 ES Module,含 import/export。new Function() 无法执行模块语法(import 只能出现在模块静态顶层)。详见 Q8-1。
三、元素隔离 — Shadow DOM(可选功能)
⚠️ 注意:Shadow DOM 不是默认的样式隔离方式!默认方式是 CSS 前缀改写。
<micro-app name="mock-app" :shadowDOM="true" />
开启后,子应用的 DOM 被包裹在 Shadow Root 里:
<micro-app name="mock-app">
#shadow-root (open) ← Shadow DOM 边界
<style>h2 { color: orange; }</style>
<h2>子应用标题</h2> ← 样式天然隔离,内外互不穿透
</micro-app>
四、三种隔离方案对比(CSS 前缀 vs Shadow DOM vs iframe)
| CSS 前缀改写 | Shadow DOM | iframe 沙箱 | |
|---|---|---|---|
| 配置 | 默认,无需配置 | shadowDOM: true |
iframe: true |
| 样式隔离方向 | 子→主 ✅ / 主→子 ❌ | 双向 ✅ | 双向 ✅ |
| JS 隔离 | 依赖沙箱配置 | 依赖沙箱配置 | ✅ 原生隔离 |
| ES Module | 不支持(with 沙箱) | 不支持(with 沙箱) | ✅ 支持 |
| DOM 树 | 共享 | 独立 Shadow Tree | 独立 Document |
| 通信 | 同 window,直接引用 | 同 window,直接引用 | postMessage,序列化 |
| 兼容性 | 最好 | 部分 UI 库不兼容 | 最好 |
| 全局样式污染 | 会(主→子) | 不会 ✅ | 不会 ✅ |
| 适用场景 | Webpack 子应用 | 纯样式隔离需求 | Vite 子应用、完整隔离 |
隔离强度递增 →
CSS 前缀改写 Shadow DOM iframe 沙箱
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ 子→主 ✅ │ │ 子→主 ✅ │ │ 子→主 ✅ │
│ 主→子 ❌ │ │ 主→子 ✅ │ │ 主→子 ✅ │
│ JS 部分 │ │ JS 部分 │ │ JS 完全 ✅ │
│ DOM 共享 │ │ DOM 隔离 │ │ 全部隔离 │
└──────────┘ └──────────────┘ └──────────────┘
选择决策树:
子应用是 Vite 构建?
├── 是 → iframe: true(必须,ES Module 限制)
└── 否(Webpack)
├── 只需样式隔离 → shadowDOM: true(可选)
├── 需完整 JS 隔离 → iframe: true
└── 隔离要求不高 → 默认配置(CSS 前缀改写)
└── 注意主→子穿透!主应用样式需用 :not() 排除子应用容器
Q2-1:为什么 CSS 前缀改写只能阻止「子→主」而不能阻止「主→子」?
答:
这是 CSS 前缀改写的单向性决定的:
子→主(子应用样式泄漏到主应用):
子应用 CSS: h2 { color: orange }
↓ micro-app 改写
改写后: micro-app[name=mock-app] h2 { color: orange }
↓
主应用的 h2 的父级链中没有 micro-app[name=mock-app] → 不匹配 ✅
主→子(主应用样式泄漏到子应用):
主应用 CSS: .app-main h2 { border-bottom: 2px solid purple; }
↓ 没有任何改写!原样生效
子应用 h2 的祖先链中有 .app-main → 匹配 ❌
核心原因:micro-app 只改写子应用的 CSS,不改写主应用的 CSS。主应用的样式规则在全 document 范围内照常匹配,而 iframe: false 模式下子应用 DOM 就在这个 document 里。
三种解决思路:
- 主应用 CSS 加
:not(.child-app-wrapper)排除子应用容器(本项目方案) - 开启
shadowDOM: true— Shadow DOM 边界双向阻断 - 开启
iframe: true— 独立 document,双向天然隔离
二、项目搭建
Q3:使用 Vue 3 + Vite 搭建 micro-app 主应用需要哪些步骤?
答: 核心 4 步:
// 1. 安装依赖
npm install @micro-zoe/micro-app vue-router@4
// 2. vite.config.ts — 将 <micro-app> 注册为自定义元素
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => /^micro-app/.test(tag)
}
}
})
],
server: { port: 8080 }
})
// 3. main.ts — 在 Vue 实例化前启动 micro-app
import microApp from '@micro-zoe/micro-app'
microApp.start({ /* 全局配置 */ })
createApp(App).use(router).mount('#app')
// 4. 页面中使用 <micro-app> 标签加载子应用
<micro-app name="my-app" url="http://localhost:3000/" baseroute="/my-app" />
Q4:为什么要在 vite.config.ts 中配置 isCustomElement?
答:
<micro-app> 是一个自定义 HTML 标签,不是 Vue 组件。Vue 模板编译器默认会把所有非标准 HTML 标签当作 Vue 组件去寻找,找不到就会报警告:
[Vue warn]: Failed to resolve component: micro-app
通过 isCustomElement: (tag) => /^micro-app/.test(tag) 告诉编译器:以 micro-app 开头的标签是自定义元素,跳过 Vue 组件解析。
延伸:如果子应用中也使用了 <micro-app>(嵌套微前端),同样需要配置。
Q5:主应用路由如何设计才能正确匹配子应用?
答:
使用 Vue Router 的通配符 :page* 匹配子应用所有内部路由:
const routes = [
{ path: '/', redirect: '/home' },
{ path: '/home', component: () => import('@/views/Home.vue') },
// 关键::page* 匹配 /child-app 及其下所有子路由
{ path: '/child-app/:page*', component: () => import('@/views/ChildApp.vue') }
]
为什么用 :page* 而不是 *?
*只在 Vue Router 3 中可用- Vue Router 4 中必须使用
:page*(或:pathMatch(.*)*)来匹配多级路径 - 这样
/child-app/page1、/child-app/a/b/c都能被正确捕获
三、沙箱机制(重点 Bug 来源)
Q6:micro-app 的两种沙箱模式有什么区别?如何选择?
答:
| 特性 | with 沙箱 | iframe 沙箱 |
|---|---|---|
| 配置 | iframe: false(默认) |
iframe: true |
| 原理 | with(window.proxy) + new Function() |
浏览器原生 iframe |
| ES Module | ❌ 不支持 import/export |
✅ 原生支持 |
| 性能 | 较快(无额外渲染) | 略慢(需创建 iframe) |
| 隔离性 | 中等(Proxy 代理) | 强(浏览器原生隔离) |
| 调试体验 | 较好(同 document) | 较差(跨 iframe) |
| 适用场景 | Webpack 构建的 UMD 应用 | Vite 构建的应用 |
选择原则:
- Vite 子应用 → 必须
iframe: true - Webpack UMD 子应用 →
iframe: false即可 - 对样式/JS 隔离要求极高的场景 →
iframe: true
Q7:遇到 Cannot use import statement outside a module 报错,是什么原因?如何解决?
答: 这是 micro-app 主应用对接 Vite 子应用时必踩的坑。
报错堆栈:
[micro-app] app vue2-app: {
error: SyntaxError: Cannot use import statement outside a module
at new Function (<anonymous>)
}
根因分析:
- Vite 开发服务器输出的 JS 是
<script type="module">格式的 ES Module - micro-app 默认的 with 沙箱 使用
new Function()来执行子应用的 JS 代码 new Function()创建的函数运行在「非模块」作用域,无法解析import/export语法
解决方案:
// src/config/subApps.ts
{
name: 'vue2-app',
url: 'http://localhost:5173/',
baseroute: '/child-app',
iframe: true, // ← 关键:开启 iframe 沙箱
}
为什么 iframe 能解决?
iframe 是浏览器原生的独立文档环境,Vite 的 <script type="module"> 在 iframe 中可以正常执行。
Q8:new Function() 和 eval() 有什么区别?为什么 micro-app 用 new Function()?
答:
| 特性 | eval() |
new Function() |
|---|---|---|
| 作用域 | 访问当前局部作用域 | 只能访问全局作用域 |
| 性能 | 慢(影响局部变量优化) | 较快(引擎可优化) |
| 安全性 | 较低(泄漏局部变量) | 较高(作用域隔离) |
micro-app 选择 new Function() 的原因:
- 它创建的函数只在全局作用域执行,配合
with(window.proxy)可以精确控制子应用对全局变量的访问 eval()会渗透到调用方的局部变量,无法做到完全隔离
但局限性也很明显: new Function() 无法处理 ES Module 语法,这就是为什么 Vite 子应用必须使用 iframe 模式。
Q8-1:为什么 Vite 子应用必须用 iframe: true,而 Webpack 子应用不需要?
答:
核心原因在于 Vite 和 Webpack 输出的 JS 代码格式不同,而 micro-app 的 with 沙箱只能执行某种格式。
一、开发环境(Dev Server)的差异
| Vite Dev Server | Webpack Dev Server | |
|---|---|---|
| 输出的 JS 格式 | <script type="module"> ES Module |
<script> IIFE / UMD bundle |
含 import/export |
✅ 是 | ❌ 否 |
| 文件数量 | 按需加载,可能几百个 | 一个 bundle(或少量 chunk) |
<!-- Vite 开发环境输出的 HTML -->
<script type="module" src="/src/main.ts"></script>
<!-- main.ts 内部还有上百个 import: -->
<!-- import { createApp } from 'vue' ← ES Module 语法 -->
<!-- import router from './router' ← ES Module 语法 -->
<!-- Webpack 开发环境输出的 HTML -->
<script src="/js/app.js"></script>
<!-- app.js 是一个巨大的 IIFE 包裹的 bundle: -->
<!-- (function(modules) { ... })({...}) ← 没有 import/export -->
二、with 沙箱的执行过程
micro-app 用 new Function() 执行子应用的 JS:
// micro-app 内部简化逻辑(with 沙箱模式)
const code = await fetchJsFromChildApp() // 拉取子应用的 JS 文本
// ⚠️ 这里执行 JS
const fn = new Function('window', `
with(window) {
${code} // ← 子应用的代码直接拼接在这里
}
`)
fn(microWindow) // microWindow 是 Proxy 代理的隔离 window
问题来了: 如果 code 包含 import/export:
// 子应用代码
import { createApp } from 'vue' // ❌ SyntaxError!
// ↑ import 只能出现在模块顶层,不能出现在 new Function() 内部
浏览器的模块系统要求 import 必须出现在 <script type="module"> 或 ES Module 文件的静态顶层,不能被 new Function() / eval() 动态执行。
三、为什么 Webpack 可以不用 iframe?
Webpack 构建流程:
import { ref } from 'vue' webpack打包 __webpack_require__('./vue')
import router from './router' ──────────→ __webpack_require__('./router')
↑ ES Module 语法 ↑ 普通函数调用
Vite 开发环境:
import { ref } from 'vue' 不打包! import { ref } from 'vue'
import router from './router' ──────────→ import router from './router'
↑ ES Module 语法 ↑ 仍然是 ES Module 语法
- Webpack 在构建时就把
import转成了__webpack_require__()这种普通函数调用,输出的是 IIFE 格式,new Function()可以正常执行 - Vite 开发模式 不做打包,直接输出浏览器原生的
import/export,只能通过 iframe 的原生模块环境执行
四、一张图总结
子应用 JS 包含 import/export?
│
┌──────┴──────┐
│ │
否 是
(Webpack) (Vite)
│ │
▼ ▼
new Function() iframe 沙箱
可直接执行 ✅ 唯一可行方案 ✅
五、面试追问:Vite 生产环境构建后还需要 iframe 吗?
生产环境 Vite 用 Rollup 打包,输出的是普通 JS(无 import),理论上用 with 沙箱也能执行。但仍然推荐 iframe: true,原因:
- 一致性:开发/生产行为一致,避免"开发没问题,上线炸了"
- 隔离性:iframe 提供更彻底的样式/JS 隔离
- 资源路径:iframe 中的相对路径自动指向子应用 origin,不用额外处理
四、TypeScript 类型问题
Q9:micro-app 的 lifeCycles 回调中,为什么 e.name 会报 TS 类型错误?
答: 这是 TypeScript 类型定义与实际使用的差异问题。
错误代码:
lifeCycles: {
created(e) {
console.log(e.name) // TS2339: Property 'name' does not exist on type 'CustomEvent<any>'
}
}
根因:
根据 @micro-zoe/micro-app 的类型定义文件 global.d.ts:
interface lifeCyclesType {
created?(e: CustomEvent, appName: string): void
beforemount?(e: CustomEvent, appName: string): void
// ...
}
生命周期回调接收两个参数:第一个是 CustomEvent 事件对象,第二个才是 appName 字符串。
正确写法:
lifeCycles: {
created(_e, appName) {
console.log(`子应用 ${appName} 被创建`)
}
}
Q10:vue-tsc --noEmit 在 CI/CD 中有什么作用?我们遇到了什么类型错误?
答:
vue-tsc --noEmit 是 Vue 项目的类型检查命令:
- 只做类型检查,不输出编译产物
- 相当于
tsc --noEmit,但额外支持.vue文件
我们在项目中遇到了两类类型错误:
- lifeCycles 回调参数类型错误:
e.name不存在于CustomEvent类型 → 改用第二个参数appName - 未使用的导入:
getSubAppConfig在ChildApp.vue中导入但未使用 → 移除无用导入
配置建议: 在 package.json 中将类型检查加入构建流程:
{
"scripts": {
"build": "vue-tsc --noEmit && vite build"
}
}
五、子应用容器与通信
Q11:<micro-app> 标签有哪些核心属性?各有什么作用?
答:
| 属性 | 类型 | 说明 |
|---|---|---|
name |
string |
必填,子应用唯一标识,字母开头 |
url |
string |
必填,子应用入口地址(HTML 路径) |
baseroute |
string |
路由前缀,主应用分配给子应用的路由基路径 |
iframe |
boolean |
是否使用 iframe 沙箱(Vite 子应用必须开启) |
keep-alive |
boolean |
保活子应用,切换路由时不销毁 |
router-mode |
string |
路由模式:native(原生同步)/ native-scope(带作用域) |
disable-scopecss |
boolean |
禁用样式隔离 |
disable-sandbox |
boolean |
禁用 JS 沙箱(调试时可用) |
data |
object |
向子应用传递的初始数据 |
shadowDOM |
boolean |
是否使用 Shadow DOM |
Q12:主应用如何向子应用传递数据?有哪几种方式?
答: 三种方式:
方式一:通过 data 属性(初始化传参)
<micro-app name="app1" url="..." :data="{ token: 'abc', userId: 1 }" />
// 子应用接收
const data = window.microApp?.getData()
方式二:通过 microApp.setData() API(动态传参)
import microApp from '@micro-zoe/micro-app'
microApp.setData('app1', { path: '/detail' })
// 子应用监听
window.microApp?.addDataListener((data) => {
console.log('收到数据:', data)
if (data.path) router.push(data.path)
}, true) // autoTrigger = true,初始化时立即触发一次
方式三:通过 EventCenterForMicroApp(双向通信)
// 主应用
import { EventCenterForMicroApp } from '@micro-zoe/micro-app'
const eventCenter = new EventCenterForMicroApp('app1')
eventCenter.dispatch({ type: '通知', payload: {} })
// 子应用
window.microApp?.dispatch({ type: '回复', payload: {} })
Q12-1:iframe: true 模式下,通信的底层原理是什么?和 iframe: false 有什么不同?
答:
API 层面完全一致 — 无论 iframe: true 还是 iframe: false,主应用和子应用都用同一套 API(setData、getData、dispatch、addDataListener)。框架在底层封装了通信差异。
底层机制对比:
iframe: false(with 沙箱) iframe: true(浏览器原生隔离)
┌──────────────────────┐ ┌──────────────────────────┐
│ 主应用 document │ │ 主应用 document │
│ window (共享) │ │ window │
│ ├── micro-app │ │ ├── micro-app │
│ │ └── 子应用 DOM │ │ │ └── <iframe> │
│ │ window ✅ 同一个│ │ │ └── 子应用 DOM │
│ │ 内存引用传递 │ │ │ window (独立) │
│ └── ... │ │ └── ... │
│ 直接读写 JS 对象 │ │ 跨域边界,必须序列化 │
└──────────────────────┘ └──────────────────────────┘
iframe 模式下的通信链路:
主应用 iframe 子应用
│ │
│ microApp.setData('app', data) │
│ ↓ JSON.stringify(data) │
│ ↓ iframe.contentWindow │
│ .postMessage(data, '*') │
│ ───────── postMessage ────────→ │
│ │ window.addEventListener
│ │ ('message', handler)
│ │ ↓ JSON.parse
│ │ ↓ 触发 dataListener
│ │
│ window.parent │
│ ←──────── postMessage ──────── │
│ addEventListener('message') │ microApp.dispatch(msg)
│ ↓ JSON.parse │
│ ↓ 触发 dataListener │
核心步骤:
-
主→子:
microApp.setData()→ 框架JSON.stringify(data)→iframe.contentWindow.postMessage(serialized, '*')→ 子应用window.addEventListener('message')→JSON.parse→ 触发dataListener -
子→主:
window.microApp.dispatch(msg)→ 框架JSON.stringify(msg)→window.parent.postMessage(serialized, '*')→ 主应用window.addEventListener('message')→JSON.parse→ 触发dataListener
关键限制:postMessage 的"结构化克隆"
✅ 可以传: ❌ 不能传:
- string/number/boolean - 函数 / class
- 普通 Object / Array - DOM 节点 / Event
- Date / RegExp - Symbol
- Map / Set - WeakMap / WeakSet
- ArrayBuffer / Blob - Proxy 对象
这和 iframe: false 模式有本质区别 — with 沙箱下主应用和子应用共享同一个 window,可以传内存引用(对象改了,两边都能看到),而 iframe 模式走的是序列化拷贝(传完之后各是各的副本)。
实战中的坑:
// ❌ iframe 模式下这样传会丢失数据
microApp.setData('app', {
callback: () => {}, // 函数 → 序列化后丢失
element: document.body, // DOM 节点 → 序列化后丢失
})
// ✅ 正确做法:只传纯数据
microApp.setData('app', {
token: 'abc123',
userId: 42,
theme: 'dark',
})
对比总结:
| 维度 | iframe: false |
iframe: true |
|---|---|---|
| 通信机制 | 直接内存引用 | postMessage 序列化 |
| 传引用 | ✅ 对象共享 | ❌ 序列化拷贝 |
| 传函数 | ✅ 可以 | ❌ 丢失 |
| 性能 | 高(无序列化开销) | 中(JSON 序列化) |
| 数据大小 | 不受限 | 受 postMessage 限制 |
| API 一致性 | 完全相同 | 完全相同 |
| 调试 | 同 console | 需切换 iframe context |
Q12-2:如何实现「主→子」「子→主」「子→子」完整的跨应用通信?给出实际代码。
答:
以下代码基于本项目真实实现,覆盖三种通信方向。
一、架构总览
┌────────────────────────────────────────┐
│ 主应用 (消息中枢) │
│ │
│ microApp.addDataListener('vue2', cb) │ ← 监听子→主
│ microApp.addDataListener('vue3', cb) │
│ │
│ 转发逻辑: │
│ 收到 vue2 → setData('vue3', ...) │ ← 子→子中转
│ 收到 vue3 → setData('vue2', ...) │
└────┬──────────────────────┬───────────┘
│ setData() │ setData()
↓ ↓
┌──────────────────┐ ┌──────────────────┐
│ vue2-app │ │ vue3-app │
│ │ │ │
│ addDataListener() │ │ addDataListener() │ ← 监听主→子
│ dispatch() │ │ dispatch() │ ← 子→主
└──────────────────┘ └──────────────────┘
二、主→子通信(主应用推送数据给子应用)
主应用:
// src/main.ts — 在 lifeCycles.mounted 中自动推送
mounted(_e, appName) {
microApp.setData(appName, {
type: 'countUpdate',
count: sharedCount.value,
from: 'main',
})
}
// src/views/Home.vue — 手动推送
function pushToVue2() {
microApp.setData('vue2-app', { type: 'countUpdate', count: sharedCount.value, from: 'main' })
}
子应用接收:
// vue2-app / vue3-app — onMounted 中监听
onMounted(() => {
window.microApp?.addDataListener((data: any) => {
if (data.type === 'countUpdate') {
count.value = data.count // 更新本地计数器
addLog(`收到来自 ${data.from} 的计数: ${data.count}`)
}
})
})
三、子→主通信(子应用通知主应用)
子应用:
function sendToMain() {
count.value++
window.microApp?.dispatch({
type: 'countUpdate',
count: count.value,
from: 'vue2-app', // 标识来源
})
}
主应用(在 main.ts 中全局注册,避免组件重复挂载时重复监听):
// src/main.ts — 全局一次性注册
microApp.addDataListener('vue2-app', (data: any) => {
if (data.type === 'countUpdate') {
sharedCount.value = data.count // 更新全局计数
addLog(`vue2-app → 主: 收到计数 ${data.count}`, 'child')
}
})
四、子→子通信(子应用 A 通知子应用 B,主应用中转)
子应用 A:
function sendToVue3() {
count.value++
// 仍然是 dispatch 给主应用,由主应用中转
window.microApp?.dispatch({
type: 'countUpdate',
count: count.value,
from: 'vue2-app',
target: 'vue3-app', // 标注目标
})
}
主应用中转:
// src/main.ts — 在 addDataListener 回调中转发
microApp.addDataListener('vue2-app', (data: any) => {
if (data.type === 'countUpdate') {
sharedCount.value = data.count
// 转发给另一个子应用
microApp.setData('vue3-app', {
type: 'countUpdate',
count: data.count,
from: 'vue2-app', // 保留原始来源
})
addLog(`主 → vue3-app: 转发计数 ${data.count}`, 'relay')
}
})
关键:micro-app 不提供子→子直接通信 API,必须通过主应用中转。
五、状态持久化(解决路由切换后计数器归零)
问题:主应用 Home.vue 中的 ref(0) 是组件局部状态,路由切走(组件卸载)后销毁,切回来时重新初始化为 0。
解决方案:模块级单例 Store
// src/stores/counterStore.ts
import { ref } from 'vue'
// ✅ 模块级 ref — 绑定到模块单例,不随组件卸载而销毁
export const sharedCount = ref(0)
export const counterLogs = ref<LogEntry[]>([])
export function addLog(msg: string, type: 'main' | 'child' | 'relay' = 'main') {
// ...
}
主应用组件使用:
// src/views/Home.vue
import { sharedCount, counterLogs, addLog } from '@/stores/counterStore'
// sharedCount 现在不会因路由切换而丢失
同时处理其他生命周期同步问题:
// src/main.ts — microApp.start() 的 lifeCycles
lifeCycles: {
mounted(_e, appName) {
// ✅ 子应用首次加载时,自动推送当前计数
microApp.setData(appName, { type: 'countUpdate', count: sharedCount.value, from: 'main' })
},
beforeshow(_e, appName) {
// ✅ keepAlive 恢复时,子应用可能状态过期,重新推送
microApp.setData(appName, { type: 'countUpdate', count: sharedCount.value, from: 'main' })
},
}
六、完整通信时序(子→子 为例)
vue2-app 主应用(main.ts) vue3-app
│ │ │
│ dispatch({ │ │
│ count: 5, │ │
│ from: 'vue2-app' │ │
│ }) │ │
│ ─────────────────────→ │ │
│ │ addDataListener 触发 │
│ │ sharedCount.value = 5 │
│ │ │
│ │ setData('vue3-app', { │
│ │ count: 5, │
│ │ from: 'vue2-app' │
│ │ }) │
│ │ ───────────────────────→ │
│ │ │ addDataListener 触发
│ │ │ count.value = 5
│ │ │ 日志: "收到来自 vue2-app"
Q12-3:有没有比 setData/dispatch 更优雅的微前端通信方案?对比各方案的优劣。
答:
有。setData/dispatch 是最基础的"点对点消息"模式,适合简单场景。对于复杂状态管理,有以下更优雅的方案。
方案一:micro-app globalData(官方全局状态)
micro-app 内置了全局状态管理,主应用设置,所有子应用都能监听。
主应用:
import microApp from '@micro-zoe/micro-app'
// 设置全局数据(所有子应用都能收到)
microApp.setGlobalData({ count: 5, theme: 'dark' })
// 强制覆盖(即使值相同也触发通知)
microApp.forceSetGlobalData({ count: 6 })
// 主应用自己也可以监听
microApp.addGlobalDataListener((data) => {
console.log('全局状态变化:', data)
}, true) // autoTrigger: 立即用当前值触发一次
子应用(任何子应用):
// 获取当前值
const data = window.microApp?.getGlobalData() // { count: 5, theme: 'dark' }
// 监听变化
window.microApp?.addGlobalDataListener((data) => {
console.log('全局状态变化:', data)
}, true)
// 子应用也可以设置全局数据
window.microApp?.setGlobalData({ count: 10 })
优点:任何应用(主/子)都可以读/写/监听,天然的全局状态树
缺点:全局命名空间,多子应用时注意 key 冲突;不利于精细的"只发给某个子应用"
方案二:BroadcastChannel API(浏览器原生)
如果所有子应用都和主应用同源部署,可以用 BroadcastChannel 实现真正的去中心化通信。
// 任何应用(主或子)创建同名 channel
const channel = new BroadcastChannel('micro-app-bus')
// 发送消息(广播给所有同源窗口/iframe)
channel.postMessage({ type: 'countUpdate', count: 5, from: 'vue2-app' })
// 接收消息
channel.onmessage = (event) => {
const { type, count, from } = event.data
if (type === 'countUpdate') {
console.log(`收到 ${from} 的计数: ${count}`)
}
}
// 清理
channel.close()
优点:
- 真正的去中心化 — 不需要主应用中转
- 浏览器原生 API,零依赖
- 同源下 window / iframe / web worker 都能通
- 比 postMessage 语义更清晰(专门为消息设计)
缺点:
- 仅限同源部署
- 不兼容 IE
- 消息全广播,需自行过滤
方案三:CustomEvent + DOM 事件总线(同 document 场景)
仅适用于 iframe: false 模式(with 沙箱),因为共享 document。
// 主应用 — 创建事件总线
const bus = new EventTarget()
// 子应用 — 通过 window 访问
// 主应用中挂载
;(window as any).__MICRO_BUS__ = bus
// 任何应用发送
window.__MICRO_BUS__?.dispatchEvent(
new CustomEvent('count-update', { detail: { count: 5, from: 'vue2' } })
)
// 任何应用接收
window.__MICRO_BUS__?.addEventListener('count-update', (e: CustomEvent) => {
console.log(e.detail)
})
缺点:iframe: true 模式不可用(不同 document),仅限 with 沙箱。
方案四:模块级单例 Store + reactive Watch(本项目采用的改进版)
将状态抽到模块级单例,用 Vue watch 驱动行为。
// src/stores/counterStore.ts — 模块级单例
import { ref, watch } from 'vue'
import microApp from '@micro-zoe/micro-app'
export const sharedCount = ref(0)
// ✅ 状态变化自动推送到所有子应用
watch(sharedCount, (val) => {
const activeApps = microApp.getActiveApps({ excludeHiddenApp: false })
activeApps.forEach(name => {
microApp.setData(name, { type: 'countUpdate', count: val, from: 'main' })
})
})
这样主应用组件里只要操作 sharedCount.value++,无需手动调用 setData。
五、各方案对比
| 方案 | 通信方向 | 去中心化 | 子→子 | iframe | 复杂度 | 适用场景 |
|---|---|---|---|---|---|---|
setData/dispatch |
主↔子点对点 | ❌ 主中转 | 需转发 | ✅ | 低 | 简单数据传递 |
globalData |
全局广播 | ✅ 任何应用可写 | ✅ | ✅ | 低 | 全局配置、主题、用户信息 |
BroadcastChannel |
全局广播 | ✅ 完全去中心 | ✅ | ✅ (同源) | 低 | 同源部署的复杂协作 |
CustomEvent |
DOM 事件 | ✅ | ✅ | ❌ (with沙箱) | 低 | iframe:false 的轻量场景 |
| 模块 Store + watch | 主→子自动 | ❌ 主驱动 | 需转发 | ✅ | 中 | 复杂状态、自动同步 |
EventCenterForMicroApp |
主↔子点对点 | ❌ | ❌ | ✅ | 低 | 按需精确通信 |
六、推荐选型
简单传参(初始化配置) → setData / getData
全局共享(主题、用户、语言) → globalData ✅
同源复杂协作 → BroadcastChannel ✅
复杂跨应用状态管理 → 模块 Store + watch + setData 自动推送 ✅
子应用间点对点通信 → dispatch + 主应用中转(目前唯一方式)
Q12-4:用 globalData 改造项目的完整过程是怎样的?前后架构对比?
答:
以下记录了本项目从 setData/dispatch 点对点模式迁移到 globalData 全局广播模式的完整过程。
一、改造前的架构(setData + dispatch + 主应用中转)
vue2-app 主应用 vue3-app
│ │ │
│ dispatch({count:5}) │ │
│ ───────────────────────→ │ │
│ │ addDataListener('vue2') │
│ │ sharedCount = 5 │
│ │ │
│ │ setData('vue3', {count:5})│
│ │ ───────────────────────→ │
│ │ │ addDataListener()
│ │ │ count = 5
痛点:
- 主应用需要为每个子应用单独注册
addDataListener - 子→子通信必须经过主应用中转
- 代码量大,新增子应用时需要在主应用中添加新的监听和中转逻辑
二、改造后的架构(globalData 全局广播)
┌──────────────────────────┐
│ globalData 池 │
│ { count: 5, from: ... } │
└─────┬──────────┬─────────┘
│ │
setGlobalData() addGlobalDataListener()
getGlobalData()
│ │
┌──────────┼──────────┼──────────┐
│ │ │ │
主应用 vue2-app vue3-app 任何一方
任何一方 setGlobalData → 其他所有方 addGlobalDataListener 都触发。
三、主应用改造(main.ts)
// ❌ 改造前:per-app 监听 + 中继
microApp.start({
lifeCycles: {
mounted(_e, appName) {
microApp.setData(appName, { type: 'countUpdate', count: sharedCount.value, from: 'main' })
}
}
})
microApp.addDataListener('vue2-app', (data: any) => {
if (data.type === 'countUpdate') {
sharedCount.value = data.count
microApp.setData('vue3-app', { type: 'countUpdate', count: data.count, from: 'vue2-app' }) // 中继
}
})
microApp.addDataListener('vue3-app', (data: any) => {
// ... 同样的中继逻辑
})
// ✅ 改造后:单一 globalData 监听,无需中继
microApp.setGlobalData({ count: 0, from: 'main' })
microApp.addGlobalDataListener((data: Record<string, any>) => {
if (data.count !== undefined) {
sharedCount.value = data.count
const from = data.from || 'unknown'
if (from !== 'main') {
addLog(`${from} → globalData: 计数 ${sharedCount.value}`, 'child')
}
}
})
// 无需 mounted/beforeshow 手动推送 — 子应用通过 autoTrigger 自动拿值
关键变化:
setData(appName, ...)→setGlobalData(...)(不再需要指定 appName)addDataListener(appName, cb)× N →addGlobalDataListener(cb)× 1- 删除所有中继逻辑
四、主应用 Home.vue 改造
// ❌ 改造前:分别推送到不同子应用
function pushToVue2() {
microApp.setData('vue2-app', { type: 'countUpdate', count: sharedCount.value, from: 'main' })
}
function pushToVue3() {
microApp.setData('vue3-app', { type: 'countUpdate', count: sharedCount.value, from: 'main' })
}
// ✅ 改造后:一键广播,所有子应用同时收到
function doBroadcast(action: string) {
microApp.setGlobalData({ count: sharedCount.value, from: 'main' })
// vue2-app 和 vue3-app 同时收到,无需区分目标
}
function increment() {
sharedCount.value++
doBroadcast('+1') // +1 后自动广播
}
UI 变化:
改造前: 改造后:
[− 1] [+ 1] [− 1] [+ 1] [📡 广播到所有子应用]
[📤 推送到 Vue2] [📤 推送到 Vue3] ↑ +1/−1 自动广播,按钮为手动同步
五、子应用改造(vue2-app / vue3-app)
// ❌ 改造前:dispatch + addDataListener
created() {
const data = window.microApp?.getData()
if (data && data.count !== undefined) this.count = data.count
window.microApp?.addDataListener((data) => {
if (data.type === 'countUpdate') {
this.count = data.count
}
})
},
methods: {
sendToMain() {
this.count++
window.microApp.dispatch({ type: 'countUpdate', count: this.count, from: 'vue2-app' })
},
sendToVue3() {
this.count++
// 需要主应用中转
window.microApp.dispatch({ type: 'countUpdate', count: this.count, from: 'vue2-app', target: 'vue3-app' })
},
}
// ✅ 改造后:setGlobalData + addGlobalDataListener(autoTrigger)
created() {
// autoTrigger: true → 立即用当前 globalData 触发一次,无需手动 getData
window.microApp?.addGlobalDataListener((data) => {
if (data.count !== undefined) {
this.count = data.count
}
}, true) // ← 这个 true 替代了 getData() + addDataListener 两件事
},
methods: {
broadcastIncrement() {
this.count++
// globalData 自动广播给主应用 + 所有其他子应用!
window.microApp.setGlobalData({ count: this.count, from: 'vue2-app' })
// vue3-app 直接收到,无需主应用中转 ✅
},
}
关键变化:
dispatch({ type: 'countUpdate', ... })→setGlobalData({ count: N, from: 'xxx' })getData()+addDataListener(cb)→addGlobalDataListener(cb, true)sendToMain()和sendToVue3()合并为broadcastIncrement()— 都是广播
六、架构对比总结
| 维度 | 改造前 (setData/dispatch) | 改造后 (globalData) |
|---|---|---|
| 主→子 | setData(appName, data) 逐个推送 |
setGlobalData(data) 全部广播 |
| 子→主 | dispatch(data) → addDataListener(appName, cb) |
setGlobalData(data) → addGlobalDataListener(cb) |
| 子→子 | dispatch → 主应用中转 → setData | setGlobalData → 其他子应用直接收到 ✅ |
| 生命周期同步 | mounted/beforeshow 中手动 setData |
autoTrigger: true 自动同步 |
| 主应用监听器 | N 个(每个子应用一个) | 1 个 |
| 新增子应用 | 需加新的 addDataListener + 转发逻辑 | 不需要改任何代码 |
| 数据格式 | { type: 'countUpdate', count, from } |
{ count, from } 更简洁 |
| 类型声明(子应用) | addDataListener / dispatch |
加 addGlobalDataListener / setGlobalData / getGlobalData |
七、globalData 的注意事项
-
广播语义:
setGlobalData会通知所有应用,无法定向推送给特定子应用。如需定向,在 data 中带target字段并在子应用端过滤。 -
合并策略:
setGlobalData会合并(不是替换)到现有 globalData,所以多次调用会累积 key。 -
不要高频调用:每次
setGlobalData都会触发所有子应用的回调。计数器这种低频场景没问题,但高频场景(如鼠标移动、实时输入)需要用节流或改用点对点setData。 -
autoTrigger 陷阱:
addGlobalDataListener(cb, true)中的autoTrigger会传递整个 globalData 对象,不是单次变更的 delta。回调逻辑要做兼容:
// ✅ 正确:通过 count 字段判断
addGlobalDataListener((data) => {
if (data.count !== undefined) { // 不是 if (data.type === 'countUpdate')
this.count = data.count
}
}, true)
- 与 keepAlive 的配合:
autoTrigger: true天然解决了 keepAlive 恢复时的状态同步 — 子应用恢复后重新挂载时,addGlobalDataListener(cb, true)会立即用最新 globalData 触发。
Q13:keep-alive 保活机制的原理是什么?有什么注意事项?
答:
原理:当 <micro-app> 元素从 DOM 中移除时(如路由切换),不执行 unmount(),而是调用 hiddenKeepAliveApp() 将子应用隐藏。当元素重新插入 DOM 时,调用 showKeepAliveApp() 恢复显示。
优点: 避免重复加载资源,保持子应用状态
注意事项:
- 保活的子应用仍然占用内存,需注意内存泄漏
- 子应用的定时器、事件监听需要在
unmount生命周期中手动清理 - 用
microApp.getActiveApps({ excludeHiddenApp: false })可以查看所有活跃应用(包括隐藏的保活应用)
六、生命周期
Q14:micro-app 子应用加载经历了哪些生命周期?顺序是什么?
答:
主应用生命周期(全局 lifeCycles):
created → beforemount → (beforeshow) → mounted → (afterhidden) → unmount
子应用内部生命周期(window 上的钩子):
window.mount() → window.unmount()
完整流程:
| 阶段 | 全局回调 | 说明 |
|---|---|---|
| 资源加载完成 | created |
HTML 和静态资源已下载 |
| 挂载前 | beforemount |
沙箱准备就绪,即将渲染 |
| 挂载前(keep-alive 恢复) | beforeshow |
保活应用恢复显示前触发 |
| 渲染完成 | mounted |
子应用已渲染到容器中 |
| 保活隐藏后 | afterhidden |
保活应用被隐藏后触发 |
| 卸载 | unmount |
子应用被销毁 |
| 报错 | error |
加载或执行出错时触发 |
Q15:全局 lifeCycles 和子应用内部的 mount/unmount 有什么区别?
答:
| 维度 | 全局 lifeCycles | 子应用 mount/unmount |
|---|---|---|
| 定义位置 | 主应用 microApp.start() |
子应用 window['micro-app-xxx'] |
| 关注点 | 框架层面的加载状态 | 子应用业务层面的渲染状态 |
| 参数 | (e: CustomEvent, appName: string) |
无参数(或接收主应用传递的配置) |
| 典型用途 | 全局 loading 控制、埋点 | 初始化 Vue Router、挂载根组件 |
// 子应用(Vue 2)入口 — 导出生命周期
if (window.__MICRO_APP_ENVIRONMENT__) {
window[`micro-app-${window.__MICRO_APP_NAME__}`] = {
mount() {
instance = new Vue({ router, render: h => h(App) }).$mount('#child-app-root')
},
unmount() {
instance.$destroy()
instance.$el.innerHTML = ''
}
}
} else {
// 独立运行时直接渲染
new Vue({ router, render: h => h(App) }).$mount('#app')
}
七、Vue 2 子应用对接
Q16:Vue 2 Webpack 子应用需要做哪些改造才能接入 micro-app?
答: 需要 4 处改造:
1. webpack 配置 — UMD 输出
// vue.config.js 或 webpack.config.js
module.exports = {
output: {
libraryTarget: 'umd',
library: 'vue2App'
}
}
2. devServer 配置 — 跨域
module.exports = {
devServer: {
port: 3000,
headers: {
'Access-Control-Allow-Origin': '*'
}
}
}
3. 入口文件 — 导出生命周期
let instance = null
function mount() {
instance = new Vue({ router, render: h => h(App) }).$mount('#child-app-root')
}
function unmount() {
instance.$destroy()
instance.$el.innerHTML = ''
}
if (window.__MICRO_APP_ENVIRONMENT__) {
window[`micro-app-${window.__MICRO_APP_NAME__}`] = { mount, unmount }
} else {
mount()
}
4. 路由 base — 动态适配
const router = new VueRouter({
mode: 'history',
base: window.__MICRO_APP_BASE_ROUTE__ || '/',
routes
})
Q17:子应用挂载的 DOM 元素 id 为什么不能用 #app?
答:
因为主应用已经使用了 <div id="app"></div>。如果子应用也挂载到 #app,会与主应用的根节点冲突,导致:
- 子应用覆盖主应用内容
- 样式污染
- 路由混乱
正确做法: 子应用使用独立的 id:
<!-- 子应用的 index.html -->
<div id="child-app-root"></div>
// 子应用入口
new Vue({ ... }).$mount('#child-app-root')
八、排错与调试
Q18:子应用加载白屏,如何排查?
答: 按以下顺序排查:
-
检查跨域:浏览器 Network 面板看子应用资源是否被 CORS 阻止 → 确认子应用 devServer 配置了
Access-Control-Allow-Origin: * -
检查控制台错误:
Cannot use import statement outside a module→ 子应用是 Vite 项目,需配置iframe: trueFailed to resolve component: micro-app→ Vite 未配置isCustomElementUnexpected token '<'→ 子应用静态资源路径错误,检查url配置
-
检查沙箱模式:临时设置
disable-sandbox: true确认是否是沙箱引起的问题 -
检查子应用生命周期:在
created/mounted/error回调中打印日志,确认子应用加载到哪一步 -
查看 micro-app 日志:启用
__DEV__模式可看到详细日志
Q19:为什么 Vite 子应用的静态资源会 404?
答: 两个常见原因:
1. 资源路径使用绝对路径:
<!-- 子应用中这样写会 404,因为主应用在 localhost:8080 -->
<script src="/assets/index.js"></script>
<!-- 实际应该请求子应用 localhost:5173/assets/index.js -->
解决:子应用 Vite 配置 base 或使用相对路径
2. baseroute 与 base 不匹配:
// 主应用
<micro-app baseroute="/child-app" />
// 子应用 vite.config.ts
export default defineConfig({
base: '/child-app/' // 必须与 baseroute 一致
})
九、实战总结
Q20:这套微前端架构在生产环境中部署需要注意什么?
答:
-
子应用独立部署:每个子应用有自己独立的 CI/CD 流水线,版本号独立管理
-
统一资源路径管理:通过环境变量管理各子应用的 URL:
export const subApps = [{ name: 'vue2-app', url: import.meta.env.VITE_CHILD_APP_URL || 'http://localhost:5173/', baseroute: '/child-app', }] -
Nginx 配置:
- 所有子应用必须配置 CORS 头
- 主应用和子应用建议部署在同源下(同协议、同域名、同端口),可避免大量跨域问题
- 如果异构部署,需要配置反向代理统一转发
-
公共依赖提取:将 Vue、Vue Router 等公共依赖配置为 external,避免重复加载:
// vite.config.ts build: { rollupOptions: { external: ['vue', 'vue-router'] } } -
灰度发布:利用
microApp.start()的plugins.global机制,可以在加载子应用 HTML 时做 A/B 测试控制 -
错误兜底:配置全局
error回调 + 子应用容器的@error事件,展示友好的降级页面
附录:快速检查清单
| 检查项 | 正确配置 |
|---|---|
| Vite isCustomElement | (tag) => /^micro-app/.test(tag) |
| Vite 子应用沙箱 | iframe: true(必须) |
| Webpack 子应用沙箱 | iframe: false 即可 |
| 子应用跨域 | Access-Control-Allow-Origin: * |
| 子应用挂载 id | 不能用 #app,需独立命名 |
| 路由通配符(Vue Router 4) | :page*,不是 * |
| lifeCycles 回调参数 | (e: CustomEvent, appName: string) |
| 子应用路由 base | window.__MICRO_APP_BASE_ROUTE__ || '/' |
十、样式穿透实战
Q21:子应用 iframe: false 模式下,主应用的全局样式为什么会穿透到子应用?如何彻底解决?
答:
这是微前端 iframe: false 模式下最常见的样式泄漏问题。即使使用了 .app-main h2 这样的"作用域选择器",样式仍然会穿透。
一、问题现象
在 http://localhost:8080/mock-app 页面中,子应用的 <h2> 元素出现了主应用的紫色 border-bottom 样式,而不是子应用自己定义的样式。
二、根因分析:DOM 层级重叠
主应用的 App.vue 布局结构:
<!-- App.vue 模板 -->
<div id="main-app">
<header class="app-header">...</header>
<main class="app-main">
<router-view /> <!-- ← 这里渲染所有页面 -->
</main>
</div>
当访问 /mock-app 时,<router-view> 渲染的是 ChildApp.vue:
<!-- ChildApp.vue 模板 -->
<div class="child-app-wrapper">
<micro-app name="mock-app" :iframe="false">
<!-- 子应用的完整 DOM 在这里!包括 <h2>、<button>、<p> 等 -->
</micro-app>
</div>
最终 DOM 树:
main.app-main ← 主应用的样式容器
├── div.home-page ← 访问 /home 时
│ └── h2 (主应用的 h2)
│
└── div.child-app-wrapper ← 访问 /mock-app 时
└── <micro-app name="mock-app">
└── h2 (子应用的 h2) ← ⚠️ 也在 .app-main 内部!
关键点:子应用的 DOM 被渲染在 .app-main 内部,所以 .app-main h2 这个后代选择器会匹配到子应用的 <h2>。
三、常见的错误认知
很多开发者以为加上父级 class 就能隔离:
/* ❌ 错误:这样写仍然会穿透! */
.app-main h2 {
border-bottom: 2px solid #667eea !important;
}
为什么不行? 因为 .app-main h2 是后代选择器(任意层级),它会匹配 .app-main 下面所有层级的 h2,包括嵌套在 micro-app 内部的子应用 h2。
/* ❌ 更差:这样写 100% 泄漏 */
h2 {
border-bottom: 2px solid purple;
}
四、正确解法
方案 1:子选择器 + :not() 排除子应用容器(本项目采用)
/* ✅ 正确:只匹配主应用页面内的 h2,排除子应用容器 */
.app-main > :not(.child-app-wrapper) h2 {
border-bottom: 2px solid #667eea !important;
}
.app-main > :not(.child-app-wrapper) button { ... }
.app-main > :not(.child-app-wrapper) p { ... }
.app-main > :not(.child-app-wrapper) table { ... }
.app-main > :not(.child-app-wrapper) code { ... }
原理:
>子选择器:只匹配.app-main的直接子元素:not(.child-app-wrapper):排除类名为.child-app-wrapper的直接子元素- 子应用的容器
.child-app-wrapper被排除 → 其内部所有元素都不受主样式影响
.app-main
├── div.home-page ← :not(.child-app-wrapper) → ✅ 样式生效
└── div.child-app-wrapper ← 被 :not() 排除 → ❌ 样式不生效
└── h2 (安全!)
方案 2:开启 iframe 沙箱(最彻底)
{
name: 'mock-app',
iframe: true, // ← 浏览器原生隔离,样式 100% 不互通
}
优点:彻底的样式/JS 隔离
缺点:性能略差、调试不便、通信成本增加
方案 3:使用 Shadow DOM(激进方案)
<micro-app name="mock-app" :shadowDOM="true" />
缺点:Shadow DOM 边界会阻断很多 CSS 特性(如全局字体),兼容性成本高。
方案 4:CSS @scope(未来方案)
@scope (.app-main) to (micro-app) {
h2 { border-bottom: 2px solid #667eea; }
}
@scope 的 to 边界可以精确控制样式不穿透到 micro-app 内部。Chrome 118+、Safari 17.4+ 已支持,但目前还不够普及。
五、各方案对比
| 方案 | 隔离程度 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
:not(.child-app-wrapper) |
中(单向阻断) | 无影响 | 低 | 已知子应用容器结构的场景 |
| iframe 沙箱 | 高(完全隔离) | 略低 | 低(配置即可) | Vite 子应用、高隔离要求 |
| Shadow DOM | 高(DOM 隔离) | 中 | 高(兼容问题多) | 对隔离有极致要求的场景 |
| CSS @scope | 中高 | 无影响 | 低 | 未来主流方案(等浏览器普及) |
六、核心教训
在
iframe: false模式下,子应用和主应用共享同一个 document,任何后代选择器都会穿透到子应用内部。给选择器加父级 class(如.app-main)只在父级和子应用不在同一容器时才有效。真正有效的做法是用子选择器限制层级深度,同时用:not()排除子应用容器。