v3-admin-vite 改造自动路由,view页面自解释Meta

本文介绍了如何在v3-admin-vite模板中实现自解释页面路由,包括目录和视图文件的元数据管理,以及如何利用Webpack/Vite的文件扫描功能。同时讨论了这种做法的分包问题和潜在优化方法。

需求

v3-admin-vite是一款不错的后端管理模板,主要是pany一直都在维护,最近将后台管理也进行了升级,顺便完成一直没时间解决的小痛痒:

在不使用后端动态管理的情况下。我不希望单独维护一份路由定义,我希望页面是自解释的。就像HTML标记,一个页面的标题等信息由页面内<title>决定,而一个页面的访问地址(路由)由页面目录决定。很自然的思维是么?单独维护一份路由感觉就没那么自然了,我希望这一切都由页面自解释。访问路径我只需要移动页面的位置,Ctrl+CV目录的结构就好了

思路

之前实战过数个项目,大部分都轻车熟路了。但是对于v3-admin-vite系统,还是有几个地方需要调整 :

1.目录的定义

目录的定义除了名称外,还有图标等信息需要管理。因此需要采用文件补充信息,我的解决方案是将要输出成为左侧目录结构的目录(好绕口)下放一个index.ts文件,为了避免和其它的文件冲突,约定默认导出export default必须包含title这个string信息,表示目录名称(神马?title为空怎么办?有点正常业务思维吧),顺便把图标的定义也在导出解决。

2.View文件的定义

View文件的定义由于目录定义一样,只需要将你导出成为菜单的vue模块添加导出定义即可。把meta信息导出,自动输出路由配置。

3.Name约定

除了meta信息以外,admin-v3还要求name不能一致(没试过 ?改个一样的试试看😏),我们可以直接从文件名读取,至少一个目录下文件名是不会一致的。当然如果多个目录的话,就要注意一下了,功能页面名称唯一这个应该很容易办到。

4.递归扫描文件

webpack,vite等工具都提供了文件扫描的接口,只是不能使用变量进行路径扫描,必须字面量(常量),好在支持通配符。解决起来不难。对于后端很早例如spring框架就具备了自动扫描功能,对于前端,有对应方案但是应用的不是很多,用好了很舒服。

5.顺序问题的解决

由于工具扫描都是基于文件名称的,而实际需要显示的结构和文件顺序 不一定相同,例如我有a.vue,b.vue,按名称扫描a会出现在前面。因此我扩充了一下Meta定义,添加了一个position属性 ,没设置时,默认以100作为排序值,根据其对所有的目录递归排序,这样就OK了。

功能实现

看一下最终的对应效果

对于目录标记,我们只需要在目录下添加一个index.ts文件:

import { RouteMetaEx } from '@/router'

export default {
  title: '二级目录测试',
} as RouteMetaEx

对于View模块,我们只需要添加多一个typescript块导出meta:

<template>
  <div>测试节点3</div>
</template>
<script lang="ts">
import { RouteMetaEx } from '@/router'
export const meta: RouteMetaEx = {
  title: '3级节点1', // 只有导出title的才会成为路由
  elIcon: 'Cpu', // element-ui的内置ICON,比svgIcon优先
  // svgIcon: 'dashboard',
  roles: ['role0'], // 哪些角色可以显示
  position: 100
  //keepAlive: true // 是否要keepAlive保持页面状态
  // hidden: true 默认为false,不会挂载到菜单
}
</script>

使用是不很简单?哈哈哈哈。

这里添加了position的RouteMetaEx在后面有定义,其它结构和功能和Meta定义一致。注意vue的文件名在view下要唯一。

然后我们的src/router/index.ts里dynamicRoutes需要按照下面方式来导出:

export interface RouteMetaEx extends RouteMeta {
  position?: number //排序,不填写的话默认为100,用于控制菜单顺序
}

/**
 * admin-vite-v3 自动路由
 * 递归扫描views下的文件,识别导出title的页面加入路由,需要配置权限 (Roles 属性)
 * 注意二级目录产生要求在目录index.ts里导出含title的meta
 * @author Jim 2024/7/14
 */
const autoRoutes: Array<RouteRecordRaw> = []

const scanDir: Record<string, any> = import.meta.glob('@/views/**/index.ts', { eager: true }) // 处理目录
const dirNodeCache = new Map<string, RouteRecordRaw>()
for (const key in scanDir) {
  const component = scanDir[key]
  if (component.meta?.title) {
    // 通过默认导出title判断
    const groups = /\/views\/((\w+\/)+)index\.ts$/.exec(key) || []
    const dirName = groups[2].slice(0, -1) // 提取目录名
    const perfix = groups[1].slice(0, -dirName.length - 1) // 提取前缀目录
    const currentNode: RouteRecordRaw = {
      path: dirName,
      name: dirName,
      children: [],
      meta: { ...component.meta, alwaysShow: true } // 合并alwaysShow进去,保持目录结构
    }
    const upperNode = dirNodeCache.get(perfix)
    if (upperNode) {
      upperNode.children?.push(currentNode)
    } else {
      // 一级目录
      currentNode.path = `/${dirName}` // 更改根格式
      currentNode.component = Layouts
      autoRoutes.push(currentNode)
    }
    dirNodeCache.set(groups[1], currentNode)
  }
}
const scanModule: Record<string, any> = import.meta.glob('@/views/**/*.vue', { eager: true }) // 处理节点
for (const key in scanModule) {
  const component = scanModule[key]
  const componentFunc = new Promise((resolve, reject) => {
    resolve(component)
  })

  if (component.meta?.title) {
    // 通过默认导出title判断
    const groups = /\/views\/((\w+\/)*)(\w+)\.vue$/.exec(key) || []
    const dirPath = groups[1]
    const moduleName = groups[3]
    if (!dirPath) {
      // 一级菜单特殊处理
      autoRoutes.push({
        path: `/${moduleName}`,
        name: moduleName,
        component: Layouts,
        redirect: `/${moduleName}/index`,
        meta: component.meta,
        children: [
          {
            path: 'index',
            name: moduleName,
            component: componentFunc,
            meta: component.meta
          }
        ]
      })
    } else {
      const currentNode: RouteRecordRaw = {
        path: moduleName,
        name: moduleName,
        component: componentFunc,
        meta: component.meta
      }
      const upperNode = dirNodeCache.get(dirPath)
      if (upperNode) {
        // 挂上级目录下,没有定义就不要挂了
        upperNode.children?.push(currentNode)
      } else {
        console.error(`upper node ${dirPath} not found`)
      }
    }
  }
}

const sortFunc = (a: RouteRecordRaw, b: RouteRecordRaw) =>
  ((a.meta as RouteMetaEx).position ?? 100) - ((b.meta as RouteMetaEx).position ?? 100)
function sortByPosition(nodes: RouteRecordRaw[]) {
  nodes.forEach((node: RouteRecordRaw) => {
    if (node.children) {
      const sortedChildren = node.children.sort(sortFunc) // 排序所有children
      sortByPosition(sortedChildren)
    }
  })
}
autoRoutes.sort(sortFunc)
sortByPosition(autoRoutes)

export const dynamicRoutes: RouteRecordRaw[] = autoRoutes

其他部分可以不用动,不到100行代码,你便妥妥的拥有了高大上的自动路由功能。

开发环境,打印自动路由生成的结构:

if (import.meta.env.MODE === 'development') {
  console.log('****** 自动路由结构 ******')
  const printFileStructure = (nodes: RouteRecordRaw[], depth: number = 0) => {
    nodes.forEach((node: RouteRecordRaw) => {
      const prefix = '--'.repeat(depth)
      console.log(
        `${prefix}${node.path} ${node.name as string} ${node.meta?.title} ${!node.component || node.component === Layouts ? '[目录]' : '[组件]'} [${(node.meta as RouteMetaEx).roles}] pos=${(node.meta as RouteMetaEx).position}`
      )
      if (node.children) {
        printFileStructure(node.children, depth + 1)
      }
    })
  }
  printFileStructure(autoRoutes)
}

副作用

这样处理虽然开发方便,但是也有它的局限和副作用。副作用就是分包问题,由于所有的模块获取菜单定义必须读文件,因此需要下载所有的模块代码,会导致扫描过程需要加载全部的模块,这样一来当模块非常多的时候加载会比较耗资源,也无法细化的分包。但对于中小项目一个gzip包全部load下来还是问题不大。大型项目可能要采用其它方案例如自动化脚本来采用传统模式输出路由。

-----------------------------------------[ 我的功能改进的分割线:2024/07/22] -----------------------------------

自动叶节点权限

使用前面的方法发现一个问题没有,就是枝节点有时候会忘记权限的标记。对于正常的思维,在下级叶节点设置了某个权限后,上级枝节点是必须访问这个权限的(否则会发现功能设置了,没法出来,还得一级一级捣鼓枝节点/目录节点)

所以我们可以进一步优化代码,通过递归的方式,将叶节点的权限一级级往上传。这样我们就只需要考虑叶节点的配置,上级目录会自动根据叶节点来设置,减少配置麻烦。

因此,改进原递归排序部分的算法,将叶节点的权限往上传就好了。将sortByPosition部分改进为如下代码:

const sortFunc = (a: RouteRecordRaw, b: RouteRecordRaw) =>
  ((a.meta as RouteMetaEx).position ?? 100) - ((b.meta as RouteMetaEx).position ?? 100)
function sortPositionAndBranchRole(nodes: RouteRecordRaw[], branchRoleCollector?: Set<string>) {
  nodes.forEach((node: RouteRecordRaw) => {
    if (node.children) {
      //枝节点
      const sortedChildren = node.children.sort(sortFunc) // 排序所有children
      const subBranchRoleCollector = new Set<string>()
      sortPositionAndBranchRole(sortedChildren, subBranchRoleCollector)
      if (node.meta) {
        node.meta.roles = Array.from(subBranchRoleCollector)
      }
      if (branchRoleCollector) {
        //合并到上级
        subBranchRoleCollector.forEach((item) => branchRoleCollector.add(item))
      }
    } else {
      if (branchRoleCollector && node.meta?.roles) {
        node.meta.roles.forEach((role: string) => branchRoleCollector?.add(role))
      }
    }
  })
}
autoRoutes.sort(sortFunc)
sortPositionAndBranchRole(autoRoutes)

export const dynamicRoutes: RouteRecordRaw[] = autoRoutes

看一下打印后导出的结构:

改进完成!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FoxMale007

文章非V全文可读,觉得好请打赏

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值