缘起
2020年1月前后,前前后后公司好几个项目均有后台管理系统,在前端视角,后台管理系统的基础功能,例如用户管理,权限管理,菜单管理,缓存以及各APP版本管理,均属于重复开发,严重浪费开发资源。
于是,各项目组的负责人和各业务线领导碰头后,提出了三种解决方案。
方案A:
使用前端组件的方式,扩大组件封装颗粒度,将基础功能抽离作为组件,把接口和数据字段作为组件参数配置,来实现个功能或模块的复用。好处是后台不需要太大改动,只需要前端重新封装组件即可。坏处是后台仍然没有清理掉冗余的相关模块,且各个系统之间的鉴权和token传递。
方案B:
使用微服务方案,前后端均采用微服务方案,按功能垂直切分模块,各自独立部署。好处是各个服务均独立部署和运行,坏处就是后续开发,要改公共功能的时候就不怎么方便了。
方案C:
使用serverless方案,服务原子化,前端只用node作为中间层,去聚合各个接口,前端功能模块部分,仍然使用组件的方式去抽离重复功能。好处是后台需要开发的功能比较少,后续公共模块需要变更时,只需要在node层重新去组合接口即可。坏处就是前端多出了维护node接口聚合的工作量,以及多了一套node层的部署成本。
综合上述考虑之后,最终使用了方案B,于是就有了此文。
前端微服务方案
确认前后端均采用微服务+单点登录的方案后,就剩下去筛选前端微服务方案了。这就是另外一个故事了,等我在有空的时候,另起一篇文章来聊聊前端微服务方案的选择。
好了,接下来,我们就手摸手,一起建立一个基于vite打包工具的single-spa项目吧。
root项目,称之为插座(基座)
以vue项目为例,搭建一个vue项目。作为基座项目的vue项目结构如下:
vue3-single-spa-base/
├── public/ # 静态资源目录
├── src/
│ ├── assets/ # 本地静态资源
│ ├── components/ # 公共组件
│ │ └── AppContainer.vue # 微应用容器
│ ├── micro-apps/ # 子应用配置目录
│ │ └── config.ts # 子应用注册表
│ ├── router/ # 路由配置
│ │ └── index.ts
│ ├── utils/ # 工具函数
│ │ └── load-app.ts # 动态加载逻辑
│ ├── App.vue # 主应用根组件
│ ├── main.ts # 应用入口
│ ├── single-spa-config.ts # single-spa核心配置
│ └── vue-shim.d.ts # 类型声明
├── .eslintrc.cjs # ESLint配置
├── .prettierrc # Prettier配置
├── index.html # 主HTML入口
├── package.json # 项目依赖
├── tsconfig.json # TypeScript配置
└── vite.config.ts # Vite配置
在20年左右的时候,vue3还没有发布,使用的vue2.x搭建的,也没有vite,再次搭建的时候,vue3 + vite 已经成为比较常用的主流技术了。默认都会使用vue3 + vite 去搭建 VUE 项目。OK,那我就重点解释一下,vue3 项目中关于single-spa的配置。
基座项目中,需要安装个核心依赖:
npm install single-spa
single-spa 目前使用的是5.x版本,参考文档。
为了灵活配置子项目,我们将子项目的配置单独处理,放在了 micro-apps 中,所有子项目相关配置,集中到这里。我的 demo 子项目,配置如下:
export interface MicroAppConfig { // 定义子项目配置类型
name: string
entry: string
containerId: string
activeRule: string,
excludePaths?: string[]
}
export const apps: MicroAppConfig[] = [
{
name: 'child-app', // 建议与子项目名称保持一致
entry: 'http://localhost:3002/src/main.ts', // 子项目的入口
containerId: 'child-container', // 子项目加载的容器ID
activeRule: '/child-app/', // 必须带斜杠,激活子项目的路径
excludePaths: ['/child-app/config'] // 排除特定子路径
}
]
配置完 demo 子项目,需要写一个工具函数,加载器,用于加载子项目
// 文件位于 utils/load-app.ts
import type { MicroAppConfig } from '@/micro-apps/config'
interface AppLifecycles {
bootstrap: () => Promise<void>
mount: (props: { container: HTMLElement }) => Promise<void>
unmount: () => Promise<void>
}
export const loadMicroApp = async (config: MicroAppConfig): Promise<AppLifecycles> => {
try {
console.log(`[加载器] 开始加载 ${config.name}`)
const { bootstrap, mount, unmount } = await import(/* @vite-ignore */ config.entry)
console.log(`[加载器] ${config.name} 加载成功`, config.entry)
return {
bootstrap: () => {
console.log(`[生命周期] ${config.name} bootstrap`)
return bootstrap()
},
mount: (props: any) => {
console.log(`[生命周期] ${config.name} mount`, props)
return mount(props) // 这里,props需要包含 子项目需要加载的目标元素,或者目标元素的id
},
unmount: () => {
console.log(`[生命周期] ${config.name} unmount`)
return unmount()
}
}
} catch (err) {
console.error(`[加载器] ${config.name} 加载失败:`, err)
return {
bootstrap: () => Promise.resolve(),
mount: () => Promise.resolve(),
unmount: () => Promise.resolve()
}
}
}
有了加载器,我们就可以来配置 single-spa 的入口了,即:初始化 single-spa 包含的项目,将子项目注册到 single-spa 中去。
// single-spa-config.ts
import { registerApplication, start } from 'single-spa'
import { loadMicroApp } from '@/utils/load-app'
import { apps } from '@/micro-apps/config'
export const initMicroApps = () => {
console.log('[single-spa] 开始注册应用')
apps.forEach(app => {
console.log('[single-spa] 注册子应用:', app.name)
registerApplication({
name: app.name,
app: () => loadMicroApp(app),
activeWhen: (location) => {
// 精确匹配逻辑
const basePath = app.activeRule.endsWith('/') ? app.activeRule : `${app.activeRule}/`
const isActive = location.pathname.startsWith(basePath) &&
location.pathname !== app.activeRule
console.log(`[激活检查] 路径: ${location.pathname} | 规则: ${app.activeRule} | 结果: ${isActive}`)
return isActive
}
})
})
start({
// 关键修复点:必须设置为 false 以允许路由事件冒泡
urlRerouteOnly: false
})
console.log('[single-spa] 应用注册完成')
}
好了,注册方法写完之后,就需要改造基座项目中的入口函数了,即:main.ts 中,vue3 的入口,代码如下:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { initMicroApps } from './single-spa-config'
// 先创建应用实例
const app = createApp(App)
// 先安装路由
app.use(router)
// 重要!确保路由安装完成后再初始化微前端 | 如果在路由未挂载完,有可能导致导航到子项目的路由会有概率失效,目前没找到原因
router.isReady().then(() => {
app.mount('#app') // 挂载实例
initMicroApps() // 注册子项目
})
哦,对了,挂载子项目,也需要一个容器,可以写死在 index.html 中,如:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Custom Vue3 Project</title>
</head>
<body>
<div id="app"></div>
<div id="child"><div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
也可以另外写一个组件,如:
// components/AppContainer.vue
<template>
<div class="micro-app-container">
<div :id="containerId"></div>
<div class="loading" v-if="loading">Loading...</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{
containerId: string
}>()
const loading = ref(true)
onMounted(() => {
console.log('AppContainer mounted', props.containerId)
setTimeout(() => loading.value = false, 500) // 模拟加载状态,可以使用子项目的mounted替换
})
</script>
<style scoped lang="scss">
.micro-app-container {
border: 1px dashed #e5e7eb;
padding: 1rem;
margin: 1rem 0;
min-height: 300px;
}
.loading {
color: #6b7280;
text-align: center;
padding: 2rem;
}
</style>
如果使用单独的 vue 路由作为容器,需要在router中配置对应路由,目前我的demo使用的是单独 vue 文件作为容器,路由配置如下:
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
component: () => import('@/views/HomeView.vue'),
meta: { title: '首页' }
},
{
// 添加精确匹配排除
path: '/child-app/:pathMatch(.*)*',
component: () => import('@/components/AppContainer.vue'),
props: { containerId: 'child' }, // 此处的containerId配置的使用,可以在AppContainer中找到
meta: { isMicroApp: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes,
// 添加严格匹配模式
strict: true
})
export default router
好了,基座项目的大概结构也就是这样了,接下来我们需要一个 demo 子项目,将子项目集成到基座中。
子项目
同样,子项目也使用 vue3 + vite + ts + vue-router 搭建。single-spa 对子项目的入侵较小,几乎无需改动,只需要在子项目的入口,即:main.ts,在 main.ts 中,将 vue3 的生命周期函数导出即可,子项目入口修改如下:
// 子项目入口
import { createApp, App as VueApp } from 'vue'
import App from './App.vue'
import router from './router'
let vueInstance: VueApp | null = null
let mounted = false
// 子应用独立运行判断
const isStandalone = !window.singleSpaNavigate
// 生命周期函数必须导出
export const bootstrap = async () => {
console.log('[子应用] bootstrap')
}
export const mount = async (props: { container: any }) => {
console.log('[子应用] mount', props)
if (!vueInstance) {
vueInstance = createApp(App)
.use(router)
}
if (!mounted) {
const container = props.container
? props.container.querySelector('#child')
: document.getElementById('child')
vueInstance.mount(container)
mounted = true
}
}
export const unmount = async () => {
console.log('[子应用] unmount')
if (vueInstance && mounted) {
vueInstance.unmount()
mounted = false
vueInstance = null
}
}
// 独立运行模式
if (isStandalone) {
console.log(isStandalone, '----')
createApp(App)
.use(router)
.mount('#child-one')
}
当然,如果子项目是react,同样也只需要将生命周期导出。如需要 demo 源码,见github