Files
microapp-main-interview/docs/micro-app面试题.md
2026-06-25 10:34:43 +08:00

63 KiB
Raw Permalink Blame History

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

Q2micro-app 主应用和子应用之间是如何隔离的?

答: micro-app 提供三层隔离体系各管各的维度css隔离(micro-app[name=xxx])、js隔离(with+proxy, iframe)、元素隔离(webcomponent, shadow-dom默认false)可以组合使用:

┌───────────────────────────────┐
│                    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 DOMDOM 树级别隔离             │
│                                                              │
└───────────────────────────────┘

一、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

二、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 Moduleimport/exportnew 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 里。

三种解决思路

  1. 主应用 CSS 加 :not(.child-app-wrapper) 排除子应用容器(本项目方案)

  2. 开启 shadowDOM: true — Shadow DOM 边界双向阻断

  3. 开启 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 来源)

Q6micro-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>)
}

根因分析:

  1. Vite 开发服务器输出的 JS 是 <script type="module"> 格式的 ES Module

  2. micro-app 默认的 with 沙箱 使用 new Function() 来执行子应用的 JS 代码

  3. 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 中可以正常执行。


Q8new 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 打包,输出的是普通 JSimport),理论上用 with 沙箱也能执行。但仍然推荐 iframe: true,原因:

  1. 一致性:开发/生产行为一致,避免"开发没问题,上线炸了"

  2. 隔离性iframe 提供更彻底的样式/JS 隔离

  3. 资源路径iframe 中的相对路径自动指向子应用 origin不用额外处理


四、TypeScript 类型问题(不用看,跳过)

Q9micro-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} 被创建`)
  }
}

Q10vue-tsc --noEmit 在 CI/CD 中有什么作用?我们遇到了什么类型错误?

答: vue-tsc --noEmit 是 Vue 项目的类型检查命令:

  • 只做类型检查,不输出编译产物

  • 相当于 tsc --noEmit,但额外支持 .vue 文件

我们在项目中遇到了两类类型错误:

  1. lifeCycles 回调参数类型错误e.name 不存在于 CustomEvent 类型 → 改用第二个参数 appName

  2. 未使用的导入getSubAppConfigChildApp.vue 中导入但未使用 → 移除无用导入

配置建议:package.json 中将类型检查加入构建流程:

{
  "scripts": {
    "build": "vue-tsc --noEmit && vite build"
  }
}

五、子应用容器与通信

Q11 标签有哪些核心属性?各有什么作用?

答:

属性 类型 说明
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

router-mode的两种方式的对比

特性 native 模式(推荐使用) native-scope 模式
路由作用域 子应用拥有独立的路由空间通过 baseroute 进行隔离。主路由的 path 和子应用的 path 是相对独立的可配置。 子应用与主应用共享全局路由路径。子应用的路由路径必须和主应用配置的路径完全对应,更像是一种“路由映射”。
配置复杂度 相对复杂。需要为主应用配置 baseroute基础路由子应用内部从 window.MICRO_APP_BASE_ROUTE 获取基础路径来配置自己的路由。 非常简单。只需设置 router-mode='native-scope'无需额外配置 baseroute。但需确保主、子应用使用相同的路由模式如都用 history 或都用 hash
优缺点 隔离性好,配置灵活,适合复杂场景。但每个子应用需要单独处理路由基础路径。 使用简单,接入成本低,特别适合简单的嵌套场景。但缺点是子应用会和主应用的路由强绑定,切换时可能会触发子应用的频繁重新加载,需要额外注意性能优化。

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-1iframe: true 模式下,通信的底层原理是什么?和 iframe: false 有什么不同?

答:

API 层面完全一致 — 无论 iframe: true 还是 iframe: false,主应用和子应用都用同一套 APIsetDatagetDatadispatchaddDataListener)。框架在底层封装了通信差异。

底层机制对比:

iframe: falsewith 沙箱)                                     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            │

核心步骤:

  1. 主→子microApp.setData() → 框架 JSON.stringify(data)iframe.contentWindow.postMessage(serialized, '*') → 子应用 window.addEventListener('message')JSON.parse → 触发 dataListener

  2. 子→主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 + addGlobalDataListenerautoTrigger
created() {
   // autoTrigger: true → 立即用当前 globalData 触发一次,无需手动 getData
   window.microApp?.addGlobalDataListener((data) => {
      if (data.count !== undefined) {
         this.count = data.count
      }
   }, true)  // ← 这个 true就是autoTrigger 替代了 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 的注意事项

  1. 广播语义setGlobalData 会通知所有应用,无法定向推送给特定子应用。如需定向,在 data 中带 target 字段并在子应用端过滤。

  2. 合并策略setGlobalData合并(不是替换)到现有 globalData所以多次调用会累积 key。

  3. 不要高频调用:每次 setGlobalData 都会触发所有子应用的回调。计数器这种低频场景没问题,但高频场景(如鼠标移动、实时输入)需要用节流或改用点对点 setData

  4. autoTrigger 陷阱addGlobalDataListener(cb, true) 中的 autoTrigger 会传递整个 globalData 对象,不是单次变更的 delta。回调逻辑要做兼容

// ✅ 正确:通过 count 字段判断
addGlobalDataListener((data) => {
   if (data.count !== undefined) {  // 不是 if (data.type === 'countUpdate')
      this.count = data.count
   }
}, true)
  1. 与 keepAlive 的配合autoTrigger: true 天然解决了 keepAlive 恢复时的状态同步 — 子应用恢复后重新挂载时,addGlobalDataListener(cb, true) 会立即用最新 globalData 触发。

Q13keep-alive 保活机制的原理是什么?有什么注意事项?

答: 原理:当 <micro-app> 元素从 DOM 中移除时(如路由切换),不执行 unmount(),而是调用 hiddenKeepAliveApp() 将子应用隐藏。当元素重新插入 DOM 时,调用 showKeepAliveApp() 恢复显示。

优点: 避免重复加载资源,保持子应用状态

注意事项:

  • 保活的子应用仍然占用内存,需注意内存泄漏

  • 子应用的定时器、事件监听需要在 unmount 生命周期中手动清理

  • microApp.getActiveApps({ excludeHiddenApp: false }) 可以查看所有活跃应用(包括隐藏的保活应用)


六、生命周期

Q14micro-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 子应用对接

Q16Vue 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子应用加载白屏如何排查

答: 按以下顺序排查:

  1. 检查跨域:浏览器 Network 面板看子应用资源是否被 CORS 阻止 → 确认子应用 devServer 配置了 Access-Control-Allow-Origin: *

  2. 检查控制台错误

    • Cannot use import statement outside a module → 子应用是 Vite 项目,需配置 iframe: true

    • Failed to resolve component: micro-app → Vite 未配置 isCustomElement

    • Unexpected token '<' → 子应用静态资源路径错误,检查 url 配置

  3. 检查沙箱模式:临时设置 disable-sandbox: true 确认是否是沙箱引起的问题

  4. 检查子应用生命周期:在 created/mounted/error 回调中打印日志,确认子应用加载到哪一步

  5. 查看 micro-app 日志:启用 __DEV__ 模式可看到详细日志


Q19为什么 Vite 子应用的静态资源会 404

答: 两个常见原因:

1. 资源路径使用绝对路径:

<!-- 子应用中这样写会 404因为主应用在 localhost:8080 -->
<script src="/assets/index.js"></script>
<!-- 实际应该请求子应用 localhost:5173/assets/index.js -->

解决:子应用 Vite 配置 base 或使用相对路径

2. baseroutebase 不匹配:

// 主应用
<micro-app baseroute="/child-app" />

// 子应用 vite.config.ts
export default defineConfig({
   base: '/child-app/'  // 必须与 baseroute 一致
})

九、实战总结

Q20这套微前端架构在生产环境中部署需要注意什么

答:

  1. 子应用独立部署:每个子应用有自己独立的 CI/CD 流水线,版本号独立管理

  2. 统一资源路径管理:通过环境变量管理各子应用的 URL

    export const subApps = [{
      name: 'vue2-app',
      url: import.meta.env.VITE_CHILD_APP_URL || 'http://localhost:5173/',
      baseroute: '/child-app',
    }]
    
  3. Nginx 配置

    • 所有子应用必须配置 CORS 头

    • 主应用和子应用建议部署在同源下(同协议、同域名、同端口),可避免大量跨域问题

    • 如果异构部署,需要配置反向代理统一转发

  4. 公共依赖提取:将 Vue、Vue Router 等公共依赖配置为 external避免重复加载

    // vite.config.ts
    build: {
      rollupOptions: {
        external: ['vue', 'vue-router']
      }
    }
    
  5. 灰度发布:利用 microApp.start()plugins.global 机制,可以在加载子应用 HTML 时做 A/B 测试控制

  6. 错误兜底:配置全局 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 特性(如全局字体),兼容性成本高。

方案 4CSS @scope未来方案

@scope (.app-main) to (micro-app) {
   h2 { border-bottom: 2px solid #667eea; }
}

@scopeto 边界可以精确控制样式不穿透到 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() 排除子应用容器。