1085 lines
38 KiB
Markdown
1085 lines
38 KiB
Markdown
# @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 前缀改写(默认方案)**
|
||
|
||
**工作原理:**
|
||
|
||
```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 沙箱的执行过程:**
|
||
|
||
```ts
|
||
// 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 前缀改写。**
|
||
|
||
```html
|
||
<micro-app name="mock-app" :shadowDOM="true" />
|
||
```
|
||
|
||
开启后,子应用的 DOM 被包裹在 Shadow Root 里:
|
||
|
||
```html
|
||
<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 步:
|
||
|
||
```ts
|
||
// 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*` 匹配子应用所有内部路由:
|
||
|
||
```ts
|
||
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>)
|
||
}
|
||
```
|
||
|
||
**根因分析:**
|
||
1. Vite 开发服务器输出的 JS 是 `<script type="module">` 格式的 ES Module
|
||
2. micro-app 默认的 **with 沙箱** 使用 `new Function()` 来执行子应用的 JS 代码
|
||
3. `new Function()` 创建的函数运行在「非模块」作用域,无法解析 `import`/`export` 语法
|
||
|
||
**解决方案:**
|
||
```ts
|
||
// 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:
|
||
|
||
```ts
|
||
// 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`:
|
||
|
||
```js
|
||
// 子应用代码
|
||
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`**,原因:
|
||
|
||
1. **一致性**:开发/生产行为一致,避免"开发没问题,上线炸了"
|
||
2. **隔离性**:iframe 提供更彻底的样式/JS 隔离
|
||
3. **资源路径**:iframe 中的相对路径自动指向子应用 origin,不用额外处理
|
||
|
||
---
|
||
|
||
## 四、TypeScript 类型问题
|
||
|
||
### Q9:micro-app 的 `lifeCycles` 回调中,为什么 `e.name` 会报 TS 类型错误?
|
||
|
||
**答:**
|
||
这是 TypeScript 类型定义与实际使用的差异问题。
|
||
|
||
**错误代码:**
|
||
```ts
|
||
lifeCycles: {
|
||
created(e) {
|
||
console.log(e.name) // TS2339: Property 'name' does not exist on type 'CustomEvent<any>'
|
||
}
|
||
}
|
||
```
|
||
|
||
**根因:**
|
||
根据 `@micro-zoe/micro-app` 的类型定义文件 `global.d.ts`:
|
||
|
||
```ts
|
||
interface lifeCyclesType {
|
||
created?(e: CustomEvent, appName: string): void
|
||
beforemount?(e: CustomEvent, appName: string): void
|
||
// ...
|
||
}
|
||
```
|
||
|
||
生命周期回调接收**两个参数**:第一个是 `CustomEvent` 事件对象,**第二个才是 `appName` 字符串**。
|
||
|
||
**正确写法:**
|
||
```ts
|
||
lifeCycles: {
|
||
created(_e, appName) {
|
||
console.log(`子应用 ${appName} 被创建`)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Q10:`vue-tsc --noEmit` 在 CI/CD 中有什么作用?我们遇到了什么类型错误?
|
||
|
||
**答:**
|
||
`vue-tsc --noEmit` 是 Vue 项目的类型检查命令:
|
||
- 只做类型检查,不输出编译产物
|
||
- 相当于 `tsc --noEmit`,但额外支持 `.vue` 文件
|
||
|
||
我们在项目中遇到了两类类型错误:
|
||
|
||
1. **lifeCycles 回调参数类型错误**:`e.name` 不存在于 `CustomEvent` 类型 → 改用第二个参数 `appName`
|
||
2. **未使用的导入**:`getSubAppConfig` 在 `ChildApp.vue` 中导入但未使用 → 移除无用导入
|
||
|
||
**配置建议:** 在 `package.json` 中将类型检查加入构建流程:
|
||
|
||
```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` 属性(初始化传参)**
|
||
```vue
|
||
<micro-app name="app1" url="..." :data="{ token: 'abc', userId: 1 }" />
|
||
```
|
||
```ts
|
||
// 子应用接收
|
||
const data = window.microApp?.getData()
|
||
```
|
||
|
||
**方式二:通过 `microApp.setData()` API(动态传参)**
|
||
```ts
|
||
import microApp from '@micro-zoe/micro-app'
|
||
microApp.setData('app1', { path: '/detail' })
|
||
```
|
||
```ts
|
||
// 子应用监听
|
||
window.microApp?.addDataListener((data) => {
|
||
console.log('收到数据:', data)
|
||
if (data.path) router.push(data.path)
|
||
}, true) // autoTrigger = true,初始化时立即触发一次
|
||
```
|
||
|
||
**方式三:通过 `EventCenterForMicroApp`(双向通信)**
|
||
```ts
|
||
// 主应用
|
||
import { EventCenterForMicroApp } from '@micro-zoe/micro-app'
|
||
const eventCenter = new EventCenterForMicroApp('app1')
|
||
eventCenter.dispatch({ type: '通知', payload: {} })
|
||
```
|
||
```ts
|
||
// 子应用
|
||
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 │
|
||
```
|
||
|
||
**核心步骤:**
|
||
|
||
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 模式走的是**序列化拷贝**(传完之后各是各的副本)。
|
||
|
||
**实战中的坑:**
|
||
|
||
```ts
|
||
// ❌ 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 |
|
||
|
||
---
|
||
|
||
### 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、挂载根组件 |
|
||
|
||
```ts
|
||
// 子应用(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 输出**
|
||
```js
|
||
// vue.config.js 或 webpack.config.js
|
||
module.exports = {
|
||
output: {
|
||
libraryTarget: 'umd',
|
||
library: 'vue2App'
|
||
}
|
||
}
|
||
```
|
||
|
||
**2. devServer 配置 — 跨域**
|
||
```js
|
||
module.exports = {
|
||
devServer: {
|
||
port: 3000,
|
||
headers: {
|
||
'Access-Control-Allow-Origin': '*'
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**3. 入口文件 — 导出生命周期**
|
||
```ts
|
||
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 — 动态适配**
|
||
```ts
|
||
const router = new VueRouter({
|
||
mode: 'history',
|
||
base: window.__MICRO_APP_BASE_ROUTE__ || '/',
|
||
routes
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### Q17:子应用挂载的 DOM 元素 id 为什么不能用 `#app`?
|
||
|
||
**答:**
|
||
因为主应用已经使用了 `<div id="app"></div>`。如果子应用也挂载到 `#app`,会与主应用的根节点冲突,导致:
|
||
|
||
- 子应用覆盖主应用内容
|
||
- 样式污染
|
||
- 路由混乱
|
||
|
||
**正确做法:** 子应用使用独立的 id:
|
||
|
||
```html
|
||
<!-- 子应用的 index.html -->
|
||
<div id="child-app-root"></div>
|
||
```
|
||
|
||
```ts
|
||
// 子应用入口
|
||
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. 资源路径使用绝对路径:**
|
||
```html
|
||
<!-- 子应用中这样写会 404,因为主应用在 localhost:8080 -->
|
||
<script src="/assets/index.js"></script>
|
||
<!-- 实际应该请求子应用 localhost:5173/assets/index.js -->
|
||
```
|
||
解决:子应用 Vite 配置 `base` 或使用相对路径
|
||
|
||
**2. `baseroute` 与 `base` 不匹配:**
|
||
```ts
|
||
// 主应用
|
||
<micro-app baseroute="/child-app" />
|
||
|
||
// 子应用 vite.config.ts
|
||
export default defineConfig({
|
||
base: '/child-app/' // 必须与 baseroute 一致
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 九、实战总结
|
||
|
||
### Q20:这套微前端架构在生产环境中部署需要注意什么?
|
||
|
||
**答:**
|
||
|
||
1. **子应用独立部署**:每个子应用有自己独立的 CI/CD 流水线,版本号独立管理
|
||
|
||
2. **统一资源路径管理**:通过环境变量管理各子应用的 URL:
|
||
```ts
|
||
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,避免重复加载:
|
||
```ts
|
||
// 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` 布局结构:
|
||
|
||
```html
|
||
<!-- 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`:
|
||
|
||
```html
|
||
<!-- 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 就能隔离:
|
||
|
||
```css
|
||
/* ❌ 错误:这样写仍然会穿透! */
|
||
.app-main h2 {
|
||
border-bottom: 2px solid #667eea !important;
|
||
}
|
||
```
|
||
|
||
**为什么不行?** 因为 `.app-main h2` 是**后代选择器**(任意层级),它会匹配 `.app-main` 下面所有层级的 `h2`,包括嵌套在 `micro-app` 内部的子应用 `h2`。
|
||
|
||
```css
|
||
/* ❌ 更差:这样写 100% 泄漏 */
|
||
h2 {
|
||
border-bottom: 2px solid purple;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
**四、正确解法**
|
||
|
||
**方案 1:子选择器 + :not() 排除子应用容器(本项目采用)**
|
||
|
||
```css
|
||
/* ✅ 正确:只匹配主应用页面内的 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 沙箱(最彻底)**
|
||
|
||
```ts
|
||
{
|
||
name: 'mock-app',
|
||
iframe: true, // ← 浏览器原生隔离,样式 100% 不互通
|
||
}
|
||
```
|
||
|
||
**优点**:彻底的样式/JS 隔离
|
||
**缺点**:性能略差、调试不便、通信成本增加
|
||
|
||
**方案 3:使用 Shadow DOM(激进方案)**
|
||
|
||
```html
|
||
<micro-app name="mock-app" :shadowDOM="true" />
|
||
```
|
||
|
||
**缺点**:Shadow DOM 边界会阻断很多 CSS 特性(如全局字体),兼容性成本高。
|
||
|
||
**方案 4:CSS @scope(未来方案)**
|
||
|
||
```css
|
||
@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()` 排除子应用容器。
|