前端路由自动生成

1,611 阅读6分钟

前端路由自动生成

shutterstock_11339820381024x582.jpg

一、常规项目定义路由

我们梳理一下常见的vue单页面项目,如果想定义一个前端路由需要哪些步骤:

  1. 首先我们需要在项目里views或pages文件夹中找一个合适的位置新建一个页面文件,给这个文件定义一个Name1
  2. 我们需要在router.js 根据文件路径path引入我们要使用到的页面文件组件,命名Name2
  3. 定义一个route对象,设定一个url path路径,同时设置component为引入的Name2,最后可能还要给这个route定义一个Name3

可以看到,这三步中需要定义文件path,引入path,url path,有文件name,引用组件name,路由对象name,这些path和name或一样,或不同,但是都需要大家手动维护,项目一大或者多人开发时一不小心路由信息就会生成多套规则,不利于之后的维护

url:统一资源定位符, url Path 是网络服务器上资源的路径。在Web的早期阶段,像这样的路径表示Web服务器上的物理文件位置。如今,它主要是由没有任何物理现实的Web服务器处理的抽象。

所以 url Path其实跟我们的文件Path 天然是有所关联的,我们完全可以将 url Path和文件 path 统一,一但我们决定根据文件path来定义url path的话,完全可以通过一定的方法自动生成route

二、自动生成路由信息的好处

  1. 便于维护,减少心智负担

    前端路由最重要的两部分:

    1. component :代表组件,通过文件path引入
    2. path:代表url路由,定义url path

    现状:

    component=>文件path=>代码目录结构
    						
    url path => 路由定义的path	
    

    最终目的:

    目录结构 === url path
    

    这样就迫使我们要合理安排文件层级结构和文件名的命名,只要我们确定了文件目录结构和文件名,那么对应的路由地址,url地址也一起确定了下来,也就是我们将三个path合为一,三个name合为一

  2. 减少无用代码量,减少代码重复率

    如果我们用常规的手段维护router.js文件,那么每增加一个路由,我们就在router.js文件中新增一段代码,随着项目变大,路由也越来越多,很快router.js文件会变成一个超长文本,且大部分代码是重复的

那么如何实现路由信息自动生成呢?

三、认识require.context

require.context 是webpack提供的一个API,通过require.context 我们可以动态读取某个文件夹下的所有文件路径,换句话说我们可以批量读取views/下的所有文件路径

下面是官方demo

require.context('./test', false, /\.test\.js$/);
//(创建出)一个 context,其中文件来自 test 目录,request 以 `.test.js` 结尾。
require.context('../', true, /\.stories\.js$/);
// (创建出)一个 context,其中所有文件都来自父文件夹及其所有子级文件夹,request 以 `.stories.js` 结尾

require.context 返回一个对象,有三个属性:resolve, keys, id

  • resolve 是一个函数,它返回 request 被解析后得到的模块 id。
  • keys 也是一个函数,它返回一个数组,由所有可能被此 context module 处理的请求(译者注:参考下面第二段代码中的 key)组成。
  • id 是 context module 的模块 id. 它可能在你使用 module.hot.accept 时会用到。
function importAll(r) {
  r.keys().forEach(r);
}

importAll(require.context('../components/', true, /\.js$/));
const cache = {};

function importAll(r) {
  r.keys().forEach((key) => (cache[key] = r(key)));
}

importAll(require.context('../components/', true, /\.js$/));
// 在构建时(build-time),所有被 require 的模块都会被填充到 cache 对象中。

所以我们可以通过require.context获取到所有views里的所有vue文件路径,并通过split 按层级生成数组

const importAll = (r) => {
  return r.keys().map((key) => key.slice(2).replace(".vue", "").split("/"));
};
const pages = importAll(require.context("../views", true, /\.vue$/));
// pages
// 0: (4) ["activity", "activityList", "dataAnalysis", "index"]
// 1: (4) ["activity", "activityList", "details", "index"]
// 2: (3) ["activity", "activityList", "index"]
// 3: (4) ["activity", "activityList", "promote", "index"]
// 4: (3) ["activity", "analysisList", "index"]
// 13: ["index"]

当我们获取到一个文件的路径后,我们就可以引入它.

四、生成路由对象

我们在生产中常用的路由应该是以下这个样子

  {
    name:"" // 路由名
    path: "",// 路径
    component: "",// 组件
    meta: {} // 路由信息
  }

接下来我们新建一个functiongetRoutes来一一获取我们需要的数据

首先,有了每个组件的路径之后,我们就可以获取组件component,那同时我们也可以获取到component中定义的namemeta,举个例子home.vue中定义好namemeta

// home.vue 
<template>
  <div class="home-wrapper">
    <div class="home-content">
      <h1>Home</h1>
    </div>
  </div>
</template>
<script>
export default {
  name: "home", // 组件name和路由name一致
  meta: {       // 新增meta属性,这里的meta跟route里的meta作用一致
    title: "首页",
    keepAlive:false
  }
};
</script>
// getRoutes
async function getRoutes() {
  const routesPromise = pages.map(async (path) => {
    const { default: component } = await import(`../views/${path.join("/")}`);
    const { name = "", meta = {} } = component; // 组件中定义的name 和 meta
    return {
      name,
      meta,
      component,
    };
  });
  const routes = await Promise.all(routesPromise); // 通过promise.all 获取所有route对象
  return {
    routes
  };
}

到目前为止,我们已经获取到了route对象需要的component,name,meta属性,接下来就是获取对应的path,path的情况比较复杂,所以我们新增一个functiongenerateRoute专门来处理path生成

// generateRoute 
// 如果有动态路由的话,动态路由对应的文件名应该设计成以 '_' 开头的
// 例如 /list/:id对应的文件结构应为:
// |--list
//    ----_id.vue 

const generateRoute = (path) => {
  // 注:处理根路由
  if (path.length === 1) {
    const shortcut = path[0].toLowerCase();
    return shortcut.startsWith("index")
      ? ""
      : // 注:处理动态路由
      shortcut.startsWith("_")
      ? shortcut.replace("_", ":")
      : shortcut;
  }
  // 注: 处理其他路由
  const lastElement = path[path.length - 1];
  // 注:移除以 index 开头的最后一个元素
  if (lastElement.toLowerCase().startsWith("index")) {
    path.pop();
    // 注:处理动态路由
  } else if (lastElement.startsWith("_")) {
    path[path.length - 1] = lastElement.replace("_", ":");
  }
  return path.map((p) => p.toLowerCase()).join("/");
};
// getRoutes方法中新增
const { default: component } = await import(`../views/${path.join("/")}`);
const { name = "", meta = {} } = component; // 组件中定义的name 和 meta
// 新增route path 
const route = `/${generateRoute([...path])}`;

return {
  name,
  path:route
  meta,
  component,
};
// 完整的router/route.js 如下

const importAll = (r) => {
  return r.keys().map((key) => key.slice(2).replace(".vue", "").split("/"));
};
const pages = importAll(require.context("../views", true, /\.vue$/));

console.log(pages);

const generateRoute = (path) => {
  // 注:如果路由以 index 开头则移除第一个元素
  // 注:处理根路由
  if (path.length === 1) {
    const shortcut = path[0].toLowerCase();
    return shortcut.startsWith("index")
      ? ""
      : // 注:处理动态路由
      shortcut.startsWith("_")
      ? shortcut.replace("_", ":")
      : shortcut;
  }
  // 注: 处理其他路由
  const lastElement = path[path.length - 1];
  // 注:移除以 index 开头的最后一个元素
  if (lastElement.toLowerCase().startsWith("index")) {
    path.pop();
    // 注:处理动态路由
  } else if (lastElement.startsWith("_")) {
    path[path.length - 1] = lastElement.replace("_", ":");
  }
  return path.map((p) => p.toLowerCase()).join("/");
};
async function getRoutes() {
  const routesPromise = pages.map(async (path) => {
    const { default: component } = await import(`../views/${path.join("/")}`);
    const { name = "", meta = {} } = component;
    // 生成路由tree
    const route = `/${generateRoute([...path])}`;
    return {
      path: route,
      name,
      meta,
      component,
    };
  });
  const routes = await Promise.all(routesPromise);
  return {
    routes
  };
}

export default getRoutes();

到这步为止,route所有属性都搞定了并生成了对应的route对象,接下来,使用我们的route对象吧

五、引入我们生成的route对象

到目前为止,我们已经通过文件路径获自动生成了整个项目的路由对象,下一步就是将我们引入route对象

// router/index.js
import Vue from "vue";
import VueRouter from "vue-router";
import routeObj from "./routes";
Vue.use(VueRouter);

async function getRouter() {
  let { routes } = await routeObj;
  const router = new VueRouter({
    routes,
  });
  router.beforeEach((to, from, next) => {
  	// TODO
    next()
  });
  router.onError((error) => {
		//TODO
  });

  return router;
}
export default getRouter();
// main.js
const init = async () => {
  const getRouter = await import("./router/index.js");
  const router = await getRouter.default;
  new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount("#app");
};

init();

六、生成路由树routeTree和keepAlives数组

到此为止,自动生成路由效果已经实现了,但是一旦我们通过文件结构生成对应的前端路由信息之后,除了不需要每次手动维护路由信息的好出外,我们也同时确定了路由之前不同层级的关系.换句话,我们有能力生成一个表明整个路由层级的 routeTree

// getRoutes 方法中新增以下代码
  const keepAlives = [];
  const routeTree = {};
  const routesPromise = pages.map(async (path) => {
    const { default: component } = await import(`../views/${path.join("/")}`);
    const { name = "", meta = {} } = component;
    // 生成路由tree
    const route = `/${generateRoute([...path])}`;
    
    // ------------------新增------------------------
    route
      .split("/")
      .filter((e) => e)
      .reduce((tree, routePath, index, arr) => {
        if (index === arr.length - 1) {
          if (!tree[routePath]) {
            tree[routePath] = {};
          }
          tree[routePath].path = route;
          tree[routePath].name = name;
          tree[routePath].title = meta.title;

          return tree;
        }
        if (!tree[routePath]) {
          tree[routePath] = { nextPath: {} };
        }
        return tree[routePath].nextPath;
      }, routeTree);
    // ----------------新增----------------------------
    
    // 生成keepAlive页面
    if (meta.keepAlive) {
      keepAlives.push(name);
    }
    return {
      path: route,
      name,
      meta,
      component,
    };
  });
  const routes = await Promise.all(routesPromise);
  return {
    keepAlives,
    routeTree,
    routes,
  };

keepAlives 的作用很容易理解,当然也可以不使用keepAlives,而是每次通过route对象里的meta.keepAlive来判断

<keep-alive :include="keepAlives" :max="10">
        <router-view></router-view>
</keep-alive>

那么routeTree有什么用呢?

大家都写过面包屑这个组件吗?每次写面包屑我们都需要传一个路由对象的数组,才能让面包屑实现点击跳转的功能,以iviewBreadcrumb组件为例

<Breadcrumb>
    <BreadcrumbItem to="/">
        <Icon type="ios-home-outline"></Icon> Home
    </BreadcrumbItem>
    <BreadcrumbItem to="/components/breadcrumb">
        <Icon type="logo-buffer"></Icon> Components
    </BreadcrumbItem>
    <BreadcrumbItem>
        <Icon type="ios-cafe"></Icon> Breadcrumb
    </BreadcrumbItem>
</Breadcrumb>

我们每次都需要手动指定path,如果我们有了routeTree,我们完全可以根据routeTree提供的层级关系自动生成Breadcrumb需要的路由信息

<!--
描述:全局面包屑组件
-->
<template>
  <div class="wxBreadCrumb">
    <div class="wxBreadCrumbFlex">
      <Breadcrumb separator=">">
        <template v-for="(item, index) in breadcrumbArr">
          <BreadcrumbItem
            v-if="index !== 0 && index < breadcrumbArr.length - 1"
            :key="item.title"
            :to="item.path"
          >
            {{ item.title }}
          </BreadcrumbItem>
          <BreadcrumbItem v-else :key="item.title">
            {{ item.title }}
          </BreadcrumbItem>
        </template>
      </Breadcrumb>
    </div>
  </div>
</template>

<script>
import routeObj from "@/router/routes";
export default {
  name: "BreadCrumb",
  // 注册组件
  data() {
    return {
      breadcrumbArr: [],
    };
  },
  //生命周期 - 创建完成(可以访问当前this实例)
  async created() {
    let { routeTree } = await routeObj;
    let breadcrumbArr = [];
    // 获取到从根路由到当前路径的所有路由节点
    this.$route.path
      .split("/")
      .filter((e) => e)
      .reduce((tree, path) => {
        breadcrumbArr.push({
          path: tree[path].path,
          name: tree[path].name,
          title: tree[path].title,
        });
        return tree[path].nextPath;
      }, routeTree);
    this.breadcrumbArr = breadcrumbArr;
  },
};
</script>

这样,我们从当前路由地址就可以依据routeTree获取到从根节点到当前节点的所有路由节点信息,也就是说我们能根据这些节点生成一个完整的面包屑路径.

除此之外,单页面应用有一个很常见的问题刷新页面之后无法返回上一页,因为刷新之后浏览器的历史记录被清空,这个时候我们还是可以通过routeTree获取到上级路由信息来做返回上一级操作

还有哪些情况可以使用routeTree呢,大家可以在开发中多多尝试

七、总结

没有一种方案是完美的,自动生成路由也只是普通的一种尝试,如果大家觉得这个方案适用于大家的项目可以尝试一下或许就会让你轻松很多,当然,目前这个方案还比较简单,很多场景还没有覆盖到,比如说目前还不支持嵌套路由(其实是可以支持的,只需要改动一下我们的getRoutes方法)等等,如果各位老师在使用过程中产生更多新的思维火花,欢迎大家多多跟我交流,毕竟众人拾柴火焰高啊

参考文献

[1] 智能路由的vuejs实现

[2] vue-router官网