From e9c570f89345904216c4f28617939a302ac670c7 Mon Sep 17 00:00:00 2001 From: cirry <812852553@qq.com> Date: Sun, 21 Jun 2026 16:31:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E5=86=B2=E7=AA=81=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/micro-app多子应用接入实战.md | 938 ++++++++++++++++++++++++++++++ src/App.vue | 94 ++- src/config/subApps.ts | 34 +- src/router/index.ts | 12 + src/views/Home.vue | 199 +++++++ 5 files changed, 1273 insertions(+), 4 deletions(-) diff --git a/docs/micro-app多子应用接入实战.md b/docs/micro-app多子应用接入实战.md index 6647700..6570649 100644 --- a/docs/micro-app多子应用接入实战.md +++ b/docs/micro-app多子应用接入实战.md @@ -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 框架: 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 }` 只作用于 `` 内部 +- 两个规则的选择器不同,各管各的区域,**零冲突** + +``` +主应用 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 的