微前端落地单页应用:React Vue项目如何拆分独立子应用实现多团队并行开发与部署
写在前面:为啥要折腾微前端?
说实话,我一开始听到”微前端”这个词的时候,心里也是咯噔一下。这名字听起来挺高深,但说白了,它就是把原本一个超级庞大的前端项目,拆成好几个小项目各自独立干。你想啊,一个团队要维护一个几十人的代码库,代码合并冲突天天闹,每次上线都要所有人都配合着重新打包,这种日子谁受得了?
我自己就经历过那种崩溃。公司的前端项目有五六十万行代码,构建一次要二十多分钟,每次发布都得停服,测试团队骂骂咧咧,开发团队累得半死。后来引入了微前端架构,把项目拆成了五个独立子应用,每个团队自己管自己的代码、自己的部署、自己的发布节奏。那种快感,就像终于从堵车的高架上转到了畅通的快速路。
今天咱们就好好聊聊,怎么把React和Vue的单页应用拆成微前端架构,让多个团队能够并行开发、独立部署。
微前端的本质是什么?先把地基打牢
什么是微前端
微前端(Micro-Frontends)是一种类似于微服务的架构理念,它把前端应用拆分成多个小型的、可以独立开发、独立部署的子应用。这些子应用可以是不同的技术栈(比如有的用React,有的用Vue),也可以是基于不同团队分工的模块。
打个比方,想象你在开一家大型连锁餐厅。传统的做法是,所有厨师都在一个厨房里工作,谁炒菜、谁切菜、谁摆盘,都要协调好。而微前端的做法是,你把厨房分成好几个独立的小厨房——西餐厨房、中餐厨房、甜品厨房。每个厨房有自己的厨师团队,可以自己决定菜单、自己决定什么时候上菜。最后,这些菜品都端到一个大厅里,顾客吃到的是一顿完整的宴席。
微前端的核心优势
- 独立开发:每个子应用可以由不同的团队独立开发,互不干扰
- 独立部署:某个子应用更新,不需要重新部署整个应用
- 技术栈无关:不同子应用可以用不同的框架(React、Vue、Angular等)
- 渐进式迁移:可以逐步迁移旧系统,而不是一刀切
- 多团队协作:不同团队负责不同业务模块,并行推进
主流微前端方案大比拼
在做技术选型之前,咱们得先了解一下目前市面上主流的几种微前端方案。每种方案都有自己的优缺点,适合不同的场景。
方案一:qiankun(蚂蚁金服开源)
qiankun是目前国内最流行的微前端框架之一,由蚂蚁金服开源。它基于single-spa改造,提供了开箱即用的能力。
核心特点:
- 基于HTML Entry加载子应用
- 自动隔离JS、CSS、全局变量
- 支持React、Vue、Angular等多种框架
- 提供了完善的生命周期管理
- 有完整的文档和生态
优点:
- 文档完善,中文友好
- 社区活跃,问题容易解决
- 沙箱机制成熟,隔离效果良好
- 提供了proxy沙箱和iframe沙箱两种模式
缺点:
- 依赖single-spa底层,有一定的学习成本
- 样式隔离需要手动处理一些边界情况
- 在某些复杂场景下,通信机制可能需要额外的封装
方案二:Module Federation(Webpack 5)
Module Federation是Webpack 5引入的原生模块联邦功能,允许一个应用运行时加载另一个应用打包的模块。
核心特点:
- 无需额外的框架依赖
- 基于Webpack构建,共享依赖管理
- 支持动态导入远程模块
- 可以在不同项目间共享React、Vue等组件库
优点:
- 构建速度较快
- 依赖共享机制灵活
- 不需要额外的沙箱处理
- 与Webpack生态无缝集成
缺点:
- 配置相对复杂
- 对版本管理要求较高
- 文档相对较少
- 社区支持不如qiankun完善
方案三:wujie(无界)
wujie是腾讯开源的微前端框架,基于iframe实现,提供了更强大的隔离能力。
核心特点:
- 基于iframe隔离,安全性高
- 支持Vue和React应用
- 提供了跨框架通信能力
- 支持子应用之间的事件通信
优点:
- iframe隔离,安全性最高
- 支持跨框架通信
- 子应用之间可以相互通信
- 对Vue的支持较好
缺点:
- iframe方案性能相对较差
- 子应用之间的通信不如JS方案方便
- 需要处理iframe的样式问题
- 移动端适配相对复杂
方案四:single-spa
single-spa是最早的微前端框架之一,是qiankun的底层基础。
核心特点:
- 最基础、最灵活
- 支持多种框架
- 可以与其他框架结合使用
优点:
- 高度可定制
- 社区稳定
- 文档完善(英文)
缺点:
- 需要自己处理沙箱和隔离问题
- 上手门槛较高
- 需要额外封装才能满足生产需求
我们的选择:为什么选qiankun
在经历了Module Federation的配置挣扎和wujie的性能瓶颈之后,我们团队最终选择了qiankun作为主框架的微前端方案。主要原因如下:
第一,qiankun的文档非常完善,特别是中文文档,对国内团队非常友好。我们的团队成员大多是中国人,中文文档大大降低了沟通成本。
第二,qiankun的社区非常活跃,遇到问题可以快速找到解决方案。在我们的项目推进过程中,确实遇到了不少坑,但大部分都通过GitHub Issues找到了答案。
第三,qiankun的沙箱机制比较成熟,能够有效隔离子应用之间的全局变量和样式。这对于多团队协作来说非常重要,避免了”我改了我的样式,结果把你那边的页面搞乱了”这种尴尬情况。
第四,qiankun对React和Vue的支持都非常好,我们的主应用是用Vue写的,子应用有用React写的,也有用Vue写的,qiankun都能很好地兼容。
项目整体架构设计
目录结构
在开始写代码之前,咱们先把整个项目的结构理清楚。一个典型的微前端项目结构如下:
monorepo/
├── package.json # 根目录package.json,使用monorepo管理
├── pnpm-workspace.yaml # pnpm workspace配置
├── packages/
│ ├── micro-app/ # 主应用(基座)
│ │ ├── package.json
│ │ ├── public/
│ │ ├── src/
│ │ │ ├── main.js # 入口文件
│ │ │ ├── App.vue
│ │ │ ├── apps/
│ │ │ │ ├── index.js # 子应用注册配置
│ │ │ │ ├── react-app.js
│ │ │ │ ├── vue-app.js
│ │ │ │ └── vue-app2.js
│ │ │ ├── router/
│ │ │ │ └── index.js # 主应用路由
│ │ │ ├── store/
│ │ │ │ └── index.js # 主应用状态管理
│ │ │ └── utils/
│ │ │ └── micro.js # 微前端工具函数
│ │ └── vite.config.js
│ │
│ ├── react-sub-app/ # React子应用
│ │ ├── package.json
│ │ ├── public/
│ │ ├── src/
│ │ │ ├── main.jsx # 入口文件(导出生命周期)
│ │ │ ├── App.jsx
│ │ │ ├── pages/
│ │ │ │ ├── Dashboard.jsx
│ │ │ │ └── Settings.jsx
│ │ │ └── micro/
│ │ │ └── index.jsx # 微前端生命周期
│ │ └── vite.config.js
│ │
│ ├── vue-sub-app/ # Vue子应用
│ │ ├── package.json
│ │ ├── public/
│ │ ├── src/
│ │ │ ├── main.js # 入口文件(导出生命周期)
│ │ │ ├── App.vue
│ │ │ ├── router/
│ │ │ │ └── index.js
│ │ │ └── micro/
│ │ │ └── index.js # 微前端生命周期
│ │ └── vite.config.js
│ │
│ └── vue-sub-app2/ # 另一个Vue子应用
│ ├── package.json
│ ├── public/
│ ├── src/
│ │ ├── main.js # 入口文件(导出生命周期)
│ │ ├── App.vue
│ │ └── micro/
│ │ └── index.js # 微前端生命周期
│ └── vite.config.js
│
├── shared/ # 共享包
│ ├── package.json
│ ├── src/
│ │ ├── utils/ # 公共工具函数
│ │ ├── components/ # 公共组件
│ │ └── styles/ # 公共样式
│ └── package.json
│
├── scripts/ # 构建和部署脚本
│ ├── dev.js # 开发脚本
│ └── build.js # 构建脚本
│
└── docker/ # Docker配置
├── Dockerfile
└── nginx/
└── default.conf
这个目录结构使用了monorepo的方式管理,所有项目都在一个仓库里。当然,你也可以选择把每个子应用放在不同的仓库里,这种方式在大型企业团队中也比较常见。
技术栈选择
| 角色 | 技术栈 | 说明 |
|---|---|---|
| 主应用 | Vue 3 + Vite + qiankun | 基座应用,负责路由分发和子应用加载 |
| 子应用1 | React 18 + Vite | 用户中心模块 |
| 子应用2 | Vue 3 + Vite | 数据看板模块 |
| 子应用3 | Vue 3 + Vite | 系统设置模块 |
| 通信 | qiankun events + 自定义事件总线 | 子应用之间通信 |
| 包管理 | pnpm + workspace | Monorepo管理 |
| 构建 | Vite + Docker | 容器化部署 |
主应用(基座)搭建
主应用是整个微前端架构的核心,它负责加载和管理所有的子应用。咱们一步一步来,把主应用搭起来。
初始化主应用
首先,我们用Vite创建一个Vue 3项目作为主应用:
# 创建主应用
npm create vite@latest micro-app -- --template vue
cd micro-app
npm install
安装依赖
# 安装qiankun和路由
npm install qiankun vue-router pinia axios
配置Vite
// micro-app/vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
server: {
port: 7000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
build: {
rollupOptions: {
output: {
// 手动分块,避免chunk文件过大
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'qiankun': ['qiankun']
}
}
}
}
})
注册子应用
这是主应用最核心的部分——注册子应用。咱们来看看具体的代码:
// micro-app/src/apps/index.js
import { registerMicroApps, start, initGlobalState } from 'qiankun'
// 初始化全局状态(用于主应用和子应用之间通信)
const initialState = {
userInfo: null,
theme: 'light',
language: 'zh-CN'
}
const actions = initGlobalState(initialState)
// 子应用配置
const apps = [
{
name: 'react-app',
entry: '//localhost:7100',
container: '#react-container',
activeRule: '/react',
props: {
basePath: '/react',
userInfo: initialState.userInfo
}
},
{
name: 'vue-dashboard',
entry: '//localhost:7200',
container: '#vue-dashboard-container',
activeRule: '/dashboard',
props: {
basePath: '/dashboard'
}
},
{
name: 'vue-settings',
entry: '//localhost:7300',
container: '#vue-settings-container',
activeRule: '/settings',
props: {
basePath: '/settings'
}
}
]
// 注册子应用
registerMicroApps(apps, {
// 子应用加载前钩子
beforeLoad: [
(app) => {
console.log(`[qiankun] 开始加载子应用: ${app.name}`)
// 可以在这里添加loading状态
document.body.classList.add('app-loading')
}
],
// 子应用加载后钩子
afterMount: [
(app) => {
console.log(`[qiankun] 子应用 ${app.name} 加载完成`)
document.body.classList.remove('app-loading')
}
],
// 子应用卸载前钩子
beforeUnmount: [
(app) => {
console.log(`[qiankun] 开始卸载子应用: ${app.name}`)
}
]
})
// 启动qiankun
start({
sandbox: {
// 启用样式隔离
strictStyleIsolation: false,
// 启用js沙箱
proxy: window
},
// 开启single Spa
singular: false,
// 允许远程加载
allowRemoteApps: true
})
// 导出actions供子应用使用
export { actions }
export default apps
这里需要特别注意几个配置项:
name:子应用的唯一标识,不能重复entry:子应用的入口地址,可以是URL也可以是本地路径container:子应用挂载的容器IDactiveRule:路由匹配规则,决定什么时候加载这个子应用props:主应用传递给子应用的数据
路由配置
主应用的路由配置非常关键,它需要与子应用的activeRule对应:
// micro-app/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
component: () => import('@/views/Layout.vue'),
children: [
{
path: '',
redirect: '/dashboard'
}
]
},
{
path: '/react',
component: () => import('@/views/Layout.vue')
},
{
path: '/settings',
component: () => import('@/views/Layout.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫,处理导航逻辑
router.beforeEach((to, from, next) => {
console.log(`[Router] 导航: ${from.path} -> ${to.path}`)
next()
})
export default router
布局组件
主应用的布局组件负责提供公共的导航和容器:
<!-- micro-app/src/views/Layout.vue -->
<template>
<div class="layout-container">
<!-- 顶部导航栏 -->
<header class="layout-header">
<div class="logo">微前端应用</div>
<nav class="nav-menu">
<router-link to="/dashboard" class="nav-item">数据看板</router-link>
<router-link to="/react" class="nav-item">用户中心</router-link>
<router-link to="/settings" class="nav-item">系统设置</router-link>
</nav>
<div class="user-info">
<span>{{ userInfo?.name || '未登录' }}</span>
</div>
</header>
<!-- 子应用挂载区域 -->
<main class="layout-main">
<router-view>
<template #default="{ Component, route }">
<transition name="fade" mode="out-in">
<component :is="Component" :key="route.path">
<!-- 子应用容器 -->
<div id="vue-dashboard-container"></div>
<div id="react-container"></div>
<div id="vue-settings-container"></div>
</component>
</transition>
</template>
</router-view>
</main>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useGlobalState } from '@/apps/index'
// 获取全局状态
const { state, on } = useGlobalState()
const userInfo = ref(state.userInfo)
// 监听全局状态变化
on('update', (newState) => {
userInfo.value = newState.userInfo
})
onMounted(() => {
console.log('[Layout] 布局组件已挂载')
})
</script>
<style scoped>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.layout-header {
display: flex;
align-items: center;
padding: 0 20px;
height: 60px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 1000;
}
.logo {
font-size: 20px;
font-weight: bold;
color: #1890ff;
margin-right: 40px;
}
.nav-menu {
display: flex;
gap: 10px;
}
.nav-item {
padding: 8px 16px;
color: #333;
text-decoration: none;
border-radius: 4px;
transition: all 0.3s;
}
.nav-item:hover,
.nav-item.router-link-active {
background: #e6f7ff;
color: #1890ff;
}
.user-info {
margin-left: auto;
padding: 8px 16px;
color: #666;
}
.layout-main {
flex: 1;
padding: 20px;
background: #f5f5f5;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
入口文件
// micro-app/src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { registerMicroApps, start } from './apps'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// 先注册子应用,再挂载主应用
registerMicroApps().then(() => {
app.mount('#app')
console.log('[Main] 主应用已挂载')
})
<!-- micro-app/src/App.vue -->
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
console.log('[App] App组件已挂载')
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
</style>
React子应用开发
现在咱们来开发第一个子应用——基于React的用户中心。这个子应用负责展示用户信息和用户设置。
初始化项目
# 创建React子应用
npm create vite@latest react-sub-app -- --template react
cd react-sub-app
npm install
安装依赖
npm install react-router-dom axios
核心文件:导出生命周期函数
这是微前端子应用最核心的部分。每个子应用都必须导出三个生命周期函数:bootstrap、mount、unmount。
// react-sub-app/src/micro/index.jsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from '../App'
import { BrowserRouter } from 'react-router-dom'
let root = null
let instance = null
/**
* 子应用生命周期 - bootstrap
* 只在应用内挂载和卸载时触发一次
*/
export async function bootstrap(props) {
console.log('[React Sub App] bootstrap 生命周期', props)
}
/**
* 子应用生命周期 - mount
* 每次应用挂载时触发
*/
export async function mount(props) {
console.log('[React Sub App] mount 生命周期', props)
const { container, basePath } = props
// 获取挂载容器
const dom = container
? container.querySelector('#react-root')
: document.getElementById('react-root')
if (!dom) {
console.error('[React Sub App] 找不到挂载容器')
return
}
// 创建React根节点
root = createRoot(dom)
// 挂载应用
instance = root.render(
React.createElement(BrowserRouter, { basename: basePath },
React.createElement(App)
)
)
console.log('[React Sub App] 应用已挂载')
}
/**
* 子应用生命周期 - unmount
* 每次应用卸载时触发
*/
export async function unmount(props) {
console.log('[React Sub App] unmount 生命周期', props)
if (root) {
root.unmount()
root = null
instance = null
}
console.log('[React Sub App] 应用已卸载')
}
主应用文件
// react-sub-app/src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
// 生产环境导出生命周期
if (window.__POWERED_BY_QIANKUN__) {
let instance = null
let root = null
window.__BOOTSTRAP__ = async () => {
console.log('[React Sub App] bootstrap')
}
window.__MOUNT__ = async (props) => {
console.log('[React Sub App] mount', props)
const { container } = props
const dom = container
? container.querySelector('#root')
: document.getElementById('root')
root = ReactDOM.createRoot(dom)
instance = root.render(<App />)
}
window.__UNMOUNT__ = async () => {
console.log('[React Sub App] unmount')
if (root) {
root.unmount()
root = null
instance = null
}
}
} else {
// 独立运行
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)
}
// react-sub-app/src/App.jsx
import React, { useState, useEffect } from 'react'
import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import Settings from './pages/Settings'
import './App.css'
function App() {
const navigate = useNavigate()
const location = useLocation()
const [userInfo, setUserInfo] = useState(null)
// 从主应用获取传递的props
const props = window.__QIANKUN_PROPERTIES__ || {}
useEffect(() => {
// 模拟获取用户信息
const fetchUserInfo = async () => {
try {
// 这里可以从主应用传递的props中获取userInfo
// 或者从localStorage/API获取
const mockUser = {
id: 1,
name: '张三',
email: 'zhangsan@example.com',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix'
}
setUserInfo(mockUser)
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
fetchUserInfo()
}, [])
// 监听路由变化,通知主应用
useEffect(() => {
if (window.__QIANKUN_BOOTSTRAP__ && window.__SET_ROUTE__) {
window.__SET_ROUTE__(location.pathname)
}
}, [location.pathname])
return (
<div className="app-container">
<div className="user-header">
{userInfo && (
<div className="user-info">
<img src={userInfo.avatar} alt="avatar" className="avatar" />
<span className="username">{userInfo.name}</span>
</div>
)}
</div>
<nav className="sub-nav">
<Link to="/react/dashboard" className={location.pathname.includes('dashboard') ? 'active' : ''}>
数据概览
</Link>
<Link to="/react/settings" className={location.pathname.includes('settings') ? 'active' : ''}>
设置
</Link>
</nav>
<div className="sub-content">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/" element={<Dashboard />} />
</Routes>
</div>
</div>
)
}
export default App
// react-sub-app/src/pages/Dashboard.jsx
import React, { useState, useEffect } from 'react'
function Dashboard() {
const [stats, setStats] = useState({
totalUsers: 0,
activeUsers: 0,
newUsers: 0,
revenue: 0
})
const [loading, setLoading] = useState(true)
useEffect(() => {
// 模拟数据获取
const timer = setTimeout(() => {
setStats({
totalUsers: 12580,
activeUsers: 3256,
newUsers: 128,
revenue: 98560
})
setLoading(false)
}, 800)
return () => clearTimeout(timer)
}, [])
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>加载中...</p>
</div>
)
}
return (
<div className="dashboard">
<h2>用户中心 - 数据概览</h2>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{stats.totalUsers.toLocaleString()}</div>
<div className="stat-label">总用户数</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.activeUsers.toLocaleString()}</div>
<div className="stat-label">活跃用户</div>
</div>
<div className="stat-card">
<div className="stat-value">+{stats.newUsers}</div>
<div className="stat-label">今日新增</div>
</div>
<div className="stat-card">
<div className="stat-value">¥{stats.revenue.toLocaleString()}</div>
<div className="stat-label">今日收入</div>
</div>
</div>
</div>
)
}
export default Dashboard
// react-sub-app/src/pages/Settings.jsx
import React, { useState } from 'react'
function Settings() {
const [settings, setSettings] = useState({
theme: 'light',
language: 'zh-CN',
notifications: true,
twoFactorAuth: false
})
const handleToggle = (key) => {
setSettings(prev => ({
...prev,
[key]: !prev[key]
}))
}
return (
<div className="settings">
<h2>用户设置</h2>
<div className="settings-group">
<div className="setting-item">
<span className="setting-label">主题模式</span>
<select
value={settings.theme}
onChange={(e) => setSettings(prev => ({ ...prev, theme: e.target.value }))}
className="setting-select"
>
<option value="light">浅色</option>
<option value="dark">深色</option>
<option value="auto">自动</option>
</select>
</div>
<div className="setting-item">
<span className="setting-label">语言</span>
<select
value={settings.language}
onChange={(e) => setSettings(prev => ({ ...prev, language: e.target.value }))}
className="setting-select"
>
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
</select>
</div>
<div className="setting-item">
<span className="setting-label">消息通知</span>
<label className="toggle-switch">
<input
type="checkbox"
checked={settings.notifications}
onChange={() => handleToggle('notifications')}
/>
<span className="slider"></span>
</label>
</div>
<div className="setting-item">
<span className="setting-label">双因素认证</span>
<label className="toggle-switch">
<input
type="checkbox"
checked={settings.twoFactorAuth}
onChange={() => handleToggle('twoFactorAuth')}
/>
<span className="slider"></span>
</label>
</div>
</div>
</div>
)
}
export default Settings
Vite配置
// react-sub-app/vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
base: './', // 子应用必须使用相对路径
server: {
port: 7100,
cors: true,
headers: {
'Access-Control-Allow-Origin': '*'
}
},
build: {
target: 'es2015',
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom']
}
}
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
HTML入口
<!-- react-sub-app/public/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React子应用</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Vue子应用开发
接下来,咱们开发Vue子应用。这个子应用负责数据看板功能。
初始化项目
# 创建Vue子应用
npm create vite@latest vue-sub-app -- --template vue
cd vue-sub-app
npm install
安装依赖
npm install vue-router pinia axios
核心文件:导出生命周期函数
// vue-sub-app/src/micro/index.js
import { createApp } from 'vue'
import App from '../App.vue'
import router from '../router'
import { createPinia } from 'pinia'
let app = null
let routerInstance = null
/**
* bootstrap生命周期
*/
export async function bootstrap(props) {
console.log('[Vue Sub App] bootstrap', props)
}
/**
* mount生命周期
*/
export async function mount(props) {
console.log('[Vue Sub App] mount', props)
const { container } = props
// 创建Vue应用实例
app = createApp(App)
app.use(createPinia())
app.use(router)
// 挂载到容器
const mountNode = container
? container.querySelector('#vue-app')
: document.getElementById('vue-app')
if (mountNode) {
app.mount(mountNode)
routerInstance = router
console.log('[Vue Sub App] 应用已挂载到', mountNode)
} else {
console.error('[Vue Sub App] 找不到挂载容器')
}
}
/**
* unmount生命周期
*/
export async function unmount(props) {
console.log('[Vue Sub App] unmount', props)
if (app) {
app.unmount()
app = null
}
if (routerInstance) {
routerInstance = null
}
console.log('[Vue Sub App] 应用已卸载')
}
// vue-sub-app/src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
// 生产环境:导出qiankun生命周期
if (window.__POWERED_BY_QIANKUN__) {
let instance = null
window.__BOOTSTRAP__ = async () => {
console.log('[Vue Sub App] bootstrap')
}
window.__MOUNT__ = async (props) => {
console.log('[Vue Sub App] mount', props)
const { container } = props
const mountNode = container
? container.querySelector('#vue-app')
: document.getElementById('vue-app')
instance = createApp(App)
instance.use(createPinia())
instance.use(router)
instance.mount(mountNode)
}
window.__UNMOUNT__ = async () => {
console.log('[Vue Sub App] unmount')
if (instance) {
instance.unmount()
instance = null
}
}
} else {
// 独立运行
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
}
App组件
<!-- vue-sub-app/src/App.vue -->
<template>
<div id="app" class="app-container">
<div class="app-header">
<h2>数据看板</h2>
</div>
<div class="app-content">
<router-view />
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
console.log('[App] Vue子应用App组件已挂载')
})
</script>
<style>
.app-container {
width: 100%;
min-height: 100vh;
background: #f5f7fa;
}
.app-header {
padding: 20px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
}
.app-content {
padding: 20px;
}
</style>
路由配置
// vue-sub-app/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '@/views/Dashboard.vue'
import Analytics from '@/views/Analytics.vue'
const routes = [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard
},
{
path: '/analytics',
name: 'Analytics',
component: Analytics
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
页面组件
<!-- vue-sub-app/src/views/Dashboard.vue -->
<template>
<div class="dashboard">
<h3>数据概览</h3>
<div class="stats-grid">
<div class="stat-card" v-for="stat in stats" :key="stat.title">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.title }}</div>
<div class="stat-trend" :class="stat.trend">
{{ stat.trend === 'up' ? '↑' : '↓' }} {{ stat.change }}
</div>
</div>
</div>
<div class="chart-section">
<h4>访问趋势</h4>
<div class="chart-placeholder">
<p>图表区域(可集成ECharts)</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const stats = ref([
{ title: '总访问量', value: '1,234,567', trend: 'up', change: '12.5%' },
{ title: '独立访客', value: '345,678', trend: 'up', change: '8.3%' },
{ title: '平均停留', value: '3分42秒', trend: 'down', change: '2.1%' },
{ title: '转化率', value: '4.56%', trend: 'up', change: '0.8%' }
])
</script>
<style scoped>
.dashboard {
padding: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-trend {
font-size: 12px;
}
.stat-trend.up {
color: #67c23a;
}
.stat-trend.down {
color: #f56c6c;
}
.chart-section {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.chart-placeholder {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
border: 2px dashed #dcdfe6;
border-radius: 8px;
}
</style>
<!-- vue-sub-app/src/views/Analytics.vue -->
<template>
<div class="analytics">
<h3>数据分析</h3>
<div class="filters">
<select v-model="timeRange" class="filter-select">
<option value="7">最近7天</option>
<option value="30">最近30天</option>
<option value="90">最近90天</option>
</select>
</div>
<div class="chart-area">
<p>分析图表区域</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const timeRange = ref('7')
</script>
<style scoped>
.analytics {
padding: 20px;
}
.filters {
margin-bottom: 20px;
}
.filter-select {
padding: 8px 16px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
}
.chart-area {
height: 400px;
background: #fff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
}
</style>
Vite配置
// vue-sub-app/vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
base: './',
server: {
port: 7200,
cors: true,
headers: {
'Access-Control-Allow-Origin': '*'
}
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia']
}
}
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
第二个Vue子应用(系统设置)
这个子应用相对简单一些,负责系统配置功能。
核心文件
// vue-sub-app2/src/micro/index.js
import { createApp } from 'vue'
import App from '../App.vue'
import router from '../router'
import { createPinia } from 'pinia'
let app = null
export async function bootstrap(props) {
console.log('[Vue Settings Sub App] bootstrap', props)
}
export async function mount(props) {
console.log('[Vue Settings Sub App] mount', props)
const { container } = props
const mountNode = container
? container.querySelector('#settings-app')
: document.getElementById('settings-app')
app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount(mountNode)
}
export async function unmount(props) {
console.log('[Vue Settings Sub App] unmount', props)
if (app) {
app.unmount()
app = null
}
}
// vue-sub-app2/src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
if (window.__POWERED_BY_QIANKUN__) {
window.__BOOTSTRAP__ = async () => {}
window.__MOUNT__ = async (props) => {
const { container } = props
const mountNode = container
? container.querySelector('#settings-app')
: document.getElementById('settings-app')
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount(mountNode)
}
window.__UNMOUNT__ = async () => {
// cleanup
}
} else {
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
}
<!-- vue-sub-app2/src/App.vue -->
<template>
<div class="settings-app">
<div class="settings-header">
<h2>系统设置</h2>
</div>
<div class="settings-content">
<router-view />
</div>
</div>
</template>
<style scoped>
.settings-app {
width: 100%;
min-height: 100vh;
background: #f5f7fa;
}
.settings-header {
padding: 20px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
}
</style>
<!-- vue-sub-app2/src/views/GeneralSettings.vue -->
<template>
<div class="general-settings">
<h3>通用设置</h3>
<div class="form-group">
<label>系统名称</label>
<input v-model="settings.systemName" type="text" class="form-input" />
</div>
<div class="form-group">
<label>系统Logo</label>
<div class="logo-upload">
<img v-if="settings.logo" :src="settings.logo" class="logo-preview" />
<button class="upload-btn">上传Logo</button>
</div>
</div>
<div class="form-group">
<label>默认语言</label>
<select v-model="settings.language" class="form-select">
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
</select>
</div>
<button class="save-btn" @click="saveSettings">保存设置</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const settings = ref({
systemName: '微前端管理系统',
logo: '',
language: 'zh-CN'
})
const saveSettings = () => {
// 保存逻辑
console.log('保存设置:', settings.value)
alert('设置已保存')
}
</script>
<style scoped>
.general-settings {
background: #fff;
padding: 30px;
border-radius: 8px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #303133;
font-weight: 500;
}
.form-input,
.form-select {
width: 100%;
max-width: 400px;
padding: 10px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
}
.logo-upload {
display: flex;
align-items: center;
gap: 16px;
}
.logo-preview {
width: 80px;
height: 80px;
border-radius: 8px;
object-fit: cover;
border: 1px solid #e4e7ed;
}
.upload-btn {
padding: 8px 16px;
background: #409eff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.save-btn {
padding: 10px 24px;
background: #67c23a;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
</style>
跨应用通信机制
多团队协作中,应用之间的通信是最关键的问题之一。qiankun提供了多种通信方式,咱们逐一讲解。
方式一:props传递
主应用在注册子应用时,可以通过props传递数据给子应用。这是最简单的方式。
// 主应用注册子应用
registerMicroApps([
{
name: 'react-app',
entry: '//localhost:7100',
container: '#react-container',
activeRule: '/react',
props: {
// 传递用户信息
userInfo: { id: 1, name: '张三' },
// 传递主题
theme: 'light',
// 传递回调函数
onUserLogout: () => {
console.log('用户已登出')
}
}
}
])
// React子应用中接收props
function App() {
const props = window.__QIANKUN_PROPERTIES__ || {}
const { userInfo, theme, onUserLogout } = props
return (
<div>
<p>用户名:{userInfo?.name}</p>
<button onClick={onUserLogout}>登出</button>
</div>
)
}
// Vue子应用中接收props
import { inject } from 'vue'
// 在main.js中
app.provide('qiankunProps', props)
方式二:globalState(全局状态)
qiankun提供了initGlobalState和actions,用于主应用和子应用之间的双向通信。
// 主应用中
import { initGlobalState, MicroAppStateActions } from 'qiankun'
const initialState = {
userInfo: null,
theme: 'light',
language: 'zh-CN'
}
const actions = initGlobalState(initialState)
// 修改状态
actions.setState({
userInfo: { id: 1, name: '张三' }
})
// 监听状态变化
actions.onGlobalStateChange((state, prev) => {
console.log('状态变更:', state, prev)
})
// 子应用中
// 获取actions
const { state, on, off, emit } = window.__QIANKUN_GLOBAL_STATE__ || {}
// 读取状态
console.log(state.userInfo)
// 监听状态变化
on('update', (newState, prevState) => {
console.log('状态更新:', newState)
})
// 修改状态
emit('update', { userInfo: { name: '李四' } })
// 取消监听
off('update')
方式三:自定义事件总线
对于子应用之间的通信,可以创建一个全局的事件总线。
// shared/event-bus.js
class EventBus {
constructor() {
this.events = {}
}
on(event, callback) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(callback)
}
emit(event, data) {
if (this.events[event]) {
this.events[event].forEach(callback => callback(data))
}
}
off(event, callback) {
if (this.events[event]) {
if (callback) {
this.events[event] = this.events[event].filter(cb => cb !== callback)
} else {
delete this.events[event]
}
}
}
}
export const eventBus = new EventBus()
// 子应用A中发送事件
import { eventBus } from '@/shared/event-bus'
eventBus.emit('user:login', { userId: 1, userName: '张三' })
// 子应用B中监听事件
import { eventBus } from '@/shared/event-bus'
eventBus.on('user:login', (data) => {
console.log('用户登录:', data)
// 更新侧边栏显示的用户名
})
共享依赖管理
在多子应用架构中,重复加载相同的依赖(如React、Vue)会严重影响性能。咱们需要配置共享依赖。
Webpack共享配置(如果使用Webpack)
// react-sub-app/webpack.config.js
module.exports = {
output: {
library: 'reactApp',
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_react_app_${process.env.NODE_ENV}`
},
optimization: {
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
shareKey: 'react'
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
shareKey: 'react-dom'
}
}
}
}
Vite共享配置(使用Rollup)
// vite.config.js(主应用)
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
// 将公共依赖提取出来
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'qiankun': ['qiankun']
}
}
}
}
})
// vite.config.js(子应用)
export default defineConfig({
build: {
target: 'es2015',
rollupOptions: {
output: {
// 避免打包Vue/React,从外部引入
external: ['vue', 'vue-router', 'pinia', 'react', 'react-dom', 'react-router-dom'],
globals: {
vue: 'Vue',
'vue-router': 'VueRouter',
pinia: 'Pinia',
react: 'React',
'react-dom': 'ReactDOM',
'react-router-dom': 'ReactRouterDOM'
}
}
}
}
})
样式隔离方案
多团队协作中,样式冲突是最常见的问题之一。qiankun提供了多种样式隔离方案。
方案一:CSS Modules(推荐)
在每个子应用中,使用CSS Modules来隔离样式。
// vite.config.js
export default defineConfig({
css: {
modules: {
localsConvention: 'camelCase'
}
}
})
<!-- React子应用 -->
<style module>
.container {
padding: 20px;
}
.title {
font-size: 24px;
color: #333;
}
</style>
<script>
export default function App() {
return (
<div className={styles.container}>
<h1 className={styles.title}>用户中心</h1>
</div>
)
}
</script>
<!-- Vue子应用 -->
<template>
<div class="container">
<h1 class="title">数据看板</h1>
</div>
</template>
<style module>
.container {
padding: 20px;
}
.title {
font-size: 24px;
color: #333;
}
</style>
方案二:Shadow DOM
// 主应用中启用shadow DOM隔离
registerMicroApps(apps, {
sandbox: {
strictStyleIsolation: true // 启用严格样式隔离(Shadow DOM)
}
})
方案三:命名空间前缀
在子应用的样式中使用命名空间前缀,避免冲突。
/* react-app-styles.css */
.react-app-container {
padding: 20px;
}
.react-app-title {
font-size: 24px;
color: #333;
}
开发环境配置
根目录配置
// package.json(根目录)
{
"name": "micro-frontend-monorepo",
"private": true,
"scripts": {
"dev": "concurrently \"pnpm dev:main\" \"pnpm dev:react\" \"pnpm dev:vue\" \"pnpm dev:vue2\"",
"dev:main": "pnpm --filter micro-app dev",
"dev:react": "pnpm --filter react-sub-app dev",
"dev:vue": "pnpm --filter vue-sub-app dev",
"dev:vue2": "pnpm --filter vue-sub-app2 dev",
"build": "pnpm build:main && pnpm build:react && pnpm build:vue && pnpm build:vue2",
"build:main": "pnpm --filter micro-app build",
"build:react": "pnpm --filter react-sub-app build",
"build:vue": "pnpm --filter vue-sub-app build",
"build:vue2": "pnpm --filter vue-sub-app2 build"
},
"devDependencies": {
"concurrently": "^8.2.2",
"pnpm": "^8.15.0"
}
}
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'shared/*'
开发脚本
// scripts/dev.js
const { spawn } = require('child_process')
const concurrently = require('concurrently')
const apps = [
{ name: '主应用', port: 7000, cmd: 'pnpm --filter micro-app dev' },
{ name: 'React子应用', port: 7100, cmd: 'pnpm --filter react-sub-app dev' },
{ name: 'Vue子应用1', port: 7200, cmd: 'pnpm --filter vue-sub-app dev' },
{ name: 'Vue子应用2', port: 7300, cmd: 'pnpm --filter vue-sub-app2 dev' }
]
console.log('启动所有开发服务器...')
const processes = apps.map(app => {
const proc = spawn('pnpm', ['run', app.cmd], {
stdio: 'inherit',
shell: true
})
return { name: app.name, proc, port: app.port }
})
process.on('SIGINT', () => {
processes.forEach(({ proc }) => proc.kill())
process.exit(0)
})
构建与部署
Docker配置
主应用Dockerfile
# micro-app/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
# 安装pnpm
RUN npm install -g pnpm
# 复制package.json和pnpm-lock.yaml
COPY package.json pnpm-workspace.yaml ./
COPY packages/micro-app/package.json ./packages/micro-app/
# 安装依赖
RUN pnpm install --frozen-lockfile
# 复制源码
COPY . .
# 构建
RUN pnpm --filter micro-app build
# 生产环境
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/packages/micro-app/dist /usr/share/nginx/html
# 复制nginx配置
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
子应用Dockerfile
# react-sub-app/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
FROM nginx:alpine
# 关键:子应用需要使用相对路径
COPY --from=builder /app/dist /usr/share/nginx/html
# nginx配置需要允许跨域
COPY docker/nginx/sub-app.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Nginx配置
# docker/nginx/default.conf(主应用)
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# 路由配置
location / {
try_files $uri $uri/ /index.html;
}
# API代理
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 子应用代理(开发环境)
location /react-app/ {
proxy_pass http://react-app:7100/;
proxy_set_header Host $host;
}
location /vue-dashboard/ {
proxy_pass http://vue-dashboard:7200/;
proxy_set_header Host $host;
}
location /vue-settings/ {
proxy_pass http://vue-settings:7300/;
proxy_set_header Host $host;
}
}
# docker/nginx/sub-app.conf(子应用)
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 允许跨域(子应用需要被主应用加载)
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'Content-Type, Authorization';
# Gzip压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Docker Compose
# docker-compose.yml
version: '3.8'
services:
# 主应用
micro-app:
build:
context: ./packages/micro-app
dockerfile: Dockerfile
ports:
- "7000:80"
depends_on:
- react-sub-app
- vue-sub-app
- vue-sub-app2
networks:
- micro-frontend
# React子应用
react-sub-app:
build:
context: ./packages/react-sub-app
dockerfile: Dockerfile
ports:
- "7100:80"
networks:
- micro-frontend
# Vue子应用1
vue-sub-app:
build:
context: ./packages/vue-sub-app
dockerfile: Dockerfile
ports:
- "7200:80"
networks:
- micro-frontend
# Vue子应用2
vue-sub-app2:
build:
context: ./packages/vue-sub-app2
dockerfile: Dockerfile
ports:
- "7300:80"
networks:
- micro-frontend
# 后端API
backend:
image: node:20-alpine
working_dir: /app
volumes:
- ./backend:/app
command: npm start
ports:
- "8080:8080"
networks:
- micro-frontend
networks:
micro-frontend:
driver: bridge
多团队并行开发流程
团队分工
在一个典型的微前端项目中,团队分工如下:
| 团队 | 负责模块 | 技术栈 | 部署频率 |
|---|---|---|---|
| 团队A | 用户中心 | React | 每周 |
| 团队B | 数据看板 | Vue 3 | 每两周 |
| 团队C | 系统设置 | Vue 3 | 每月 |
| 团队D | 主应用(基座) | Vue 3 | 根据需要 |
| 团队E | 共享组件库 | 多框架 | 按需 |
开发流程
┌─────────────────────────────────────────────────────────────┐
│ 主应用团队(团队D) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 路由配置 │ │ 布局组件 │ │ 通信机制 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 负责:注册子应用、管理全局状态、提供公共UI框架 │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↑ ↓
┌─────────────────────────────────────────────────────────────┐
│ 子应用团队 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 团队A │ │ 团队B │ │ 团队C │ │
│ │ React子应用 │ │ Vue子应用 │ │ Vue子应用 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ↓ ↓ ↓ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 独立开发 │ │ 独立开发 │ │ 独立开发 │ │
│ │ 独立测试 │ │ 独立测试 │ │ 独立测试 │ │
│ │ 独立部署 │ │ 独立部署 │ │ 独立部署 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
CI/CD流水线
# .gitlab-ci.yml(示例)
stages:
- build
- test
- deploy
# 所有子应用并行构建
build:react-app:
stage: build
image: node:20
script:
- pnpm install
- pnpm --filter react-sub-app build
artifacts:
paths:
- packages/react-sub-app/dist/
only:
- main
- /^feature\/react/
build:vue-dashboard:
stage: build
image: node:20
script:
- pnpm install
- pnpm --filter vue-sub-app build
artifacts:
paths:
- packages/vue-sub-app/dist/
only:
- main
- /^feature\/vue/
build:vue-settings:
stage: build
image: node:20
script:
- pnpm install
- pnpm --filter vue-sub-app2 build
artifacts:
paths:
- packages/vue-sub-app2/dist/
only:
- main
- /^feature\/settings/
build:main-app:
stage: build
image: node:20
script:
- pnpm install
- pnpm --filter micro-app build
artifacts:
paths:
- packages/micro-app/dist/
only:
- main
- /^feature\/main/
dependencies:
- build:react-app
- build:vue-dashboard
- build:vue-settings
# 部署
deploy:
stage: deploy
image: docker:latest
script:
- docker-compose up -d
environment:
name: production
only:
- main
常见问题与解决方案
问题一:子应用加载失败
现象:子应用页面显示空白,控制台报错。
可能原因:
- 子应用entry地址配置错误
- 子应用没有正确导出生命周期函数
- 网络问题导致子应用资源加载失败
解决方案:
// 在主应用中添加错误处理
registerMicroApps(apps, {
beforeLoad: [(app) => console.log(`加载 ${app.name}`)],
beforeMount: [(app) => console.log(`挂载 ${app.name}`)],
afterUnmount: [(app) => console.log(`卸载 ${app.name}`)],
errors: {
// 自定义错误处理
onAppError: (error, app) => {
console.error(`子应用 ${app.name} 加载失败:`, error)
// 可以显示错误页面或重试
}
}
})
问题二:样式冲突
现象:多个子应用的样式互相干扰。
解决方案:
- 使用CSS Modules
- 启用qiankun的严格样式隔离
- 使用命名空间前缀
// 启用严格样式隔离(使用Shadow DOM)
start({
sandbox: {
strictStyleIsolation: true
}
})
问题三:全局变量污染
现象:子应用A修改了window上的全局变量,影响了子应用B。
解决方案:
// qiankun默认会隔离全局变量
// 但如果有特殊需求,可以使用proxy沙箱
start({
sandbox: {
proxy: window,
strictStyleIsolation: false
}
})
问题四:路由冲突
现象:主应用和子应用的路由发生冲突。
解决方案:
// 主应用路由
const routes = [
{
path: '/react',
component: () => import('@/views/Layout.vue')
},
{
path: '/dashboard',
component: () => import('@/views/Layout.vue')
}
]
// 子应用路由(React)
const routes = [
{
path: '/',
element: <Dashboard />
},
{
path: '/settings',
element: <Settings />
}
]
// 注意:子应用的BrowserRouter需要使用basename
<BrowserRouter basename="/react">
<App />
</BrowserRouter>
问题五:性能问题
现象:页面加载慢,首屏时间长。
解决方案:
- 使用路由懒加载
- 配置依赖共享
- 启用Gzip压缩
- 使用CDN加载公共依赖
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'react-vendor': ['react', 'react-dom'],
'qiankun': ['qiankun']
}
}
}
},
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia']
}
})
监控与日志
主应用监控
// micro-app/src/utils/monitor.js
class Monitor {
constructor() {
this.logs = []
}
log(type, message, data = {}) {
const log = {
timestamp: new Date().toISOString(),
type,
message,
data,
userAgent: navigator.userAgent
}
this.logs.push(log)
console.log(`[Monitor] [${type}] ${message}`, data)
// 上报到日志服务
this.report(log)
}
report(log) {
// 这里可以对接Sentry、阿里云日志服务等
fetch('/api/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(log)
}).catch(err => console.error('日志上报失败:', err))
}
// 子应用加载监控
trackAppLoad(appName, status, duration) {
this.log('app-load', `${appName} 加载完成`, { status, duration })
}
// 错误监控
trackError(appName, error) {
this.log('error', `${appName} 发生错误`, { error: error.message, stack: error.stack })
}
}
export const monitor = new Monitor()
// 在主应用中集成监控
import { monitor } from './utils/monitor'
registerMicroApps(apps, {
beforeMount: [(app) => {
const start = Date.now()
app.__mountStartTime = start
}],
afterMount: [(app) => {
const duration = Date.now() - app.__mountStartTime
monitor.trackAppLoad(app.name, 'success', duration)
}],
errors: {
onAppError: (error, app) => {
monitor.trackError(app.name, error)
}
}
})
性能优化建议
1. 预加载子应用
// 在主应用启动时预加载子应用
function preloadApps() {
const preloadApps = ['react-app', 'vue-dashboard']
preloadApps.forEach(appName => {
const app = apps.find(a => a.name === appName)
if (app) {
// 预加载子应用资源
const link = document.createElement('link')
link.rel = 'prefetch'
link.href = `${app.entry}/index.html`
document.head.appendChild(link)
}
})
}
preloadApps()
2. 按需加载子应用
// 根据路由动态加载子应用
const appRegistry = {}
async function loadApp(appName) {
if (!appRegistry[appName]) {
const app = apps.find(a => a.name === appName)
if (app) {
// 动态加载子应用
await import(/* webpackChunkName: "[request]" */ app.entry)
appRegistry[appName] = true
}
}
}
// 路由变化时预加载可能用到的子应用
router.afterEach((to) => {
const appName = getAppNameFromRoute(to.path)
if (appName) {
loadApp(appName).catch(console.error)
}
})
3. 缓存策略
// 子应用资源缓存
// nginx配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
总结与最佳实践
关键要点回顾
- 架构设计:微前端不是银弹,要根据实际业务需求选择合适的方案
- 通信机制:合理使用props、globalState和eventBus,避免过度耦合
- 样式隔离:优先使用CSS Modules,必要时启用Shadow DOM
- 依赖管理:配置共享依赖,避免重复加载
- CI/CD:建立完善的自动化构建和部署流程
- 监控日志:建立完善的监控体系,及时发现和解决问题
团队最佳实践
| 实践 | 说明 |
|---|---|
| 独立分支开发 | 每个子应用有独立的Git仓库和分支策略 |
| 版本管理 | 子应用使用语义化版本,主应用依赖具体版本 |
| 接口契约 | 定义清晰的API契约,前后端并行开发 |
| 自动化测试 | 每个子应用有独立的单元测试和集成测试 |
| 代码审查 | 子应用代码由对应团队审查,主应用代码由主团队审查 |
| 文档维护 | 每个子应用维护独立的README和API文档 |
避坑指南
- 不要过度拆分:微前端有额外的复杂度,不要为了拆分而拆分
- 统一规范:制定统一的技术规范和编码规范
- 充分测试:上线前进行充分的集成测试和回归测试
- 监控告警:建立完善的监控告警机制
- 灰度发布:重要变更采用灰度发布策略
最后的唠叨
聊了这么多,其实微前端的核心思想很简单:把大问题拆成小问题,每个小问题交给合适的人去解决。
我见过太多团队因为一个超级大的前端项目而痛苦不堪——代码合并冲突天天有,上线发布像打仗,新人入职要学半年。引入微前端之后,这些痛苦真的会减少很多。
当然,微前端也不是完美的。它带来了额外的复杂度,需要更多的协调和沟通成本。但如果你的团队规模够大、业务模块足够复杂,那么微前端的收益是远超成本的。
希望这篇文章能帮到你。如果有任何问题,欢迎在评论区交流。记住,最好的架构不是最复杂的,而是最适合你团队和业务的那一个。
加油,前端人!🚀
