43 KiB
micro-app 多子应用接入实战面试题
基于在已有主应用中接入第二个 Vue 3 + Vite 子应用(端口 3001)的完整实战经验整理。
一、多子应用注册与路由
Q1:如何在已有 micro-app 主应用中新增一个子应用?需要改哪些文件?
答: 只需要改 3 个文件,遵循「配置 → 路由 → 导航」的顺序:
1. src/config/subApps.ts — 添加子应用配置
export const subApps: SubAppConfig[] = [
{
name: 'vue2-app',
url: 'http://localhost:5173/',
baseroute: '/child-app',
iframe: true,
keepAlive: true,
routerMode: 'native'
},
// ✅ 新增
{
name: 'vue3-app',
url: 'http://localhost:3001/',
baseroute: '/vue3-app',
iframe: true,
keepAlive: true,
routerMode: 'native'
}
]
2. src/router/index.ts — 添加通配路由
const routes = [
// ... 其他路由
{
path: '/vue3-app/:page*', // ✅ 新增:通配符匹配子应用所有内部路由
name: 'vue3App',
component: () => import('@/views/ChildApp.vue')
}
]
3. src/App.vue — 导航栏添加入口
<router-link to="/vue3-app">Vue3 子应用</router-link>
不需要改的文件:
ChildApp.vue— 容器组件是通用的,根据 URL 自动匹配配置main.ts—microApp.start()是框架级初始化,不绑定具体子应用vite.config.ts—isCustomElement配置对所有<micro-app>标签生效
Q2:为什么要设计成"3 个文件改动"的模式?能否进一步减少?
答: 这是 micro-app 架构设计中最简洁的扩展模式:
| 改动 | 为什么必须 |
|---|---|
subApps.ts |
子应用的元信息(name、url、baseroute)必须有地方存储 |
router/index.ts |
Vue Router 需要知道哪些 URL 前缀交给子应用处理 |
App.vue |
用户需要入口点击进入子应用 |
能否合并?
可以。如果子应用数量很多,可以优化为自动注册模式:
// router/index.ts — 自动为所有子应用生成路由
import { subApps } from '@/config/subApps'
const subAppRoutes = subApps.map(app => ({
path: `${app.baseroute}/:page*`,
name: app.name,
component: () => import('@/views/ChildApp.vue')
}))
const routes = [
{ path: '/', redirect: '/home' },
{ path: '/home', component: () => import('@/views/Home.vue') },
...subAppRoutes // 自动展开
]
导航栏同理,用 v-for 遍历 subApps 自动生成。这样新增子应用只需要改 subApps.ts 一个文件。
Q3:ChildApp.vue 组件是如何做到"一个组件服务所有子应用"的?
答: 核心原理是通过 路由路径匹配配置:
// ChildApp.vue 的核心逻辑
const currentApp = computed(() => {
const matched = subApps.find((app) =>
route.path.startsWith(app.baseroute)
)
return matched || subApps[0]
})
<micro-app
:key="currentApp.url"
:name="currentApp.name"
:url="currentApp.url"
:baseroute="currentApp.baseroute"
<!-- 所有属性都从 currentApp 动态绑定 -->
/>
工作流程:
- 用户访问
/vue3-app/dashboard - Vue Router 匹配到通配路由 → 渲染
ChildApp.vue ChildApp.vue内的computed检查route.path以哪个baseroute开头- 找到
vue3-app配置 → 绑定到<micro-app>标签 - micro-app 框架根据
name+url加载对应的子应用
关键设计点:
:key="currentApp.url"— 当切换到不同子应用时,Vue 会销毁旧的<micro-app>重建新的keepAlive: true— 切换回来时不销毁,保持应用状态- 未匹配到任何
baseroute时的兜底逻辑:|| subApps[0]
二、路由冲突与优先级
Q4:如果主应用有一个 /about 路由,子应用也有一个 /about 路由,且子应用没有设置 router base,访问时会发生什么?
答: 结论:不会冲突,展示哪个取决于 URL。
| 访问路径 | 展示内容 | 原因 |
|---|---|---|
/about |
主应用的 About | 主应用路由直接匹配,不经过子应用 |
/vue3-app/about |
子应用的 About | 主应用路由匹配 /vue3-app/:page*,交给子应用处理 |
完整流程图:
浏览器输入: http://localhost:8080/about
│
▼
主应用 Vue Router 开始匹配
│
┌───────┴───────┐
│ │
主应用有 /about 主应用没有 /about
│ │
▼ ▼
展示主应用 About 404 / fallback
(子应用完全不会
被加载)
浏览器输入: http://localhost:8080/vue3-app/about
│
▼
主应用 Vue Router 匹配
│
▼
命中 /vue3-app/:page* 通配路由
│
▼
渲染 ChildApp.vue → 加载 <micro-app>
│
▼
micro-app 剥离 baseroute(/vue3-app)
传给子应用内部路由: /about
│
▼
子应用 Vue Router 匹配 /about
│
▼
展示子应用 About 页面
Q5:子应用不设置 router base 会有什么隐患?
答:
虽然 micro-app 的 baseroute 机制能拦截 pushState 并自动拼接前缀,让大部分场景"看起来正常",但仍有 4 个隐患:
| 隐患 | 说明 | 风险等级 |
|---|---|---|
window.location 误用 |
代码中直接读 location.pathname 会得到完整路径 /vue3-app/about,而不是期望的 /about |
⭐⭐⭐ 高 |
| 静态资源 404 | <img src="/assets/logo.png"> 会请求主应用的 8080 端口而非子应用的 3001 端口 |
⭐⭐⭐ 高 |
| 独立运行失败 | 不经过主应用直接访问子应用时,base 为空字符串,路由跳转可能出错 | ⭐⭐ 中 |
history.go() 异常 |
直接操作浏览器历史时不经过 micro-app 拦截,可能跳出微前端上下文 | ⭐ 低 |
最佳实践:
// 子应用 router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(
// 动态适配:微前端环境用 baseroute,独立运行用 '/'
window.__MICRO_APP_BASE_ROUTE__ || '/'
),
routes: [
{ path: '/about', component: () => import('@/views/About.vue') }
]
})
window.__MICRO_APP_BASE_ROUTE__ 是 micro-app 注入到子应用 window 上的全局变量,值就是主应用中配置的 baseroute。
Q6:micro-app 是如何实现"主应用路由和子应用路由互不干扰"的?
答: 通过 URL 命名空间隔离 机制:
主应用路由空间(Vue Router 管理)
├── / → 重定向到 /home
├── /home → Home.vue
├── /about → About.vue (如果定义了)
├── /child-app/:page* → 子应用 vue2-app 的领地
└── /vue3-app/:page* → 子应用 vue3-app 的领地
子应用 vue2-app 路由空间(内部 Router 管理,剥离 /child-app 前缀后)
├── / → 首页
├── /about → 关于页
└── /user/:id → 用户详情
子应用 vue3-app 路由空间(内部 Router 管理,剥离 /vue3-app 前缀后)
├── / → 首页
├── /about → 关于页
└── /dashboard → 控制台
三层隔离保证:
- Vue Router 层级 — 主应用的
/about和通配路由/vue3-app/:page*互斥匹配,不会同时命中 - baseroute 剥离 — micro-app 在将 URL 传给子应用前,自动去除 baseroute 前缀
- iframe 沙箱 — Vite 子应用运行在独立 iframe 中,
window.location指向自己的 origin
三、SubAppConfig 接口设计
Q7:SubAppConfig 接口各字段的作用和设计考量是什么?
答:
export interface SubAppConfig {
name: string // 全局唯一标识,字母开头
url: string // 子应用 devServer / 生产地址
baseroute: string // 主应用分配给子应用的 URL 命名空间
iframe?: boolean // 是否使用 iframe 沙箱
keepAlive?: boolean // 切换时是否保活
routerMode?: 'native' | 'native-scope'
disableScopecss?: boolean // 禁用样式隔离
disableSandbox?: boolean // 禁用 JS 沙箱
}
| 字段 | 为什么可选/必填 | 设计考量 |
|---|---|---|
name |
必填 | micro-app 框架需要唯一标识来管理子应用生命周期 |
url |
必填 | 不知道地址就无法加载;生产环境应通过环境变量注入 |
baseroute |
必填 | 决定 URL 命名空间,也影响子应用的资源路径解析 |
iframe |
可选,默认 false | Vite 子应用必须设为 true(ES Module 兼容性) |
keepAlive |
可选 | 高频切换场景开启可提升体验,但占用内存 |
routerMode |
可选 | native-scope 提供额外的作用域隔离 |
disableScopecss |
可选 | 调试时临时关闭样式隔离,排查样式问题 |
disableSandbox |
可选 | 调试时临时关闭沙箱,确认是否是沙箱引起的 bug |
Q8:为什么 Vite 子应用的 iframe 必须设为 true?
答: 见 基础面试题 Q7,核心原因:
Vite devServer 输出 ES Module (type="module")
↓
with 沙箱用 new Function() 执行 JS
↓
new Function() 无法解析 import/export 语法
↓
❌ SyntaxError: Cannot use import statement outside a module
解决: iframe: true → 浏览器原生 iframe 天然支持 <script type="module">
四、扩展与自动化
Q9:子应用数量从 1 个增长到 10 个后,路由和导航如何自动管理?
答: 将硬编码改为配置驱动:
路由自动生成:
// router/index.ts
import { subApps } from '@/config/subApps'
const routes = [
{ path: '/', redirect: '/home' },
{ path: '/home', name: 'home', component: () => import('@/views/Home.vue') },
// 自动为每个子应用生成通配路由
...subApps.map(app => ({
path: `${app.baseroute}/:page*`,
name: app.name,
component: () => import('@/views/ChildApp.vue')
}))
]
导航自动生成:
<!-- App.vue -->
<router-link
v-for="app in subApps"
:key="app.name"
:to="app.baseroute"
>
{{ app.name }}
</router-link>
这样新增子应用只需要在 subApps 数组中加一行配置,路由和导航自动生效。
Q10:如果有两个子应用的 baseroute 配置成了同一个值,会发生什么?
答: 会出现不确定行为:
- 路由层面:Vue Router 按数组顺序匹配,第一个通配路由先命中,后面的永远不会被访问
ChildApp.vue:subApps.find(app => route.path.startsWith(app.baseroute))返回数组中第一个匹配的配置- 结果:第二个子应用永远加载不到,且不会有任何报错提示
防御措施:
// subApps.ts — 启动时校验
if (import.meta.env.DEV) {
const routes = subApps.map(a => a.baseroute)
const duplicates = routes.filter((r, i) => routes.indexOf(r) !== i)
if (duplicates.length) {
console.error(`[SubApp] 重复的 baseroute: ${duplicates.join(', ')}`)
}
const names = subApps.map(a => a.name)
const dupNames = names.filter((n, i) => names.indexOf(n) !== i)
if (dupNames.length) {
console.error(`[SubApp] 重复的 name: ${dupNames.join(', ')}`)
}
}
五、环境管理与部署
Q11:开发环境和生产环境子应用 URL 不同,如何管理?
答: 使用环境变量:
// src/config/subApps.ts
export const subApps: SubAppConfig[] = [
{
name: 'vue3-app',
// 开发环境用 localhost,生产环境用实际部署地址
url: import.meta.env.VITE_VUE3_APP_URL || 'http://localhost:3001/',
baseroute: '/vue3-app',
iframe: true,
keepAlive: true,
routerMode: 'native'
}
]
# .env.development
VITE_VUE3_APP_URL=http://localhost:3001/
# .env.production
VITE_VUE3_APP_URL=https://vue3-app.your-domain.com/
这样开发和部署环境自动切换,无需修改代码。
Q12:这套架构中,如何保证子应用独立部署时不影响其他子应用?
答: 三个关键点:
- 独立端口/域名:每个子应用有自己的 devServer(3001、5173)和生产域名
- 独立的 CI/CD:每个子应用独立构建、独立发版,不耦合
- 主应用只做路由分发:主应用不包含子应用的业务逻辑,子应用挂了只影响自己的路由
故障隔离效果:
| 场景 | 影响范围 |
|---|---|
| 子应用 vue2-app 挂了 | 只有 /child-app/* 不可用,主应用和 vue3-app 正常 |
| 主应用挂了 | 所有子应用都不可访问(主应用是入口) |
| 子应用 JS 报错 | iframe 沙箱内错误不污染主应用 |
六、Vue 3 子应用工程化创建(实战完整流程)
Q13:从零创建一个 Vue 3 + Vite 子应用,需要哪几步?
答: 完整流程分 4 个阶段,共 8 步:
| 阶段 | 步骤 | 操作 |
|---|---|---|
| 脚手架 | 1 | npm create vite@latest microapp-vue3 -- --template vue-ts |
| 脚手架 | 2 | npm install && npm install vue-router@4 @micro-zoe/micro-app |
| 配置改造 | 3 | 改造 vite.config.ts(端口/base/CORS/alias/isCustomElement) |
| 配置改造 | 4 | 改造 tsconfig.app.json(@/* 路径别名) |
| 入口改造 | 5 | 创建 vite-env.d.ts(micro-app 全局类型声明) |
| 入口改造 | 6 | 改造 index.html(独立挂载点 id) |
| 入口改造 | 7 | 改造 main.ts(mount/unmount 生命周期 + 双模式) |
| 业务代码 | 8 | 创建 router + views(完成路由通配和页面) |
下面逐一拆解每个阶段的细节和踩坑点。
Q14:子应用的 vite.config.ts 需要配置哪些关键项?为什么?
答: 完整的 Vite 子应用配置文件如下,每个配置项都有明确的原因:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
// ① 路径别名 — 与 tsconfig 的 paths 配合,支持 @/ 导入
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
plugins: [
vue({
template: {
compilerOptions: {
// ② 自定义元素注册 — 避免 Vue 对 <micro-app> 标签报警告
isCustomElement: (tag) => /^micro-app/.test(tag)
}
}
})
],
// ③ base 路径 — 必须与主应用的 baseroute 一致
base: '/vue3-app/',
server: {
// ④ 端口 — 子应用独立端口,避免与主应用(8080)或其他子应用冲突
port: 3001,
// ⑤ CORS 头 — 主应用跨域加载子应用资源必须配置
headers: {
'Access-Control-Allow-Origin': '*'
}
}
})
| 配置项 | 必须? | 不配置的后果 |
|---|---|---|
resolve.alias |
推荐 | import xxx from '@/views/Home.vue' 无法解析 |
isCustomElement |
推荐 | Vue 警告 Failed to resolve component: micro-app |
base |
必须 | 静态资源 404,路由跳转路径错乱 |
port |
推荐 | 默认 5173,可能与已有子应用冲突 |
CORS |
必须 | 主应用无法加载子应用资源(跨域被浏览器拦截) |
Q15:为什么子应用的 index.html 挂载点不能用 #app?
答: 这是一个两个项目共用同一个 DOM 根节点的经典问题。
主应用 index.html: <div id="app"></div>
子应用 index.html: <div id="app"></div> ← ❌ 冲突!
在 iframe 沙箱模式下,子应用运行在独立 iframe 中,理论上 id 重复不会直接冲突。但有两个隐患:
- 语义混乱 — 维护者无法从 id 区分是主应用还是子应用的根节点
- 非 iframe 模式风险 — 如果未来改用 with 沙箱(禁用 iframe),两个
#app同时存在于同一个 document,mount('#app')会挂载到错误的节点,导致页面错乱
正确做法: 使用语义化的独立命名:
<!-- 子应用 index.html -->
<div id="microapp-vue3-root"></div>
// 子应用 main.ts — 挂载点保持一致
app.mount('#microapp-vue3-root')
// 卸载时清理
function unmount() {
const root = document.getElementById('microapp-vue3-root')
if (root) {
root.innerHTML = ''
}
}
命名建议: <项目名>-root,如 microapp-vue3-root、portal-root、dashboard-root。
Q16:子应用的 main.ts 中 mount/unmount 生命周期是如何设计的?
答: 核心模式是 双模式入口:微前端环境导出生命周期,独立运行直接挂载。
import { createApp } from 'vue'
import type { App as VueApp } from 'vue' // ① type-only import
import App from './App.vue'
import router from './router'
let app: VueApp | null = null
function mount(): void {
app = createApp(App)
app.use(router)
app.mount('#microapp-vue3-root') // ② 独立挂载点
}
function unmount(): void {
if (app) {
app.unmount() // ③ Vue 3 销毁
const root = document.getElementById('microapp-vue3-root')
if (root) root.innerHTML = '' // ④ 清空 DOM 残留
app = null
}
}
// ⑤ 环境判断 — 决定使用哪种模式
if (window.__MICRO_APP_ENVIRONMENT__) {
// 微前端环境:导出生命周期给 micro-app 框架调用
window[`micro-app-${window.__MICRO_APP_NAME__}`] = { mount, unmount }
} else {
// 独立运行:直接挂载,方便开发调试
mount()
}
设计要点:
| 要点 | 说明 |
|---|---|
type-only import |
App 类型从 vue 导入必须用 import type,否则生产构建报 MISSING_EXPORT 错误 |
app: VueApp | null |
用闭包变量持有实例引用,mount 创建、unmount 销毁 |
unmount() 清理 |
Vue 3 的 app.unmount() 不会清除 DOM,需手动 innerHTML = '' |
| 微前端环境判断 | window.__MICRO_APP_ENVIRONMENT__ 是 micro-app 注入的,框架级保证 |
| 生命周期导出 | window['micro-app-' + name] 是 micro-app 约定的命名规则 |
Q17:为什么 import { App } from 'vue' 在生产构建时会报错?如何修复?
答: 这是 Vite 8 / Rolldown 构建时的 类型导出与运行时分包 问题。
报错信息:
[MISSING_EXPORT] "App" is not exported by "node_modules/vue/dist/vue.runtime.esm-bundler.js"
根因分析:
Vue 3 的 App 是一个 TypeScript 接口,不是运行时值。在生产构建中,Vue 的 esm-bundler 版本只导出运行时 API(createApp、ref、computed 等),不导出类型。
// ❌ 错误写法:App 既是值又是类型,构建工具无法区分
import { createApp, App as VueApp } from 'vue'
// ✅ 正确写法:用 import type 明确告知这仅用于类型标注
import { createApp } from 'vue'
import type { App as VueApp } from 'vue'
为什么会混淆?
vue-tsc --noEmit类型检查能通过(TypeScript 知道App是类型)vite build构建失败(Rolldown 试图在运行时模块中找App的值导出)
核心原则: 凡是只用于 TypeScript 类型标注的导入,一律使用 import type:
import type { App } from 'vue' // ✅ Vue 实例类型
import type { Router } from 'vue-router' // ✅ 路由实例类型
import type { DefineComponent } from 'vue' // ✅ 组件类型
Q18:vite-env.d.ts 中需要声明哪些 micro-app 相关的全局类型?
答:
micro-app 框架会在子应用的 window 上注入 4 个关键全局变量,必须显式声明类型:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
// micro-app 全局类型声明
interface Window {
/** 是否运行在微前端环境中 */
__MICRO_APP_ENVIRONMENT__?: boolean
/** 应用名称(等于主应用中配置的 name) */
__MICRO_APP_NAME__?: string
/** 基础路由(等于主应用中配置的 baseroute) */
__MICRO_APP_BASE_ROUTE__?: string
/** 公共路径(用于拼接静态资源路径) */
__MICRO_APP_PUBLIC_PATH__?: string
}
| 全局变量 | 谁注入 | 典型用途 | 示例值 |
|---|---|---|---|
__MICRO_APP_ENVIRONMENT__ |
micro-app | 判断是否在微前端环境,决定导出生命周期还是直接挂载 | true / undefined |
__MICRO_APP_NAME__ |
micro-app | 拼接生命周期导出 key:window['micro-app-' + name] |
'vue3-app' |
__MICRO_APP_BASE_ROUTE__ |
micro-app | Vue Router 的 base 参数 |
'/vue3-app' |
__MICRO_APP_PUBLIC_PATH__ |
micro-app | 拼接静态资源 URL | 'http://localhost:3001/' |
面试延伸: 为什么要声明为 ?:(可选)而不是必填?
因为子应用需要同时支持独立运行(直接
npm run dev访问 3001 端口)和微前端环境(通过主应用 8080 端口加载)。独立运行时这些变量不存在,所以必须标记为可选。
Q19:tsconfig.app.json 中 @/* 路径别名和 Vite 的 resolve.alias 有什么关系?为什么需要两处配置?
答: 这对应了类型系统和构建系统两条独立管线:
tsconfig.app.json 的 paths vite.config.ts 的 resolve.alias
│ │
▼ ▼
TypeScript 编译器 Vite/Rolldown 打包器
(类型检查阶段) (构建阶段)
│ │
└───────────┬───────────────────────────────┘
│
▼
开发者写 import xxx from '@/views/Home.vue'
TypeScript 能找对类型 ✓
Vite 打包能找到文件 ✓
配置对比:
| 位置 | 配置 | 作用 |
|---|---|---|
tsconfig.app.json |
"paths": { "@/*": ["./src/*"] } + "baseUrl": "." |
让 TS 理解 @/ 指向 src/ |
vite.config.ts |
resolve: { alias: { "@": fileURLToPath(...) } } |
让 Vite 构建时正确解析路径 |
常见错误:
| 现象 | 原因 | 修复 |
|---|---|---|
| TS 报红波浪线但能跑 | 只配了 Vite alias,没配 tsconfig paths | 补充 tsconfig.app.json 的 paths |
vue-tsc --noEmit 报错但能构建 |
同上(tsconfig 缺 paths) | 同上 |
| 构建报错找不到模块 | 只配了 tsconfig paths,没配 Vite alias | 补充 vite.config.ts 的 resolve.alias |
Q20:子应用开发完成后,如何验证它作为 micro-app 子应用的可用性?
答: 三层验证策略:
第一层:类型检查
npx vue-tsc --noEmit
- 验证所有类型标注正确
- 检查 micro-app 全局变量的类型使用
- 确保
import type用在正确的位置
第二层:独立构建
npm run build
- 验证 Vite 生产构建能通过(暴露
import type问题) - 输出
dist/产物,确认资源路径基于/vue3-app/正确生成 - 典型坑:
"App" is not exported→ 修复import type
第三层:联调验证
# 终端 1 — 启动子应用(独立模式)
cd microapp-vue3 && npm run dev
# 访问 http://localhost:3001 — 确认页面正常、路由跳转正常
# 终端 2 — 启动主应用
cd microapp-main && npm run dev
# 访问 http://localhost:8080/vue3-app — 确认通过主应用加载正常
验证清单:
| 检查项 | 通过标准 |
|---|---|
| 独立运行首页 | localhost:3001 展示 Home 页面,路由跳转正常 |
| 独立运行 about | localhost:3001/about 展示 About 页面 |
| 主应用加载 | localhost:8080/vue3-app 通过主应用正常渲染子应用 |
| 子应用内路由 | localhost:8080/vue3-app/about 展示子应用的 About 页 |
| 环境检测 | Home 页面显示"🟢 微前端环境"而非"🔵 独立运行" |
| 控制台无报错 | 无 CORS 错误、无 MIME type 错误、无 Vue 警告 |
Q21:Vite 8 项目中 erasableSyntaxOnly 和 noUnusedLocals 配置对 micro-app 子应用开发有什么影响?
答:
Vite 8 的默认 tsconfig.app.json 引入了更严格的 TypeScript 检查:
{
"compilerOptions": {
"noUnusedLocals": true, // 未使用的局部变量报错
"noUnusedParameters": true, // 未使用的参数报错
"erasableSyntaxOnly": true // 仅允许可擦除的 TS 语法
}
}
| 选项 | 含义 | 对子应用开发的影响 |
|---|---|---|
erasableSyntaxOnly |
只允许不产生运行时痕迹的 TS 语法 | 强制使用 import type — 因为普通的 import { App } 会在 JS 输出中留下导入语句,如果模块不存在该导出,会运行时/构建时出错 |
noUnusedLocals |
未使用的局部变量报错 | 生命周期回调参数如果未使用,需用 _ 前缀(如 _e) |
noUnusedParameters |
未使用的函数参数报错 | 同上,回调参数必须消费或以 _ 开头 |
erasableSyntaxOnly 与 micro-app 的关系:
这个选项恰好帮我们避免了 Q17 中的构建错误 — 如果开启了 erasableSyntaxOnly,你写的 import { App } from 'vue' 会在类型检查阶段就报错(因为 App 是类型,应使用 import type),从而在开发阶段就暴露了生产构建才会出现的问题。
这是一个很好的 "左移"工程实践 — 类型检查期间就捕获构建期错误。
Q22:子应用路由中使用 window.__MICRO_APP_BASE_ROUTE__ || '/' 的兜底逻辑,在什么场景下会用到 '/'?
答:
'/' 作为 fallback 值的设计覆盖了 两个关键场景:
场景一:独立开发调试
npm run dev
# 子应用独立运行在 localhost:3001
# window.__MICRO_APP_BASE_ROUTE__ === undefined
# router base → '/' ← 走兜底
# 访问 /、/about 正常工作
场景二:单元测试 / E2E 测试
// 测试文件中直接创建 router
const router = createRouter({
history: createWebHistory('/'),
routes
})
// 不需要 mock __MICRO_APP_BASE_ROUTE__
如果不写兜底会怎样?
// ❌ 没有兜底
const router = createRouter({
history: createWebHistory(window.__MICRO_APP_BASE_ROUTE__)
// 独立运行时:createWebHistory(undefined) → 可能报错或行为异常
})
设计哲学: 子应用不应该依赖微前端环境才能运行。'/' 兜底体现了 "独立可运行" 的工程原则 — 子应用脱离主应用后依然是一个完整的、可访问的网站。
Q23:在子应用中展示"当前运行环境"指示器有什么实用价值?
答:
在 Home.vue 中我们设计了环境检测展示:
<p v-if="isMicroApp">🟢 当前运行在 <strong>micro-app 微前端环境</strong> 中</p>
<p v-else>🔵 当前<strong>独立运行</strong>模式</p>
这个看似简单的小功能有 4 个实用价值:
| 价值 | 说明 |
|---|---|
| 调试可视化 | 一眼看出当前是通过主应用加载还是独立访问,快速定位"为什么行为不同"的问题 |
| 新人友好 | 新加入的开发者不需要理解整个架构就能知道当前运行模式 |
| 排查跨环境 Bug | "在独立运行正常、主应用里出问题" → 有了指示器更容易复现和对比 |
| 演示/汇报 | 向 Leader 或面试官展示时,直观证明微前端架构确实在工作 |
延伸设计: 可以在指示器中进一步展示:
// 环境信息面板
const envInfo = computed(() => ({
运行模式: isMicroApp ? '微前端环境' : '独立运行',
baseroute: window.__MICRO_APP_BASE_ROUTE__ || '/',
应用名称: window.__MICRO_APP_NAME__ || '独立运行',
实际路径: window.location.pathname
}))
Q24:这次 Vue3 子应用创建过程中遇到了什么实际 bug?是如何修复的?
答: 实际遇到了一个构建错误:
Bug 表现:
npm run build
# ❌ Build failed with 1 error:
# [MISSING_EXPORT] "App" is not exported by
# "node_modules/vue/dist/vue.runtime.esm-bundler.js"
# at src/main.ts:1:21
Bug 现场代码:
// src/main.ts — 第 1 行
import { createApp, App as VueApp } from 'vue'
// ^^^ 这是类型,不是值
排查过程:
| 排查步骤 | 结果 | 判断 |
|---|---|---|
vue-tsc --noEmit |
✅ 零错误 | 类型检查通过,排除了类型标注问题 |
检查 node_modules/vue/dist/ |
vue.runtime.esm-bundler.js 存在 |
排除安装问题 |
在 vue 包中搜索 App 导出 |
只找到 interface App,无运行时导出 |
确认:App 是类型,不是值 |
检查 tsconfig.app.json |
"erasableSyntaxOnly": true 但未触发报错 |
TS 认为用法正确(作为类型的别名) |
根因: Vite 8 的 Rolldown 打包器在生产构建时使用 Vue 的 esm-bundler 版本,该版本只导出运行时 API。App 是 TypeScript 接口,在 JS 运行时不存在。import { App } 让 Rolldown 尝试从运行时模块中找 App,找不到就报 MISSING_EXPORT。
修复: 将值导入和类型导入分开:
import { createApp } from 'vue' // 值导入
import type { App as VueApp } from 'vue' // 类型导入 — Rolldown 会删除此行
修复后验证:
npx vue-tsc --noEmit # ✅ 类型检查通过
npx vite build # ✅ 构建成功,产物正常
教训总结:
| 经验 | 说明 |
|---|---|
vue-tsc 通过 ≠ 构建通过 |
类型检查器不模拟打包器的模块解析 |
import type 不是可选的 |
在 Vite 8 项目中使用 Vue 类型,必须用 type-only import |
erasableSyntaxOnly 是好配置 |
理论上能提前发现此类问题(但别名场景未覆盖) |
七、加载状态与沙箱机制
Q25:<micro-app> 标签加载子应用时的 Loading 效果是如何实现的?
答: 利用了 micro-app 自定义元素的 默认插槽(slot)机制 配合框架的生命周期,是一个零配置的内置能力。
核心代码:
<micro-app
name="vue3-app"
url="http://localhost:3001/"
baseroute="/vue3-app"
@created="onCreated"
@mounted="onMounted"
>
<!-- 🔑 这里的内容就是 Loading 占位 -->
<div class="loading-placeholder">
<div class="spinner"></div>
<p>正在加载子应用 {{ currentApp.name }}...</p>
<p class="loading-hint">
请确保子应用已启动:<code>{{ currentApp.url }}</code>
</p>
</div>
</micro-app>
工作原理(时间线):
时间 →
──────────────────────────────────────────────────────────────
① micro-app 标签插入 DOM
│
│ 此时 slot 内容立即渲染:
│ ┌─────────────────────────┐
│ │ ⟳ 正在加载子应用... │
│ │ 请确保子应用已启动 │
│ └─────────────────────────┘
│
▼
② fetch(url) — 请求子应用 HTML
│ [Loading 持续展示]
│
▼
③ @created 触发 — HTML 已下载,资源正在加载
│ [Loading 持续展示]
│
▼
④ @beforemount 触发 — 沙箱准备就绪
│ [Loading 持续展示]
│
▼
⑤ 子应用 JS 执行完毕,根组件挂载完成
│
│ micro-app 框架做 DOM 替换:
│ 移除 slot 内容 → 插入子应用渲染结果
│
▼
⑥ @mounted 触发 — 子应用完全渲染
┌─────────────────────────┐
│ 子应用的实际页面内容 │
└─────────────────────────┘
| 阶段 | DOM 状态 | 用户看到 |
|---|---|---|
<micro-app> 插入 DOM |
框架初始化 | 插槽内容(Loading) |
fetch 请求中 |
插槽内容 | Loading + spinner 动画 |
created 触发 |
插槽内容 | Loading |
beforemount 触发 |
插槽内容 | Loading |
子应用挂载完成 → mounted |
插槽被替换为子应用内容 | 子应用页面 |
为什么不需要手动控制 Loading 的显示/隐藏?
这是 <micro-app> 作为 Web Component 的原生插槽机制:
<micro-app> 的 Shadow DOM 内部逻辑:
if (子应用未就绪) {
渲染 <slot></slot> ← 显示用户传入的 Loading 内容
} else {
渲染子应用的 DOM ← 替换掉 slot
}
当 mounted 生命周期触发时,micro-app 框架自动用子应用的实际 DOM 替换插槽内容。开发者不需要手动写 v-if / v-show 来控制 loading 和子应用之间的切换。
Loading 设计的最佳实践:
| 要素 | 示例 | 作用 |
|---|---|---|
| 加载动画 | CSS spinner 旋转圈 | 视觉反馈,安抚用户等待 |
| 应用标识 | {{ currentApp.name }} |
多个子应用时,用户知道正在加载哪个 |
| 调试信息 | {{ currentApp.url }} |
开发阶段帮助排查"子应用是否已启动" |
| 错误兜底 | @error="onError" |
加载失败时在回调中做降级处理 |
常见优化:
<micro-app ... @error="onError">
<!-- 正常加载 -->
<div class="loading-placeholder">
<div class="spinner"></div>
<p>正在加载子应用...</p>
</div>
<!-- 加载失败(可在 error 回调中切换状态来展示) -->
<!-- 这里需要用 Vue 响应式变量控制,因为 slot 的可见性由 micro-app 框架管理 -->
</micro-app>
// error 回调中可以做重试或降级
function onError(error: Error) {
console.error(`[ChildApp] ${currentApp.value.name} 加载失败:`, error)
// 可在此处设置 UI 状态提示用户刷新
}
Q26:为什么 Vite 子应用必须用 iframe: true,而 Webpack 子应用可以用 iframe: false?
答: 根因是两种构建工具产出的 JS 格式 不同,导致 micro-app 的 with 沙箱对 Vite 输出的 ES Module 无法执行。
Webpack 子应用:with 沙箱可执行
Webpack 的代码输出:
<!-- Webpack dev/build 输出的 HTML -->
<script src="/js/chunk-vendors.js"></script> <!-- 普通脚本 -->
<script src="/js/app.js"></script> <!-- 普通脚本 -->
// Webpack 打包后的 JS 格式(UMD / IIFE):
(function(module, exports, __webpack_require__) {
// 所有模块代码都在一个函数闭包里
// 没有 import/export 语句!
var Vue = __webpack_require__('vue')
// ...
})
关键特征: Webpack 打包产物是普通脚本(Regular Script),不包含 import/export 语句。
with 沙箱的工作方式:
micro-app with 沙箱执行流程:
1. 获取子应用的 JS 代码(字符串)
2. 用 with(window.proxy) 创建一个作用域链
3. 通过 new Function(code) 执行 JS
伪代码:
const sandboxCode = `
with (this.proxyWindow) {
${子应用的JS代码} ← 普通 JS,可以执行 ✅
}
`
const fn = new Function('window', 'document', sandboxCode)
fn(proxyWindow, proxyDocument)
因为 Webpack 产出的 JS 是普通的 IIFE/UMD 代码,new Function() 完全可以执行它。Proxy 劫持了 window.document 等全局变量的访问,实现了沙箱隔离。
Vite 子应用:with 沙箱不可执行(必须用 iframe)
Vite devServer 的代码输出:
<!-- Vite devServer 返回的 HTML -->
<script type="module" src="/src/main.ts"></script>
// Vite 不对源码打包,直接输出 ES Module:
import { createApp } from '/node_modules/.vite/deps/vue.js?v=abc123'
import App from '/src/App.vue'
import router from '/src/router/index.ts'
// ↑ 全是 import/export 语句!
关键特征: Vite devServer 输出的是 ES Module(<script type="module">),包含大量 import/export 语句。
with 沙箱为什么失败:
micro-app with 沙箱执行流程:
1. 获取子应用的 JS 代码(字符串)
2. 用 with(window.proxy) 创建作用域
3. 通过 new Function(code) 执行 JS
伪代码:
const sandboxCode = `
with (this.proxyWindow) {
import { createApp } from '...' ← ❌ SyntaxError!
// ↑
// import 语句只能在 Module 上下文中使用
// new Function() 创建的是 Script 上下文,不是 Module 上下文
}
`
const fn = new Function('window', 'document', sandboxCode)
fn(proxyWindow, proxyDocument)
报错本质:
浏览器 JS 规范中有两种脚本类型:
┌─────────────────────────────────────────────────────────┐
│ Script(普通脚本) │ Module(ES 模块) │
├───────────────────────────────┼─────────────────────────┤
│ <script src="..."> │ <script type="module"> │
│ 不能用 import/export │ 可以用 import/export │
│ 顶层 this = window │ 顶层 this = undefined │
│ new Function() 创建的 │ ❌ 没有 new Module() │
│ 就是这个类型 │ 这个 API 不存在! │
└───────────────────────────────┴─────────────────────────┘
核心矛盾: JavaScript 规范里没有 new Module() 这样的 API。new Function() 创建的函数永远运行在 Script 上下文,永远无法解析 import/export。这是 ECMAScript 规范层面的限制,不是 micro-app 的 bug。
iframe 沙箱为什么能解决
┌─────────────────────────────────────────────┐
│ 主应用页面 (8080) │
│ │
│ <micro-app iframe="true" url="...3001"> │
│ │ │
│ │ 创建 iframe │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ iframe (沙箱) │ │
│ │ │ │
│ │ <html> │ │
│ │ <script type="module" src="..."> │ │
│ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ │ │
│ │ iframe 是浏览器原生文档环境 │ │
│ │ 天然支持 ES Module! │ │
│ │ </html> │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
iframe 是浏览器原生的独立文档环境,拥有完整的浏览器能力——包括 <script type="module"> 的解析和执行。Vite 的所有 ES Module 在 iframe 中可以正常工作,因为浏览器内核直接处理模块加载。
完整对比表
| 维度 | Webpack (with 沙箱) | Vite (iframe 沙箱) |
|---|---|---|
| 产物格式 | UMD / IIFE 普通脚本 | ES Module |
<script> 标签 |
<script src="..."> |
<script type="module" src="..."> |
| 含 import/export? | ❌ 不含有 | ✅ 大量 |
new Function() 能执行? |
✅ 可以(普通 JS) | ❌ 不行(模块语法) |
| 沙箱方案 | with + Proxy 代理 window | 浏览器原生 iframe |
| 性能 | 较快(无额外渲染上下文) | 略慢(需创建 iframe) |
| 隔离性 | 中等(Proxy 可被绕过) | 强(浏览器原生隔离) |
| Vite 兼容 | ❌ 不兼容 | ✅ 兼容 |
| 配置 | iframe: false(默认) |
iframe: true(必须) |
深入追问:Vite 生产构建后也不能用 with 沙箱吗?
答:即使生产构建也不行。
Vite 生产构建虽然打包了代码,但内部仍然使用 动态 import 和 ES Module 语法:
// Vite 生产构建产物(简化)
const __vite__mapDeps = (i) => {
// 大量 import() 动态导入,用于 code splitting
}
// 仍然是 Module 格式
而且 Vite 的子应用在生产环境下图片、字体等静态资源的路径依赖 base 配置和 ES Module 的 import.meta.url,这些在 with 沙箱的 new Function() 上下文中同样不可用。
结论:只要是 Vite 项目,无论开发还是生产,一律 iframe: true。
延伸:Webpack 子应用也能用 iframe 吗?
答:可以,但不是必须。
Webpack 子应用用 with 沙箱就能正常工作,用 iframe 也行——只是在不需要的情况下创建额外的 iframe 略微浪费资源。选择逻辑:
子应用构建工具是什么?
│
├── Vite → 必须 iframe: true
│ (new Function() 无法处理 ES Module)
│
└── Webpack → iframe: false 即可
(UMD/IIFE 在 new Function() 中正常执行)
│
└── 但如果对隔离性要求极高
→ 也可以设 iframe: true(牺牲少量性能换更强隔离)
附录:接入新子应用检查清单
| 步骤 | 文件 | 操作 |
|---|---|---|
| 1 | src/config/subApps.ts |
在 subApps 数组中添加新条目 |
| 2 | src/router/index.ts |
添加 path: '/xxx/:page*' 通配路由 |
| 3 | src/App.vue |
导航栏添加 <router-link to="/xxx"> |
| 4 | 子应用 vite.config.ts | 设置 base: '/xxx/' 或使用 __MICRO_APP_BASE_ROUTE__ |
| 5 | 子应用 router | createWebHistory(window.__MICRO_APP_BASE_ROUTE__ || '/') |
| 6 | 子应用 devServer | 配置 CORS 头 Access-Control-Allow-Origin: * |
| 7 | 验证 | 启动子应用 → 启动主应用 → 访问对应路由确认加载成功 |