前言
随着技术的飞速发展,我们公司内部一个基于"上古神器" jQuery + PHP 构建的十年历史老项目已显力不从心,技术非常老旧且维护成本高昂,其实已经无数次想要重构,但是苦于历史遗留原因以及业务的稳定性而一直难以下手,而且一时半会又不能全部重构。本次新页面较多且后续将持续迭代新模块,而老页面的改动较少且代码库错综复杂,牵一发而动全身。
经过几番思考,我们发现微前端是一种非常实用的去实施渐进式重构的架构 ,很适合用微前端技术来完成本次需求,最终决定**利用 Vue3 + Vite 搭建一个全新的基座(主应用),作为新旧系统融合的桥梁,将原来的老项目接入到基座,后面的新需求都在新项目里面开发就行,不用再动老项目。**此举不仅实现了新页面用 Vue3 开发,而且老项目也能和新项目融合在一起,既保持了旧系统的稳定运行,又引入了新技术栈的活力。
同时,鉴于我们另一个 Vue2 + webpack 项目也同样面临技术过时和项目规模庞大的问题,每次开发时运行起来非常卡顿,打包很慢,后期难以维护,也需要用微前端来进行一些拆分,不可能一直往该项目上堆代码。
所以,我们决定一步到位,设计了一套微前端项目模板,将微前端的核心配置抽象为可复用的插件,并结合自研组件库、HTTP请求、权限控制等插件,构建了一个全面的项目脚手架,旨在简化未来项目的搭建流程,提升开发效率,确保技术栈的先进性与可持续性。
微前端框架选型(MicroApp)
从对⽐图可以看出,⽬前开源的微前端框架中有的功能 Micro App都有,并提供了⼀些它们不具备的功能,⽐如静态资源地址补全,元素隔离,插件系统等。
我们本次项目使用的是 Vue3+Vite+TypeScript 的技术栈,在综合对比了各个框架之后,认为MicroApp是最适合我们当前现实情况的。原因有下:
-
使用简单,将所有功能都封装到一个类WebComponent组件内,从而实现在基座应用中嵌入一行代码即可渲染一个微前端应用。
-
不需要像 single-spa 和 qiankun 一样要求子应用修改渲染逻辑并暴露出方法,也不需要修改webpack配置,是目前市面上接入微前端成本最低的方案。
-
功能丰富,提供了 js沙箱、样式隔离、元素隔离、预加载、数据通信、静态资源补全等一系列完善的功能。
-
零依赖,这赋予它小巧的体积和更高的扩展性。
-
兼容所有框架,为了保证各个业务之间独立开发、独立部署的能力,micro-app做了诸多兼容,在任何技术框架中都可以正常运行。
-
侵入性低:对原代码几乎没有影响。
-
组件化:基于webComponents思想实现微前端。
微前端架构设计(pnpm+monorepo)
使用 pnpm 和 monorepo 管理项目依赖和代码结构,确保所有子应用和基座应用都位于同一仓库的不同目录下,便于集中管理和版本控制。
javascript
/root
|-- /packages
|-- /main-app # 基座应用
|-- /old-app-wrapper # 老项目接入容器
|-- /new-module-a # 新应用A
|-- /new-module-b # 新应用B
...
|-- /pnpm-workspace.yaml
|-- /package.json
-
基座应用(Main App)
- 使用 Vue 3 + Vite 搭建的基座应用将成为整个系统的核心,负责路由管理、权限验证、资源加载等基础设施功能。基座应用应保持轻量级,避免过度耦合,并提供必要的API和事件系统供子应用使用。
-
子应用(Micro Apps)
-
老项目接入(旧应用容器) :将老项目(PHP+jQuery)作为一个子应用,确保与基座应用的隔离和独立运行。新建一个项目,用于专门展示老项目页面,先在路由表中给所有路由都添加一个 iframeUrl参数(存的是旧页面的地址),并封装一个 iframe 组件,在组件中监听路由变化,动态更新Iframe的src,每一次切换路由,就将页面的地址传入 iframe 组件,从而加载出对应的老页面。
-
新模块开发(新应用容器) :新页面和模块直接在Vue 3项目中开发,利用Vue 3的Composition API、响应式系统等优势,提高开发效率和代码质量。当旧系统中有某个部分要重构时,则将旧项目中的路由下线,并将重构后的模块进行上线,实现无缝替换。
-
-
通信机制
-
建立基座应用与子应用之间的有效通信机制,如使用自定义事件、全局状态管理(如Vuex或Zustand)或专门的通信库。
-
自定义事件:基座应用可以监听来自子应用的自定义事件,并作出相应处理。子应用同样可以监听基座应用的事件。
-
全局状态管理:使用Vuex或Zustand等状态管理库,在基座应用中维护全局状态,子应用可以通过API访问或修改这些状态(如果允许)。
-
专门的通信库:如使用single-spa、qiankun等微前端框架提供的API进行通信。
-
项目模板与脚手架
-
模板设计:
-
核心配置插件化:将微前端的核心配置(如子应用注册、加载策略、生命周期管理等)封装成可复用的插件,便于在不同项目中快速集成。
-
自研组件库:整合并封装常用的UI组件,提高开发效率和界面一致性。
-
HTTP请求插件:封装统一的HTTP请求处理逻辑,包括请求拦截、响应处理、错误重试等,简化API调用。
-
权限控制插件:基于角色或权限的动态路由控制,确保系统的安全性。
-
-
脚手架构建:
-
使用Vite作为构建工具,利用其快速冷启动和热模块替换特性,提升开发体验。
-
集成ESLint、Prettier等代码质量工具,确保代码风格统一和减少错误。
-
提供一键生成项目结构的脚本,包括基础目录、配置文件、基础路由和页面模板等。
-
-
CICD:
- 统一的CICD流程为各个子应用和主应用提供统一的构建/部署流程
微前端设计思路
-
拆分功能模块:首先,我们需要将整个后台管理系统拆分为多个独立的功能模块,如用户管理模块、专项管理模块、订单管理模块等。每个模块都可以作为一个独立的微应用进行开发和维护。
-
设计通信协议:为了实现各个微应用之间的通信和资源共享,我们需要设计一套统一的通信协议和API。例如,我们可以定义一个emit方法来触发自定义事件,以及一个on方法来监听自定义事件;我们还可以使用Webpack的CommonsChunkPlugin插件来实现公共资源的提取和共享。
-
开发主应用:主应用是整个后台管理系统的入口,它负责加载和管理各个微应用。主应用需要提供一个容器元素来承载各个微应用的内容,并提供一些基础设施服务,如路由管理、状态管理等。此外,主应用还需要实现与各个微应用的通信和资源共享。
-
开发微应用:每个微应用都是一个独立的功能模块,它可以独立开发、部署和运行。每个微应用都需要提供一个容器元素来承载该应用的内容,并提供一些与主应用交互的接口,如共享资源、通信等。此外,微应用还需要实现自身的业务逻辑和界面展示。
-
集成测试:在完成各个微应用的开发后,我们需要对整个系统进行集成测试,确保各个微应用之间的通信和资源共享正常工作。此外,我们还需要对整个系统的性能、稳定性等进行测试和优化。
v-micro-app-plugin
本文中的微前端项目,使用的是 v-micro-app-plugin
插件 ,它是一款基于京东MicroApp框架的微前端插件,旨在帮助开发者快速地将微应用集成到不同的系统中,实现高效、灵活的前端模块化开发。以下是详细的使用指南,希望能够帮助你快速上手。
本文中的案例-资源地址
- 微前端插件 v-micro-app-plugin 源码地址:GitHub - yoguoer/v-micro-app-plugin: v-micro-app-plugin是一款基于MicroApp的微前端插件,实现快速安装使用。
- 用该插件搭建的的示例项目 vMicroVerseHub 源码地址:GitHub - yoguoer/vMicroVerseHub: vMicroversehub 是一个使用 v-micro-app-plugin搭建的 monorepo 微前端项目框架,主要用于给出案例,更好地展示插件的功能和用法。
该插件暂时放在私有 npm 包中,外部无法获取,但是可以直接将项目中打包后生成的dist文件夹复制下来,放到需要使用的项目中的 node_modules 文件夹下即可。(虽然这并不是一个推荐的做法,因为它绕过了 npm 的包管理和版本控制功能,可能会导致一系列问题,包括版本冲突、难以维护和更新等。)当然,也可以私信我,我直接把包分享给你啦!~~
项目实践
技术栈:
主应用:Vue3+Vite+TypeScript
子应用1(老项目):用 iframe 挨个嵌入
子应用2(新模块):react / Vue3 ...
本次重构的是一个后台管理系统,最外层是基座,基座不仅是微前端应用集成的一个关键平台,还承载着维护公共资源、管理依赖项以及确立开发规范的重要使命。具体而言,其职责可概括为以下几点:
-
子应用集成,给子应用提供渲染容器
-
权限管理
-
会话管理
-
路由、菜单管理
-
主题管理
-
共享依赖
-
多语言管理(important)
因为micro-app对主应用和子应用的技术栈没有任何要求,所以,我们新建三个项目,my-app(Vue3)、my-app1(React)、my-app2(Vue2)。my-app是整体项目的主应用,也就是基座,my-app1和my-app2都是平级的子应用。
搭建微前端基座
1、创建一个项目作为主应用,这个步骤就不赘述了。
笔者创建了一个主应用叫 main-app,提供一个框架给子应用。
2、安装 v-micro-app-plugin 微前端插件
bash
pnpm i v-micro-app-plugin --save
3、配置并使用
为了便于后续复用该配置信息(配置路由、菜单、名称等等),我们将 options 参数独立出来,放在 settings 文件夹下的 microAppSetting.ts 文件中。
- microAppSetting.ts:
javascript
const env = import.meta.env.MODE
const microAppUrl = {
appFirst: {
development: 'http://localhost:3000/#/',
test: 'https://test.example.com/vivien/appFirst/',
production: 'https://www.example.com/vivien/appFirst/'
},
appSecond: {
development: 'http://localhost:4000/#/',
test: 'https://test.example.com/vivien/appSecond/',
production: 'https://www.example.com/vivien/appSecond/'
},
};
const microAppSetting = {
projectName: 'mainApp',
subAppConfigs: {
'appFirst': {
name: 'appFirst',
url: microAppUrl['appFirst'][env]
},
'appSecond': {
name: 'appSecond',
url: microAppUrl['appSecond'][env]
}
},
isBaseApp: true, // 标记当前应用为主应用
basePath: '/', // 打包路径或其他基础路径
disableSandbox: false, // 是否禁用沙箱
iframe: true, // 是否使用 iframe
}
export default microAppSetting
export { microAppUrl }
- main.js:
javascript
import microAppSetting from '@/settings/microAppSetting'
const options = microAppSetting
// 初始化微前端插件
await initMyMicroApp(app, options, router, store);
**⚠注意:**一定要在 router 和 store 初始化后,才可以使用 initMyMicroApp 进行初始化!!!举个简单的例子:
javascriptimport { createApp } from 'vue' import './style.css' import App from './App.vue'y import { createPinia } from 'pinia' import router from './router/index' import ElementPlus from 'element-plus' import 'element-plus/theme-chalk/index.css' import initMyMicroApp from 'v-micro-app-plugin' import microAppSetting from '@/settings/microAppSetting' const app = createApp(App) const store = createPinia() app.use(router).use(ElementPlus).use(store) // 初始化微前端插件 const options = microAppSetting await initMyMicroApp(app, options, router, store); app.mount('#app')
构建子应用
1、创建任意多个项目作为子应用,这个步骤就不赘述了。
笔者创建了两个子应用,一个叫 sub-app-first,一个叫 sub-app-second。
2、安装 v-micro-app-plugin 微前端插件
bash
pnpm i v-micro-app-plugin --save
3、配置并使用
- sub-app-first:
javascript
const options = {
projectName: 'appFirst',
subAppConfigs: {},
isBaseApp: false, // 标记当前应用不为主应用
basePath: '/', // 打包路径或其他基础路径
disableSandbox: false, // 是否禁用沙箱
iframe: true, // 是否使用 iframe
}
// 初始化微前端插件
await initMyMicroApp(app, options, router, store);
- sub-app-second:
javascript
const options = {
projectName: 'appSecond',
subAppConfigs: {},
isBaseApp: false, // 标记当前应用不为主应用
basePath: '/', // 打包路径或其他基础路径
disableSandbox: false, // 是否禁用沙箱
iframe: true, // 是否使用 iframe
}
// 初始化微前端插件
await initMyMicroApp(app, options, router, store);
配置路由信息
有了主子应用之后,我们就需要在主应用中给子应用配置路由信息,这里一共有 2 个子应用,我们为它们分别进行配置。
- appFirst:
javascript
import microAppSetting from '@/settings/microAppSetting'
export default {
path: '/appFirst',
name: 'appFirst',
component: Layout,
order: 1,
hidden: false,
meta: {
title: 'appFirst',
hideBreadcrumb: false,
icon: Document,
microAppOptions: microAppSetting.subAppConfigs!['appFirst']
}
}
- appSecond:
javascript
import microAppSetting from '@/settings/microAppSetting'
export default {
path: '/appSecond',
name: 'appSecond',
component: Layout,
order: 2,
hidden: false,
meta: {
title: 'appSecond',
hideBreadcrumb: false,
icon: Document,
microAppOptions: microAppSetting.subAppConfigs!['appSecond'],
}
}
封装 MicroAppContainer
众所周知,路由切换时,可以给<router-view />
填充上对应路径的内容,同理,microApp中的<micro-app></micro-app>
也有同样的功能。我们可以对其进行二次封装,结合 v-if
,以便于根据是路由指向的是子应用,还是本系统自由模块,来判断究竟是渲染微应用视图,还是渲染普通视图。
为了达到这个目的,我们可以新建一个 MicroAppContainer 文件夹,在其中创建一个index.vue
,然后键入以下内容:
javascript
<template>
<div :class="[`${prefixCls}-container`]">
<!-- name:应用名称, url:应用地址 -->
<micro-app v-bind="options" :name="options.name" keep-alive></micro-app>
</div>
</template>
<script setup lang="ts">
import { watch } from "vue";
const props = defineProps<{
options: {
[key: string]: any;
};
}>();
let prefixCls = props.options.name
watch(
() => props.options,
(newValue) => {
prefixCls = newValue.name
},
{ immediate: true, deep: true }
);
</script>
<style></style>
⚠注意:
keep-alive 属性可根据需要决定是否设置。
区分是否微应用视图
- 在你需要加载子应用页面的地方:
html
<div :class="[`${prefixCls}-viewer-microapp`]" v-if="isMicroAppView">
<MicroAppContainer :options="microAppOptions" />
</div>
<div v-else>
<router-view />
</div>
- 一些必要的逻辑语句:
javascript
import { watchEffect, ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
let isMicroAppView: Ref = ref(false)
let microAppOptions: Ref = ref({})
watchEffect(async () => {
microAppOptions.value = route.meta.microAppOptions
isMicroAppView.value = !isNullOrUnDef(microAppOptions.value) && !isEmpty(microAppOptions.value)
})
运行项目
我们可以看到,sub-app-first 和 sub-app-second 均能独立作为一个系统去运行,并且在 main-app 下也能作为一个模块存在。
- sub-app-first:
- sub-app-second:
- main-app:
- 控制台输出信息:
封装 Iframe 组件
前文已经提到,老页面不需要做任何修改,且牵一发而动全身,只适合直接用 iframe 搬过来,相当于换个皮肤展示就好。但又因页面数量庞大,所以我们选择直接封装一个 iframe 组件,配合路由动态设置其 src 值,实现页面的动态切换。
在这里,我们专门创建了一个子应用,用于独立地展示该老系统,起到新旧隔离的作用,具体操作步骤如下:
1、首先,在 views 中新建一个 iframeViews 文件夹,然后创建 index.vue
javascript
<template>
<div ref="iframeContainers" v-if="loading">
<iframe
:key="name"
:src="url"
height="100%"
width="100%"
sandbox="allow-scripts allow-same-origin"
frameborder="0"
></iframe>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
let route = useRoute() // 获取当前路由信息
const iframeContainers = ref(null)
let url = ref(null)
let name = ref(null)
let loading = ref(false)
onMounted(() => {
})
onUnmounted(() => {
})
watch(
() => route,
(newRoute) => {
loading.value = true
url.value = newRoute.meta.iframeUrl
name.value = newRoute.name
// console.log('🚀 ~ watch ~ newPath:', newRoute, url.value, name.value)
},
{ immediate: true, deep: true }
)
</script>
<style scoped>
</style>
2、配置路由表
javascript
// 用户管理
import { Layout } from '@/router/layout'
import { $t } from "@/plugins/locales/setupLocale";
import { User } from "@element-plus/icons-vue";
import { getIframeUrl } from "@/settings/iframeUrlSetting";
export default {
path: "/user",
name: 'user',
component: Layout,
order: 1,
hidden: false,
redirect: "userList",
meta: {
title: $t('用户管理'),
hideBreadcrumb: false,
icon: User
},
children: [
{
path: '/userList',
component: () => import("@/views/iframeViews/index.vue"),
name: 'userList',
hidden: false,
meta: {
title: $t('用户列表'),
iframeUrl: getIframeUrl('userList'),
}
},
{
path: '/auth',
component: () => import("@/views/iframeViews/index.vue"),
name: 'auth',
hidden: false,
meta: {
title: $t('权限列表'),
iframeUrl: getIframeUrl('auth'),
}
}
]
}
3、为了能够在开发、测试、部署环境下都能正常运行,避免跨域问题,我们还需要通过灵活的方式来动态获取 iframeUrl
javascript
const env = import.meta.env.VITE_NODE_ENV
const url = {
development: "https://example.com/vivien_test/",
production: "https://example.com/vivien_prod/",
test: "https://example.tcl.com/vivien_test/",
}
const iframeUrl = {
development: {
userList: '/vivien/user/index.html',
auth: '/vivien/auth/index.html'
},
production: {
userList: '/prod/user/index.html',
auth: '/prod/auth/index.html'
},
test: {
userList: '/test/user/index.html',
auth: '/test/auth/index.html'
}
}
// 获取iframeUrl
export function getIframeUrl(name: string): string {
return url[env] + iframeUrl[env][name]
}
完成基本功能
经过这番操作,我们的旧系统就全部都嵌入进来啦!至于新系统,我们就和平常的开发一样,常规操作就可以了。主应用打开的视图如下:
不管我们拆分成了多少个项目来开发然后拼接成一个页面,对于用户来说,这完完全全就是一个系统,只是对于开发者来说有区别而已。
通信功能
完成了基础功能之后,我们还需要确保应用之间能够相互通信,由于主应用和子应用的通信 API 有一点差别,用的时候容易混淆,不够简便,所以我们对其进行了二次封装,提供了统一的通信 API。
对于具体的使用方法,我们通过几个简单的例子来说明:
准备工作
首先,要引入我们的 getMicroAppMessage() 方法,获取一个通信对象
javascriptimport { getMicroAppMessage } from "v-micro-app-plugin"; const microAppMessage = getMicroAppMessage();
- 发出全局信息:用法一致
javascript
microAppMessage.sendGlobal({
data: { fun: "sendGlobal", text: "给全局发送数据~sendGlobal" },
callback: () => {
console.log("使用sendGlobal发送数据成功,执行回调!");
},
});
- 子应用给主应用发出信息:无需 appName 参数
javascript
microAppMessage.sendMessage({
data: { app: "appSecond", value: "子应用给主应用发送数据~sendMessage" },
callback: () => {
console.log("子应用使用sendMessage发送数据成功,执行回调!");
},
});
- 主应用给子应用发出信息:需要 appName 参数
javascript
microAppMessage.sendMessage({
data: { app: "mainApp", value: "主应用给appFirst发送数据~sendMessage" },
appName: "appFirst",
callback: () => {
console.log("主应用使用sendMessage发送数据成功,执行回调!");
},
});
- 接收全局信息: 用法一致
javascript
setTimeout(() => {
console.log("接收到的全局信息getGlobalMessage:", microAppMessage.getGlobalMessage());
}, 1000);
- 子应用接收主应用发来的信息:无需 appName 参数
javascript
setTimeout(() => {
console.log(
"子应用接收到主应用发来的非全局信息getMessage:",
microAppMessage.getMessage()
);
}, 1000);
- 主应用接收子应用发来的信息:需要 appName 参数
javascript
setTimeout(() => {
console.log(
"主应用收到appFirst发来的信息getMessage:", microAppMessage.getMessage('appFirst'),
"主应用收到appSecond发来的信息getMessage:", microAppMessage.getMessage('appSecond')
);
}, 1000);
- 控制台信息: