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

/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流程为各个子应用和主应用提供统一的构建/部署流程

微前端设计思路

  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:

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:

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

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

import { 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 微前端插件

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

3、配置并使用

  • sub-app-first:

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

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

配置路由信息

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

  • appFirst:

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:

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,然后键入以下内容:

<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 属性可根据需要决定是否设置。

5186e97e26a94cebb17e51d66517cdc8.png

区分是否微应用视图

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

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

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:

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

<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、配置路由表

// 用户管理
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

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]
}

完成基本功能

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

7f2c1f721d0548a696fb18606fb3a2ff.png

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

通信功能

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

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

准备工作

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

import { getMicroAppMessage } from "v-micro-app-plugin";

const microAppMessage = getMicroAppMessage();
  • 发出全局信息:用法一致

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

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

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

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

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

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

10722fc0d5bd431fa6781fc0bd0d281b.png

954c95d401f145f28dc31453a84c9c9c.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值