“带薪练级”之我用npm多包抽离解耦项目(一)

810 阅读9分钟

前言

本文介绍如何去将现有后台管理系统抽离,业务页面菜单成一个个npm包提高复用,再也不用手copy页面。 这篇文章会用多篇去写完内容,里面有许多点可以优化也有缺点,主要分享思路和我的做法。

水平有限只是分享,大家取其精华,去其糟粕,轻喷!

项目背景

某日我翘着二郎腿听着歌喝着茶,我的组长正巧从我身旁经过,看着我惬意悠然的样子,早已失去打工人应有的样子,拍了拍我的肩膀说,“来,给你一个事做”。

项目是公司新搞的Vue3 + TS + element-plus,需求是想要我抽离业务菜单,能给公司其他项目复用,比如:基础设置能在多个项目中使用,且不需要额外的前端心智负担。

我一拍脑门就已经想好路子了,“这,不就是让我搞微前端那套嘛”,随即便提了iframeqiankun的微前端方案,并普及微前端框架的好,但是一听工时便否了我,理由就是工时太长,且项目中需要对微框架容器做适配需要工作量且没有经验。

最后讨论许久,定下来就用npm包的形式将页面应用封装成组件,多页面直接靠挂载组件使用,从项目中解耦使用。

那就开始干吧,听起来也不复杂。

主要要求如下:

  • 框架分包后需要和之前一样保证功能正常(最基本的)
  • 框架要保证使用能互相通讯,包括路由、全局工具函数、状态、组件等;
  • 能让其他项目直接引用

准备工作

先设计框架结构:

  • 主框架,负责初始化加载前端各种库,路由、Vuex、UI库和我们需要分的业务包及组件包。
  • 业务包,负责编写各种不同业务组件页面,就是大家CURD的页面。
  • 组件包(组件库),抽离公司二次开发组件单独打包。

很好懂,就是之前一个项目拆成多个包进行import,业务包中对应基础项目中的一级菜单,组件库就是类似自己封装的二开组件库。

接下来内容我会利用开源vue-element-plus-admin项目来作为项目进行拆包,后续会放git上供参考。

拆解项目: 复制当前项目即vue-element-plus-admin,clone下来的项目,准备复制三个项目内容如下展示。

image.png

拆解目标:

就将组件一栏先拆成单独业务包吧

image.png 好了,那我们按照这个结构就可以正式开始拉!

分包

准备工作中我们准备了3个项目,现在我们先开始将其中业务包组件包打包能让我们主框架内使用。

首先配置Vite打包

我们在业务包package项目中会使用lib模式打包指定对应入口脚本,如果使用默认在打包后会多生成index.html。

找到vite.config.ts文件增加lib模式打包内容

 // package项目下 /vite.config.ts
 build: {
      // 需要增加以下内容
      lib: {
        // 打包时执行的入口文件
        entry: resolve(__dirname, '/src/package/index.ts'),
        name: 'package',
        // 配置对应的模式 主要是加上es
        formats: ['es', 'cjs'],
        fileName: (format) => {
            switch (format) {
            case 'es':
                // ES Module 格式的文件名
                return 'index.mjs'
            case 'cjs':
                // CommonJS 格式的文件名
                return 'index.cjs'
            default:
                return 'index.min.js'
            }
        }
      },
    },

接着配置package.json

找到package.json文件增加对外声明引入包的入口文件,到时候我们打包完会出现index.cjs如上vite中配置内容

// package项目下 /package.json
{
    // 其他忽略...
    "main": "./dist/index.cjs",
    "files": [
        "dist",
        "lib",
        "es"
    ],
}

开始编写我们lib打包脚本

之前我们在 lib 下的 entry 指定 '/src/package/index.ts',我们需要创建ts内容。

这里的主要逻辑是想通过Vite动态读取对应的index.vue将他们按照驼峰的方式命名成组件注册到主框架内容,同时这里我们可以将路由和Pina也一并注册到主框架中。

// src/package/index.ts
// 字符串转驼峰方法
import { cssStyle2DomStyle } from '@/utils'
// 路由和store
import { constantRouterMap } from '@/router'
// import { storeRegisterModule } from '@/store'

const PACKAGE_NAME = 'base'

// 定义组件数组
interface pageComponentType {
  name: string
  component: any
}
const pageComponent: Array<pageComponentType> = []
// 动态引入目录
// 匹配除 components 以为的文件夹下 index.vue
import.meta.glob('/src/**/!(components)/**/*.vue')
const vueModules = import.meta.glob('../views/**/!(components)/**/*.vue', {
  eager: true
}) as unknown as any
// 获取路径

const pathList: Array<string> = []
for (const key in vueModules) {
  pathList.push(key)
}

const modulesPage: any = []

// 动态无限层级拼接名称方法
const generateComponentNames = (path) => {
  const pathSegments = path.split('/')
  const fileName = pathSegments[pathSegments.length - 1].replace('.vue', '')
  return `${pathSegments[2].toLowerCase()}-${fileName.toLowerCase()}`
}

// 拼接动态对象
pathList.forEach((i: any) => {
  const arr = i.split('/')
  // 名称截取如:views/pub/branch/index.vue 得 [views,pub,branch,index.vue] 取 pub及brnach
  console.log(generateComponentNames(i))
  const name = cssStyle2DomStyle(generateComponentNames(i)) as any
  modulesPage[name] = vueModules[i].default
  pageComponent.push({ name: name, component: vueModules[i].default })
})
// options type
interface installOptios {
  router: any
  store?: any
  mitt?: any
}

// install 方法入口
const install = function (Vue: any, options: installOptios) {
  // 注入模块的组件路由
  if (options.router) {
    asyncRouterMap.forEach((s: any) => {
      options.router.push(s)
    })
  }
  // 注入子模块的 stroe module
  // storeRegisterModule()
  pageComponent.forEach((item) => {
    Vue.component(item.name, item.component)
  })
}

// 判断是否时直接引入文件,如果是,就不用调用Vue.use,script直接引用
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}

export default { install, ...vueModules }

这里主要做了两个操作

  • 把views下的所有符合的页面注册成组件,通过主框架app.use的方式注册进主框架中
  • 注册store进主框架中,通过storeRegisterModule方法

我这里使用Vite中import.meta.glob用通配语法src/**/!(components)/**/*.vue,大家根据自己的项目目录做,我这里是直接用vue-element-plus-admin下src/views的目录来做的。

打印出注册的组件名称如下,这里的组件名称会在主框架中使用到,代码里命名的逻辑大致为:读取到的路径../views/Com/CountTo 命名为 comCountto 这种驼峰名称。

image.png

运行打包

做好之前操作后,我们执行一下build,得到如下结果,那就算没有问题了。

// 在vue-element-plus-admin项目里是这样
yarn build:pro

image.png

提示:如果打包出来的文件很多不只是一个index.cjs和index.mjs那么应该在src/package/index.ts里有报错主框架使用会报错。

调试测试包及主框架使用

上一步我们可以成功打出目标文件和包了,但是需要使用和调试,每次改代码都要重新运行build

调试方法主要是使用link方法来操作,在你的package项目根目录下执行:

yarn link

然后cd到主框架也就是main项目里

// 包名是刚刚package项目下 package.json 里的name
yarn link 包名

在main主框架下使用实例,找到/src/main.ts

// 正常使用就import使用 link的包名
import otherPackage from 'testforpackage'
// 或者想要调试就
// import otherPackage from 'testforpackage/src/package/index'
// 要被注入的router数组
import { asyncRouterMap } from './router'
......
// use的第二个参数是自定义参数,我们之前需要把子包路由注册到主包中,这里的router就需要我们给router数组
app.use(otherPackage, { router: asyncRouterMap })

注入路由

上面提到我们之前需要把子包路由注册到主包中,这里的router就需要我们给router数组,思路如下:

  • 想让路由注册进主框架,就需要在app.use过程将自己的router数组就是需要菜单的内容渲染传进来。
  • 当主框架切换路由的时候,动态的去渲染注册进来的组件。

这里就用vue-element-plus-admin菜单里的组件一块抽离做业务包。

第一步改动,那我们现在需要修改一些main项目和package项目的/src/router/index.ts,进行一些改动,在main中将路由中的表示组件的路由注释掉,在package剔除其他路由(这里将所有组件path改成了Com之前componets)。

第二步改动,在package项目里的路由里多增加,moduleInComponent字段用来判断这个注入进来的路由对应哪个组件,因为直接注入进来的 component:()=>import(/省略/)的动态路由会找不到具体文件。moduleInComponent的值就是之前动态注册进的组件名。

//package项目的/src/router/index.ts
..... 省略其他
 export const asyncRouterMap: AppRouteRecordRaw[] = [
  {
    path: '/Com',
    component: Layout,
    name: 'ComponentsDemo',
    meta: {
      title: t('router.component'),
      icon: 'bx:bxs-component',
      alwaysShow: true
    },
    children: [
      {
        path: 'form',
        component: getParentLayout(),
        redirect: '/Com/form/default-form',
        name: 'Form',
        meta: {
          title: t('router.form'),
          alwaysShow: true,
           // 重要moduleInComponent 相当于告诉 主框架要渲染组件名称
           // 重定向的也需要配置重要moduleInComponent
          moduleInComponent: 'comDefaultform'
        },
        children: [
          {
            path: 'default-form',
            component: () => import('@/views/Com/Form/DefaultForm.vue'),
            name: 'DefaultForm',
            meta: {
              title: t('router.defaultForm'),
              // 重要moduleInComponent 相当于告诉 主框架要渲染组件名称
              moduleInComponent: 'comDefaultform'
            }
          },
          {
            path: 'use-form',
            component: () => import('@/views/Com/Form/UseFormDemo.vue'),
            name: 'UseForm',
            meta: {
              title: 'UseForm',
              moduleInComponent: 'comUseformdemo'
            }
          },
          {
            path: 'ref-form',
            component: () => import('@/views/Com/Form/RefForm.vue'),
            name: 'RefForm',
            meta: {
              title: 'RefForm',
              moduleInComponent: 'comRefform'
            }
          }
        ]
      },
      ...... 省略其他
    ]
 ]

第三步改动,修改路由守卫,当路由跳转到业务包中的路由时,也就是meta存在moduleInComponent字段,跳转至我们的中间页中间页面负责渲染对应组件。

vue-element-plus-admin里修改/src/store/modules/permission.ts/src/utils/routerHelper.ts

// /src/store/modules/permission.ts

// 这里新增generateRoutesFn3方法
import {
  generateRoutesFn1,
  generateRoutesFn2,
  flatMultiLevelRoutes,
  generateRoutesFn3
} from '@/utils/routerHelper'

......省略其他

  actions: {
    generateRoutes(
      type: 'admin' | 'test' | 'none',
      routers?: AppCustomRouteRecordRaw[] | string[]
    ): Promise<unknown> {
      return new Promise<void>((resolve) => {
        let routerMap: AppRouteRecordRaw[] = []
        if (type === 'admin') {
          // 模拟后端过滤菜单
          routerMap = generateRoutesFn2(routers as AppCustomRouteRecordRaw[])
        } else if (type === 'test') {
          // 模拟前端过滤菜单
          routerMap = generateRoutesFn1(cloneDeep(asyncRouterMap), routers as string[])
        } else {
          // 这里加的逻辑
          routerMap = generateRoutesFn3(cloneDeep(asyncRouterMap))
        }
        // 动态路由,404一定要放到最后面
        this.addRouters = routerMap.concat([
          {
            path: '/:path(.*)*',
            redirect: '/404',
            name: '404Page',
            meta: {
              hidden: true,
              breadcrumb: false
            }
          }
        ])
        // 渲染菜单的所有路由
        this.routers = cloneDeep(constantRouterMap).concat(routerMap)
        resolve()
      })
    },
  }
})
// /src/utils/routerHelper.ts

export const generateRoutesFn3 = (routes: AppCustomRouteRecordRaw[]): AppRouteRecordRaw[] => {
  const res: AppRouteRecordRaw[] = []

  for (const route of routes) {
    const data: AppRouteRecordRaw = {
      path: route.path,
      name: route.name,
      redirect: route.redirect,
      meta: route.meta
    }
    // 这里加了两句代码
    if (route.meta?.moduleInComponent) {
      data.component = modules[`../views/Middleware/Middleware.vue`]
    } else {
      data.component = route.component
    }
    // recursive child routes
    if (route.children) {
      data.children = generateRoutesFn3(route.children)
    }
    res.push(data as AppRouteRecordRaw)
  }
  return res
}

上面两端内容就是通过监听路由,递归找有moduleInComponent,去指定跳转到/views/Middleware/Middleware.vue,当然项目有路由后端获取的大致需要在处理的逻辑里加递归逻辑即可。

第四步就是创建我们的/views/Middleware/Middleware.vue页面。

// /src/views/Middleware/Middleware.vue
<template>
  <div style="height: 100%; width: 100%">
    <h2>包里的页面</h2>
    <errorPage v-if="!componentName" />
    // 使用component标签做渲染
    <component v-else :is="componentName" />
  </div>
</template>
<script lang="ts">
export default {
  name: 'Middleware'
}
</script>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref } from 'vue'

import errorPage from '@/views/Error/404.vue'

const route = useRoute()
const componentName = ref(route.meta.moduleInComponent)
</script>
<style lang="scss" scoped></style>

预览成果

好了,运行项目就会发现如下内容,那在页面上显示就ok啦 ,路由层面已经互通!

image.png

后续

路由已经解决,下篇会描述如何业务包如何通信问题,全局函数、pinia、router等还有业务包如何发布,发布至私有npm平台等。

水平有限只是分享,大家大家取其精华,去其糟粕,轻喷!

git地址:shareToSplit (github.com)