样式冲突问题模板
This commit is contained in:
@@ -1234,6 +1234,944 @@ Webpack 子应用用 with 沙箱就能正常工作,用 iframe 也行——只
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 八、子应用路由改造与业务兼容
|
||||||
|
|
||||||
|
### 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 属性。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 附录:接入新子应用检查清单
|
## 附录:接入新子应用检查清单
|
||||||
|
|
||||||
| 步骤 | 文件 | 操作 |
|
| 步骤 | 文件 | 操作 |
|
||||||
|
|||||||
94
src/App.vue
94
src/App.vue
@@ -8,6 +8,8 @@
|
|||||||
<router-link to="/home">首页</router-link>
|
<router-link to="/home">首页</router-link>
|
||||||
<router-link to="/child-app">Vue2 子应用</router-link>
|
<router-link to="/child-app">Vue2 子应用</router-link>
|
||||||
<router-link to="/vue3-app">Vue3 子应用</router-link>
|
<router-link to="/vue3-app">Vue3 子应用</router-link>
|
||||||
|
<router-link to="/mock-app">💥主vs子</router-link>
|
||||||
|
<router-link to="/mock-app-2">🔵子vs子</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
@@ -21,7 +23,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* 全局样式重置 */
|
/* ============================================
|
||||||
|
全局样式重置(主应用)
|
||||||
|
注意:以下使用了 h2 / button / p / table / code
|
||||||
|
等通用选择器。在微前端场景中,这些选择器会:
|
||||||
|
1. 影响主应用自身的元素 ✅ (预期行为)
|
||||||
|
2. 泄漏到 iframe: false 的子应用中 ⚠️ (样式冲突)
|
||||||
|
============================================ */
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -40,6 +48,90 @@ html, body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 冲突演示:主应用全局样式 ---------- */
|
||||||
|
/* 这些通用选择器如果被子应用的同名选择器覆盖,就会发生冲突 */
|
||||||
|
|
||||||
|
/* 冲突点 ①: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 冲突点 ②:button — 主应用想要紫色直角按钮 */
|
||||||
|
button {
|
||||||
|
background: #667eea !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 6px 16px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
letter-spacing: 0 !important;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #5a6fd6 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 冲突点 ③:p 段落 — 主应用想要紧凑排版 */
|
||||||
|
p {
|
||||||
|
font-size: 14px !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
color: #444 !important;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 冲突点 ④:table — 主应用想要紫色无边框表格 */
|
||||||
|
table {
|
||||||
|
border-collapse: collapse !important;
|
||||||
|
width: 100% !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #667eea !important;
|
||||||
|
color: white !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
text-align: left !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 6px 8px !important;
|
||||||
|
border-bottom: 1px solid #e0e0e0 !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 冲突点 ⑤:code 标签 — 主应用想要紫色代码块 */
|
||||||
|
code {
|
||||||
|
background: #e8e0f0 !important;
|
||||||
|
color: #5e35b1 !important;
|
||||||
|
border: 1px solid #b39ddb !important;
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主应用布局 */
|
||||||
|
.main-style-source {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.label-main {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
#main-app {
|
#main-app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -32,11 +32,10 @@ export interface SubAppConfig {
|
|||||||
export const subApps: SubAppConfig[] = [
|
export const subApps: SubAppConfig[] = [
|
||||||
{
|
{
|
||||||
name: 'vue2-app',
|
name: 'vue2-app',
|
||||||
// TODO: 替换为你的 Vue 2 子应用实际地址
|
|
||||||
url: 'http://localhost:5173/',
|
url: 'http://localhost:5173/',
|
||||||
baseroute: '/child-app',
|
baseroute: '/child-app',
|
||||||
// Vite 子应用必须开启 iframe 模式
|
// Vite 子应用必须开启 iframe 沙箱
|
||||||
// (with 沙箱的 new Function() 不支持 ES Module 的 import/export 语法)
|
// my-vue2-app 虽然是 Vue 2,但用 Vite 构建,输出 ES Module
|
||||||
iframe: true,
|
iframe: true,
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
routerMode: 'native'
|
routerMode: 'native'
|
||||||
@@ -49,6 +48,35 @@ export const subApps: SubAppConfig[] = [
|
|||||||
iframe: true,
|
iframe: true,
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
routerMode: 'native'
|
routerMode: 'native'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mock-app',
|
||||||
|
url: 'http://localhost:5174/',
|
||||||
|
baseroute: '/mock-app',
|
||||||
|
// ============================================
|
||||||
|
// ⚠️ 样式冲突演示专用:
|
||||||
|
// iframe: false → 子应用 DOM 直接嵌入主文档
|
||||||
|
// disableScopecss: true → 关闭样式隔离,双方样式互相泄漏
|
||||||
|
// 这是一个纯 HTML 页面(无 JS 框架),不需要 ES Module 支持
|
||||||
|
// ============================================
|
||||||
|
iframe: false,
|
||||||
|
disableScopecss: true,
|
||||||
|
keepAlive: true,
|
||||||
|
routerMode: 'native'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mock-app-2',
|
||||||
|
url: 'http://localhost:5175/',
|
||||||
|
baseroute: '/mock-app-2',
|
||||||
|
// ============================================
|
||||||
|
// ⚠️ 子应用间样式冲突演示:
|
||||||
|
// 与 mock-app 相同:iframe: false, disableScopecss: true
|
||||||
|
// 用于验证:前一个子应用的样式是否会影响后一个子应用
|
||||||
|
// ============================================
|
||||||
|
iframe: false,
|
||||||
|
disableScopecss: true,
|
||||||
|
keepAlive: true,
|
||||||
|
routerMode: 'native'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ const routes = [
|
|||||||
path: '/vue3-app/:page*',
|
path: '/vue3-app/:page*',
|
||||||
name: 'vue3App',
|
name: 'vue3App',
|
||||||
component: () => import('@/views/ChildApp.vue')
|
component: () => import('@/views/ChildApp.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Mock 子应用路由 — 样式冲突演示
|
||||||
|
path: '/mock-app/:page*',
|
||||||
|
name: 'mockApp',
|
||||||
|
component: () => import('@/views/ChildApp.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Mock 子应用 2 路由 — 子应用间样式冲突演示
|
||||||
|
path: '/mock-app-2/:page*',
|
||||||
|
name: 'mockApp2',
|
||||||
|
component: () => import('@/views/ChildApp.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,49 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================
|
||||||
|
样式冲突演示说明
|
||||||
|
============================================ -->
|
||||||
|
<div class="conflict-demo-notice">
|
||||||
|
<div class="notice-header">
|
||||||
|
⚠️ 样式冲突演示区
|
||||||
|
</div>
|
||||||
|
<div class="notice-body">
|
||||||
|
<p><strong>演示目的:</strong>展示微前端中<span class="highlight-bad">样式隔离关闭</span>时的 CSS 冲突现象。</p>
|
||||||
|
<div class="demo-setup">
|
||||||
|
<div class="demo-card isolated">
|
||||||
|
<div class="demo-title">🛡️ Vue3 子应用(正常)</div>
|
||||||
|
<div class="demo-config">
|
||||||
|
<code>iframe: true</code>
|
||||||
|
<code>disableScopecss: false</code>
|
||||||
|
</div>
|
||||||
|
<p class="demo-desc">iframe 提供浏览器原生隔离,样式完全隔离</p>
|
||||||
|
<router-link to="/vue3-app" class="demo-link">查看正常效果 →</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="demo-card conflict">
|
||||||
|
<div class="demo-title">💥 Vue2 子应用(冲突)</div>
|
||||||
|
<div class="demo-config">
|
||||||
|
<code>iframe: false</code>
|
||||||
|
<code>disableScopecss: true</code>
|
||||||
|
</div>
|
||||||
|
<p class="demo-desc">DOM 嵌入主文档 + 样式隔离关闭 → 样式互相泄漏</p>
|
||||||
|
<router-link to="/child-app" class="demo-link">查看冲突效果 →</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="conflict-elements">
|
||||||
|
<span class="label">冲突元素:</span>
|
||||||
|
<code>h2</code> <code>button</code> <code>p</code> <code>table</code> <code>code</code>
|
||||||
|
</div>
|
||||||
|
<p class="notice-hint">
|
||||||
|
👆 先点击右边的「查看冲突效果」,进入子应用页面后,观察 h2/button/table 等元素的样式变化。
|
||||||
|
你会看到主应用的「紫色系」样式和子应用的「橙色系」样式<span class="highlight-bad">同时作用</span>在同一元素上。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================
|
||||||
|
已接入的子应用列表
|
||||||
|
============================================ -->
|
||||||
<div class="sub-app-list">
|
<div class="sub-app-list">
|
||||||
<h3>已接入的子应用</h3>
|
<h3>已接入的子应用</h3>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
@@ -164,4 +207,160 @@ import { subApps } from '@/config/subApps'
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
样式冲突演示区块样式
|
||||||
|
============================================ */
|
||||||
|
.conflict-demo-notice {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #ff9800;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-header {
|
||||||
|
background: linear-gradient(135deg, #ff9800, #f57c00);
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-body p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-bad {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #e65100;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid #ffcc80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 演示卡片容器 */
|
||||||
|
.demo-setup {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card.isolated {
|
||||||
|
background: #f0faf3;
|
||||||
|
border-color: #42b883;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card.conflict {
|
||||||
|
background: #fff3e0;
|
||||||
|
border-color: #ff6b35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-config {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-config code {
|
||||||
|
background: #333;
|
||||||
|
color: #4ec9b0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Consolas', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #777;
|
||||||
|
margin-bottom: 10px !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card.isolated .demo-link {
|
||||||
|
background: #42b883;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card.conflict .demo-link {
|
||||||
|
background: #ff6b35;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-link:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 冲突元素标签 */
|
||||||
|
.conflict-elements {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff8e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-elements .label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-elements code {
|
||||||
|
background: #333;
|
||||||
|
color: #ffab40;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Consolas', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-hint {
|
||||||
|
background: #fff3e0 !important;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
color: #e65100 !important;
|
||||||
|
line-height: 1.8 !important;
|
||||||
|
border: 1px dashed #ffcc80;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user