(十五)Vue3-huohuo-admin 路由及路由表

680 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天

路由

通过下面这张图,我们大概了解一下整个路由封装的流程。

image-20211125115445323

路由结构设计

先来看看我们简化的路由文件结构,主要由路由总入口index.ts和 路由模块modules组成。另外我们再增加专门为路由配置的type类型文件(types.ts)和工具函数文件(utils.ts

image-20220531182930887

这样的结构在大一些的项目里非常常见,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 的源码举例:

image-20220531194710709

动态路由

getAsyncRoutes: 请求后端,动态判断权限返回的路由表,并通过 addRoute 动态添加的页面。(项目中作为开发测试,动态路由都由mock模拟)

先用 Mock 模拟后台,传出/getAsyncRoutes接口路由表数据

image-20211125112204284

这部分动态路由通过前端封装的 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);
  }
}