前言
在上一篇中,我们初始化了整个项目,并且添加了最基本的依赖,已经实现了效果如下:
而在常见的后台项目中,常见于左侧有一菜单栏,右侧是标题栏和内容的布局。这一篇中,我们主要来实现该基本布局。
安装 vue-router
vue-router 官网:https://router.vuejs.org/zh/
安装
pnpm install vue-router@4
创建两个测试页面
在 src 目录下,新建 views 目录,用于存放页面文件。
分别创建两个测试页面,dashboard
和 table
:
dashboard
/index.vue
:<script setup lang="ts"> </script> <template> <div> <h3>Dashboard</h3> <router-link to="/table">Go to Table</router-link> </div> </template> <style scoped> </style>
table
/index.vue
:<script setup lang="ts"> </script> <template> <div> <h3>Table</h3> <router-link to="/dashboard">Go to Dashboard</router-link> </div> </template> <style scoped> </style>
配置页面路由
有了页面文件之后,第一件事情就是需要配置当前页面的路由。
在 src 目录下,新建 router 文件夹,用于存放路由配置文件。首先在其下创建一个 modules 文件夹,区分各模块不同的路由配置文件。
创建两个路由配置文件:dashboard.ts
和 table.ts
:
router
/modules
/dashboard.ts
:import type { RouteRecordRaw } from 'vue-router' const dashboardRoutes: RouteRecordRaw[] = [ { path: '/dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index.vue') } ] export default dashboardRoutes
router
/modules
/table.ts
:import type { RouteRecordRaw } from 'vue-router' const tableRoutes: RouteRecordRaw[] = [ { path: '/table', name: 'Table', component: () => import('@/views/table/index.vue') } ] export default tableRoutes
动态获取所有路由配置
配置好前面的路由信息后,需要将这些路由配置加载到 vue-router 中,这里采用动态读取文件的加载方式,即在 modules 目录下创建好路由配置后,可以自动加载到 vue-router 中,不需要再次导入。
在 router 目录下创建一个 routes.ts
文件,用于读取 modules 目录下的路由配置信息:
routes.ts
import type { RouteRecordRaw } from 'vue-router' const modules = import.meta.glob('./modules/**/*.ts', { eager: true }) const routes = Object.keys(modules).reduce((routes, key) => { // @ts-ignore const module = modules[key].default if (Array.isArray(module)) { return [...routes, ...module] } else { return [...routes, ...module.routes] } }, [] as RouteRecordRaw[]) export default routes
启用 vue-router
经过前面的准备工作后,就可以将所有路由应用到 vue 中了。在 router 目录下创建 index.ts
:
index.ts
import {createWebHistory, createRouter} from "vue-router"; import type {App} from 'vue' // 获取所有路由 import routes from './routes' const router = createRouter({ routes, // 这里使用历史记录模式 history: createWebHistory() }) export const useRouter = (app: App<Element>): void => { app.use(router) }
随后在 main.ts
中启用:
main.ts
// router import { useRouter } from '@/router' useRouter(app)
最后,在 App.vue
中,渲染路由匹配到的组件:
<script setup lang="ts">
</script>
<template>
<router-view></router-view>
</template>
<style scoped>
</style>
测试
启动项目之后,打开发现页面空白,这是因为我们没有配置根路径,之后会加上的,一步步来。
可以先在浏览器的地址栏后面增加 /dashboard,就可以访问到 dashboard 下的页面啦:
点击 「Go To Table」:,可以正常访问到 table 页面:
OK,到目前为止,vue-router 已经成功整合完成,接下来完善下。
配置根路径
在前面讲到,打开页面时,需要手动输入要访问的链接,这对于用户来说是非常不友好的,那如何配置打开页面时,自动跳转到 /dashbaord 呢?
接下来,我们就来解决这个问题。
在 router/modules 目录下,创建 index.ts
文件:
router
/modules
/index.ts
:import type { RouteRecordRaw } from "vue-router" const rootRoutes: RouteRecordRaw[] = [ { path: '/', name: 'Home', redirect: '/dashboard' } ] export default rootRoutes
在这个文件中,配置了 /
根路径,通过 redirect
指定根路径直接跳转到 /dashboard
,则重新打开浏览器后,会发现地址栏,又 /
自动变为 /dashboard
。
配置 404
当输入一个错误的 URL 地址时,这里的错误是指,没有配置相关的路由信息,会出现页面空白的现象,例如最开始访问 /
时,其实就是没有匹配到相关的路由。这里可以配置一个 404 页面,当出现这种情况时,跳转到该页面。
创建一个 404 页面
在 views 目录下,创建一个 error 文件夹,用于存放 404 页面:
views
/error
/404.vue
:<script setup lang="ts"> import { ref } from 'vue' import { useIntervalFn } from '@vueuse/core' import { useRouter } from 'vue-router' const router = useRouter() const timeRef = ref<number>(10) const { pause } = useIntervalFn(() => { if (timeRef.value > 1) { timeRef.value-- } else { pause() router.push('/') } }, 1000) </script> <template> <div class="flex h-full flex-col items-center mt-20"> <n-result status="404" title="404 资源不存在" size="huge"> <template #footer> <n-button @click="$router.push('/')">返回首页</n-button> </template> <div class="text-center">{{ timeRef }} 秒后返回首页</div> </n-result> </div> </template>
这里需要注意,这里用到了一个之前没有提到的模块:@vueuse/core
。这是一个 Vue 组合式 Api 必不可少的功能函数库。里面封装了许多常用功能,开箱即用。
安装:pnpm i @vueuse/core
配置404路由
OK,回到正题。
新建好 404 页面后,相应需要创建一个路由,在 router/modules 目录下创建一个 error.ts
文件:
import type { RouteRecordRaw } from "vue-router"
const errorRoutes: RouteRecordRaw[] = [
{
path: '/404',
name: 'NotFound',
meta: {
title: 'Page Not Found'
},
component: () => import('@/views/error/404.vue')
},
// 所有未定义路由,全部重定向到 404
{
path: '/:pathMatch(.*)*',
redirect: '/404'
}
]
export default errorRoutes
测试
在浏览器中输入一个未配置 URL,效果如下:
10 秒后,会自动跳转到主页。
实现菜单栏
实现一个最简单的左侧菜单栏,主要有以下四步:
- 获取所有路由信息,组装成一个树状菜单结构;
- 将树状菜单结构应用于菜单组件;
- 配置菜单点击切换路由;
- 菜单固定在左侧;
当然了,还有很多额外的特性,例如:嵌套菜单、菜单icon、隐藏菜单等等,下面我们来一起实现它。
路由信息组装成树状菜单结构
首先,我们组装成的树状菜单结构,其属性需要与实际要应用的菜单组件相对应,这里我们用到的是 NaiveUI 的 菜单 Menu 组件,通过翻阅文档,可以看到菜单选项中必传的配置属性就两个:label
和 key
。
- **
label
**:「string | (() => VNodeChild)
」菜单项的内容 - **
key
**:「string
」菜单项的标识符
了解了这个,直接开干:
获取路由配置
这一步非常简单,因为在上面的路由章节中,
routes.ts
已经能够直接返回所有的路由配置。所以,可以直接导入:import routes from '@/router/routes'
组装成菜单信息
let menuOptions: MenuOption[] = []; routes.forEach((route: RouteRecordRaw) => { const menuOption: MenuOption = { label: route.name, key: route.name as string, }; if (route.children && route.children.length > 0) { menuOption.children = getMenuOptions(route.children) } menuOptions.push(menuOption); });
其中
MenuOption
是 NaiveUI 中定义的一种类型
这里将菜单相关的一些属性和操作,封装成为 “组合式函数”(Composables) ,在 src 目录中新建 composables
文件夹,用于存放“组合式函数”文件。创建 useMenu.ts
文件:
composables
/useMenu.ts
:import type { Ref } from "vue"; import { ref, watch } from "vue"; import { MenuOption } from "naive-ui"; import routes from "@/router/routes"; import { RouteRecordRaw, useRoute } from "vue-router"; export interface UserMenu { /** * 菜单选项 */ menuOptions: Ref<MenuOption[]>; /** * 展开的子菜单标识符数组 */ expandKeys: Ref<string[]>; /** * 更改子菜单标识符数组回调方法 */ updateExpandKeys: (keys: string[]) => void; /** * 当前选中的菜单 */ currentMenu: Ref<string>; /** * 修改选中菜单时的回调方法 */ updateValue: (key: string) => void; } const getMenuOptions = (routes: RouteRecordRaw[]): MenuOption[] => { let menuOptions: MenuOption[] = []; routes.forEach((route: RouteRecordRaw) => { const menuOption: MenuOption = { label: route.name, key: route.name as string, }; if (route.children && route.children.length > 0) { menuOption.children = getMenuOptions(route.children) } menuOptions.push(menuOption); }); return menuOptions; }; export function useMenu(): UserMenu { const menus: MenuOption[] = getMenuOptions(routes); /** * 菜单选项 */ const menuOptions = ref(menus); /** * 展开的子菜单标识符数组 */ const expandKeys: Ref<string[]> = ref<string[]>([]); /** * 当前菜单 */ const currentMenu: Ref<string> = ref<string>(""); const route = useRoute(); /** * 监听路由变化 */ watch( () => route.path, () => { routeChanged(); }, { immediate: true } ); /** * 判断路由是否包含在菜单列表中 * * @param routeName 路由名称 * @param menuList 菜单列表 * @returns 如果包含则返回 true;否则返回 false */ function menuContains(routeName: string, menuList: MenuOption[]): boolean { for (let menu of menuList) { if (menu.key === routeName) { return true; } if (menu.children && menu.children.length > 0) { const childMenuContains = menuContains(routeName, menu.children); if (childMenuContains) { return true; } } } return false; } /** * 路由发生变化时的回调 */ function routeChanged(): void { // 获取匹配到的路由列表 const matched = route.matched; // 获取匹配到路由名称 const matchedNames = matched .filter((it) => menuContains(it.name as string, menus)) .map((it) => it.name as string); const matchLen = matchedNames.length; const matchExpandKeys = matchedNames.slice(0, matchLen - 1); const openKey = matchedNames[matchLen - 1]; expandKeys.value = matchExpandKeys; currentMenu.value = openKey; } /** * 更改子菜单标识符数组回调方法 */ function updateExpandKeys(keys: string[]): void { expandKeys.value = keys } /** * 选中的菜单发生改变 */ function updateValue(key: string): void { currentMenu.value = key } return { menuOptions, expandKeys, updateExpandKeys, currentMenu, updateValue } as UserMenu }
将树状菜单结构应用于菜单组件
生成菜单数据后,应用于 NaiveUI 的 Menu 组件非常简单:
<script setup lang="ts">
import { useMenu } from "@/composables/useMenu";
const { menuOptions, expandKeys, updateExpandKeys, currentMenu, updateValue } = useMenu();
</script>
<template>
<n-menu
:options="menuOptions"
:expanded-keys="expandKeys"
:on-update:expanded-keys="updateExpandKeys"
:value="currentMenu"
:on-update:value="updateValue"
></n-menu>
</tempalte>
菜单固定在左侧
NaiveUI 提供了一个布局(Layout)组件,可以非常方便地进行常用的页面布局。例如最常见的如下布局:
左侧为菜单栏,右侧上部分为标题栏,中间是内容,切换路由时,会在该部分渲染,下面是网站的 Footer。
这种组件呢比较通用,所以通常会封装成为一个单独的组件文件。
封装布局组件
在 src 目录下,新建一个 layouts 文件夹,用于存放布局组件文件。
在 layouts 文件夹下新建一个 BasicLayout.vue
文件,这里先实现一个简单的布局,左边是菜单栏,右边是实际路由内容:
layouts
/BasicLayout.vue
:<script setup lang="ts"> import { useMenu } from "@/composables/useMenu"; const { menuOptions, expandKeys, updateExpandKeys, currentMenu, updateValue } = useMenu(); </script> <template> <n-layout has-sider> <n-layout-sider bordered collapse-mode="width" :width="220" :native-scrollbar="false" > <n-scrollbar> <n-menu :options="menuOptions" :expanded-keys="expandKeys" :on-update:expanded-keys="updateExpandKeys" :value="currentMenu" :on-update:value="updateValue" ></n-menu> </n-scrollbar> </n-layout-sider> <article flex-1 flex-col overflow-hidden> <section flex-1 overflow-hidden bg="#f5f6fb"> <router-view v-slot="{ Component, route }"> <template v-if="Component"> <component :is="Component" :key="route.path" /> </template> </router-view> </section> </article> </n-layout> </template> <style scoped></style>
修改路由信息
定义好通用布局组件后,需要将前面定义的 dashboard
和 table
页面的路由:
router
/modules
/dashboard.ts
:import type { RouteRecordRaw } from "vue-router"; import BasicLayout from "@/layouts/BasicLayout.vue"; const dashboardRoutes: RouteRecordRaw[] = [ { path: "/", name: "Dashboard", component: BasicLayout, children: [ { path: "/dashboard", name: "Dashboard", component: () => import("@/views/dashboard/index.vue"), }, ], }, ]; export default dashboardRoutes;
router
/modules
/table.ts
:import type { RouteRecordRaw } from "vue-router"; import BasicLayout from "@/layouts/BasicLayout.vue"; const tableRoutes: RouteRecordRaw[] = [ { path: "/", name: "Table", component: BasicLayout, children: [ { path: "/table", name: "Table", component: () => import("@/views/table/index.vue"), }, ], }, ]; export default tableRoutes;
这样子配置布局组件就能生效,是因为 vue-router 的嵌套路由功能所支持,在渲染时,是根据匹配到的组件,一级一级来渲染的。
具体可以查看文档 嵌套路由 | Vue Router (vuejs.org)
测试效果
完成后呢,先来简单测试下,效果如下:
非常简陋,但已初具雏形。同时发现样式很奇怪,似乎全都挤在了中间。通过浏览器样式面板中查看,原来 vite 默认生成的 vue 项目,会在 style.css
中添加如下一个 css 配置:
style.css
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
所以造成了全部居中的现象,OK,了解了原因,直接把这一整个文件删掉,后面根据我们的需求,再进行样式调整。
删掉后,同时在 main.ts
中,把 import './style.css'
这一行也删掉,重新查看页面,看起来正常多了。
完善菜单
在上面生成的菜单中,有一个问题,迫切的需要解决下。那就是不能够隐藏一些菜单,例如 404 等。先来解决下这个:
隐藏菜单
我们先定义在菜单的元数据中,增加一个属性:hidden
,当配置该属性为 true 的时候,则该路由不在菜单中展示。
例如配置 404 页面的路由如下:
import type { RouteRecordRaw } from "vue-router"
const errorRoutes: RouteRecordRaw[] = [
{
path: '/404',
name: 'NotFound',
meta: {
title: 'Page Not Found'
hidden: true
},
component: () => import('@/views/error/404.vue')
},
// 所有未定义路由,全部重定向到 404
{
path: '/:pathMatch(.*)*',
redirect: '/404',
hidden: true
}
]
export default errorRoutes
同理,对 /
根目录路由,同样配置在菜单中隐藏。
修改上面定义的「组装成菜单信息」逻辑如下:
routes.forEach((route: RouteRecordRaw) => {
if (!route.meta?.hidden) {
const menuOption: MenuOption = {
label: route.name,
key: route.name as string,
};
menuOptions.value.push(menuOption);
}
});
重新刷新页面,效果如下:
设置点击菜单时切换路由
目前还没有实现点击相应菜单时,切换不同的页面。要想实现该效果,NaiveUI 的 Menu 组件,提供了非常好的支持,可以通过将 label
渲染为 <router-link />
来改变路由,具体可以查看相应文档:菜单 Menu - Naive UI
这里,我们改一下生成菜单数据的地方 —— useMenu.ts
:
import { h } from "vue";
import { MenuOption } from "naive-ui";
import { RouteRecordRaw, RouterLink } from "vue-router";
const getMenuOptions = (routes: RouteRecordRaw[]): MenuOption[] => {
let menuOptions: MenuOption[] = [];
routes.forEach((route: RouteRecordRaw) => {
// @ts-ignore
if (!route.meta?.hidden) {
const menuOption: MenuOption = {
label: () => {
if (route.children && Array.isArray(route.children)) {
return route.name;
} else {
return h(
RouterLink,
{ to: { name: route.name } },
{ default: () => route.name }
);
}
},
key: route.name as string,
};
if (route.children && route.children.length > 0) {
menuOption.children = getMenuOptions(route.children);
}
menuOptions.push(menuOption);
}
});
return menuOptions;
};
刷新页面之后呢,点击 Table 子菜单,发现并没有跳转过来,这是因为
dashboard
和 table
路由配置中,因为只有一个页面,父子路由名称一样,只匹配到了父路由。这里暂时把父节点的 name
属性删掉,重新测试,可以正常跳转啦:
但父级菜单都变成空白啦,这个有两种方式,可以通过修改名称,或者配置当只有一个子菜单时,不显示父菜单来解决。下面我们就实现一下第二种方式。
当只有一个子菜单时不显示父菜单
这里同样还是修改生成菜单数据的地方 —— useMenu.ts
文件,判断,当子菜单只有一个时,直接取子菜单即可。修改为如下:
/**
* 判断路由是否只有一个子路由
* @param route 路由
* @returns 如果该路由只有一个子路由,则返回 true;否则返回 false
*/
const isSingleChildren = (route: RouteRecordRaw): boolean => {
return route?.children?.length === 1;
};
/**
* 过滤路由配置中需要在菜单中隐藏的路由
* @param routes 路由列表
* @returns 路由列表
*/
const filterHiddenRouter = (routes: RouteRecordRaw[]): RouteRecordRaw[] => {
return routes.filter((item: RouteRecordRaw) => {
return !item.meta?.hidden;
});
};
/**
* 将路由信息转换为菜单信息
* @param route 路由信息
* @returns 菜单信息
*/
const getMenuOption = (route: RouteRecordRaw): MenuOption | undefined => {
// @ts-ignore
const routeInfo = isSingleChildren(route) ? route.children[0] : route;
const menuOption: MenuOption = {
label: () => {
if (routeInfo.children && Array.isArray(routeInfo.children)) {
return routeInfo.name;
} else {
return h(
RouterLink,
{ to: { name: routeInfo.name } },
{ default: () => routeInfo.name }
);
}
},
key: routeInfo.name as string,
};
if (routeInfo.children && routeInfo.children.length > 0) {
menuOption.children = getMenuOptions(routeInfo.children);
}
return menuOption;
};
const getMenuOptions = (routes: RouteRecordRaw[]): MenuOption[] => {
let menuOptions: MenuOption[] = [];
filterHiddenRouter(routes).forEach((route: RouteRecordRaw) => {
// @ts-ignore
const menuOption = getMenuOption(route);
if (menuOption) {
menuOptions.push(menuOption);
}
});
return menuOptions;
};
刷新页面,已经实现效果啦:
添加菜单Icon
NaiveUI 的 Menu 组件,提供了比较方便的图标实现。并且在文档「菜单 Menu - Naive UI」中也提供了比较详细的示例。
这里参考文档,简单实现一下:
首先,还是在生成菜单数据的地方,先来获取路由配置中的图标属性。
composables
/useMenu.ts
:import { h, Component } from "vue"; import { NIcon } from "naive-ui"; const renderIcon = (icon: Component) => { return () => h(NIcon, null, { default: () => h(icon) }) } const getMenuOption = (route: RouteRecordRaw): MenuOption | undefined => { // @ts-ignore const routeInfo = isSingleChildren(route) ? route.children[0] : route; const menuOption: MenuOption = { label: () => { if (routeInfo.children && Array.isArray(routeInfo.children)) { return routeInfo.name; } else { return h( RouterLink, { to: { name: routeInfo.name } }, { default: () => routeInfo.name } ); } }, key: routeInfo.name as string, icon: routeInfo.meta?.icon ? renderIcon(routeInfo.meta?.icon as Component) : undefined }; if (routeInfo.children && routeInfo.children.length > 0) { menuOption.children = getMenuOptions(routeInfo.children); } return menuOption; };
之后修改路由配置信息,在路由的元数据中增加 icon 属性:
以 dashboard
的路由配置为例:
import type { RouteRecordRaw } from "vue-router";
import BasicLayout from "@/layouts/BasicLayout.vue";
import { DashboardCustomizeRound } from '@vicons/material'
const dashboardRoutes: RouteRecordRaw[] = [
{
path: "/dashboard",
component: BasicLayout,
children: [
{
path: "",
name: "Dashboard",
component: () => import("@/views/dashboard/index.vue"),
meta: {
icon: DashboardCustomizeRound
}
},
],
},
];
export default dashboardRoutes;
刷新页面:
这里需要注意哈,路由配置中是 ts 文件,不能够自动导入,所以需要手动导入需要依赖的图标组件。
解决了上面一系列问题后,基本看起来就像一个正常的菜单了,但样式还有一些问题,打开浏览器的「开发者工具」,可以看到高度并没有撑满浏览器,后面我们会解决下这个问题。
使用 TS 来重新定义路由的 Meta
在前面的编码过程中,我们在路由配置的元数据中,添加了两个属性:hidden
和 icon
,当用到这两个属性时,没有任何提示,其实与 ts 的理念是相违背的,所以这里我们来重新定义下路由的元数据类型。
首先,在 router
文件夹下,添加 type.ts
文件,来定义路由元数据类型和新的路由类型:
router
/type.ts
:import { RouteRecordRaw } from "vue-router" import { Component } from 'vue' interface RouteRecordMeta { hidden?: boolean, icon?: Component } // @ts-expect-error export interface RouteRecord extends Omit<RouteRecordRaw, 'meta'> { name?: string, meta?: RouteRecordMeta, children?: RouteRecord[] }
在这里定义了两个接口类型,分别是路由元数据(
RouteRecordMeta
)和路由类型(RouteRecord
)。
之后使用到 vue-router
的 RouteRecordRaw
类型的地方,修改为我们刚定义的 RouteRecord
。例如 dashboard
路由配置文件中:
import { RouteRecord } from "@/router/type"
import BasicLayout from "@/layouts/BasicLayout.vue";
import { DashboardCustomizeRound } from '@vicons/material'
const dashboardRoutes: RouteRecord[] = [
{
path: "/dashboard",
component: BasicLayout,
children: [
{
path: "",
name: "Dashboard",
component: () => import("@/views/dashboard/index.vue"),
meta: {
icon: DashboardCustomizeRound,
}
},
],
},
];
export default dashboardRoutes;
结语
这一篇,从如何使用 vue-router,及其各种场景的配置,到实现了一个左侧菜单栏和其基本的功能,下一篇让我们来针对菜单栏和内容栏进行一定的美化,让项目的样式变得更加美观一些。