基于VITE手动搭建 Single-spa框架项目

322 阅读6分钟

缘起

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