持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天
路由
通过下面这张图,我们大概了解一下整个路由封装的流程。
路由结构设计
先来看看我们简化的路由文件结构,主要由路由总入口index.ts和 路由模块modules组成。另外我们再增加专门为路由配置的type类型文件(types.ts)和工具函数文件(utils.ts)
这样的结构在大一些的项目里非常常见,modules里面的每个文件代表一个路由模块,每个路由模块对应各自相同的功能页面,这样就非常利于项目的维护。举个例子:
├── modules
│ ├── error.ts # 错误页模块
│ ├── components.ts # 组件页模块
│ ├── home.ts # 主页面
│ └── remaining.ts # 其他页面模块(如登录页面、重定向页面)
......
路由配置项设计
举例列出本项目可能用到的配置选项
const routes = {
path: "/", // 路由地址
name: "test", // 路由名(对应,不要重复)
component: Layout, // Layout 组件,一般不用动(如需整体空白页面,请去掉)
redirect: "/redirect", // 路由重定向
meta: {
title: "测试", // 菜单名称(兼容国际化、非国际化,非国际化的话可以直接 title 写中文,i18n 可写 false,也可直接不加 i18n 字段)
icon: "icon", // 菜单图标(只针对顶级路由,也就是与当前 meta 平级的 component 为 Layout 的路由)
i18n: true, // 国际化(开启 true、关闭 false)
showLink: true, // 是否在菜单中显示
rank: 9 // 菜单升序排序,值越高排的越后(只针对顶级路由,也就是与当前 meta 平级的 component 为 Layout 的路由)
},
children: [ // 子路由配置项
{
path: "/error/404", // 子路由地址
name: "404", // 路由名字(对应不要重复,根当前组件的name保持一致)
component: () => import("/@/views/error/404.vue"), // 按需加载组件
meta: {
authority: ['admin'], // 路由权限设置
keepAlive: true // 路由组件缓存(开启 true、关闭 false)
transition: { // 页面加载动画(有两种形式,一种直接采用 vue 内置的 transitions 动画,另一种是使用 animate.css 进、离场动画)
name: "fade", // 当前路由动画效果,参考https://next.router.vuejs.org/guide/advanced/transitions.html#transitions
enterTransition: "animate__zoomIn", // 进场动画
leaveTransition: "animate__zoomOut", // 离场动画
},
dynamicLevel: 3, // 动态路由可打开的最大数量
refreshRedirect: "/tabs/index", // 刷新重定向(用于未开启标签页缓存,刷新页面获取不到动态title)
}
extraIcon: { // 菜单名称右侧的额外图标,支持fontawesome、iconfont、element-plus-icon
svg: true,
name: "team-icon",
},
},
]
}
路由示例
比如登录后首页的路由代码示例
// src\router\modules\home.ts
const Layout = () => import("/@/layout/index.vue");
const homeRouter = {
path: "/",
name: "Home",
component: Layout, // 该页面所处的布局结构
redirect: "/welcome",
meta: {
icon: "home-filled",
title: "首页",
rank: 0,
},
children: [
{
path: "/welcome",
name: "Welcome",
component: () => import("/@/views/welcome/index.vue"),
meta: {
title: "首页",
},
},
],
};
export default homeRouter;
路由分类
项目中把路由分为了静态路由与动态路由,静态路由主要由前端进行控制,动态路由统一从后端请求。我们关注src\router\index.ts文件来看看。
静态路由
constantRoutes: 不需要动态判断权限的路由,拿 pure 的源码举例:
动态路由
getAsyncRoutes: 请求后端,动态判断权限返回的路由表,并通过 addRoute 动态添加的页面。(项目中作为开发测试,动态路由都由mock模拟)
先用 Mock 模拟后台,传出/getAsyncRoutes接口路由表数据
这部分动态路由通过前端封装的 API 接口拿到(src/api/routes.ts)
import { http } from "../utils/http";
export const getAsyncRoutes = (params?: object) => {
return http.request("get", "/getAsyncRoutes", { params });
};
实际上,会将从后端拿到的路由表进行过滤,生成规范化路由,通过 router.addRoute 添加到最终创建的路由实例。
路由处理
上面我们已经拿到了所有的静态路由表和动态路由表,我们需要将两个表进行整合,并按页面的渲染需要生成最终的路由表。
通过上一小节我们设定好的路由配置项,可以进行调整或过滤,得到符合配置的路由表,以适应不同的路由需要。
路由排序
按照路由配置项中meta下的rank等级升序来排序路由,这里同时做了一些额外的优化处理,避免rank为空或者rank=0的冲突问题。
function ascending(arr: any[]) {
arr.forEach((v) => {
if (v?.meta?.rank === null) v.meta.rank = undefined;
if (v?.meta?.rank === 0) {
if (v.name !== "Home" && v.path !== "/") {
console.warn("rank only the home page can be 0");
}
}
});
return arr.sort(
(a: { meta: { rank: number } }, b: { meta: { rank: number } }) => {
return a?.meta?.rank - b?.meta?.rank;
}
);
}
静态路由导出
将所有经过处理后的静态路由导出(三级及以上的路由全部拍成二级)
export const constantRoutes: Array<RouteRecordRaw> = formatTwoStageRoutes(
formatFlatteningRoutes(buildHierarchyTree(ascending(routes)))
);
注:因为
keep-alive只支持到二级缓存,所以这里先将路由表中的所有路由进行排序并创建层级关系,然后将所有多级嵌套路由处理成一维数组,再把一维数组处理成多级嵌套数组(二级)。
路由创建
创建路由实例
export const router: Router = createRouter({
history: getHistoryMode(),
routes: constantRoutes.concat(...remainingRouter), // 初始化路由表
// 在页面之间导航时控制滚动的函数,返回的是一个 promise
scrollBehavior(to, from, savedPosition) {
return new Promise((resolve) => {
if (savedPosition) {
return savedPosition;
} else {
if (from.meta.saveSrollTop) {
const top: number =
document.documentElement.scrollTop || document.body.scrollTop;
resolve({ left: 0, top });
}
}
});
},
});
history选项中我们需要获取到项目的路由历史模式
// 获取路由历史模式 https://next.router.vuejs.org/zh/guide/essentials/history-mode.html
function getHistoryMode(): RouterHistory {
const routerHistory = loadEnv().VITE_ROUTER_HISTORY;
// len为1 代表只有历史模式 为2 代表历史模式中存在base参数 https://next.router.vuejs.org/zh/api/#%E5%8F%82%E6%95%B0-1
const historyMode = routerHistory.split(",");
const leftMode = historyMode[0];
const rightMode = historyMode[1];
// no param
if (historyMode.length === 1) {
if (leftMode === "hash") {
return createWebHashHistory("");
} else if (leftMode === "h5") {
return createWebHistory("");
}
} //has param
else if (historyMode.length === 2) {
if (leftMode === "hash") {
return createWebHashHistory(rightMode);
} else if (leftMode === "h5") {
return createWebHistory(rightMode);
}
}
}
路由白名单
const whiteList = ["/login"];
路由导航守卫
router.beforeEach((to: toRouteType, _from, next) => {
if (to.meta?.keepAlive) {
const newMatched = to.matched;
// 处理缓存路由
handleAliveRoute(newMatched, "add");
// 页面整体刷新和点击标签页刷新
if (_from.name === undefined || _from.name === "Redirect") {
// 处理缓存路由
handleAliveRoute(newMatched);
}
}
const name = storageSession.getItem("info");
NProgress.start();
const externalLink = isUrl(to?.name);
if (!externalLink)
to.matched.some((item) => {
if (!item.meta.title) return "";
const Title = getConfig().Title;
// 这里加了一步国际化处理,如果你的项目没有使用国际化,把该部分删除即可
if (Title)
document.title = `${transformI18n(item.meta.title)} | ${Title}`;
else document.title = transformI18n(item.meta.title);
});
if (name) {
if (_from?.name) {
// name为超链接
if (externalLink) {
openLink(to?.name);
NProgress.done();
} else {
next();
}
} else {
// 刷新
if (usePermissionStoreHook().wholeMenus.length === 0)
// 刷新页面需要重新生成路由表、菜单栏、标签栏
initRouter(name.username).then((router: Router) => {
if (!useMultiTagsStoreHook().getMultiTagsCache) {
const handTag = (
path: string,
parentPath: string,
name: RouteRecordName,
meta: RouteMeta
): void => {
useMultiTagsStoreHook().handleTags("push", {
path,
parentPath,
name,
meta,
});
};
// 未开启标签页缓存,刷新页面重定向到顶级路由(参考标签页操作例子,只针对静态路由)
if (to.meta?.refreshRedirect) {
const routes = router.options.routes;
const { refreshRedirect } = to.meta;
const { name, meta } = findRouteByPath(refreshRedirect, routes);
handTag(
refreshRedirect,
getParentPaths(refreshRedirect, routes)[1],
name,
meta
);
return router.push(refreshRedirect);
} else {
const { path } = to;
const index = findIndex(remainingRouter, (v) => {
return v.path == path;
});
const routes =
index === -1
? router.options.routes[0].children
: router.options.routes;
const route = findRouteByPath(path, routes);
const routePartent = getParentPaths(path, routes);
// 未开启标签页缓存,刷新页面重定向到顶级路由(参考标签页操作例子,只针对动态路由)
if (
path !== routes[0].path &&
route?.meta?.rank !== 0 &&
routePartent.length === 0
) {
if (!route?.meta?.refreshRedirect) return;
const { name, meta } = findRouteByPath(
route.meta.refreshRedirect,
routes
);
handTag(
route.meta?.refreshRedirect,
getParentPaths(route.meta?.refreshRedirect, routes)[0],
name,
meta
);
return router.push(route.meta?.refreshRedirect);
} else {
handTag(
route.path,
routePartent[routePartent.length - 1],
route.name,
route.meta
);
return router.push(path);
}
}
}
router.push(to.fullPath);
});
next();
}
} else {
if (to.path !== "/login") {
if (whiteList.indexOf(to.path) !== -1) {
next();
} else {
next({ path: "/login" });
}
} else {
next();
}
}
});
router.afterEach(() => {
NProgress.done();
});
路由表初始化
创建完成后,还需要调用initRouter将路由表初始化(登录成功后或刷新调用)
// 初始化路由
function initRouter(name: string) {
return new Promise((resolve) => {
getAsyncRoutes({ name }).then(({ info }) => {
if (info.length === 0) {
usePermissionStoreHook().changeSetting(info);
} else {
// 过滤后端传来的动态路由 重新生成规范路由。并将多级嵌套路由处理成一维数组
formatFlatteningRoutes(addAsyncRoutes(info)).map(
(v: RouteRecordRaw) => {
// 防止重复添加路由
if (
router.options.routes[0].children.findIndex(
(value) => value.path === v.path
) !== -1
) {
return;
} else {
// 切记将路由push到routes后还需要使用addRoute,这样路由才能正常跳转
router.options.routes[0].children.push(v);
// 最终路由进行升序
ascending(router.options.routes[0].children);
if (!router.hasRoute(v?.name)) router.addRoute(v);
const flattenRouters = router
.getRoutes()
.find((n) => n.path === "/");
router.addRoute(flattenRouters);
}
resolve(router);
}
);
usePermissionStoreHook().changeSetting(info);
}
router.addRoute({
path: "/:pathMatch(.*)",
redirect: "/error/404",
});
});
});
}
这里经过了
getAsyncRoutes接口调用,store中才会有路由表相关数据。所以当你去掉mock模拟请求,使用http请求后端的时候,由于调用getAsyncRoutes接口失败,store中 的路由数据将为空(默认),所以菜单栏是不会被渲染出来的。
其他路由辅助功能
重置路由
function resetRouter(): void {
router.getRoutes().forEach((route) => {
const { name } = route;
if (name) {
router.hasRoute(name) && router.removeRoute(name);
}
});
}
过滤渲染路由
// 过滤meta中showLink为false的路由
function filterTree(data: RouteComponent[]) {
const newTree = data.filter(
(v: { meta: { showLink: boolean } }) => v.meta?.showLink !== false
);
newTree.forEach(
(v: { children }) => v.children && (v.children = filterTree(v.children))
);
return newTree;
}
过滤缓存路由
// 从路由中提取 keepAlive 为 true 的 name 组成数组
export const getAliveRoute = () => {
const alivePageList = [];
const recursiveSearch = (treeLists) => {
if (!treeLists || !treeLists.length) {
return;
}
for (let i = 0; i < treeLists.length; i++) {
if (treeLists[i]?.meta?.keepAlive) alivePageList.push(treeLists[i].name);
recursiveSearch(treeLists[i].children);
}
};
recursiveSearch(router.options.routes);
return alivePageList;
};
缓存路由处理(批量删除、增删刷新)
// 批量删除缓存路由(keepalive)
function delAliveRoutes(delAliveRouteList: Array<RouteConfigs>) {
delAliveRouteList.forEach((route) => {
usePermissionStoreHook().cacheOperate({
mode: "delete",
name: route?.name,
});
});
}
// 处理缓存路由(添加、删除、刷新)
function handleAliveRoute(matched: RouteRecordNormalized[], mode?: string) {
switch (mode) {
case "add":
matched.forEach((v) => {
usePermissionStoreHook().cacheOperate({ mode: "add", name: v.name });
});
break;
case "delete":
usePermissionStoreHook().cacheOperate({
mode: "delete",
name: matched[matched.length - 1].name,
});
break;
default:
usePermissionStoreHook().cacheOperate({
mode: "delete",
name: matched[matched.length - 1].name,
});
useTimeoutFn(() => {
matched.forEach((v) => {
usePermissionStoreHook().cacheOperate({ mode: "add", name: v.name });
});
}, 100);
}
}