Files
microapp-main-interview/docs/micro-app多子应用接入实战.md
2026-06-21 20:19:21 +08:00

2412 lines
88 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# micro-app 多子应用接入实战面试题
> 基于在已有主应用中接入第二个 Vue 3 + Vite 子应用(端口 3001的完整实战经验整理。
---
## 一、多子应用注册与路由
### Q1如何在已有 micro-app 主应用中新增一个子应用?需要改哪些文件?
**答:**
只需要改 **3 个文件**,遵循「配置 → 路由 → 导航」的顺序:
**1. `src/config/subApps.ts` — 添加子应用配置**
```ts
export const subApps: SubAppConfig[] = [
{
name: 'vue2-app',
url: 'http://localhost:5173/',
baseroute: '/child-app',
iframe: true,
keepAlive: true,
routerMode: 'native'
},
// ✅ 新增
{
name: 'vue3-app',
url: 'http://localhost:3001/',
baseroute: '/vue3-app',
iframe: true,
keepAlive: true,
routerMode: 'native'
}
]
```
**2. `src/router/index.ts` — 添加通配路由**
```ts
const routes = [
// ... 其他路由
{
path: '/vue3-app/:page*', // ✅ 新增:通配符匹配子应用所有内部路由
name: 'vue3App',
component: () => import('@/views/ChildApp.vue')
}
]
```
**3. `src/App.vue` — 导航栏添加入口**
```html
<router-link to="/vue3-app">Vue3 子应用</router-link>
```
**不需要改的文件:**
- `ChildApp.vue` — 容器组件是通用的,根据 URL 自动匹配配置
- `main.ts``microApp.start()` 是框架级初始化,不绑定具体子应用
- `vite.config.ts``isCustomElement` 配置对所有 `<micro-app>` 标签生效
---
### Q2为什么要设计成"3 个文件改动"的模式?能否进一步减少?
**答:**
这是 micro-app 架构设计中最简洁的扩展模式:
| 改动 | 为什么必须 |
|------|-----------|
| `subApps.ts` | 子应用的元信息name、url、baseroute必须有地方存储 |
| `router/index.ts` | Vue Router 需要知道哪些 URL 前缀交给子应用处理 |
| `App.vue` | 用户需要入口点击进入子应用 |
**能否合并?**
可以。如果子应用数量很多,可以优化为**自动注册**模式:
```ts
// router/index.ts — 自动为所有子应用生成路由
import { subApps } from '@/config/subApps'
const subAppRoutes = subApps.map(app => ({
path: `${app.baseroute}/:page*`,
name: app.name,
component: () => import('@/views/ChildApp.vue')
}))
const routes = [
{ path: '/', redirect: '/home' },
{ path: '/home', component: () => import('@/views/Home.vue') },
...subAppRoutes // 自动展开
]
```
导航栏同理,用 `v-for` 遍历 `subApps` 自动生成。这样新增子应用只需要改 `subApps.ts` 一个文件。
---
### Q3`ChildApp.vue` 组件是如何做到"一个组件服务所有子应用"的?
**答:**
核心原理是通过 **路由路径匹配配置**
```ts
// ChildApp.vue 的核心逻辑
const currentApp = computed(() => {
const matched = subApps.find((app) =>
route.path.startsWith(app.baseroute)
)
return matched || subApps[0]
})
```
```html
<micro-app
:key="currentApp.url"
:name="currentApp.name"
:url="currentApp.url"
:baseroute="currentApp.baseroute"
<!-- 所有属性都从 currentApp 动态绑定 -->
/>
```
**工作流程:**
1. 用户访问 `/vue3-app/dashboard`
2. Vue Router 匹配到通配路由 → 渲染 `ChildApp.vue`
3. `ChildApp.vue` 内的 `computed` 检查 `route.path` 以哪个 `baseroute` 开头
4. 找到 `vue3-app` 配置 → 绑定到 `<micro-app>` 标签
5. micro-app 框架根据 `name` + `url` 加载对应的子应用
**关键设计点:**
- `:key="currentApp.url"` — 当切换到不同子应用时Vue 会销毁旧的 `<micro-app>` 重建新的
- `keepAlive: true` — 切换回来时不销毁,保持应用状态
- 未匹配到任何 `baseroute` 时的兜底逻辑:`|| subApps[0]`
---
## 二、路由冲突与优先级
### Q4如果主应用有一个 `/about` 路由,子应用也有一个 `/about` 路由,且子应用没有设置 router base访问时会发生什么
**答:**
**结论:不会冲突,展示哪个取决于 URL。**
| 访问路径 | 展示内容 | 原因 |
|----------|----------|------|
| `/about` | 主应用的 About | 主应用路由直接匹配,不经过子应用 |
| `/vue3-app/about` | 子应用的 About | 主应用路由匹配 `/vue3-app/:page*`,交给子应用处理 |
**完整流程图:**
```
浏览器输入: http://localhost:8080/about
主应用 Vue Router 开始匹配
┌───────┴───────┐
│ │
主应用有 /about 主应用没有 /about
│ │
▼ ▼
展示主应用 About 404 / fallback
(子应用完全不会
被加载)
```
```
浏览器输入: http://localhost:8080/vue3-app/about
主应用 Vue Router 匹配
命中 /vue3-app/:page* 通配路由
渲染 ChildApp.vue → 加载 <micro-app>
micro-app 剥离 baseroute(/vue3-app)
传给子应用内部路由: /about
子应用 Vue Router 匹配 /about
展示子应用 About 页面
```
---
### Q5子应用不设置 router base 会有什么隐患?
**答:**
虽然 micro-app 的 `baseroute` 机制能拦截 pushState 并自动拼接前缀,让大部分场景"看起来正常",但仍有 **4 个隐患**
| 隐患 | 说明 | 风险等级 |
|------|------|----------|
| `window.location` 误用 | 代码中直接读 `location.pathname` 会得到完整路径 `/vue3-app/about`,而不是期望的 `/about` | ⭐⭐⭐ 高 |
| 静态资源 404 | `<img src="/assets/logo.png">` 会请求主应用的 8080 端口而非子应用的 3001 端口 | ⭐⭐⭐ 高 |
| 独立运行失败 | 不经过主应用直接访问子应用时base 为空字符串,路由跳转可能出错 | ⭐⭐ 中 |
| `history.go()` 异常 | 直接操作浏览器历史时不经过 micro-app 拦截,可能跳出微前端上下文 | ⭐ 低 |
**最佳实践:**
```ts
// 子应用 router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(
// 动态适配:微前端环境用 baseroute独立运行用 '/'
window.__MICRO_APP_BASE_ROUTE__ || '/'
),
routes: [
{ path: '/about', component: () => import('@/views/About.vue') }
]
})
```
`window.__MICRO_APP_BASE_ROUTE__` 是 micro-app 注入到子应用 window 上的全局变量,值就是主应用中配置的 `baseroute`
---
### Q6micro-app 是如何实现"主应用路由和子应用路由互不干扰"的?
**答:**
通过 **URL 命名空间隔离** 机制:
```
主应用路由空间Vue Router 管理)
├── / → 重定向到 /home
├── /home → Home.vue
├── /about → About.vue (如果定义了)
├── /child-app/:page* → 子应用 vue2-app 的领地
└── /vue3-app/:page* → 子应用 vue3-app 的领地
子应用 vue2-app 路由空间(内部 Router 管理,剥离 /child-app 前缀后)
├── / → 首页
├── /about → 关于页
└── /user/:id → 用户详情
子应用 vue3-app 路由空间(内部 Router 管理,剥离 /vue3-app 前缀后)
├── / → 首页
├── /about → 关于页
└── /dashboard → 控制台
```
**三层隔离保证:**
1. **Vue Router 层级** — 主应用的 `/about` 和通配路由 `/vue3-app/:page*` 互斥匹配,不会同时命中
2. **baseroute 剥离** — micro-app 在将 URL 传给子应用前,自动去除 baseroute 前缀
3. **iframe 沙箱** — Vite 子应用运行在独立 iframe 中,`window.location` 指向自己的 origin
---
## 三、SubAppConfig 接口设计
### Q7`SubAppConfig` 接口各字段的作用和设计考量是什么?
**答:**
```ts
export interface SubAppConfig {
name: string // 全局唯一标识,字母开头
url: string // 子应用 devServer / 生产地址
baseroute: string // 主应用分配给子应用的 URL 命名空间
iframe?: boolean // 是否使用 iframe 沙箱
keepAlive?: boolean // 切换时是否保活
routerMode?: 'native' | 'native-scope'
disableScopecss?: boolean // 禁用样式隔离
disableSandbox?: boolean // 禁用 JS 沙箱
}
```
| 字段 | 为什么可选/必填 | 设计考量 |
|------|----------------|----------|
| `name` | 必填 | micro-app 框架需要唯一标识来管理子应用生命周期 |
| `url` | 必填 | 不知道地址就无法加载;生产环境应通过环境变量注入 |
| `baseroute` | 必填 | 决定 URL 命名空间,也影响子应用的资源路径解析 |
| `iframe` | 可选,默认 false | Vite 子应用必须设为 trueES Module 兼容性) |
| `keepAlive` | 可选 | 高频切换场景开启可提升体验,但占用内存 |
| `routerMode` | 可选 | `native-scope` 提供额外的作用域隔离 |
| `disableScopecss` | 可选 | 调试时临时关闭样式隔离,排查样式问题 |
| `disableSandbox` | 可选 | 调试时临时关闭沙箱,确认是否是沙箱引起的 bug |
---
### Q8为什么 Vite 子应用的 `iframe` 必须设为 `true`
**答:**
见 [基础面试题 Q7](./micro-app面试题.md#q7遇到-cannot-use-import-statement-outside-a-module-报错是什么原因如何解决),核心原因:
```
Vite devServer 输出 ES Module (type="module")
with 沙箱用 new Function() 执行 JS
new Function() 无法解析 import/export 语法
❌ SyntaxError: Cannot use import statement outside a module
```
**解决:** `iframe: true` → 浏览器原生 iframe 天然支持 `<script type="module">`
---
## 四、扩展与自动化
### Q9子应用数量从 1 个增长到 10 个后,路由和导航如何自动管理?
**答:**
将硬编码改为**配置驱动**
**路由自动生成:**
```ts
// router/index.ts
import { subApps } from '@/config/subApps'
const routes = [
{ path: '/', redirect: '/home' },
{ path: '/home', name: 'home', component: () => import('@/views/Home.vue') },
// 自动为每个子应用生成通配路由
...subApps.map(app => ({
path: `${app.baseroute}/:page*`,
name: app.name,
component: () => import('@/views/ChildApp.vue')
}))
]
```
**导航自动生成:**
```html
<!-- App.vue -->
<router-link
v-for="app in subApps"
:key="app.name"
:to="app.baseroute"
>
{{ app.name }}
</router-link>
```
这样新增子应用只需要在 `subApps` 数组中加一行配置,路由和导航自动生效。
---
### Q10如果有两个子应用的 `baseroute` 配置成了同一个值,会发生什么?
**答:**
会出现**不确定行为**
1. **路由层面**Vue Router 按数组顺序匹配,第一个通配路由先命中,后面的永远不会被访问
2. **`ChildApp.vue`**`subApps.find(app => route.path.startsWith(app.baseroute))` 返回数组中第一个匹配的配置
3. **结果**:第二个子应用永远加载不到,且不会有任何报错提示
**防御措施:**
```ts
// subApps.ts — 启动时校验
if (import.meta.env.DEV) {
const routes = subApps.map(a => a.baseroute)
const duplicates = routes.filter((r, i) => routes.indexOf(r) !== i)
if (duplicates.length) {
console.error(`[SubApp] 重复的 baseroute: ${duplicates.join(', ')}`)
}
const names = subApps.map(a => a.name)
const dupNames = names.filter((n, i) => names.indexOf(n) !== i)
if (dupNames.length) {
console.error(`[SubApp] 重复的 name: ${dupNames.join(', ')}`)
}
}
```
---
## 五、环境管理与部署
### Q11开发环境和生产环境子应用 URL 不同,如何管理?
**答:**
使用环境变量:
```ts
// src/config/subApps.ts
export const subApps: SubAppConfig[] = [
{
name: 'vue3-app',
// 开发环境用 localhost生产环境用实际部署地址
url: import.meta.env.VITE_VUE3_APP_URL || 'http://localhost:3001/',
baseroute: '/vue3-app',
iframe: true,
keepAlive: true,
routerMode: 'native'
}
]
```
```bash
# .env.development
VITE_VUE3_APP_URL=http://localhost:3001/
# .env.production
VITE_VUE3_APP_URL=https://vue3-app.your-domain.com/
```
这样开发和部署环境自动切换,无需修改代码。
---
### Q12这套架构中如何保证子应用独立部署时不影响其他子应用
**答:**
三个关键点:
1. **独立端口/域名**:每个子应用有自己的 devServer3001、5173和生产域名
2. **独立的 CI/CD**:每个子应用独立构建、独立发版,不耦合
3. **主应用只做路由分发**:主应用不包含子应用的业务逻辑,子应用挂了只影响自己的路由
**故障隔离效果:**
| 场景 | 影响范围 |
|------|----------|
| 子应用 vue2-app 挂了 | 只有 `/child-app/*` 不可用,主应用和 vue3-app 正常 |
| 主应用挂了 | 所有子应用都不可访问(主应用是入口) |
| 子应用 JS 报错 | iframe 沙箱内错误不污染主应用 |
---
## 六、Vue 3 子应用工程化创建(实战完整流程)
### Q13从零创建一个 Vue 3 + Vite 子应用,需要哪几步?
**答:**
完整流程分 **4 个阶段,共 8 步**
| 阶段 | 步骤 | 操作 |
|------|------|------|
| 脚手架 | 1 | `npm create vite@latest microapp-vue3 -- --template vue-ts` |
| 脚手架 | 2 | `npm install && npm install vue-router@4 @micro-zoe/micro-app` |
| 配置改造 | 3 | 改造 `vite.config.ts`(端口/base/CORS/alias/isCustomElement |
| 配置改造 | 4 | 改造 `tsconfig.app.json``@/*` 路径别名) |
| 入口改造 | 5 | 创建 `vite-env.d.ts`micro-app 全局类型声明) |
| 入口改造 | 6 | 改造 `index.html`(独立挂载点 id |
| 入口改造 | 7 | 改造 `main.ts`mount/unmount 生命周期 + 双模式) |
| 业务代码 | 8 | 创建 router + views完成路由通配和页面 |
下面逐一拆解每个阶段的细节和踩坑点。
---
### Q14子应用的 `vite.config.ts` 需要配置哪些关键项?为什么?
**答:**
完整的 Vite 子应用配置文件如下,每个配置项都有明确的原因:
```ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
// ① 路径别名 — 与 tsconfig 的 paths 配合,支持 @/ 导入
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
plugins: [
vue({
template: {
compilerOptions: {
// ② 自定义元素注册 — 避免 Vue 对 <micro-app> 标签报警告
isCustomElement: (tag) => /^micro-app/.test(tag)
}
}
})
],
// ③ base 路径 — 必须与主应用的 baseroute 一致
base: '/vue3-app/',
server: {
// ④ 端口 — 子应用独立端口,避免与主应用(8080)或其他子应用冲突
port: 3001,
// ⑤ CORS 头 — 主应用跨域加载子应用资源必须配置
headers: {
'Access-Control-Allow-Origin': '*'
}
}
})
```
| 配置项 | 必须? | 不配置的后果 |
|--------|--------|-------------|
| `resolve.alias` | 推荐 | `import xxx from '@/views/Home.vue'` 无法解析 |
| `isCustomElement` | 推荐 | Vue 警告 `Failed to resolve component: micro-app` |
| `base` | **必须** | 静态资源 404路由跳转路径错乱 |
| `port` | 推荐 | 默认 5173可能与已有子应用冲突 |
| `CORS` | **必须** | 主应用无法加载子应用资源(跨域被浏览器拦截) |
---
### Q15为什么子应用的 `index.html` 挂载点不能用 `#app`
**答:**
这是一个**两个项目共用同一个 DOM 根节点**的经典问题。
```
主应用 index.html <div id="app"></div>
子应用 index.html <div id="app"></div> ← ❌ 冲突!
```
在 iframe 沙箱模式下,子应用运行在独立 iframe 中,理论上 id 重复不会直接冲突。但有两个隐患:
1. **语义混乱** — 维护者无法从 id 区分是主应用还是子应用的根节点
2. **非 iframe 模式风险** — 如果未来改用 with 沙箱(禁用 iframe两个 `#app` 同时存在于同一个 document`mount('#app')` 会挂载到错误的节点,导致页面错乱
**正确做法:** 使用语义化的独立命名:
```html
<!-- 子应用 index.html -->
<div id="microapp-vue3-root"></div>
```
```ts
// 子应用 main.ts — 挂载点保持一致
app.mount('#microapp-vue3-root')
// 卸载时清理
function unmount() {
const root = document.getElementById('microapp-vue3-root')
if (root) {
root.innerHTML = ''
}
}
```
**命名建议:** `<项目名>-root`,如 `microapp-vue3-root``portal-root``dashboard-root`
---
### Q16子应用的 `main.ts` 中 mount/unmount 生命周期是如何设计的?
**答:**
核心模式是 **双模式入口**:微前端环境导出生命周期,独立运行直接挂载。
```ts
import { createApp } from 'vue'
import type { App as VueApp } from 'vue' // ① type-only import
import App from './App.vue'
import router from './router'
let app: VueApp | null = null
function mount(): void {
app = createApp(App)
app.use(router)
app.mount('#microapp-vue3-root') // ② 独立挂载点
}
function unmount(): void {
if (app) {
app.unmount() // ③ Vue 3 销毁
const root = document.getElementById('microapp-vue3-root')
if (root) root.innerHTML = '' // ④ 清空 DOM 残留
app = null
}
}
// ⑤ 环境判断 — 决定使用哪种模式
if (window.__MICRO_APP_ENVIRONMENT__) {
// 微前端环境:导出生命周期给 micro-app 框架调用
window[`micro-app-${window.__MICRO_APP_NAME__}`] = { mount, unmount }
} else {
// 独立运行:直接挂载,方便开发调试
mount()
}
```
**设计要点:**
| 要点 | 说明 |
|------|------|
| `type-only import` | `App` 类型从 `vue` 导入必须用 `import type`,否则生产构建报 `MISSING_EXPORT` 错误 |
| `app: VueApp \| null` | 用闭包变量持有实例引用mount 创建、unmount 销毁 |
| `unmount()` 清理 | Vue 3 的 `app.unmount()` 不会清除 DOM需手动 `innerHTML = ''` |
| 微前端环境判断 | `window.__MICRO_APP_ENVIRONMENT__` 是 micro-app 注入的,框架级保证 |
| 生命周期导出 | `window['micro-app-' + name]` 是 micro-app 约定的命名规则 |
---
### Q17为什么 `import { App } from 'vue'` 在生产构建时会报错?如何修复?
**答:**
这是 Vite 8 / Rolldown 构建时的 **类型导出与运行时分包** 问题。
**报错信息:**
```
[MISSING_EXPORT] "App" is not exported by "node_modules/vue/dist/vue.runtime.esm-bundler.js"
```
**根因分析:**
Vue 3 的 `App` 是一个 **TypeScript 接口**不是运行时值。在生产构建中Vue 的 `esm-bundler` 版本只导出运行时 API`createApp``ref``computed` 等),不导出类型。
```ts
// ❌ 错误写法App 既是值又是类型,构建工具无法区分
import { createApp, App as VueApp } from 'vue'
// ✅ 正确写法:用 import type 明确告知这仅用于类型标注
import { createApp } from 'vue'
import type { App as VueApp } from 'vue'
```
**为什么会混淆?**
- `vue-tsc --noEmit` 类型检查能通过TypeScript 知道 `App` 是类型)
- `vite build` 构建失败Rolldown 试图在运行时模块中找 `App` 的值导出)
**核心原则:** 凡是只用于 TypeScript 类型标注的导入,一律使用 `import type`
```ts
import type { App } from 'vue' // ✅ Vue 实例类型
import type { Router } from 'vue-router' // ✅ 路由实例类型
import type { DefineComponent } from 'vue' // ✅ 组件类型
```
---
### Q18`vite-env.d.ts` 中需要声明哪些 micro-app 相关的全局类型?
**答:**
micro-app 框架会在子应用的 `window` 上注入 4 个关键全局变量,必须显式声明类型:
```ts
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
// micro-app 全局类型声明
interface Window {
/** 是否运行在微前端环境中 */
__MICRO_APP_ENVIRONMENT__?: boolean
/** 应用名称(等于主应用中配置的 name */
__MICRO_APP_NAME__?: string
/** 基础路由(等于主应用中配置的 baseroute */
__MICRO_APP_BASE_ROUTE__?: string
/** 公共路径(用于拼接静态资源路径) */
__MICRO_APP_PUBLIC_PATH__?: string
}
```
| 全局变量 | 谁注入 | 典型用途 | 示例值 |
|----------|--------|----------|--------|
| `__MICRO_APP_ENVIRONMENT__` | micro-app | 判断是否在微前端环境,决定导出生命周期还是直接挂载 | `true` / `undefined` |
| `__MICRO_APP_NAME__` | micro-app | 拼接生命周期导出 key`window['micro-app-' + name]` | `'vue3-app'` |
| `__MICRO_APP_BASE_ROUTE__` | micro-app | Vue Router 的 `base` 参数 | `'/vue3-app'` |
| `__MICRO_APP_PUBLIC_PATH__` | micro-app | 拼接静态资源 URL | `'http://localhost:3001/'` |
**面试延伸:** 为什么要声明为 `?:`(可选)而不是必填?
> 因为子应用需要同时支持**独立运行**(直接 `npm run dev` 访问 3001 端口)和**微前端环境**(通过主应用 8080 端口加载)。独立运行时这些变量不存在,所以必须标记为可选。
---
### Q19`tsconfig.app.json` 中 `@/*` 路径别名和 Vite 的 `resolve.alias` 有什么关系?为什么需要两处配置?
**答:**
这对应了**类型系统**和**构建系统**两条独立管线:
```
tsconfig.app.json 的 paths vite.config.ts 的 resolve.alias
│ │
▼ ▼
TypeScript 编译器 Vite/Rolldown 打包器
(类型检查阶段) (构建阶段)
│ │
└───────────┬───────────────────────────────┘
开发者写 import xxx from '@/views/Home.vue'
TypeScript 能找对类型 ✓
Vite 打包能找到文件 ✓
```
**配置对比:**
| 位置 | 配置 | 作用 |
|------|------|------|
| `tsconfig.app.json` | `"paths": { "@/*": ["./src/*"] }` + `"baseUrl": "."` | 让 TS 理解 `@/` 指向 `src/` |
| `vite.config.ts` | `resolve: { alias: { "@": fileURLToPath(...) } }` | 让 Vite 构建时正确解析路径 |
**常见错误:**
| 现象 | 原因 | 修复 |
|------|------|------|
| TS 报红波浪线但能跑 | 只配了 Vite alias没配 tsconfig paths | 补充 `tsconfig.app.json` 的 paths |
| `vue-tsc --noEmit` 报错但能构建 | 同上tsconfig 缺 paths | 同上 |
| 构建报错找不到模块 | 只配了 tsconfig paths没配 Vite alias | 补充 `vite.config.ts` 的 resolve.alias |
---
### Q20子应用开发完成后如何验证它作为 micro-app 子应用的可用性?
**答:**
三层验证策略:
**第一层:类型检查**
```bash
npx vue-tsc --noEmit
```
- 验证所有类型标注正确
- 检查 micro-app 全局变量的类型使用
- 确保 `import type` 用在正确的位置
**第二层:独立构建**
```bash
npm run build
```
- 验证 Vite 生产构建能通过(暴露 `import type` 问题)
- 输出 `dist/` 产物,确认资源路径基于 `/vue3-app/` 正确生成
- 典型坑:`"App" is not exported` → 修复 `import type`
**第三层:联调验证**
```bash
# 终端 1 — 启动子应用(独立模式)
cd microapp-vue3 && npm run dev
# 访问 http://localhost:3001 — 确认页面正常、路由跳转正常
# 终端 2 — 启动主应用
cd microapp-main && npm run dev
# 访问 http://localhost:8080/vue3-app — 确认通过主应用加载正常
```
**验证清单:**
| 检查项 | 通过标准 |
|--------|----------|
| 独立运行首页 | `localhost:3001` 展示 Home 页面,路由跳转正常 |
| 独立运行 about | `localhost:3001/about` 展示 About 页面 |
| 主应用加载 | `localhost:8080/vue3-app` 通过主应用正常渲染子应用 |
| 子应用内路由 | `localhost:8080/vue3-app/about` 展示子应用的 About 页 |
| 环境检测 | Home 页面显示"🟢 微前端环境"而非"🔵 独立运行" |
| 控制台无报错 | 无 CORS 错误、无 MIME type 错误、无 Vue 警告 |
---
### Q21Vite 8 项目中 `erasableSyntaxOnly` 和 `noUnusedLocals` 配置对 micro-app 子应用开发有什么影响?
**答:**
Vite 8 的默认 `tsconfig.app.json` 引入了更严格的 TypeScript 检查:
```json
{
"compilerOptions": {
"noUnusedLocals": true, // 未使用的局部变量报错
"noUnusedParameters": true, // 未使用的参数报错
"erasableSyntaxOnly": true // 仅允许可擦除的 TS 语法
}
}
```
| 选项 | 含义 | 对子应用开发的影响 |
|------|------|-------------------|
| `erasableSyntaxOnly` | 只允许不产生运行时痕迹的 TS 语法 | **强制使用 `import type`** — 因为普通的 `import { App }` 会在 JS 输出中留下导入语句,如果模块不存在该导出,会运行时/构建时出错 |
| `noUnusedLocals` | 未使用的局部变量报错 | 生命周期回调参数如果未使用,需用 `_` 前缀(如 `_e` |
| `noUnusedParameters` | 未使用的函数参数报错 | 同上,回调参数必须消费或以 `_` 开头 |
**`erasableSyntaxOnly` 与 micro-app 的关系:**
这个选项恰好帮我们避免了 Q17 中的构建错误 — 如果开启了 `erasableSyntaxOnly`,你写的 `import { App } from 'vue'` 会在类型检查阶段就报错(因为 `App` 是类型,应使用 `import type`),从而**在开发阶段就暴露了生产构建才会出现的问题**。
这是一个很好的 **"左移"工程实践** — 类型检查期间就捕获构建期错误。
---
### Q22子应用路由中使用 `window.__MICRO_APP_BASE_ROUTE__ || '/'` 的兜底逻辑,在什么场景下会用到 `'/'`
**答:**
`'/'` 作为 fallback 值的设计覆盖了 **两个关键场景**
**场景一:独立开发调试**
```bash
npm run dev
# 子应用独立运行在 localhost:3001
# window.__MICRO_APP_BASE_ROUTE__ === undefined
# router base → '/' ← 走兜底
# 访问 /、/about 正常工作
```
**场景二:单元测试 / E2E 测试**
```ts
// 测试文件中直接创建 router
const router = createRouter({
history: createWebHistory('/'),
routes
})
// 不需要 mock __MICRO_APP_BASE_ROUTE__
```
**如果不写兜底会怎样?**
```ts
// ❌ 没有兜底
const router = createRouter({
history: createWebHistory(window.__MICRO_APP_BASE_ROUTE__)
// 独立运行时createWebHistory(undefined) → 可能报错或行为异常
})
```
**设计哲学:** 子应用不应该依赖微前端环境才能运行。`'/'` 兜底体现了 **"独立可运行"** 的工程原则 — 子应用脱离主应用后依然是一个完整的、可访问的网站。
---
### Q23在子应用中展示"当前运行环境"指示器有什么实用价值?
**答:**
`Home.vue` 中我们设计了环境检测展示:
```vue
<p v-if="isMicroApp">🟢 当前运行在 <strong>micro-app 微前端环境</strong> </p>
<p v-else>🔵 当前<strong>独立运行</strong>模式</p>
```
这个看似简单的小功能有 4 个实用价值:
| 价值 | 说明 |
|------|------|
| **调试可视化** | 一眼看出当前是通过主应用加载还是独立访问,快速定位"为什么行为不同"的问题 |
| **新人友好** | 新加入的开发者不需要理解整个架构就能知道当前运行模式 |
| **排查跨环境 Bug** | "在独立运行正常、主应用里出问题" → 有了指示器更容易复现和对比 |
| **演示/汇报** | 向 Leader 或面试官展示时,直观证明微前端架构确实在工作 |
**延伸设计:** 可以在指示器中进一步展示:
```ts
// 环境信息面板
const envInfo = computed(() => ({
运行模式: isMicroApp ? '微前端环境' : '独立运行',
baseroute: window.__MICRO_APP_BASE_ROUTE__ || '/',
应用名称: window.__MICRO_APP_NAME__ || '独立运行',
实际路径: window.location.pathname
}))
```
---
### Q24这次 Vue3 子应用创建过程中遇到了什么实际 bug是如何修复的
**答:**
实际遇到了**一个构建错误**
**Bug 表现:**
```bash
npm run build
# ❌ Build failed with 1 error:
# [MISSING_EXPORT] "App" is not exported by
# "node_modules/vue/dist/vue.runtime.esm-bundler.js"
# at src/main.ts:1:21
```
**Bug 现场代码:**
```ts
// src/main.ts — 第 1 行
import { createApp, App as VueApp } from 'vue'
// ^^^ 这是类型,不是值
```
**排查过程:**
| 排查步骤 | 结果 | 判断 |
|----------|------|------|
| `vue-tsc --noEmit` | ✅ 零错误 | 类型检查通过,排除了类型标注问题 |
| 检查 `node_modules/vue/dist/` | `vue.runtime.esm-bundler.js` 存在 | 排除安装问题 |
| 在 `vue` 包中搜索 `App` 导出 | 只找到 `interface App`,无运行时导出 | **确认:`App` 是类型,不是值** |
| 检查 `tsconfig.app.json` | `"erasableSyntaxOnly": true` 但未触发报错 | TS 认为用法正确(作为类型的别名) |
**根因:** Vite 8 的 Rolldown 打包器在生产构建时使用 Vue 的 `esm-bundler` 版本,该版本只导出运行时 API。`App` 是 TypeScript 接口,在 JS 运行时不存在。`import { App }` 让 Rolldown 尝试从运行时模块中找 `App`,找不到就报 `MISSING_EXPORT`
**修复:** 将值导入和类型导入分开:
```ts
import { createApp } from 'vue' // 值导入
import type { App as VueApp } from 'vue' // 类型导入 — Rolldown 会删除此行
```
**修复后验证:**
```bash
npx vue-tsc --noEmit # ✅ 类型检查通过
npx vite build # ✅ 构建成功,产物正常
```
**教训总结:**
| 经验 | 说明 |
|------|------|
| `vue-tsc` 通过 ≠ 构建通过 | 类型检查器不模拟打包器的模块解析 |
| `import type` 不是可选的 | 在 Vite 8 项目中使用 Vue 类型,必须用 type-only import |
| `erasableSyntaxOnly` 是好配置 | 理论上能提前发现此类问题(但别名场景未覆盖) |
---
## 七、加载状态与沙箱机制
### Q25`<micro-app>` 标签加载子应用时的 Loading 效果是如何实现的?
**答:**
利用了 micro-app 自定义元素的 **默认插槽slot机制** 配合框架的生命周期,是一个零配置的内置能力。
**核心代码:**
```html
<micro-app
name="vue3-app"
url="http://localhost:3001/"
baseroute="/vue3-app"
@created="onCreated"
@mounted="onMounted"
>
<!-- 🔑 这里的内容就是 Loading 占位 -->
<div class="loading-placeholder">
<div class="spinner"></div>
<p>正在加载子应用 {{ currentApp.name }}...</p>
<p class="loading-hint">
请确保子应用已启动:<code>{{ currentApp.url }}</code>
</p>
</div>
</micro-app>
```
**工作原理(时间线):**
```
时间 →
──────────────────────────────────────────────────────────────
① micro-app 标签插入 DOM
│ 此时 slot 内容立即渲染:
│ ┌─────────────────────────┐
│ │ ⟳ 正在加载子应用... │
│ │ 请确保子应用已启动 │
│ └─────────────────────────┘
② fetch(url) — 请求子应用 HTML
│ [Loading 持续展示]
③ @created 触发 — HTML 已下载,资源正在加载
│ [Loading 持续展示]
④ @beforemount 触发 — 沙箱准备就绪
│ [Loading 持续展示]
⑤ 子应用 JS 执行完毕,根组件挂载完成
│ micro-app 框架做 DOM 替换:
│ 移除 slot 内容 → 插入子应用渲染结果
⑥ @mounted 触发 — 子应用完全渲染
┌─────────────────────────┐
│ 子应用的实际页面内容 │
└─────────────────────────┘
```
| 阶段 | DOM 状态 | 用户看到 |
|------|----------|----------|
| `<micro-app>` 插入 DOM | 框架初始化 | 插槽内容Loading |
| `fetch` 请求中 | 插槽内容 | Loading + spinner 动画 |
| `created` 触发 | 插槽内容 | Loading |
| `beforemount` 触发 | 插槽内容 | Loading |
| 子应用挂载完成 → `mounted` | **插槽被替换为子应用内容** | 子应用页面 |
**为什么不需要手动控制 Loading 的显示/隐藏?**
这是 `<micro-app>` 作为 Web Component 的**原生插槽机制**
```
<micro-app> 的 Shadow DOM 内部逻辑:
if (子应用未就绪) {
渲染 <slot></slot> ← 显示用户传入的 Loading 内容
} else {
渲染子应用的 DOM ← 替换掉 slot
}
```
`mounted` 生命周期触发时micro-app 框架自动用子应用的实际 DOM 替换插槽内容。开发者**不需要手动写 `v-if` / `v-show`** 来控制 loading 和子应用之间的切换。
**Loading 设计的最佳实践:**
| 要素 | 示例 | 作用 |
|------|------|------|
| **加载动画** | CSS spinner 旋转圈 | 视觉反馈,安抚用户等待 |
| **应用标识** | `{{ currentApp.name }}` | 多个子应用时,用户知道正在加载哪个 |
| **调试信息** | `{{ currentApp.url }}` | 开发阶段帮助排查"子应用是否已启动" |
| **错误兜底** | `@error="onError"` | 加载失败时在回调中做降级处理 |
**常见优化:**
```html
<micro-app ... @error="onError">
<!-- 正常加载 -->
<div class="loading-placeholder">
<div class="spinner"></div>
<p>正在加载子应用...</p>
</div>
<!-- 加载失败(可在 error 回调中切换状态来展示) -->
<!-- 这里需要用 Vue 响应式变量控制,因为 slot 的可见性由 micro-app 框架管理 -->
</micro-app>
```
```ts
// error 回调中可以做重试或降级
function onError(error: Error) {
console.error(`[ChildApp] ${currentApp.value.name} 加载失败:`, error)
// 可在此处设置 UI 状态提示用户刷新
}
```
---
### Q26为什么 Vite 子应用必须用 `iframe: true`,而 Webpack 子应用可以用 `iframe: false`
**答:**
根因是两种构建工具产出的 **JS 格式** 不同,导致 micro-app 的 with 沙箱对 Vite 输出的 ES Module 无法执行。
---
#### Webpack 子应用with 沙箱可执行
**Webpack 的代码输出:**
```html
<!-- Webpack dev/build 输出的 HTML -->
<script src="/js/chunk-vendors.js"></script> <!-- 普通脚本 -->
<script src="/js/app.js"></script> <!-- 普通脚本 -->
```
```js
// Webpack 打包后的 JS 格式UMD / IIFE
(function(module, exports, __webpack_require__) {
// 所有模块代码都在一个函数闭包里
// 没有 import/export 语句!
var Vue = __webpack_require__('vue')
// ...
})
```
**关键特征:** Webpack 打包产物是**普通脚本Regular Script**,不包含 `import`/`export` 语句。
**with 沙箱的工作方式:**
```
micro-app with 沙箱执行流程:
1. 获取子应用的 JS 代码(字符串)
2. 用 with(window.proxy) 创建一个作用域链
3. 通过 new Function(code) 执行 JS
伪代码:
const sandboxCode = `
with (this.proxyWindow) {
${子应用的JS代码} ← 普通 JS可以执行 ✅
}
`
const fn = new Function('window', 'document', sandboxCode)
fn(proxyWindow, proxyDocument)
```
因为 Webpack 产出的 JS 是普通的 IIFE/UMD 代码,`new Function()` 完全可以执行它。Proxy 劫持了 `window.document` 等全局变量的访问,实现了沙箱隔离。
---
#### Vite 子应用with 沙箱不可执行(必须用 iframe
**Vite devServer 的代码输出:**
```html
<!-- Vite devServer 返回的 HTML -->
<script type="module" src="/src/main.ts"></script>
```
```js
// Vite 不对源码打包,直接输出 ES Module
import { createApp } from '/node_modules/.vite/deps/vue.js?v=abc123'
import App from '/src/App.vue'
import router from '/src/router/index.ts'
// ↑ 全是 import/export 语句!
```
**关键特征:** Vite devServer 输出的是 **ES Module`<script type="module">`**,包含大量 `import`/`export` 语句。
**with 沙箱为什么失败:**
```
micro-app with 沙箱执行流程:
1. 获取子应用的 JS 代码(字符串)
2. 用 with(window.proxy) 创建作用域
3. 通过 new Function(code) 执行 JS
伪代码:
const sandboxCode = `
with (this.proxyWindow) {
import { createApp } from '...' ← ❌ SyntaxError!
// ↑
// import 语句只能在 Module 上下文中使用
// new Function() 创建的是 Script 上下文,不是 Module 上下文
}
`
const fn = new Function('window', 'document', sandboxCode)
fn(proxyWindow, proxyDocument)
```
**报错本质:**
```
浏览器 JS 规范中有两种脚本类型:
┌─────────────────────────────────────────────────────────┐
│ Script普通脚本 │ ModuleES 模块) │
├───────────────────────────────┼─────────────────────────┤
│ <script src="..."> │ <script type="module"> │
│ 不能用 import/export │ 可以用 import/export │
│ 顶层 this = window │ 顶层 this = undefined │
│ new Function() 创建的 │ ❌ 没有 new Module() │
│ 就是这个类型 │ 这个 API 不存在! │
└───────────────────────────────┴─────────────────────────┘
```
**核心矛盾:** JavaScript 规范里**没有 `new Module()` 这样的 API**。`new Function()` 创建的函数永远运行在 Script 上下文,永远无法解析 `import`/`export`。这是 ECMAScript 规范层面的限制,不是 micro-app 的 bug。
---
#### iframe 沙箱为什么能解决
```
┌─────────────────────────────────────────────┐
│ 主应用页面 (8080) │
│ │
│ <micro-app iframe="true" url="...3001"> │
│ │ │
│ │ 创建 iframe │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ iframe (沙箱) │ │
│ │ │ │
│ │ <html> │ │
│ │ <script type="module" src="..."> │ │
│ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ │ │
│ │ iframe 是浏览器原生文档环境 │ │
│ │ 天然支持 ES Module │ │
│ │ </html> │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
iframe 是浏览器原生的独立文档环境,拥有完整的浏览器能力——包括 `<script type="module">` 的解析和执行。Vite 的所有 ES Module 在 iframe 中可以正常工作,因为浏览器内核直接处理模块加载。
---
#### 完整对比表
| 维度 | Webpack (with 沙箱) | Vite (iframe 沙箱) |
|------|---------------------|--------------------|
| **产物格式** | UMD / IIFE 普通脚本 | ES Module |
| **`<script>` 标签** | `<script src="...">` | `<script type="module" src="...">` |
| **含 import/export** | ❌ 不含有 | ✅ 大量 |
| **`new Function()` 能执行?** | ✅ 可以(普通 JS | ❌ 不行(模块语法) |
| **沙箱方案** | with + Proxy 代理 window | 浏览器原生 iframe |
| **性能** | 较快(无额外渲染上下文) | 略慢(需创建 iframe |
| **隔离性** | 中等Proxy 可被绕过) | 强(浏览器原生隔离) |
| **Vite 兼容** | ❌ 不兼容 | ✅ 兼容 |
| **配置** | `iframe: false`(默认) | `iframe: true`(必须) |
---
#### 深入追问Vite 生产构建后也不能用 with 沙箱吗?
**答:即使生产构建也不行。**
Vite 生产构建虽然打包了代码,但内部仍然使用 **动态 import****ES Module 语法**
```js
// Vite 生产构建产物(简化)
const __vite__mapDeps = (i) => {
// 大量 import() 动态导入,用于 code splitting
}
// 仍然是 Module 格式
```
而且 Vite 的子应用在生产环境下图片、字体等静态资源的路径依赖 `base` 配置和 ES Module 的 `import.meta.url`,这些在 with 沙箱的 `new Function()` 上下文中同样不可用。
**结论:只要是 Vite 项目,无论开发还是生产,一律 `iframe: true`。**
---
#### 延伸Webpack 子应用也能用 iframe 吗?
**答:可以,但不是必须。**
Webpack 子应用用 with 沙箱就能正常工作,用 iframe 也行——只是在不需要的情况下创建额外的 iframe 略微浪费资源。选择逻辑:
```
子应用构建工具是什么?
├── Vite → 必须 iframe: true
new Function() 无法处理 ES Module
└── Webpack → iframe: false 即可
UMD/IIFE 在 new Function() 中正常执行)
└── 但如果对隔离性要求极高
→ 也可以设 iframe: true牺牲少量性能换更强隔离
```
---
## 八、子应用路由改造与业务兼容
### Q27接入 micro-app 后,子应用的路由行为发生了哪些变化?原本基于 `route.name` 的业务判断该如何改造?
**答:**
这是一个子应用迁移中最容易忽视但影响面最大的问题。结论先行:**`route.name``route.path`(子应用内部值)不变,但 `window.location``route.fullPath` 变了。所有直接操作浏览器 URL 的代码必须改为使用 Vue Router API。**
---
#### 一、路由行为变化全景图
假设子应用 `vue3-app` 有一个 `/dashboard` 路由,用户通过主应用访问 `localhost:8080/vue3-app/dashboard`
```
┌─────────────────────────────────────────────────────────────────┐
│ 浏览器地址栏 │
│ localhost:8080/vue3-app/dashboard │
└──────────────────────────────┬──────────────────────────────────┘
主应用 Vue Router (createWebHistory)
匹配 /vue3-app/:page*
渲染 ChildApp.vue → 加载 <micro-app>
micro-app 框架: baseroute 剥离
去掉 /vue3-app 前缀
子应用内部 Vue Router (createWebHistory)
收到路径: /dashboard
匹配路由: { name: 'dashboard', path: '/dashboard' }
```
**关键结论:子应用的 Vue Router 在内部看到的是一个"干净"的路径baseroute 已剥离),所有 route 对象的 `name`、`path`、`params`、`query` 都和独立运行时完全一致。**
但是 — `window.location` 是浏览器层面的,不受 Vue Router 控制,所以它不变。
---
#### 二、各路由 API 的实际值对比
假设子应用 `/dashboard` 路由定义如下:
```ts
// 子应用 router
const routes = [
{
path: '/dashboard',
name: 'dashboard',
meta: { requiresAuth: true },
component: () => import('@/views/Dashboard.vue')
}
]
```
用户在浏览器访问:`http://localhost:8080/vue3-app/dashboard?id=1`
| API | 独立运行时的值 | 微前端环境中的值 | 是否变化 |
|-----|--------------|-----------------|---------|
| `route.name` | `'dashboard'` | `'dashboard'` | ✅ 不变 |
| `route.path` | `'/dashboard'` | `'/dashboard'` | ✅ 不变 |
| `route.params` | `{}` | `{}` | ✅ 不变 |
| `route.query` | `{ id: '1' }` | `{ id: '1' }` | ✅ 不变 |
| `route.meta` | `{ requiresAuth: true }` | `{ requiresAuth: true }` | ✅ 不变 |
| `route.fullPath` | `'/dashboard?id=1'` | ⚠️ **取决于 micro-app 版本** | ⚠️ 可能变化 |
| `route.matched` | `[dashboardRoute]` | `[dashboardRoute]` | ✅ 不变 |
| `window.location.pathname` | `'/dashboard'` | **`'/vue3-app/dashboard'`** | ❌ 变了! |
| `window.location.href` | `'http://localhost:3001/dashboard?id=1'` | **`'http://localhost:8080/vue3-app/dashboard?id=1'`** | ❌ 变了! |
| `window.location.origin` | `'http://localhost:3001'` | **`'http://localhost:8080'`** | ❌ 变了! |
---
#### 三、典型业务场景的改造方法
##### 场景 1基于 `route.name` 控制侧边栏/菜单高亮
这是最常见也最无需担心的场景。
```ts
// ✅ 这段代码在微前端环境中完全不需要改动
import { useRoute } from 'vue-router'
const route = useRoute()
// 判断当前是否在 dashboard 页面
const isDashboard = computed(() => route.name === 'dashboard')
// 侧边栏菜单项
const menuItems = [
{ title: '首页', routeName: 'home', icon: 'home' },
{ title: '控制台', routeName: 'dashboard', icon: 'dashboard' },
]
// 当前激活的菜单项
const activeMenu = computed(() => menuItems.find(item => item.name === route.name))
```
**原因:** `route.name` 是子应用内部路由器解析出来的,不经过浏览器 URL。baseroute 对它完全透明。
---
##### 场景 2基于 `route.name` 做导航守卫
```ts
// ✅ 同样不需要改动
router.beforeEach((to, from) => {
if (to.name === 'dashboard' && !isLoggedIn()) {
return { name: 'login', query: { redirect: to.path } }
}
})
```
**原因:** 导航守卫接收的 `to`/`from` 是子应用内部路由解析结果,`name``path` 都已被 micro-app 处理过。
---
##### 场景 3❌ 直接读取 `window.location.pathname` 做业务判断
这是**最容易出 bug 的写法**,必须改造。
```ts
// ❌ 错误:独立运行能工作,微前端环境判断失败
function getActiveTab() {
if (window.location.pathname === '/dashboard') {
return 'dashboard' // → 微前端环境pathname 是 '/vue3-app/dashboard',永不匹配
}
if (window.location.pathname.startsWith('/user')) {
return 'user'
}
return 'home'
}
// ✅ 正确:使用 Vue Router 的 route 对象
import { useRoute } from 'vue-router'
function getActiveTab() {
if (route.name === 'dashboard') {
return 'dashboard' // → 无论独立运行还是微前端,永远正确
}
if (route.path.startsWith('/user')) {
return 'user'
}
return 'home'
}
```
---
##### 场景 4❌ 使用 `window.location.href` 做页面跳转
```ts
// ❌ 错误:跳过 Vue Router且跳出微前端命名空间
function goToDashboard() {
window.location.href = '/dashboard'
// 微前端环境实际跳转: localhost:8080/dashboard
// 但主应用 /dashboard 可能是主应用的页面!子应用的 dashboard 实际路径是 /vue3-app/dashboard
}
// ✅ 正确:使用 Vue Router
import { useRouter } from 'vue-router'
const router = useRouter()
function goToDashboard() {
router.push({ name: 'dashboard' }) // ← 最推荐:命名路由
// 或 router.push('/dashboard') // ← 也可以micro-app 会自动加 baseroute 前缀
}
```
**为什么 `router.push('/dashboard')` 也能正常工作?**
```
子应用代码: router.push('/dashboard')
子应用 Vue Router: 解析路径 → /dashboard
micro-app 拦截 pushState
自动拼接 baseroute: /vue3-app + /dashboard = /vue3-app/dashboard
浏览器地址栏: localhost:8080/vue3-app/dashboard ✅
```
micro-app 通过**劫持 `history.pushState` / `replaceState`** 实现了路由的自动前缀拼接。
---
##### 场景 5❌ 使用 `window.open()` 打开子应用内页面
```ts
// ❌ 错误
function openDetail(id: string) {
window.open(`/detail/${id}`)
// 微前端环境实际打开: localhost:8080/detail/123
// 主应用无此路由 → 404
}
// ✅ 正确方案 1使用 Vue Router
function openDetail(id: string) {
const url = router.resolve({ name: 'detail', params: { id } }).href
window.open(url) // micro-app 已处理路径拼接
}
// ✅ 正确方案 2手动拼接 baseroute
function openDetail(id: string) {
const base = window.__MICRO_APP_BASE_ROUTE__ || ''
window.open(`${base}/detail/${id}`)
}
```
---
##### 场景 6❌ 面包屑基于 `window.location` 解析
```ts
// ❌ 错误
function getBreadcrumbs() {
const segments = window.location.pathname.split('/').filter(Boolean)
// 微前端环境: segments = ['vue3-app', 'dashboard', 'overview']
// 独立运行: segments = ['dashboard', 'overview']
// → 第一段不一致,面包屑不一致!
return segments.map((seg, i) => ({
label: seg,
path: '/' + segments.slice(0, i + 1).join('/')
}))
}
// ✅ 正确:基于 route.matched
import { useRoute } from 'vue-router'
function getBreadcrumbs() {
return route.matched
.filter(r => r.meta?.title)
.map(r => ({
label: r.meta.title as string,
path: r.path
}))
}
// 路由定义中配置 meta.title
// { path: '/dashboard', name: 'dashboard', meta: { title: '控制台' } }
```
---
##### 场景 7❌ 接口回调 URL / OAuth 重定向使用硬编码路径
```ts
// ❌ 错误OAuth 回调写死路径
function login() {
const callbackUrl = 'http://localhost:3001/auth/callback'
// 微前端环境的实际域名是 localhost:8080端口也不对
window.location.href = `https://oauth.example.com?redirect=${callbackUrl}`
}
// ✅ 正确:动态构建完整 URL
function login() {
const base = window.__MICRO_APP_BASE_ROUTE__ || ''
const callbackPath = router.resolve({ name: 'authCallback' }).href
const callbackUrl = window.location.origin + callbackPath
// → localhost:8080/vue3-app/auth/callback
window.location.href = `https://oauth.example.com?redirect=${callbackUrl}`
}
```
---
#### 四、改造原则总结
```
是否涉及 URL 字符串?
┌────────┴────────┐
│ │
否 是
│ │
▼ ▼
✅ 不需要改 这个 URL 是谁用的?
┌──────────┼──────────┐
│ │ │
Vue Router 外部系统 浏览器跳转
router.push 回调URL window.open
route.name fetch location.href
route.path
│ │ │
▼ ▼ ▼
✅ 不改 ⚠️ 需要 ⚠️ 需要
micro-app 手动拼接 使用 router
自动处理 baseroute API
```
**核心口诀:**
| 旧习惯 | 新习惯 | 原因 |
|--------|--------|------|
| `window.location.pathname` | `route.path` / `route.name` | route 对象已被 micro-app 处理,值正确 |
| `window.location.href = '...'` | `router.push(...)` | 走 Vue Routermicro-app 自动加前缀 |
| `window.open('/xxx')` | `window.open(router.resolve(...).href)` | 手动解析出带 baseroute 的正确路径 |
| 手动 `split('/')` 解析路径 | `route.matched` + `meta` | 用路由元信息替代字符串解析 |
| 硬编码完整 URL | `window.location.origin + baseroute + path` | 动态拼接,适配任何环境 |
---
#### 五、一个完整的改造前后对比
```ts
// ============================================
// 改造前 — 充满隐患的旧代码
// ============================================
export function useNavigation() {
// ❌ 基于 window.location 判断
const currentPage = window.location.pathname.split('/').pop()
function navigateTo(page: string) {
// ❌ 硬编码路径
window.location.href = `/${page}`
}
function isActive(pageName: string): boolean {
// ❌ 字符串匹配
return window.location.pathname.includes(pageName)
}
// ❌ 直接拼接 URL 给外部
function getShareUrl(): string {
return `http://localhost:3001${window.location.pathname}`
}
return { currentPage, navigateTo, isActive, getShareUrl }
}
// ============================================
// 改造后 — 微前端安全的代码
// ============================================
import { useRouter, useRoute } from 'vue-router'
export function useNavigation() {
const router = useRouter()
const route = useRoute()
// ✅ 基于 route.name与环境无关
const currentPage = computed(() => route.name as string)
function navigateTo(name: string) {
// ✅ 命名路由跳转micro-app 自动处理 URL
router.push({ name })
}
function isActive(name: string): boolean {
// ✅ 精确匹配 route.name
return route.name === name
}
function getShareUrl(): string {
// ✅ 动态拼接完整路径
const base = (window as any).__MICRO_APP_BASE_ROUTE__ || ''
const fullPath = base + route.fullPath
return window.location.origin + fullPath
}
return { currentPage, navigateTo, isActive, getShareUrl }
}
```
---
## 九、CORS 跨域排错实战
### Q28子应用加载时报 CORS 错误 `No 'Access-Control-Allow-Origin' header is present`,如何排查和修复?
**答:**
这是搭建样式冲突 Demo 时实际遇到的一个排错案例,完整的排查链路非常有教学价值。
---
#### 一、错误现场
**控制台报错:**
```
Access to fetch at 'http://localhost:5173/' from origin 'http://localhost:8080'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
```
**micro-app 错误回调:**
```
[micro-app] 子应用 vue2-app 加载错误:
CustomEvent { isTrusted: false, detail: {...}, type: 'error', ... }
```
**用户看到的现象:** 子应用页面白屏(或一直显示 Loading无法加载。
---
#### 二、排查步骤
```
第 1 步:确认子应用服务器是否在运行
─────────────────────────────────
curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/
→ 200 ✅ 服务器在运行HTML 能正常返回
第 2 步:检查响应头中是否有 CORS 头
─────────────────────────────────
curl -s -I http://localhost:5173/ | grep -i access-control
→ (空) ❌ 没有 Access-Control-Allow-Origin 头!
第 3 步:确认请求的 Origin 和服务器的 Origin 是否不同
─────────────────────────────────
主应用: http://localhost:8080
子应用: http://localhost:5173
→ 不同端口 = 不同源 → 浏览器强制 CORS 检查
第 4 步:定位服务器为什么不发 CORS 头
─────────────────────────────────
当前使用: npx serve -p 5173
→ npx serve 默认不添加 CORS 头 ❌
```
---
#### 三、根因分析
```
┌─────────────────────────────────────────────────────────────┐
│ 浏览器同源策略 │
│ │
│ http://localhost:8080 (主应用) │
│ │ │
│ │ fetch('http://localhost:5173/') │
│ │ 协议相同 (http) │
│ │ 域名相同 (localhost) │
│ │ 端口不同 (8080 ≠ 5173) ← 跨域! │
│ │ │
│ ▼ │
│ http://localhost:5173 (子应用服务器) │
│ │ │
│ │ 响应头中没有 Access-Control-Allow-Origin │
│ │ │
│ ▼ │
│ ❌ 浏览器拦截响应 → 子应用加载失败 │
└─────────────────────────────────────────────────────────────┘
```
**本质:** `npx serve` 是一个简化版静态文件服务器,设计目标是本地预览,不包含 CORS 配置。在微前端场景下,主应用需要通过 `fetch()` 跨域请求子应用的 HTML所以子应用服务器**必须**返回 CORS 头。
**注意:** 即使子应用服务器正常运行、curl 能拿到 HTML浏览器也会拦截跨域请求的响应。这是浏览器安全策略不是网络问题。
---
#### 四、解决方案
**方案一:写一个带 CORS 的 Node.js 服务器Demo 采用)**
```js
// server.js — 最简单的带 CORS 的静态文件服务器
const http = require('http')
const fs = require('fs')
const path = require('path')
const PORT = 5173
http.createServer((req, res) => {
// 每个响应都加上 CORS 头
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', '*')
// 处理预检请求
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
// 返回静态文件
let filePath = req.url === '/' ? '/index.html' : req.url.split('?')[0]
const fullPath = path.join(__dirname, filePath)
fs.readFile(fullPath, (err, data) => {
if (err) {
res.writeHead(404)
res.end('Not Found')
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(data)
}
})
}).listen(PORT, () => {
console.log(`Mock sub-app with CORS: http://localhost:${PORT}/`)
})
```
```bash
node server.js
# → Mock sub-app with CORS: http://localhost:5173/
```
**验证修复:**
```bash
curl -s -I http://localhost:5173/ | grep -i access-control
# → Access-Control-Allow-Origin: * ✅
# → Access-Control-Allow-Methods: GET, POST ✅
```
**方案二Vite 项目自带 CORS推荐用于真实子应用**
```ts
// vite.config.ts
export default defineConfig({
server: {
port: 3001,
headers: {
'Access-Control-Allow-Origin': '*'
}
}
})
```
Vite 的 dev server 本身支持自定义 headers不需要额外写服务器代码。
**方案三Webpack 项目配置 devServer**
```js
// vue.config.js 或 webpack.config.js
module.exports = {
devServer: {
port: 3000,
headers: {
'Access-Control-Allow-Origin': '*'
}
}
}
```
**方案四nginx 反向代理(生产环境)**
```nginx
location /child-app/ {
proxy_pass http://localhost:5173/;
add_header Access-Control-Allow-Origin *;
}
```
---
#### 五、排查 CORS 问题的通用检查清单
| 检查项 | 命令/方法 | 通过标准 |
|--------|-----------|----------|
| 子应用服务器在运行 | `curl -s -o /dev/null -w "%{http_code}" http://localhost:PORT/` | 返回 200 |
| 有 CORS 头 | `curl -s -I http://localhost:PORT/ \| grep -i access-control` | 看到 `Access-Control-Allow-Origin` |
| 主应用和子应用同源? | 比较 `protocol + host + port` | 如果同源则不需要 CORS |
| 浏览器 Network 面板 | F12 → Network → 找到子应用请求 → Response Headers | 有 CORS 相关头 |
| 控制台无 CORS 报错 | F12 → Console | 无 `blocked by CORS policy` |
---
#### 六、常见 CORS 相关误区
| 误区 | 纠正 |
|------|------|
| "curl 能返回 HTML说明服务器没问题" | curl 不执行同源策略浏览器会执行。curl 成功 ≠ 浏览器能加载 |
| "两个 localhost 不同端口也跨域?" | 对,同源 = 协议+域名+端口**完全相同**。不同端口就是跨域 |
| "加了 CORS 头就不安全了" | `Access-Control-Allow-Origin: *` 确实允许任何来源,生产环境建议限定具体域名 |
| "生产环境部署在同域就不需要 CORS" | 正确。如果子应用部署在主应用的子路径(如 `/vue3-app/`),由 nginx 统一转发,浏览器看到的是同源请求,不需要 CORS |
---
## 十、样式冲突深度剖析
### Q29mock-app 的 h2 样式(橙色系)在页面上实际渲染出来了吗?有样式冲突吗?为什么?
**答:**
这是一个考察 CSS 级联Cascade机制的经典问题。答案分两层**冲突确实存在,但不是"谁覆盖谁",而是产生了双方都不想要的"混合样式"。**
---
#### 一、两套样式规则对比
```css
/* 主应用的 h2紫色系*/
h2 {
font-family: 'Microsoft YaHei', sans-serif !important;
font-size: 20px !important;
color: #667eea !important;
border-bottom: 2px solid #667eea !important; /* ← 底边框 */
padding-bottom: 8px !important; /* ← 下内边距 */
margin: 12px 0 !important;
text-transform: none !important;
border-left: none !important; /* ← 明确取消左边框 */
}
/* mock-app 的 h2橙色系*/
h2 {
font-family: 'Georgia', serif !important;
font-size: 32px !important;
color: #ff6b35 !important;
border-left: 6px solid #ff6b35 !important; /* ← 左边框 */
padding-left: 14px !important; /* ← 左内边距 */
margin: 18px 0 !important;
text-transform: uppercase !important;
}
```
---
#### 二、为什么会产生冲突:两个条件同时满足
`subApps.ts` 中 mock-app 的配置:
```ts
{
name: 'mock-app',
iframe: false, // ① DOM 直接嵌入主文档,无隔离
disableScopecss: true, // ② 关闭样式前缀h2 就是全局 h2
}
```
| 条件 | 作用 | 如果没有会怎样 |
|------|------|---------------|
| `iframe: false` | 子应用 HTML 被直接注入主文档的 DOM 树 | `iframe: true` → 独立文档,双方样式物理隔离 |
| `disableScopecss: true` | micro-app 不为子应用 CSS 加 `micro-app[name=mock-app]` 前缀 | 子应用的 `h2` 变成 `micro-app[name=mock-app] h2`,只作用于子应用内部 |
**两者叠加 → 子应用的 `h2 { ... }` 就是整个文档的 `h2 { ... }`。**
---
#### 三、CSS 属性级别的逐条分析
关键认知:**CSS 的覆盖单位是"属性",不是"规则"。** 浏览器不会整体选择"用主应用的 h2"还是"用子应用的 h2",而是逐属性比较。
由于两套规则都使用了 `!important` 且选择器都是 `h2`(特异性相同),**后加载的样式表在冲突属性上胜出**。mock-app 的样式是在主应用样式之后由 micro-app 注入的,所以 mock-app 的属性在冲突时优先。
下面是 h2 元素实际渲染结果的逐属性分析:
```
h2 最终渲染结果:
┌──────────────────────────────────────────────────────┐
│ │
│ ┃ ← 橙色左边框 (border-left: 6px solid #ff6b35) │
│ ┃ 来源: mock-app胜出覆盖了主应用的 none
│ ┃ │
│ ┃ GEORGIA 32px 橙色大写文字 │
│ ┃ 来源: mock-appfont-family/size/color/text-transform 均胜出)│
│ ┃ │
│ ━━━━━ ← 紫色底部边框 (border-bottom: 2px solid #667eea) │
│ 来源: 主应用mock-app 没设这个属性,所以保留) │
│ │
└──────────────────────────────────────────────────────┘
```
| CSS 属性 | 主应用设置的值 | mock-app 设置的值 | 最终渲染 | 胜出方 |
|----------|--------------|------------------|---------|--------|
| `font-family` | `'Microsoft YaHei', sans-serif` | `'Georgia', serif` | **Georgia** | mock后加载 |
| `font-size` | `20px` | `32px` | **32px** | mock |
| `color` | `#667eea` (紫色) | `#ff6b35` (橙色) | **橙色** | mock |
| `text-transform` | `none` | `uppercase` | **uppercase** | mock |
| `margin` | `12px 0` | `18px 0` | **18px 0** | mock |
| `border-left` | `none` | `6px solid #ff6b35` | **6px 橙色** | mock |
| `padding-left` | (未设置) | `14px` | **14px** | mock |
| **`border-bottom`** | `2px solid #667eea` | **(未设置)** | **2px 紫色** | ⚠️ 主应用! |
| **`padding-bottom`** | `8px` | **(未设置)** | **8px** | ⚠️ 主应用! |
---
#### 四、关键洞察:这不是覆盖,是缝合
**这就是样式冲突的本质 — 它不是 A 替代 B而是 A 和 B 搅拌在一起:**
- mock-app 的设计师期望 h2 是一个纯橙色的标题,没有底边框
- 主应用的设计师期望 h2 是一个纯紫色的标题,没有左边框
- 实际渲染:**既有橙色左边框,又有紫色底边框** — 双方都不想要这个效果
```
预期的 mock-app h2: 预期的 main-app h2:
┃ GEORGIA 橙色 大写 Microsoft YaHei 紫色
┃ ━━━━━━━━━━━━━━
实际渲染(冲突产物):
┃ GEORGIA 橙色 大写
┃ ← 橙色左边框 (mock-app 的)
━━━ ← 紫色底边框 (主应用的)
← 双方都不想要的"缝合怪"
```
---
#### 五、为什么 `border-left` mock 胜出而 `border-bottom` 主应用胜出?
这是 CSS Cascade 中最容易误解的点:
```
border-left:
- 主应用明确设置了 border-left: none
- mock-app 明确设置了 border-left: 6px solid #ff6b35
- 两者都声明了同一个属性 → 后加载的 mock-app 胜出 ✅
border-bottom:
- 主应用设置了 border-bottom: 2px solid #667eea
- mock-app 没有设置 border-bottom ← 关键!
- 只有一方声明 → 不存在冲突 → 主应用的设置保留 ✅
```
**CSS 级联规则:** 后加载的规则只会覆盖它**声明了的属性**不会影响前一个规则中它未涉及的属性。mock-app 没有声明 `border-bottom`,所以主应用的 `border-bottom` 永远不会被 mock-app 覆盖。
---
#### 六、如果 `disableScopecss: false`(默认),会怎样?
当样式隔离开启时micro-app 会自动给子应用的 CSS 规则添加作用域前缀:
```css
/* 子应用写的 */
h2 { color: #ff6b35; }
/* micro-app 处理后注入(概念示意)*/
micro-app[name=mock-app] h2 { color: #ff6b35; }
```
此时:
- 主应用的 `h2 { color: #667eea }` 作用于整个文档
- 子应用的 `micro-app[name=mock-app] h2 { color: #ff6b35 }` 只作用于 `<micro-app name="mock-app">` 内部
- 两个规则的选择器不同,各管各的区域,**零冲突**
```
主应用 h2 → color: purple 子应用内部的 h2 → color: orange
(选择性作用在全局 h2) (选择性作用在 micro-app[name=mock-app] 内的 h2)
```
---
#### 七、实际验证方法
在浏览器中打开 `http://localhost:8081/mock-app`,按 F12 打开开发者工具:
```bash
# 1. 检查 h2 元素的实际样式
# F12 → Elements → 选中 mock-app 内的 h2 → Styles 面板
# 你会看到:
# h2 {
# font-family: 'Georgia', serif !important; ← (来自 mock-app)
# font-size: 32px !important; ← (来自 mock-app)
# color: #ff6b35 !important; ← (来自 mock-app)
# border-left: 6px solid #ff6b35 !important; ← (来自 mock-app)
# border-bottom: 2px solid #667eea !important; ← (来自主应用!被 mock-app 覆盖失败)
# ... ← 有些属性来自主应用,有些来自 mock-app
# }
# 2. 被划掉strikethrough的属性说明它被覆盖了
# 3. 注意 border-bottom 没有被划掉 — 因为 mock-app 根本没声明它
```
你会清楚地看到:**同一个 `h2` 的 Computed Style 里,既有主应用的紫色底边框,又有子应用的橙色左边框。**
---
#### 八、总结
| 问题 | 答案 |
|------|------|
| mock-app 的 h2 样式符合预期吗? | **不符合。** mock-app 期望纯橙色无底边框,实际多了紫色底边框 |
| 有样式冲突吗? | **有。** 颜色/字体/大小 mock 胜出(预期内),但底边框主应用残留(预期外) |
| 为什么有冲突? | `iframe: false` + `disableScopecss: true` → 两套 h2 规则作用于同一文档,属性级搅拌 |
| 为什么底边框没被覆盖? | mock-app 没声明 `border-bottom`CSS 级联只覆盖声明的属性,不覆盖未涉及的 |
**核心教训:** CSS 冲突不是"整个规则"的替换,而是"逐属性"的混搭。在 `disableScopecss: true` 时,任何一个子应用未声明而主应用声明了的属性,都会在主应用的默认值上"残留",产生双方都不想要的缝合效果。
---
### Q30子应用之间的样式会互相影响吗keepAlive 在其中扮演了什么角色?
**答:**
会。而且 **keepAlive 会让子应用间的样式冲突更隐蔽、更严重**。我们通过创建两个 mock 子应用进行了实际实验验证。
---
#### 一、实验设计
| 子应用 | 颜色主题 | 关键样式特征 | 端口 | 配置 |
|--------|----------|-------------|------|------|
| `mock-app` | 🟠 橙色 | h2 左边框 `border-left: 6px solid #ff6b35` | 5174 | `iframe: false, disableScopecss: true, keepAlive: true` |
| `mock-app-2` | 🔵 青色 | h2 右边框 `border-right: 5px solid #00838f` | 5175 | 同上 |
两个子应用都用了 `iframe: false` + `disableScopecss: true` + `keepAlive: true`
---
#### 二、实验步骤与结果
```
步骤 1: 访问 http://localhost:8081/mock-app橙色
→ mock-app 的 CSS 被注入主文档
→ 文档中现有: main h2 + mock-app h2
步骤 2: 点击导航回到首页
→ mock-app DOM 隐藏keepAlive
→ ⚠️ 但 mock-app 的 <style> 标签仍在主文档 <head> 中!
步骤 3: 访问 http://localhost:8081/mock-app-2青色
→ mock-app-2 的 CSS 也被注入主文档
→ 文档中现有: main h2 + mock-app h2 + mock-app-2 h2
→ 三个 h2 规则同时存在!
```
**mock-app-2 的 h2 实际渲染结果(预期 vs 实际):**
```
预期mock-app-2 设计师想要):
青色文字右对齐 → 右边青色边框
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━→
实际(被 mock-app 污染后):
┃ ← 橙色左边框mock-app 残留mock-app-2 根本没写 border-left
┃ 青色文字右对齐 → 右边青色边框mock-app-2 本意)
┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━→
┃ ← 同时存在橙色左 + 青色右,双方都不想要!
```
---
#### 三、keepAlive 是"帮凶"
```
无 keepAlive 时:
访问 mock-app → CSS 注入
切换到首页 → mock-app 被销毁CSS 被移除 ✅
访问 mock-app-2 → 只有 mock-app-2 的 CSS ✅
→ 无残留问题
有 keepAlive 时:
访问 mock-app → CSS 注入
切换到首页 → mock-app DOM 隐藏CSS 保留在 <head> ❌
访问 mock-app-2 → mock-app 的 CSS + mock-app-2 的 CSS 共存 ❌
→ 前一个子应用的样式污染了后一个子应用!
```
**为什么会这样?**
micro-app 的 keepAlive 机制只负责隐藏/恢复子应用的 DOM**不会去 `<head>` 中移除 `<style>` 标签**。这本身是性能优化(下次恢复时不需要重新注入 CSS但在 `disableScopecss: true` 的场景下,这些残留的全局样式就成了污染源。
---
#### 四、冲突范围矩阵(完整版)
| 配置 | 主应用↔子应用 | 子应用↔子应用 | 原理 |
|------|:---:|:---:|------|
| `iframe: true` | ❌ 无 | ❌ 无 | iframe 独立文档,物理隔离 |
| `iframe: false` + `disableScopecss: false`(默认) | ❌ 无 | ❌ 无 | micro-app 自动给子应用 CSS 加 `[name=xxx]` 作用域前缀 |
| **`iframe: false` + `disableScopecss: true`** | **✅ 有** | **✅ 有** | 双方 `h2` 都是全局选择器,同一文档内逐属性混搭 |
| `iframe: false` + `disableScopecss: true` + **`keepAlive: true`** | **✅ 有** | **✅ 更严重** | 前一个子应用的 CSS 残留不清理,与后一个的 CSS 叠加 |
---
┌──────────────────────────────────────────────────────────────┬────────────────┬─────────────────────────────────────────────────────────┐
│ 场景 │ 子应用间冲突? │ 原因 │
├──────────────────────────────────────────────────────────────┼────────────────┼─────────────────────────────────────────────────────────┤
│ 两方都 iframe: true │ ❌ 无 │ iframe 独立文档,物理隔离 │
├──────────────────────────────────────────────────────────────┼────────────────┼─────────────────────────────────────────────────────────┤
│ 一方 iframe: true一方 iframe: false │ ❌ 无 │ iframe 内的样式出不来,传不进去 │
├──────────────────────────────────────────────────────────────┼────────────────┼─────────────────────────────────────────────────────────┤
│ 两方都 iframe: false, disableScopecss: false │ ❌ 无 │ micro-app 自动添加 micro-app[name=xxx] 前缀 │
├──────────────────────────────────────────────────────────────┼────────────────┼─────────────────────────────────────────────────────────┤
│ 两方都 iframe: false, disableScopecss: true │ ✅ 有! │ 所有 h2/button/p 等全局选择器作用在同一文档,逐属性混搭 │
├──────────────────────────────────────────────────────────────┼────────────────┼─────────────────────────────────────────────────────────┤
│ 两方都 iframe: false, disableScopecss: true, keepAlive: true │ ✅ 更严重! │ 前一个子应用的 CSS 残留 + 后一个子应用的 CSS 同时存在 │
└──────────────────────────────────────────────────────────────┴────────────────┴─────────────────────────────────────────────────────────┘
你可以现在就在浏览器里验证 — 先访问 mock-app再切换到 mock-app-2观察 h2 是否同时出现了橙色左边框和青色右边框。这就是子应用间样式污染的真实表现。
#### 五、在浏览器中亲自验证的方法
```
1. 打开 http://localhost:8081/mock-app
F12 → Elements → <head> 观察 <style> 标签(橙色主题)
2. 导航到首页 /home
F12 → <head> 中橙色 <style> 标签仍然存在keepAlive 未清理)
3. 打开 http://localhost:8081/mock-app-2
F12 → <head> 中同时存在:
- 主应用的紫色 <style>
- mock-app 的橙色 <style> ← 残留!
- mock-app-2 的青色 <style>
4. 选中 h2 元素 → Styles 面板
你会看到 border-left: orange 被划掉(或被覆盖)
同时 border-right: teal 正常生效
→ Computed 面板中两个边框同时存在
```
---
#### 六、如何杜绝子应用间样式冲突
| 方案 | 做法 | 代价 |
|------|------|------|
| **用 `iframe: true`**(最推荐) | 每个子应用独立 iframe物理隔离 | 性能略降Vite 项目本来就必须用 |
| **用 `disableScopecss: false`**(默认) | 让 micro-app 自动加作用域前缀 | 几乎无代价,是最佳默认值 |
| **关掉 keepAlive** | 切换时销毁子应用CSS 一并移除 | 切换回来需重新加载 |
| **子应用用 BEM/Scoped CSS** | 不强依赖全局选择器 | 工程量大,需改造存量代码 |
**推荐组合:**
```
Vite 子应用 → iframe: true (物理隔离 + ES Module 兼容)
Webpack 子应用 → iframe: false + 默认 scoped (性能好 + 样式安全)
纯 HTML mock → iframe: false + 默认 scoped (除非刻意演示冲突)
```
---
#### 七、总结
| 问题 | 答案 |
|------|------|
| 子应用之间会互相影响吗? | **会**,当 `iframe: false` + `disableScopecss: true` 时 |
| keepAlive 有什么影响? | **加重**冲突 — 隐藏子应用时不清理 `<style>`CSS 残留累积 |
| 如何验证? | F12 → Elements → `<head>``<style>` 标签数量 — 访问过的子应用越多,残留的 `<style>` 越多 |
| 如何避免? | 默认配置(`disableScopecss: false`就是安全的Vite 子应用 `iframe: true` 物理隔离更安全 |
**一句话:主应用 ↔ 子应用之间有冲突,子应用 ↔ 子应用之间同样有,而且 keepAlive 让后者更隐蔽。三者在同一文档中共同搅拌所有同名的全局 CSS 属性。**
---
### Q31已经发现了样式冲突问题如何修复有哪些方案
**答:**
修复方案从简单到体系化分为四层。最简单的方案(也是我们实际采用的)只需**删掉一行配置**。
---
#### 一、方案一:恢复样式隔离(最简单,一行改动)
**根因回顾:**
```ts
// ❌ 冲突的配置
{
name: 'mock-app',
iframe: false,
disableScopecss: true, // ← 这行是罪魁祸首
keepAlive: true,
}
```
**修复:删除 `disableScopecss: true`(恢复默认值 `false`**
```ts
// ✅ 修复后的配置
{
name: 'mock-app',
iframe: false,
// disableScopecss 不设置,默认为 false
// → micro-app 自动为子应用 CSS 添加作用域前缀
keepAlive: true,
}
```
**一行改动,问题解决。**
---
#### 二、修复原理micro-app 的 CSS 作用域机制
`disableScopecss: false`默认micro-app 在注入子应用的 CSS 时,自动给每个选择器加上属性选择器前缀:
```
子应用原始 CSS:
─────────────────────────────────────────────
h2 { color: #ff6b35; border-left: 6px solid orange; }
button { background: #ff6b35; }
p { font-size: 13px; }
↓ micro-app 自动转换 ↓
注入主文档后的 CSS:
─────────────────────────────────────────────
micro-app[name=mock-app] h2 {
color: #ff6b35;
border-left: 6px solid orange;
}
micro-app[name=mock-app] button {
background: #ff6b35;
}
micro-app[name=mock-app] p {
font-size: 13px;
}
```
**效果:**
| 选择器 | 作用范围 |
|--------|----------|
| 主应用的 `h2 { color: purple }` | 整个文档 |
| 子应用转换后的 `micro-app[name=mock-app] h2 { color: orange }` | 只作用于 `<micro-app name="mock-app">` 内部的 h2 |
**两个选择器不再冲突** — 它们选择的是不同 DOM 范围内的元素。特异性不同,作用范围不同,互不干扰。
---
#### 三、修复前后对比
```
修复前 (disableScopecss: true):
───────────────────────────────────────────────
文档中的 CSS:
h2 { color: purple; border-bottom: 2px solid purple; } ← 主应用
h2 { color: orange; border-left: 6px solid orange; } ← mock-app
h2 { color: teal; border-right: 5px solid teal; } ← mock-app-2
结果: 三个 h2 规则搅拌在一起,逐属性混搭
→ 紫色底边框 + 橙色左边框 + 青色右边框 同时出现 ❌
修复后 (disableScopecss: false):
───────────────────────────────────────────────
文档中的 CSS:
h2 { color: purple; ... } ← 主应用(全局)
micro-app[name=mock-app] h2 { color: orange; ... } ← 仅 mock-app 内
micro-app[name=mock-app-2] h2 { color: teal; ... } ← 仅 mock-app-2 内
结果: 主应用的 h2 是紫色
mock-app 内的 h2 是橙色 ✅
mock-app-2 内的 h2 是青色 ✅
→ 各管各的区域,零冲突 ✅
```
---
#### 四、修复验证方法
```
1. 修复前先看一次:
访问 /mock-app → F12 → Elements → <style> 标签
→ 看到 h2 { ... } 没有前缀 ← 这是冲突的根源
2. 修复后再看一次:
刷新页面 → F12 → Elements → <style> 标签
→ 看到 micro-app[name=mock-app] h2 { ... } ← 自动加了前缀!
→ 主应用的紫色底边框不再出现在子应用的 h2 上 ✅
```
---
#### 五、多层防御体系(从简单到彻底)
| 层级 | 方案 | 适用场景 | 效果 |
|------|------|----------|------|
| **L1: 框架层** | `disableScopecss: false`(默认) | 所有 `iframe: false` 的子应用 | micro-app 自动加前缀,零成本 |
| **L2: 构建层** | `iframe: true` | Vite 子应用(必须)或高隔离需求 | 浏览器原生 iframe 隔离,最强 |
| **L3: 组件层** | Vue SFC `<style scoped>` | 子应用内部组件 | 组件级隔离,防止子应用内部样式冲突 |
| **L4: 规范层** | CSS Modules / BEM 命名 | 全局样式、公共组件 | 命名空间隔离,防止类名冲突 |
**推荐策略:**
```
Vite 子应用:
L2 (iframe: true) → 物理隔离,必选 ✅
L3 (Vue scoped) → 组件级隔离,推荐 ✅
Webpack / 普通 HTML 子应用:
L1 (disableScopecss: false) → 框架自动隔离,必选 ✅
L3 (Vue scoped / BEM) → 额外防护,推荐 ✅
```
---
#### 六、`disableScopecss: true` 的正确使用场景
这个选项不是为了"省事",它的合法用途很窄:
| 场景 | 说明 |
|------|------|
| **调试排错** | 临时关闭隔离,确认是否是样式隔离导致的渲染问题 |
| **第三方 UI 库** | 子应用使用了不兼容 CSS 作用域的 UI 库(罕见) |
| **刻意演示** | 比如我们的 mock-app故意制造冲突来教学 |
**生产环境应该永远保持 `disableScopecss: false`(默认值)。**
---
#### 七、如果主应用的全局样式也过于宽泛怎么办?
前面的修复解决了"子应用样式泄漏到主应用/其他子应用"。反向的问题——**主应用的全局样式污染子应用**——同样需要处理。
**问题回顾Q29** 主应用的 `h2 { border-bottom: 2px solid purple !important }` 泄漏进了子应用。
**修复方案:**
```css
/* ❌ 主应用当前写法 — 全局 h2 会影响所有 iframe: false 的子应用 */
h2 {
color: #667eea;
border-bottom: 2px solid #667eea;
}
/* ✅ 修复方案 1给主应用的内容区加作用域 */
.app-main h2 {
color: #667eea;
border-bottom: 2px solid #667eea;
}
/* ✅ 修复方案 2重置子应用容器内的样式 */
micro-app h2 {
border-bottom: none; /* 清除主应用的底边框 */
}
/* ✅ 修复方案 3主应用使用更具体的选择器 */
#main-app h2 { ... } /* ID 选择器,特异性更高 */
.main-content h2 { ... } /* 类选择器 + 标签,限定范围 */
```
**推荐:** 主应用的全局样式始终挂在容器选择器下(如 `.app-main h2``#main-app h2`),避免使用裸标签选择器(`h2``button``p`)。
---
#### 八、决策树:拿到冲突问题怎么修?
```
发现样式冲突
├── 子应用是 Vite 项目?
│ └── 是 → 设置 iframe: true物理隔离 + ES Module 兼容)
├── 子应用是 Webpack / 普通 HTML
│ └── 检查 disableScopecss 配置
│ ├── 是 true → 删掉它!(恢复默认隔离)← 90% 的情况
│ └── 是 false → 检查是否是主应用的全局样式泄漏
│ └── 主应用样式加 .app-main 等容器前缀
└── keepAlive 导致残留?
└── 修复了样式隔离后keepAlive 不再是问题
(因为每个子应用的 CSS 有独立的作用域前缀)
```
---
#### 九、总结
| 问题 | 答案 |
|------|------|
| 修复成本高吗? | **极低。** 删掉一行 `disableScopecss: true` 即可 |
| 默认值就是安全的吗? | **是的。** `disableScopecss` 默认为 `false`micro-app 自动隔离 |
| `disableScopecss: true` 什么时候用? | 调试排错时临时开启,或刻意演示冲突时 |
| 主应用的全局样式泄漏怎么办? | 全局样式加 `.app-main` 等容器前缀,不用裸标签选择器 |
| 为什么 Vite 项目不需要担心? | `iframe: true` 提供物理隔离,比 CSS 作用域更强 |
**一句话修复指南:不要设置 `disableScopecss: true`Vite 项目必须 `iframe: true`,主应用全局样式挂容器选择器。三层到位,样式冲突清零。**
---
## 附录:接入新子应用检查清单
| 步骤 | 文件 | 操作 |
|------|------|------|
| 1 | `src/config/subApps.ts` | 在 `subApps` 数组中添加新条目 |
| 2 | `src/router/index.ts` | 添加 `path: '/xxx/:page*'` 通配路由 |
| 3 | `src/App.vue` | 导航栏添加 `<router-link to="/xxx">` |
| 4 | 子应用 vite.config.ts | 设置 `base: '/xxx/'` 或使用 `__MICRO_APP_BASE_ROUTE__` |
| 5 | 子应用 router | `createWebHistory(window.__MICRO_APP_BASE_ROUTE__ \|\| '/')` |
| 6 | 子应用 devServer | 配置 CORS 头 `Access-Control-Allow-Origin: *` |
| 7 | 验证 | 启动子应用 → 启动主应用 → 访问对应路由确认加载成功 |