样式冲突问题模板
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 属性。**
|
||||
|
||||
---
|
||||
|
||||
## 附录:接入新子应用检查清单
|
||||
|
||||
| 步骤 | 文件 | 操作 |
|
||||
|
||||
Reference in New Issue
Block a user