Vue3 Vite2 TypeScript Pinia NaiveUI 实现动态路由生成

2,133 阅读6分钟

依赖包版本

vue3.2.25 vue-router4.0.0 pinia2.0.14 mockjs1.1.0

方案

我选择的方案是后端仅存储关键的path信息,返回一个string[]类型的数据,这样做的好处是,后台存储的数据足够简单,前端调试的时候也不需要依赖于后台,开了mock或者修改一下比对函数,就可以轻松的访问所有路由。

逻辑

大致的逻辑是,进入系统判断当前页面是否是白名单页面,是的话直接进入,如果不是的话,判断路由是否生成,如果没生成的话,生成路由,然后跳转。

VueRouter 类型扩展

原有的 RouteRecordRawRouteMeta 不能满足我们的需求,我们扩展一下。

src目录下,新建types目录,创建router.d.ts文件

import { Component } from 'vue';
import { RouteMeta, RouteRecordName, RouteRecordRaw } from 'vue-router';

export type AppRouteRecordRaw = {
  meta: RouteMeta;
  name: RouteRecordName;
  orderBy?: number;
  children?: AppRouteRecordRaw[];
  hidden?: boolean;
} & RouteRecordRaw;

declare module 'vue-router' {
  interface RouteMeta {
    title: string;
    noPerm?: boolean;
    icon?: Component;
    url?: string;
  }
}

之后我们使用AppRouteRecordRaw来定义路由类型,orderBy字段用于排序,hidden字段用于标注当前路由是否在侧边菜单显示,noPerm字段用于判断当前路由是否需要后端权限,icon字段为图标,url字段表示当前字段的外链地址。

其中icon的类型,可以按照你喜欢的方式自定义,我这里选择传入一个Component

如果写了之后没生效,可以看一下tsconfig.jsoninclude字段。

路由

路由目录结构

- src
    - router
        - guard // 拦截器文件夹
        - routes // 路由文件夹
            - modules  // 存放对应的模块的路由
            - index.ts
        - constants.ts // 静态路由,无需权限
        - index.ts 

constants

constants.ts文件,用来存放前端的一些静态路由,如欢迎页 404 403 等,按实际需求添加即可,我这里添加一个示例的首页。

import { AppRouteRecordRaw } from '@/types/router';
import { RouteRecordRaw } from 'vue-router';

export const LAYOUT = () => import('@/layouts/default/index.vue');
export const BLANK_LAYOUT = () => import('@/layouts/default/blankLayout.vue');

const routes: AppRouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    redirect: '/dashboard',
    meta: {
      title: 'Home'
    }
  }
];

export const constants = routes as RouteRecordRaw[];

modules

我们把对应的侧边栏一级菜单的路由作为路由文件,与views下的目录一一对应,创建路由文件,例如:

import { LAYOUT } from '@/router/constants';
import { AppRouteRecordRaw } from '@/types/router';
import Icon from '@/components/Icon/index.vue';

const dashboard: AppRouteRecordRaw = {
  orderBy: 0,
  path: '/dashboard',
  name: 'Dashboard',
  redirect: '/dashboard/analysis',
  component: LAYOUT,
  meta: {
    title: 'Dashboard',
    icon: h(Icon, { name: 'dashboard' })
  },
  children: [
    {
      path: 'analysis',
      name: 'DashboardAnalysis',
      component: () => import('@/views/dashboard/analysis/index.vue'),
      meta: {
        title: '分析页',
        icon: h(Icon, { name: 'analysis' })
      }
    },
    {
      path: 'workbench',
      name: 'DashboardWorkbench',
      component: () => import('@/views/dashboard/workbench/index.vue'),
      meta: {
        title: '工作台',
        icon: h(Icon, { name: 'workbench' })
      }
    }
  ]
};

export default dashboard;

这里的 Icon 是一个SVG图标组件,不影响逻辑,不赘述。

image.png

我个人认为,modules下的文件与views下的目录一一对应,能够减少心智负担,寻找对应的路由页面无需查看路由文件,毕竟我们的路由是自动收集的。

路由自动收集

我们使用import.meta.globEager自动收集modules下的路由文件。

// /src/router/routes/index.ts

import { AppRouteRecordRaw } from '@/types/router';
import { RouteRecordRaw } from 'vue-router';

const modules = import.meta.globEager('./modules/*.ts');
const routeModules: AppRouteRecordRaw[] = [];

Object.keys(modules).forEach((key) => {
  const route: AppRouteRecordRaw = modules[key].default;
  routeModules.push(route);
});

export const asyncRoutes = routeModules.sort((current, next) => (current.orderBy || 0) - (next.orderBy || 0)) as RouteRecordRaw[];

创建路由

// /src/router/index.ts

import { createRouter, createWebHashHistory } from 'vue-router';
import { constants } from './constants';

const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    ...constants
  ]
});

export default router;

静态路由就已经创建好了,到main.ts注册即可,如果想要看效果的话,可以把asyncRoutes引入到routes里查看,接下来,我们去弄pinia,搞定asyncRoutes比对生成最终路由的问题。

pinia

创建store目录,新建index.ts文件夹,到main.ts注册即可。

// /src/store/index.ts

import { createPinia } from 'pinia';

const store = createPinia();

export default store;

permission

接着在store目录下新建modules目录,用来存放对应的状态文件,我们新建一个permission.ts处理权限相关的数据,该文件无需额外处理,创建后引入使用即可。

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

import { onLogin } from '@/api/app';
import { filterAsyncRoutes } from '@/helper/router';
import { asyncRoutes } from '@/router/routes';
import { defineStore } from 'pinia';
import { RouteRecordRaw } from 'vue-router';

interface IPermissionStore {
  authRoutes: RouteRecordRaw []
}

export const usePermissionStore = defineStore('permission', () => {
  const store: IPermissionStore = reactive({
    authRoutes: []
  });

  async function generateRoutes() {
    // 这里是因为我之前封装的 useRequest 的妥协,用来请求对应权限列表的,可按照自己的axios封装修改,重要的是取出permList列表即可。
    const { data: { data: { permList = [] } = {} } } = await onLogin().instance;
    
    store.authRoutes = filterAsyncRoutes(asyncRoutes, permList);
  }

  return {
    ...toRefs(store),
    generateRoutes
  };
});

mock数据如下

import { MockMethod } from 'vite-plugin-mock';

const appMocks: MockMethod[] = [
  {
    url: '/api/login',
    method: 'get',
    timeout: 1000,
    response: () => {
      return {
        code: 200,
        msg: '',
        data: {
          permList: [
            '/dashboard',
            '/dashboard/analysis',
            '/dashboard/workbench'
          ]
        }
      };
    }
  }
];

export default appMocks;

filterAsyncRoutes 则是将前端全量路由与后台权限数组做比对,配合vue-router的写法,做了一个递归判断。

// /src/helper/router.ts
import { RouteRecordRaw } from 'vue-router';
import { cloneDeep } from 'lodash-es';

export function filterAsyncRoutes(routes: RouteRecordRaw[], permList: string[], prefix = ''): RouteRecordRaw[] {
  const res: RouteRecordRaw[] = [];
  routes.forEach((route) => {
    const tmp: RouteRecordRaw = cloneDeep<RouteRecordRaw>(route);
    const path = tmp.path.charAt(0) === '/' ? tmp.path : `${prefix}${tmp.path}`;
    if(tmp.meta?.noPerm) {
      res.push(tmp);
    } else if(permList.includes(path)) {
      if(tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, permList, `${prefix}${tmp.path}/`);
      }
      res.push(tmp);
    }
  });
  return res;
}

到这一步,我们需要的权限路由就已经存放在pinia中的authRoutes数组下了,之后,我们只需要在路由拦截中判断添加即可。

添加路由

我们到/src/router/guard目录下新建permissionGuard.ts目录,用来专门处理权限相关的拦截器。

import { usePermissionStore } from '@/store/modules/permisssion';
import { Router } from 'vue-router';

export function createPermissionGuard(router: Router) {
  router.beforeEach(async (to, from, next) => {
    const permissionStore = usePermissionStore();
    if(!permissionStore.authRoutes.length) {
      await permissionStore.generateRoutes();
      permissionStore.authRoutes.map((route) => {
        router.addRoute(route);
      });
      next(to);
    }
    next();
  });
}

判断当前路由是否生成,如果没生成就生成路由再跳转到下一页。

这里实际处理会更复杂,比如对login register 404 403等页面添加白名单处理,无需添加权限路由这一步,这里依据对应的业务实现即可,我这里不做登录这些。

为了更好的管理guard目录下的拦截器,我们在/src/router/guard目录下新建一个index.ts统一管理。

import { Router } from 'vue-router';
import { createPermissionGuard } from './permissionGuard';

export function createGuard(router: Router) {
  createPermissionGuard(router);
}

/src/router/index.ts中注册

import { createRouter, createWebHashHistory } from 'vue-router';
import { constants } from './constants';
import { createGuard } from './guard';

const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    ...constants
  ]
});

createGuard(router);

export default router;

现在路由的生成逻辑就完成了。

生成侧边菜单

NaiveUI 的侧边栏菜单使用比较简单,确定对应的数据源及渲染函数即可。

<script lang="ts" setup>
import { renderMenuLabel, renderIcon } from '@/helper/router';
import { useAppStore } from '@/store/modules/app';
import { usePermissionStore } from '@/store/modules/permisssion';
import { useRoute } from 'vue-router';

const appStore = useAppStore();
const permissionStore = usePermissionStore();

const route = useRoute();

</script>
<template>
  <div
    class="h-full fixed top-0 left-0 transition-all duration-300 z-30 flex flex-col shadow-lg"
    :class="[appStore.collapsed ? 'w-sidebar--collapsed' : 'w-sidebar']"
  >
    <div class="h-header flex items-center justify-center font-bold text-medium text-primary whitespace-nowrap overflow-hidden">
      {{ appStore.collapsed ? 'N' : 'Naive Template' }}
    </div>
    <div class="flex-1 overflow-auto">
      <NMenu
        :value="(route.name as string)"
        :collapsed="appStore.collapsed"
        :collapsed-width="56"
        :indent="20"
        :options="permissionStore.authRoutes"
        key-field="name"
        :render-label="(route: any) => renderMenuLabel(route)"
        :render-icon="(route: any) => renderIcon(route)"
      />
    </div>
  </div>
</template>

其中renderMenuLabelrenderIcon控制渲染的结果。

// /src/helper/router.ts

import { RouteRecordRaw, RouterLink } from 'vue-router';
import { cloneDeep } from 'lodash-es';
import { AppRouteRecordRaw } from '@/types/router';
import { NIcon } from 'naive-ui';
import { Component } from 'vue';

export function renderMenuLabel(route: AppRouteRecordRaw) {
  if(route.meta.url) {
    return h(
      'a',
      {
        href: route.meta.url,
        target: '_black'
      },
      { default: () => route.meta.title }
    );
  }
  return h(
    RouterLink,
    {
      to: {
        name: route.children ? '' : route.name
      }
    },
    { default: () => route.meta.title }
  );
}

export function renderIcon(route: AppRouteRecordRaw) {
  return h(NIcon, null, { default: () => h(route.meta.icon as Component) });
}

渲染的结果添加一点儿样式就是这样的效果

image.png

结语

这种方案是我认为比较友好简单的方式,通常会配合一个权限管理平台一起使用。 GitHub,觉得有用的可以点个星,这个项目是写给组内的人学习的,所以提交记录比较干净,以后也会一直更新的。