2412 lines
88 KiB
Markdown
2412 lines
88 KiB
Markdown
# 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`。
|
||
|
||
---
|
||
|
||
### Q6:micro-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 子应用必须设为 true(ES 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. **独立端口/域名**:每个子应用有自己的 devServer(3001、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 警告 |
|
||
|
||
---
|
||
|
||
### Q21:Vite 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(普通脚本) │ Module(ES 模块) │
|
||
├───────────────────────────────┼─────────────────────────┤
|
||
│ <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 Router,micro-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 |
|
||
|
||
---
|
||
|
||
## 十、样式冲突深度剖析
|
||
|
||
### Q29:mock-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-app(font-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 | 验证 | 启动子应用 → 启动主应用 → 访问对应路由确认加载成功 |
|