偷懒必备,按需加载省掉注册路由的麻烦

568 阅读4分钟

项目中常常需要一些专题或者系列的页面场景,初期的时候一两个在路由表里面注册也不算什么,如果子项目越来越多了,一个个去注册也麻烦,也不方便后期查找或修改。

假定我们有一个游戏专题,以如下目录结构呈现,有多个项目,每个项目又可能有子项目,这时候如果一个个注册会使得路由表非常冗长且繁琐。

Game 
├── A
│   ├── Aa
│   │	├──Aaa
│   │   │  └── index.(vue/tsx) // game/a/aa/aaa
|   │	└── index.(vue/tsx)  // game/a/aa
│   └── index.(vue/tsx)  // game/a
├── B
│   └── index.(vue/tsx)  // game/b

为了减少这些问题带来的负担,可以通过利用webpack动态加载结合路由通配符的方式来解决。这一方法同时适用于Vue 和React 项目。通过这种方式,我们无需再向路由中对每个页面进行注册,比如,只要我们创建了./Views/Game/A/Aa/index.(vue/tsx) ,就可以访问game/a/aa,创建了./Views/Game/A/Aa/Aaa/index.(vue/tsx) ,则可以访问game/a/aa/aaa


首先,我们需要在主路由表中定义一条路由,通过通配符将所有 game 开头的路径指向在专题页面存在的目录下的入口组件,这一组件用于匹配和加载具体页面文件。

路由注册如下:

// 使用 Vue-router
//... 
 {
    path: '/game/*',
    name: 'game',
    component: () =>
      import(/* webpackChunkName: "game" */ '@views/Game/index.vue')
  },
//...

// 使用 React

import Game from "./views/game";
//...
<Route path="/game/:page*?" component={Game} />
//... 

入口组件如下:

适用Vue的入口组件如下

// Vue - ./view/game/index.vue
<template>
  <component :is="Game" />
</template>
<script>
function getGame(params) {
  let { pathMatch = 'NotFound' } = params;
  pathMatch = `${pathMatch.toLowerCase()}`
    .split('/')
    .map((item) => item.replace(/\w/, ($0) => $0.toUpperCase()))
    .join('/');

  return () =>
    new Promise((resolve) => {
      const comp = import(
        /* webpackInclude: /index\.vue$/ */ /* webpackChunkName: "game-[request]" */ `./${pathMatch}`
      );
      comp.then(resolve).catch(() => {
        import(/* webpackChunkName: "game-notfound" */ './NotFound').then(
          resolve
        );
      });
    });
}
export default {
  computed: {
    Game() {
      return getGame(this.$route.params);
    },
  },
};
</script>

在React 中也很类似:

// preact - ./views/game/index.tsx
import { FunctionalComponent, h } from "preact";
import { Router } from "preact-router";
import AsyncRoute from "preact-async-route";

function getComponent(
  url: string,
  callback: (component: unknown) => void,
  props: { page: string }
): void | Promise<unknown> {
  const page = `/${props.page.toLowerCase()}`.replace(/\//, "");
  return import(
    /* webpackInclude: /index\.tsx$/ */ /* webpackChunkName: "game-[request]" */ `./${page || "notfound"}/index.tsx`
  )
    .then(module => module.default)
    .catch(() =>
      import(/* webpackChunkName: "game-notfound" */ `./notfound`).then(
        module => module.default
      )
    );
}

const App: FunctionalComponent = () => {
  return (
    <div class="game">
      <Router>
        <AsyncRoute path="/game/:page*?" getComponent={getComponent} />
      </Router>
    </div>
  );
};
export default App;

可以看到,入口组件和原理其实十分简单,通过获取到访问路径,然后进行对路径进行转换,匹配到目录下对应的入口文件,这样一来就可以在后续开发中不再需要在路由表中注册项目,而是通过遵循一定命名规范新建文件即可完成项目的按需加载,且支持子页面的加载。


不过,这个方法存在一些知识点和需要注意的地方,在这里进行一下讲解:

  • 边界条件处理 在我们输入一个不存在的页面的时候,动态import 会抛出异常告知我们页面组件加载失败,这个时候我们需要做一下处理,因为这种情况不会被最外层的 { path: '*' } 捕获,我们需要自己在匹配组件的代码中做一下边界条件的处理,可以通过两种方式:
  1. Vue 2.3.0+ 新增支持的返回方式

    参考Vue 文档 动态组件 部分,可以把上面返回的函数改造成如下的返回格式

    return () => ({
      // 需要加载的组件 (应该是一个 `Promise` 对象)
      component: import(
            /* webpackInclude: /index\.vue$/ */ /* webpackChunkName: "game-[request]" */ `./${pathMatch}`
      ),
      // 异步组件加载时使用的组件
      loading: LoadingComponent,
      // 加载失败时使用的组件
      error: ErrorComponent,
      // 展示加载时组件的延时时间。默认值是 200 (毫秒)
      delay: 200,
      // 如果提供了超时时间且组件加载也超时了,
      // 则使用加载失败时使用的组件。默认值是:`Infinity`
      timeout: 3000
    })
    

    但这种方式有个限制条件,即 errorloading 传入的组件是同步组件,无法通过异步组件的方式引入,因此,可以通过下一种方式来返回

    1. 捕获出错返回指定组件

    由于import() 实际上是返回一个Promise,因此,我们可以自己完成一个自定义的import,如下所示,我们在 import 的catch 中处理错误状况,比如动态加载 NotFound 页面组件来呈现。

    //...
    return () =>
        new Promise((resolve) => {
          const comp = import(
            /* webpackInclude: /index\.vue$/ */ /* webpackChunkName: "game-[request]" */ `./${pathMatch}`
          );
          comp.then(resolve).catch(() => {
            import(/* webpackChunkName: "game-notfound" */ './NotFound').then(
              resolve
            );
          });
        });
    
  • 路由通配符的作用

    在Vue-router 动态路由匹配 这一文档 中,我们可以知道,* 号可以通配任意路径,此时我们可以通过 $route.params.pathMatch 获取到页面的访问路径。比如,当我们注册了/game/*后,访问 /game/a/aa/aaa 的时候,pathMatch 的值为 a/aa/aaa,这个时候我们就可以通过这个路径去转换成我们目录中文件的路径。

  • 动态加载

    1. 文件命名规则

      页面路由的访问通常是小写的,但是架不住有些混输字母进行的情况,比如你的目录是**/game/a**,用户通过 /game/A 直接访问,是会匹配不到 ./views/game/a/index.(vue/tsx) 的,因此我们需要规范好文件夹的命名方式,比如首字母大写或全小写,并对匹配到的路径进行转换,再传入 import()

    2. 魔法注释

      动态加载的时候,如果我们直接去使用import('xx.js') 的方式,最终生成出来的就是类似 0.js, 1.js 这样子的文件,如果想要以具体文件名来命名生成文件,可以通过webpack 的魔法注释 来帮忙。

      我们平时都知道使用webpackChunkName 可以命名生成的chunk 文件名,也常用比如[name].[contenthash:8].js 这样的注释,而魔法注释中就有一个类似的神奇字符[request] 可以帮助我们自动把匹配到的路径名映射到生成的文件名中,其规则是将import()中的变量生成文件名,比如在./views/Game/index.vue 中使用import(/* webpackChunkName: "game-[request]" */ "./" + game) 时,会匹配./views/Game/ 目录下的子目录中的所有文件,假设我们有 ./views/Game/A/AA/ 目录中有index.vuestyle.scss 两个文件, 就会生成 game-A-AA-index-vue.jsgame-A-AA-style-scss.js

      是不是发现了有个问题,项目下的其他文件也生成了对应的js 文件,这是因为不指定规则的情况下import() 会匹配到所有文件。如果你不想要,可以通过两种方式解决,

      一种是通过import("./" + game + "/index.vue") 的方式,但是这种方式生成的文件名仍带有 -index-vue.js,如果不希望带上后缀,就可以通过下面这第二种方式了。

      魔法注释支持/* webpackInclude: /\.vue$/ *//* webpackExclude: /\.scss$/ */ 的方式,通过正则来帮助筛选import 的文件,此时我们只需要import(/* webpackInclude: /index\.vue$/ */ /* webpackChunkName: "game-[request]" */ "./" + game) 的方式,即只选择目录下的 index.vue 文件,最终生成的文件名也会变成 game-A-AA.js 的形式。


至此,我们就完成了一个仅需一个入口,一条路由,剩下的只需要创建对应目录文件夹的方式来完成我们动态路由注册的功能了。