• 使用MicroApp重构旧项目


    前言

            随着技术的飞速发展,我们公司内部一个基于“上古神器” jQuery + PHP 构建的十年历史老项目已显力不从心,技术非常老旧且维护成本高昂,其实已经无数次想要重构,但是苦于历史遗留原因以及业务的稳定性而一直难以下手,而且一时半会又不能全部重构。本次新页面较多且后续将持续迭代新模块,而老页面的改动较少且代码库错综复杂,牵一发而动全身。

    e0df795fe36a4503b367b5fc4729f593.png

            经过几番思考,我们发现微前端是一种非常实用的去实施渐进式重构的架构,很适合用微前端技术来完成本次需求,最终决定利用 Vue3 + Vite 搭建一个全新的基座(主应用),作为新旧系统融合的桥梁,将原来的老项目接入到基座,后面的新需求都在新项目里面开发就行,不用再动老项目。此举不仅实现了新页面用 Vue3 开发,而且老项目也能和新项目融合在一起,既保持了旧系统的稳定运行,又引入了新技术栈的活力。

            同时,鉴于我们另一个 Vue2 + webpack 项目也同样面临技术过时和项目规模庞大的问题,每次开发时运行起来非常卡顿,打包很慢,后期难以维护,也需要用微前端来进行一些拆分,不可能一直往该项目上堆代码。

            所以,我们决定一步到位,设计了一套微前端项目模板,将微前端的核心配置抽象为可复用的插件,并结合自研组件库、HTTP请求、权限控制等插件,构建了一个全面的项目脚手架,旨在简化未来项目的搭建流程,提升开发效率,确保技术栈的先进性与可持续性。

    e9c066812016475cb3e0d3aed80897d6.png

    微前端框架选型(MicroApp)

            从对⽐图可以看出,⽬前开源的微前端框架中有的功能 Micro App都有,并提供了⼀些它们不具备的功能,⽐如静态资源地址补全,元素隔离,插件系统等。

    546a9dc13263446bb4ca0d4f0da95e14.png

            我们本次项目使用的是 Vue3+Vite+TypeScript 的技术栈,在综合对比了各个框架之后,认为MicroApp是最适合我们当前现实情况的。原因有下:

    1. 使用简单,将所有功能都封装到一个类WebComponent组件内,从而实现在基座应用中嵌入一行代码即可渲染一个微前端应用。

    2. 不需要像 single-spa 和 qiankun 一样要求子应用修改渲染逻辑并暴露出方法,也不需要修改webpack配置,是目前市面上接入微前端成本最低的方案。

    3. 功能丰富,提供了 js沙箱、样式隔离、元素隔离、预加载、数据通信、静态资源补全等一系列完善的功能。

    4. 零依赖,这赋予它小巧的体积和更高的扩展性。

    5. 兼容所有框架,为了保证各个业务之间独立开发、独立部署的能力,micro-app做了诸多兼容,在任何技术框架中都可以正常运行。

    6. 侵入性低:对原代码几乎没有影响。

    7. 组件化:基于webComponents思想实现微前端。

    微前端架构设计(pnpm+monorepo)

            使用 pnpm 和 monorepo 管理项目依赖和代码结构,确保所有子应用和基座应用都位于同一仓库的不同目录下,便于集中管理和版本控制。

    1. /root  
    2. |-- /packages  
    3.    |-- /main-app       # 基座应用  
    4.    |-- /old-app-wrapper # 老项目接入容器  
    5.    |-- /new-module-a   # 新应用A  
    6.    |-- /new-module-b   # 新应用B  
    7.    ...  
    8. |-- /pnpm-workspace.yaml  
    9. |-- /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流程为各个子应用和主应用提供统一的构建/部署流程

    微前端设计思路

    1. 拆分功能模块:首先,我们需要将整个后台管理系统拆分为多个独立的功能模块,如用户管理模块、专项管理模块、订单管理模块等。每个模块都可以作为一个独立的微应用进行开发和维护。

    2. 设计通信协议:为了实现各个微应用之间的通信和资源共享,我们需要设计一套统一的通信协议和API。例如,我们可以定义一个emit方法来触发自定义事件,以及一个on方法来监听自定义事件;我们还可以使用Webpack的CommonsChunkPlugin插件来实现公共资源的提取和共享。

    3. 开发主应用:主应用是整个后台管理系统的入口,它负责加载和管理各个微应用。主应用需要提供一个容器元素来承载各个微应用的内容,并提供一些基础设施服务,如路由管理、状态管理等。此外,主应用还需要实现与各个微应用的通信和资源共享。

    4. 开发微应用:每个微应用都是一个独立的功能模块,它可以独立开发、部署和运行。每个微应用都需要提供一个容器元素来承载该应用的内容,并提供一些与主应用交互的接口,如共享资源、通信等。此外,微应用还需要实现自身的业务逻辑和界面展示。

    5. 集成测试:在完成各个微应用的开发后,我们需要对整个系统进行集成测试,确保各个微应用之间的通信和资源共享正常工作。此外,我们还需要对整个系统的性能、稳定性等进行测试和优化。

    v-micro-app-plugin

            本文中的微前端项目,使用的是 v-micro-app-plugin插件 ,它是一款基于京东MicroApp框架的微前端插件,旨在帮助开发者快速地将微应用集成到不同的系统中,实现高效、灵活的前端模块化开发。以下是详细的使用指南,希望能够帮助你快速上手。

            本文中的案例-资源地址

    项目实践

    技术栈:

    • 主应用:Vue3+Vite+TypeScript

    • 子应用1(老项目):用 iframe 挨个嵌入

    • 子应用2(新模块):react / Vue3 ...

            本次重构的是一个后台管理系统,最外层是基座,基座不仅是微前端应用集成的一个关键平台,还承载着维护公共资源、管理依赖项以及确立开发规范的重要使命。具体而言,其职责可概括为以下几点:

    1. 子应用集成,给子应用提供渲染容器

    2. 权限管理

    3. 会话管理

    4. 路由、菜单管理

    5. 主题管理

    6. 共享依赖

    7. 多语言管理(important)

    b4dd65a3c9e34025aa4469edd1d6f998.png

            因为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 微前端插件

    pnpm i v-micro-app-plugin --save

    3、配置并使用

            为了便于后续复用该配置信息(配置路由、菜单、名称等等),我们将 options 参数独立出来,放在 settings 文件夹下的 microAppSetting.ts 文件中。

    • microAppSetting.ts:

    1. const env = import.meta.env.MODE
    2. const microAppUrl = {  
    3.    appFirst: {  
    4.      development: 'http://localhost:3000/#/',  
    5.      test: 'https://test.example.com/vivien/appFirst/',  
    6.      production: 'https://www.example.com/vivien/appFirst/'  
    7.   },  
    8.    appSecond: {  
    9.      development: 'http://localhost:4000/#/',  
    10.      test: 'https://test.example.com/vivien/appSecond/',  
    11.      production: 'https://www.example.com/vivien/appSecond/'  
    12.   },  
    13. };  
    14. const microAppSetting = {
    15.    projectName: 'mainApp',
    16.    subAppConfigs: {
    17.        'appFirst': {
    18.            name: 'appFirst',
    19.            url: microAppUrl['appFirst'][env]
    20.       },
    21.        'appSecond': {
    22.            name: 'appSecond',
    23.            url: microAppUrl['appSecond'][env]
    24.       }
    25.   },
    26.    isBaseApp: true, // 标记当前应用为主应用
    27.    basePath: '/', // 打包路径或其他基础路径
    28.    disableSandbox: false, // 是否禁用沙箱
    29.    iframe: true, // 是否使用 iframe
    30. }
    31. export default microAppSetting
    32. export { microAppUrl }
    • main.js:

    1. import microAppSetting from '@/settings/microAppSetting'
    2. const options = microAppSetting
    3. // 初始化微前端插件  
    4. await initMyMicroApp(app, options, router, store);

    ⚠注意:一定要在 router 和 store 初始化后,才可以使用 initMyMicroApp 进行初始化!!!举个简单的例子:

    1. import { createApp } from 'vue'
    2. import './style.css'
    3. import App from './App.vue'y
    4. import { createPinia } from 'pinia'
    5. import router from './router/index'
    6. import ElementPlus from 'element-plus'
    7. import 'element-plus/theme-chalk/index.css'
    8. import initMyMicroApp from 'v-micro-app-plugin'
    9. import microAppSetting from '@/settings/microAppSetting'
    10. const app = createApp(App)
    11. const store = createPinia()
    12. app.use(router).use(ElementPlus).use(store)
    13. // 初始化微前端插件  
    14. const options = microAppSetting
    15. await initMyMicroApp(app, options, router, store);
    16. app.mount('#app')

    构建子应用

    1、创建任意多个项目作为子应用,这个步骤就不赘述了。

    笔者创建了两个子应用,一个叫 sub-app-first,一个叫 sub-app-second。

    2、安装 v-micro-app-plugin 微前端插件

    pnpm i v-micro-app-plugin --save

    3、配置并使用

    • sub-app-first:

    1. const options = {
    2.  projectName: 'appFirst',
    3.  subAppConfigs: {},
    4.  isBaseApp: false, // 标记当前应用不为主应用
    5.  basePath: '/', // 打包路径或其他基础路径
    6.  disableSandbox: false, // 是否禁用沙箱
    7.  iframe: true, // 是否使用 iframe
    8. }
    9. // 初始化微前端插件  
    10. await initMyMicroApp(app, options, router, store);
    • sub-app-second:

    1. const options = {
    2.  projectName: 'appSecond',
    3.  subAppConfigs: {},
    4.  isBaseApp: false, // 标记当前应用不为主应用
    5.  basePath: '/', // 打包路径或其他基础路径
    6.  disableSandbox: false, // 是否禁用沙箱
    7.  iframe: true, // 是否使用 iframe
    8. }
    9. // 初始化微前端插件  
    10. await initMyMicroApp(app, options, router, store);

    配置路由信息

            有了主子应用之后,我们就需要在主应用中给子应用配置路由信息,这里一共有 2 个子应用,我们为它们分别进行配置。

    • appFirst:

    1. import microAppSetting from '@/settings/microAppSetting'
    2. export default {
    3. path: '/appFirst',
    4. name: 'appFirst',
    5. component: Layout,
    6. order: 1,
    7. hidden: false,
    8. meta: {
    9. title: 'appFirst',
    10. hideBreadcrumb: false,
    11. icon: Document,
    12. microAppOptions: microAppSetting.subAppConfigs!['appFirst']
    13. }
    14. }
    • appSecond:

    1. import microAppSetting from '@/settings/microAppSetting'
    2. export default {
    3. path: '/appSecond',
    4. name: 'appSecond',
    5. component: Layout,
    6. order: 2,
    7. hidden: false,
    8. meta: {
    9. title: 'appSecond',
    10. hideBreadcrumb: false,
    11. icon: Document,
    12. microAppOptions: microAppSetting.subAppConfigs!['appSecond'],
    13. }
    14. }

    封装 MicroAppContainer

            众所周知,路由切换时,可以给填充上对应路径的内容,同理,microApp中的也有同样的功能。我们可以对其进行二次封装,结合 v-if,以便于根据是路由指向的是子应用,还是本系统自由模块,来判断究竟是渲染微应用视图,还是渲染普通视图。

            为了达到这个目的,我们可以新建一个 MicroAppContainer 文件夹,在其中创建一个index.vue,然后键入以下内容:

    1. <script setup lang="ts">
    2. import { watch } from "vue";
    3. const props = defineProps<{
    4. options: {
    5. [key: string]: any;
    6. };
    7. }>();
    8. let prefixCls = props.options.name
    9. watch(
    10. () => props.options,
    11. (newValue) => {
    12. prefixCls = newValue.name
    13. },
    14. { immediate: true, deep: true }
    15. );
    16. script>
    17. <style>style>

    ⚠注意:

    keep-alive 属性可根据需要决定是否设置。

    5186e97e26a94cebb17e51d66517cdc8.png

    区分是否微应用视图

    • 在你需要加载子应用页面的地方:

    1. <div :class="[`${prefixCls}-viewer-microapp`]" v-if="isMicroAppView">
    2. <MicroAppContainer :options="microAppOptions" />
    3. div>
    4. <div v-else>
    5. <router-view />
    6. div>
    • 一些必要的逻辑语句:

    1. import { watchEffect, ref } from 'vue'
    2. import { useRoute } from 'vue-router'
    3. const route = useRoute()
    4. let isMicroAppView: Ref = ref(false)
    5. let microAppOptions: Ref = ref({})
    6. watchEffect(async () => {
    7. microAppOptions.value = route.meta.microAppOptions
    8. isMicroAppView.value = !isNullOrUnDef(microAppOptions.value) && !isEmpty(microAppOptions.value)
    9. })

    运行项目

            我们可以看到,sub-app-first 和 sub-app-second 均能独立作为一个系统去运行,并且在 main-app 下也能作为一个模块存在。

    • sub-app-first:

    d4ad3c832553400489844e05092c67b4.png

    250b783776d54a14a6ce7a8e6e2687c4.png

    • sub-app-second:

    a4afae81ae6c454c8505582f69ec9775.png

    0f21759b652a45b9beb7eda0989c3234.png

    • main-app:

    e0e73ebfc588402a85df1941a9b95cb7.png

    ed5011d9712444c0b27c1466c0560ca6.png

    • 控制台输出信息:

    a29ae05ab2b44e428a22bdbf46d1958e.png

    3592848a7db54c5c9e704e71e76d1212.png

    封装 Iframe 组件

            前文已经提到,老页面不需要做任何修改,且牵一发而动全身,只适合直接用 iframe 搬过来,相当于换个皮肤展示就好。但又因页面数量庞大,所以我们选择直接封装一个 iframe 组件,配合路由动态设置其 src 值,实现页面的动态切换。

            在这里,我们专门创建了一个子应用,用于独立地展示该老系统,起到新旧隔离的作用,具体操作步骤如下:

    1、首先,在 views 中新建一个 iframeViews 文件夹,然后创建 index.vue

    1. <script setup>
    2. import { ref, onMounted, watch, onUnmounted } from 'vue'
    3. import { useRoute } from 'vue-router'
    4. let route = useRoute() // 获取当前路由信息
    5. const iframeContainers = ref(null)
    6. let url = ref(null)
    7. let name = ref(null)
    8. let loading = ref(false)
    9. onMounted(() => {
    10. })
    11. onUnmounted(() => {
    12. })
    13. watch(
    14. () => route,
    15. (newRoute) => {
    16. loading.value = true
    17. url.value = newRoute.meta.iframeUrl
    18. name.value = newRoute.name
    19. // console.log('🚀 ~ watch ~ newPath:', newRoute, url.value, name.value)
    20. },
    21. { immediate: true, deep: true }
    22. )
    23. script>
    24. <style scoped>
    25. style>

    2、配置路由表

    1. // 用户管理
    2. import { Layout } from '@/router/layout'
    3. import { $t } from "@/plugins/locales/setupLocale";
    4. import { User } from "@element-plus/icons-vue";
    5. import { getIframeUrl } from "@/settings/iframeUrlSetting";
    6. export default {
    7. path: "/user",
    8. name: 'user',
    9. component: Layout,
    10. order: 1,
    11. hidden: false,
    12. redirect: "userList",
    13. meta: {
    14. title: $t('用户管理'),
    15. hideBreadcrumb: false,
    16. icon: User
    17. },
    18. children: [
    19. {
    20. path: '/userList',
    21. component: () => import("@/views/iframeViews/index.vue"),
    22. name: 'userList',
    23. hidden: false,
    24. meta: {
    25. title: $t('用户列表'),
    26. iframeUrl: getIframeUrl('userList'),
    27. }
    28. },
    29. {
    30. path: '/auth',
    31. component: () => import("@/views/iframeViews/index.vue"),
    32. name: 'auth',
    33. hidden: false,
    34. meta: {
    35. title: $t('权限列表'),
    36. iframeUrl: getIframeUrl('auth'),
    37. }
    38. }
    39. ]
    40. }

    3、为了能够在开发、测试、部署环境下都能正常运行,避免跨域问题,我们还需要通过灵活的方式来动态获取 iframeUrl

    1. const env = import.meta.env.VITE_NODE_ENV
    2. const url = {
    3. development: "https://example.com/vivien_test/",
    4. production: "https://example.com/vivien_prod/",
    5. test: "https://example.tcl.com/vivien_test/",
    6. }
    7. const iframeUrl = {
    8. development: {
    9. userList: '/vivien/user/index.html',
    10. auth: '/vivien/auth/index.html'
    11. },
    12. production: {
    13. userList: '/prod/user/index.html',
    14. auth: '/prod/auth/index.html'
    15. },
    16. test: {
    17. userList: '/test/user/index.html',
    18. auth: '/test/auth/index.html'
    19. }
    20. }
    21. // 获取iframeUrl
    22. export function getIframeUrl(name: string): string {
    23. return url[env] + iframeUrl[env][name]
    24. }

    完成基本功能

            经过这番操作,我们的旧系统就全部都嵌入进来啦!至于新系统,我们就和平常的开发一样,常规操作就可以了。主应用打开的视图如下:

    7f2c1f721d0548a696fb18606fb3a2ff.png

            不管我们拆分成了多少个项目来开发然后拼接成一个页面,对于用户来说,这完完全全就是一个系统,只是对于开发者来说有区别而已。

    通信功能

            完成了基础功能之后,我们还需要确保应用之间能够相互通信,由于主应用和子应用的通信 API 有一点差别,用的时候容易混淆,不够简便,所以我们对其进行了二次封装,提供了统一的通信 API。

    对于具体的使用方法,我们通过几个简单的例子来说明:

    准备工作

            首先,要引入我们的 getMicroAppMessage() 方法,获取一个通信对象

    1. import { getMicroAppMessage } from "v-micro-app-plugin";
    2. const microAppMessage = getMicroAppMessage();
    • 发出全局信息:用法一致

    1. microAppMessage.sendGlobal({
    2. data: { fun: "sendGlobal", text: "给全局发送数据~sendGlobal" },
    3. callback: () => {
    4. console.log("使用sendGlobal发送数据成功,执行回调!");
    5. },
    6. });
    • 子应用给主应用发出信息:无需 appName 参数

    1. microAppMessage.sendMessage({
    2. data: { app: "appSecond", value: "子应用给主应用发送数据~sendMessage" },
    3. callback: () => {
    4. console.log("子应用使用sendMessage发送数据成功,执行回调!");
    5. },
    6. });
    • 主应用给子应用发出信息:需要 appName 参数

    1. microAppMessage.sendMessage({
    2. data: { app: "mainApp", value: "主应用给appFirst发送数据~sendMessage" },
    3. appName: "appFirst",
    4. callback: () => {
    5. console.log("主应用使用sendMessage发送数据成功,执行回调!");
    6. },
    7. });
    • 接收全局信息: 用法一致

    1. setTimeout(() => {
    2. console.log("接收到的全局信息getGlobalMessage:", microAppMessage.getGlobalMessage());
    3. }, 1000);
    • 子应用接收主应用发来的信息:无需 appName 参数

    1. setTimeout(() => {
    2. console.log(
    3. "子应用接收到主应用发来的非全局信息getMessage:",
    4. microAppMessage.getMessage()
    5. );
    6. }, 1000);
    • 主应用接收子应用发来的信息:需要 appName 参数

    1. setTimeout(() => {
    2. console.log(
    3. "主应用收到appFirst发来的信息getMessage:", microAppMessage.getMessage('appFirst'),
    4. "主应用收到appSecond发来的信息getMessage:", microAppMessage.getMessage('appSecond')
    5. );
    6. }, 1000);
    • 控制台信息:

    10722fc0d5bd431fa6781fc0bd0d281b.png

    954c95d401f145f28dc31453a84c9c9c.png

  • 相关阅读:
    cpp中的内存管理 静态存储 内存分配与链接性的讨论
    微服务分布式基于Springcloud的拍卖管理系统597wx
    生信教程:使用拓扑加权探索基因组进化(2)
    LeetCode二叉树系列——199二叉树的右视图
    postgresql数据库备份
    百度算法面试小总结
    【Qt】:常用控件(五:显示类控件)
    147. SAP UI5 SmartTable 控件的使用介绍
    被忽视的数据中心非业务网络规划
    C# 语言的基本语法结构
  • 原文地址:https://blog.csdn.net/Vivien_CC/article/details/141226504