说实话,做大型前端架构演进的时候,最让人头疼的往往不是技术选型,而是“怎么把一堆不想动的历史包袱拆分开,同时还不让用户感觉到卡顿和割裂”。很多团队在引入微前端时,容易陷入两个极端:要么为了微而微,搞出一套复杂的方案反而增加了维护成本;要么完全不敢动,任由代码库膨胀到难以维护。
今天我们就聊聊在单页面应用(SPA)语境下,如何优雅地实现 Vue 和 React 主应用与子应用之间的协同。这不是教科书式的理论堆砌,而是基于真实生产环境踩过坑后的实战总结。
为什么我们需要在 SPA 里谈微前端拆分
首先得厘清一个概念:传统的微前端更多出现在多页应用(MPA)或者混合架构中,但在 Vue/React 这类 SPA 框架主导的项目里,我们面临的挑战非常具体。
想象一下你的公司有一块核心业务板块,最初是用 Vue 2 写的,随着业务发展,团队规模扩大,技术栈也分化了。新业务想用 React 18 来构建,但老业务不能动,也不想重写。如果强行合并在一个代码库里,会出现什么场景?
- 依赖冲突:Vue 2 和 React 18 的某些工具链版本可能不兼容。
- 构建缓慢:一个巨大的 monorepo 会导致 webpack/vite 构建时间 exponentially 增长。
- 部署困难:修改一处代码可能需要重新部署整个应用,即使两个模块毫无关联。
- 技术栈僵化:新团队被迫使用旧技术栈,老团队也被迫适应新技术栈的约束。
这就是微前端要解决的问题:让不同的技术栈可以独立开发、独立部署,但在用户看来,它们是一个无缝的整体应用。
核心架构选型:基座与子应用的边界定义
在实际操作中,我们通常会构建一个“基座应用”(Shell App),它负责全局路由、状态管理和子应用的加载调度。而各个子应用则保持相对独立,拥有自己的路由栈和运行环境。
这里有一个关键的设计原则:子应用不应该知道自己是“被加载”的,基座也不应该直接干预子应用的内部逻辑。
基座应用的设计思路
基座应用通常是一个简单的 Vue 或 React 项目,它的核心职责只有三件事:
- 路由分发:监听 URL 变化,判断当前请求应该由哪个子应用处理。
- 生命周期管理:控制子应用的 mount、unmount 和 update。
- 全局状态同步:处理用户认证信息、主题配置等需要跨子应用共享的数据。
// 基座应用的路由配置示例 (Vue Router)
const routes = [
{
path: '/',
redirect: '/home'
},
{
path: '/home',
component: () => import('@/views/HomeView.vue') // 基座自带的页面
},
{
path: '/react-app',
component: () => import('@/views/MicroApp.vue'), // 微前端容器组件
props: { name: 'react-app' }
},
{
path: '/vue-old',
component: () => import('@/views/MicroApp.vue'),
props: { name: 'vue-old' }
}
]
注意,我们这里用了一个通用的 MicroApp.vue 组件来承载所有子应用,通过 props 传入应用的标识符。这样设计的好处是基座的路由配置非常干净,新增子应用只需要在这里加一条记录。
子应用的独立性与通信协议
子应用之所以能独立运行,是因为它们都遵循一套标准的通信协议。最常见的是采用 qiankun 或 single-spa 这样的微前端框架,它们为子应用定义了明确的 bootstrap、mount、unmount 生命周期。
在 Vue 子应用中,你需要导出这些生命周期钩子:
// src/main.js (Vue 子应用)
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
let instance = null
let routerInstance = null
export async function bootstrap() {
console.log('[vue-app] bootstrap')
}
export async function mount(props) {
console.log('[vue-app] mount', props)
// 创建实例
instance = createApp(App)
routerInstance = new router.constructor({
routes: props.routes || [] // 可以动态注入路由
})
// 挂载到基座提供的容器中
instance.use(routerInstance)
const container = props.appContainer || '#vue-app-container'
instance.mount(container)
// 暴露基座传递的方法
props.onGlobalStateChange && props.onGlobalStateChange((newState, prev) => {
console.log('[vue-app] global state changed', newState)
})
}
export async function unmount() {
console.log('[vue-app] unmount')
instance?.unmount()
instance = null
routerInstance = null
}
同样的逻辑在 React 子应用中也有对应的实现:
// src/index.js (React 子应用)
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { BrowserRouter } from 'react-router-dom'
let root = null
export async function bootstrap() {
console.log('[react-app] bootstrap')
}
export async function mount(props) {
console.log('[react-app] mount', props)
const container = props.appContainer || '#react-app-container'
root = ReactDOM.createRoot(container)
root.render(
<React.StrictMode>
<BrowserRouter basename="/react-app">
<App />
</BrowserRouter>
</React.StrictMode>
)
// 监听全局状态变化
if (props.onGlobalStateChange) {
props.onGlobalStateChange(
(newState, prev) => console.log('[react-app] state change', newState, prev),
true // immediate
)
}
}
export async function unmount(props) {
console.log('[react-app] unmount')
root?.unmount()
root = null
}
这里有一个细节值得注意:React 的 StrictMode 和 BrowserRouter 的 basename 设置。在微前端环境中,子应用的路由是嵌套在基座路由之下的,所以必须通过 basename 来限定路由的作用域,否则会出现路由匹配冲突。
路由协同:解决 SPA 嵌套路由的经典难题
路由协同是微前端中最棘手的问题之一。想象一下这个场景:用户在基座应用的路由 /react-app/settings 中,子应用应该渲染设置页面。但基座的路由器可能并不认识 /settings 这个路径,它只知道 /react-app。
方案一:白名单机制
最简单的方案是让基座维护一个子应用的路由白名单。当 URL 变化时,基座检查当前路径是否属于某个子应用的白名单,如果是,则将控制权交给子应用。
// 基座应用中的路由监听逻辑
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { loadMicroApp } from 'qiankun'
const appRegistry = {
'react-app': {
activeRule: '/react-app',
entry: '//localhost:3001',
routes: ['/settings', '/profile', '/dashboard']
},
'vue-old': {
activeRule: '/vue-old',
entry: '//localhost:3002',
routes: ['/user-list', '/order']
}
}
export function useMicroAppRouter() {
const route = useRoute()
let currentApp = null
watch(() => route.path, (newPath) => {
// 找出当前应该激活的子应用
const matchedApp = Object.entries(appRegistry).find(([name, config]) => {
return newPath.startsWith(config.activeRule)
})
if (matchedApp) {
const [appName, config] = matchedApp
if (currentApp !== appName) {
// 切换子应用
switchToApp(appName, config)
}
} else {
// 回退到基座默认路由
if (currentApp) {
unmountCurrentApp()
currentApp = null
}
}
})
function switchToApp(name, config) {
// 这里调用 qiankun 的 loadMicroApp 或其他加载逻辑
currentApp = name
// ...加载子应用
}
function unmountCurrentApp() {
// ...卸载子应用
currentApp = null
}
}
这种方案的优点是实现简单,但缺点是路由维护成本高,每次新增子应用页面都需要在基座中更新白名单。
方案二:动态路由注入
更优雅的方案是让子应用自己管理路由,基座只负责“空壳”路由,然后通过某种机制将子应用的路由表动态注入到基座的路由配置中。
// 基座应用路由配置
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: '/home'
},
{
path: '/home',
component: () => import('@/views/HomeView.vue')
},
// 通配路由,捕获所有微应用路径
{
path: '/:app(.*)',
component: () => import('@/views/MicroShell.vue')
}
]
})
// 全局前置守卫中动态注入子应用路由
router.beforeEach(async (to, from, next) => {
const appMatch = to.path.match(/^\/([^\/]+)/)
if (appMatch) {
const appName = appMatch[1]
// 尝试从本地缓存或远程配置获取子应用的路由表
const subRoutes = await loadSubAppRoutes(appName)
if (subRoutes) {
// 动态添加路由
router.addRoute({
path: `/${appName}/:pathMatch(.*)`,
component: () => import('@/views/MicroShell.vue'),
props: { appName }
})
}
}
next()
})
async function loadSubAppRoutes(appName) {
// 从子应用的 manifest 或远程配置中获取路由信息
// 这通常需要子应用在启动时向基座注册自己的路由能力
try {
const response = await fetch(`//${appName}.example.com/routes.json`)
return await response.json()
} catch (e) {
console.error(`Failed to load routes for ${appName}`, e)
return null
}
}
这个方案的关键在于子应用需要提供自己的路由元数据。在 Vue 子应用中,你可以这样实现:
// Vue 子应用暴露路由信息
export function getSubAppRoutes() {
return [
{ path: '/settings', component: SettingsView },
{ path: '/profile', component: ProfileView },
{ path: '/dashboard', component: DashboardView }
]
}
// 在 mount 时注册
export async function mount(props) {
// ...
if (props.registerRoutes) {
props.registerRoutes('vue-old', getSubAppRoutes())
}
}
状态共享:跨应用通信的实战技巧
在微前端架构中,不同子应用之间的状态共享是一个常见需求。比如用户登录状态、主题颜色、语言设置等。解决方案通常有几种:
1. 基座全局状态总线
基座应用提供一个全局的状态管理对象,子应用可以通过基座暴露的 API 来读写状态。
// 基座应用提供的全局状态管理器
class GlobalState {
constructor() {
this.state = {
userInfo: null,
theme: 'light',
language: 'zh-CN'
}
this.listeners = new Map()
}
getState(key) {
return this.state[key]
}
setState(key, value) {
this.state[key] = value
this.notify(key, value)
}
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set())
}
this.listeners.get(key).add(callback)
}
notify(key, value) {
const callbacks = this.listeners.get(key)
if (callbacks) {
callbacks.forEach(cb => cb(value))
}
}
}
// 导出单例
export const globalState = new GlobalState()
子应用在使用时:
// Vue 子应用中使用全局状态
import { globalState } from '@/microAppState'
export async function mount(props) {
// 监听用户信息变化
globalState.subscribe('userInfo', (userInfo) => {
console.log('[vue-app] user info changed', userInfo)
// 更新子应用内的状态
})
// 读取当前用户信息
const user = globalState.getState('userInfo')
}
2. URL 参数传递
对于简单的一次性数据传递,可以通过 URL 参数来实现。基座在加载子应用时,可以将需要的参数附加到子应用的 entry URL 中。
// 基座加载子应用时传递参数
const params = new URLSearchParams({
userId: '12345',
token: 'abcxyz',
from: 'home'
})
loadMicroApp({
name: 'react-app',
entry: `//localhost:3001?${params.toString()}`,
container: '#react-app-container',
props: {
initialData: { userId: '12345', token: 'abcxyz' }
}
})
子应用则在启动时解析这些参数:
// React 子应用解析 URL 参数
export async function mount(props) {
const params = new URLSearchParams(window.location.search)
const userId = params.get('userId')
const token = params.get('token')
// 使用这些参数初始化应用
initApp({ userId, token })
}
3. 自定义事件总线
对于更复杂的跨应用通信,可以基于 CustomEvent 实现一个事件总线。
// 基座提供的事件总线
export const microEventBus = {
emit(event, data) {
window.dispatchEvent(new CustomEvent(`micro:${event}`, { detail: data }))
},
on(event, callback) {
window.addEventListener(`micro:${event}`, (e) => callback(e.detail))
},
off(event, callback) {
window.removeEventListener(`micro:${event}`, callback)
}
}
子应用可以这样使用:
// Vue 子应用监听事件
microEventBus.on('user:login', (userInfo) => {
// 处理登录事件
updateUserInfo(userInfo)
})
// React 子应用触发自定义事件
microEventBus.emit('theme:change', { theme: 'dark' })
性能优化:首屏加载与资源预取
微前端架构最常见的性能问题是首屏加载变慢。因为需要加载多个应用,资源总量可能比单体应用更大。解决这个问题需要从几个角度入手:
1. 懒加载与路由预取
不要一次性加载所有子应用,而是根据用户当前访问的路由,只加载对应的子应用。同时,可以利用用户浏览其他页面时的空闲时间,预取即将可能访问的子应用资源。
// 基座应用中的预取逻辑
const prefetchCache = new Map()
async function prefetchApp(appName) {
if (prefetchCache.has(appName)) return
// 预取子应用的 JS bundle
const response = await fetch(`//${appName}.example.com/main.js`)
const code = await response.text()
prefetchCache.set(appName, code)
console.log(`Prefetched ${appName}`)
}
// 在用户 hover 或空闲时触发预取
window.addEventListener('mouseover', (e) => {
const appLink = e.target.closest('[data-app]')
if (appLink) {
const appName = appLink.dataset.app
prefetchApp(appName)
}
})
// 使用 requestIdleCallback 在空闲时预取
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
prefetchApp('react-app')
prefetchApp('vue-old')
})
}
2. 资源复用与缓存策略
如果多个子应用依赖相同的第三方库(如 React、Vue、lodash 等),应该考虑将这些公共依赖提取出来,由基座统一加载,子应用通过 System.import 或类似机制复用,避免重复加载。
// 在基座中声明公共依赖
const sharedLibs = {
react: '//cdn.example.com/react.production.min.js',
'react-dom': '//cdn.example.com/react-dom.production.min.js',
vue: '//cdn.example.com/vue.global.prod.js',
lodash: '//cdn.example.com/lodash.min.js'
}
// 子应用配置中声明需要共享的依赖
const appConfig = {
'react-app': {
shared: ['react', 'react-dom', 'lodash']
},
'vue-old': {
shared: ['vue', 'lodash']
}
}
3. 沙箱隔离与样式隔离
为了避免子应用之间的样式污染,建议使用 CSS Modules 或 Scoped CSS。在微前端框架层面,可以使用 shadow DOM 或 iframe 来实现样式隔离,但要注意这可能会带来一些兼容性问题。
”`vue
<!-- 子应用内容 -->
