微前端 Mirco-app 基于vite的使用指南

874 阅读7分钟

前言

因后端项目要上微服务。为项目长远考虑也同时上微前端这种技术架构,从而有了改基于vite的微服务使用指南。

微前端的概念

借鉴了微服务的概念, 将多个项目融合唯一,减少项目之间的耦合,提升项目扩展项。有一个基座主应用来管理各个子应用的加载和卸载。所以微前端不关心子应用的依赖库与应用框架,不关心具体的工具只是一种架构模式。

项目概述

一、框架选型

1.调研框架

  1. qiankun 是一款基于 single-spa 封装的微前端框架
  2. MircoApp 京东开源, 一款基于 WebComponet 的思想的微前端框架
  3. vite-micro  基于vite 将模块联邦思想融入到微前端框架中 基于公司项目现有的项目多数为 vite+vue 的技术栈, 而qiankun框架对vite的支持并不完美(但3.0发布在即,等3.0发布后在试试)。所以本次为了项目扩展的便利性选择了MircoApp的这个微前端框架。但vite-micro的概念个人觉得是一个十分完美的方案,qiankun与mircoapp都是应用级别的模块, 都需要对现有的系统的鉴权与路由模块进行改造,但vite-micro基于模块联邦的思想既可以是微组件也可是微应用。

2. 场景概述

外层的 基座, 是微前端应用的重要平台。主要负责管理公共资源、依赖、规范的责任,主要有以下责任

  1. 子应用集成,给子应用提供渲染器
  2. 权限管理
  3. 会话管理
  4. 路由,菜单管理
  5. 主题管理
  6. 共享依赖
  7. 多语言管理

二、搭建项目

1. 项目采用monorepo项目结构管理, 使用turbo对项目进行脚本通道管理。

mkdir micro-demo
cd micro-demo
pnpm init

2. 项目根目录创建 pnpm-workspace.yaml, 管理monorepo项目目录。

// pnpm-workspace.yaml
packages:
  # all packages in direct subdirs of packages/
  - 'packages/*'
  # all packages in subdirs of components/
  - 'apps/**'

3. 创建项目目录如下:

├───📁 apps/
│   ├───📁 micro-project-a/
│   └───📁 micro-project-b/
├───📁 packages/
│   └───📁 micro-base/
├───📄 package.json
├───📄 pnpm-workspace.yaml
└───📄 turbo.json

4. 安装全局应用turbo:

pnpm install -w turbo

5. 添加配置 turbo文件

{
  "$schema": "https://turborepo.org/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": [
        "^build"
      ],
      "outputs": [
        "dist/**"
      ]
    },
    "stub": {},
    "lint": {},
    "clean": {
      "cache": false
    },
    "dev": {
      "dependsOn": [],
      "cache": false,
      "persistent": true
    }
  }
}

6. 创建基座应用与子应用

npm create vue


Vue.js - The Progressive JavaScript Framework

? 请输入项目名称: » vue-project
? 是否使用 TypeScript 语法? » 否 / 是
? 是否启用 JSX 支持? » 否 / 是
? 是否引入 Vue Router 进行单页面应用开发? » 否 / 是
? 是否引入 Pinia 用于状态管理? ... 否 / 是 
? 是否引入 Vitest 用于单元测试? » 否 / 是
? 是否要引入一款端到端(End to End)测试工具? » - 使用箭头切换按Enter确认。
>   不需要
    Cypress
    Nightwatch
    Playwright
? 是否引入 ESLint 用于代码质量检测? » 否 / 是
? 是否引入 Prettier 用于代码格式化? » 否 / 是

正在构建项目 micro-demo\apps\vue-project...

项目构建完成,可执行以下命令:

  cd vue-project
  npm install
  npm run format
  npm run dev

三、构建基座应用

1. 安装 `n@micro-zoe/micro-app ,并修改package.json的name值

npm i @micro-zoe/micro-app --save

// package.json
 "name": "@base/micro-app",

2. 在main.ts 当中初始化 microapp

import microApp from '@micro-zoe/micro-app'

microApp.start({
    lifeCycles: {
        created() {
            console.log('created 全局监听')
        },
        beforemount() {
            console.log('beforemount 全局监听')
        },
        mounted() {
            console.log('mounted 全局监听')
        },
        unmount() {
            console.log('unmount 全局监听')
        },
        error() {
            console.log('error 全局监听')
        }
    },
    // 预加载
    preFetchApps: [
        //当子应用是vite时,除了name和url外,还要设置第三个参数iframe为true,开启iframe沙箱。
        { name: 'child-app-1', url: 'http://localhost:4002/child-app-1/', iframe: true, level: 3 }
    ],
    plugins: { },
    /**
     * 自定义fetch
     * @param url 静态资源地址
     * @param options fetch请求配置项
     * @returns Promise<string>
     * (url: string, options: Record<string, unknown>, appName: string | null) => Promise<string>
     */
    fetch(url: string, options: any, appName: string | null) {
        return fetch(url, Object.assign(options, {})).then((res) => {
            return res.text()
        })
    }
})

3. 维护基座应用与子应用的路由信息

  1. 主应用 route, history路由模式
const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about2',
    name: 'about',
    component: () => import('../views/AboutView.vue')
  },
  {
    name: 'child-app-1',
    // 👇 非严格匹配,/child-app-1/* 都指向 vue3-vite 页面
    path: '/child-app-1/:page*',
    component: () => import('../views/AppOne.vue')
  },
]
  1. 页面菜单路由 子应用路径应注意为 : 子应用名称/子应用页面路径
const pageRoutes = [
  {
    path: '/',
    name: 'home',
    app: 'main'
  },
  {
    path: '/about2',
    name: 'about',
    app: 'main'
  },
  {
    path: '/child-app-1',
    name: '合同管理',
    children: [
      {
        path: '/child-app-1/',
        name: '收款',
        app: 'child-app-1'
      },
      {
        path: '/child-app-1/element-plus',
        name: '付款',
        app: 'child-app-1'
      },
      {
        path: '/child-app-1/ant-design-vue',
        name: '首页d',
        app: 'child-app-1'
      }
    ]
  },
]

4. 创建基座应用页面

<template>
  <a-layout>
    <a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible>
      <div class="logoc" />
      <a-menu v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
        <template v-for="item in pageRoutes">
          <template v-if="item.children">
            <a-sub-menu :key="item.path">
              <template #title>
                <span>
                  <user-outlined />
                  {{ item.name }}
                </span>
              </template>
              <a-menu-item v-for="child in item.children" :key="child.path" @click="select(child)">
                <user-outlined />
                <span>{{ child.name }}</span>
              </a-menu-item>
            </a-sub-menu>
          </template>
          <template v-else>
            <a-menu-item :key="item.path" @click="select(item)">
              <user-outlined />
              <span>{{ item.name }}</span>
            </a-menu-item>
          </template>
        </template>
      </a-menu>
    </a-layout-sider>
    <a-layout>
      <a-layout-header style="background: #fff; padding: 0">
        <menu-unfold-outlined v-if="collapsed" class="trigger" @click="() => (collapsed = !collapsed)" />
        <menu-fold-outlined v-else class="trigger" @click="() => (collapsed = !collapsed)" />
      </a-layout-header>
      <a-layout-content :style="{ margin: '24px 16px', padding: '24px', background: '#fff', minHeight: '91vh' }">
        <RouterView />
      </a-layout-content>
    </a-layout>
  </a-layout>
</template>

<script setup lang="ts">

import { RouterView, useRouter } from 'vue-router'
import { ref } from 'vue';
import {
  UserOutlined,
  MenuUnfoldOutlined,
  MenuFoldOutlined,
} from '@ant-design/icons-vue';
import microApp, { getActiveApps } from '@micro-zoe/micro-app'
import { pageRoutes } from '@/router'

const router = useRouter()
const selectedKeys = ref<string[]>(['/']);
const collapsed = ref<boolean>(false);

/** 用户子菜单内部路由跳转回显主应用选中状态
 * @param {object} to 即将要进入的路由
 * @param {object} from 正要离开的路由
 * @param {string} name 子应用的name
 * @return cancel function 解绑路由监听函数
 */

microApp.router.beforeEach((to, from, name) => {
  selectedKeys.value = [to.pathname]
})

// 用户点击菜单时控制基座应用跳转
function select(item: any) {
  // 获取子应用appName
  const appName = item.app
  router.push(item.path)

  if (item.app !== 'main') {
    // 判断子应用是否存在
    if (getActiveApps().includes(appName)) {
      // 子应用存在,控制子应用跳转
      microApp.router.replace({
        name: appName,
        path: item.path,
      })
    } else {
      // 子应用不存在,设置defaultPage,控制子应用初次渲染时的默认页面
      microApp.router.setDefaultPage({
        name: appName, path: item.path
      })
    }
  }
}

</script>

<style scoped>
.trigger {
  font-size: 18px;
  line-height: 64px;
  padding: 0 24px;
  cursor: pointer;
  transition: color 0.3s;
}

.trigger:hover {
  color: #1890ff;
}

.logoc {
  height: 32px;
  background: rgba(255, 255, 255, 0.3);
  margin: 16px;
}

.site-layout .site-layout-background {
  background: #fff;
}
</style>


5. 嵌入子应用

// views/AppOne.vue
<template>
    <div>
        <micro-app name='child-app-1' url='http://localhost:4002/child-app-1/' :data="data" inline iframe
            @datachange='handleDataChange'></micro-app>
    </div>
</template>

<script setup lang="ts">

import { ref } from 'vue';
const data = ref({
    type: '发送给子应用的数据'
})

function handleDataChange(e: CustomEvent): void {
    console.log('来自子应用 child-vite 的数据:', e.detail.data)
}
</script>

<style scoped></style>

四、构建子应用

1. 安装 `@micro-zoe/micro-app ,并修改package.json的name值

npm i @micro-zoe/micro-app --save

// package.json
  "name": "@app/micro-project-a",

2, 在main.ts 中注册加载

import './assets/main.css'

import { createApp, } from 'vue'
import { createRouter, createWebHistory, } from 'vue-router'
import { routes } from './router'

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'

import App from './App.vue'

declare global {
    interface Window {
        microApp: any
        mount: CallableFunction
        unmount: CallableFunction
        __MICRO_APP_ENVIRONMENT__: string
        __MICRO_APP_BASE_ROUTE__: string
        __MICRO_APP_NAME__: string
        __MICRO_APP_PUBLIC_PATH__: string
        __MICRO_APP_BASE_APPLICATION__: string
    }
}

let app: any = null
let router: any = null
let history: any = null

function handleMicroData() {
    console.log('child-vite getData:', window.microApp?.getData())
    // 监听基座下发的数据变化
    window.microApp?.addDataListener((data: any) => {
        console.log('child-vite addDataListener:', data)
    }, true)
    // 向基座发送数据
    setTimeout(() => {
        window.microApp?.dispatch({ myname: 'child-vite' })
    }, 3000)
}

// 将渲染操作放入 mount 函数
window.mount = (data: any) => {

    history = createWebHistory(window.__MICRO_APP_BASE_ROUTE__ || import.meta.env.BASE_URL)

    router = createRouter({
        history,
        routes,
    })
    app = createApp(App)
    app.use(router)
    app.use(ElementPlus)
    app.use(Antd)
    app.mount('#vite-app')
    
    handleMicroData()
}

// 将卸载操作放入 unmount 函数
window.unmount = () => {
    app && app.unmount()
    history && history.destroy()
    app = null
    router = null
    history = null
    console.log('微应用vite卸载了 -- UMD模式');
}

// 非微前端环境直接渲染
if (!window.__MICRO_APP_ENVIRONMENT__) {
    window.mount()
}

3. 修改 vite.config.ts

// 添加base路径
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'

export default defineConfig({
  base: '/child-app-1/',
  plugins: [
    vue(),
    vueJsx(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  server: {
    port: 4002,
    host: true,
    headers: {
      'Access-Control-Allow-Origin': '*'
    },
    // Allow services to be provided for non root directories of projects
    fs: {
      strict: false
    },
  }
})

4. 修改index.html, 修改根div的id为 vite-app, 为防止多个相同id造成microapp报错提示

<!DOCTYPE html>

<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vite App</title>
  </head>
  <body>
    <div id="vite-app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

5. 监听路由变化, 根据基座应用跳转选中路由菜单

  1. 创建子应用路由
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import Home from '../views/home.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/element-plus',
    name: 'element-plus',
    component: () => import(/* webpackChunkName: "element-plus" */ '../views/element-plus.vue')
  },
  {
    path: '/ant-design-vue',
    name: 'ant-design-vue',
    component: () => import(/* webpackChunkName: "ant-design-vue" */ '../views/ant-design-vue.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

export {
  routes
}
  1. 修改App.vue
<script setup lang="ts">
import { RouterView, useRouter } from 'vue-router'
import { watch, ref } from 'vue';

const router = useRouter()
const activeIndex = ref('/')

watch(() =>
  router.currentRoute.value.path,
  (toPath) => {
    activeIndex.value = toPath
    window.microApp.dispatch({ toPath })
  }, { deep: true })

</script>

<template>
  <div>
    <div>
      <el-menu :default-active="activeIndex" class="el-menu-demo" mode="horizontal" router>
        <el-menu-item index="/">home</el-menu-item>
        <el-menu-item index="/element-plus">element-plus</el-menu-item>
        <el-menu-item index="/ant-design-vue">ant-design-vue</el-menu-item>
      </el-menu>
    </div>
    <RouterView />
  </div>
</template>

<style scoped>
header {
  line-height: 1.5;
  max-height: 100vh;
}

.logo {
  display: block;
  margin: 0 auto 2rem;
}

nav {
  width: 100%;
  font-size: 12px;
  text-align: center;
  margin-top: 2rem;
}

nav a.router-link-exact-active {
  color: var(--color-text);
}

nav a.router-link-exact-active:hover {
  background-color: transparent;
}

nav a {
  display: inline-block;
  padding: 0 1rem;
  border-left: 1px solid var(--color-border);
}

nav a:first-of-type {
  border: 0;
}

@media (min-width: 1024px) {
  header {
    display: flex;
    place-items: center;
    padding-right: calc(var(--section-gap) / 2);
  }

  .logo {
    margin: 0 2rem 0 0;
  }

  header .wrapper {
    display: flex;
    place-items: flex-start;
    flex-wrap: wrap;
  }

  nav {
    text-align: left;
    margin-left: -1rem;
    font-size: 1rem;
    padding: 1rem 0;
    margin-top: 1rem;
  }
}
</style>

五、示例

image1.png

六、参考资料:

  1. micro-app官网: zeroing.jd.com/docs.html#/
  2. 浅入深出的微前端MicroApp | 京东云技术团队
  3. 项目源码: github.com/1514100951/…