文章

前端开发整理(四): Vue框架的路由和插件

前端开发整理(四): Vue框架的路由和插件

路由的使用

路由的目录结构

Vue3的路由管理在src/router的目录下:

1
2
3
4
5
6
7
8
9
src
│ # 路由目录
├─router
│   # 路由入口文件
├───index.ts
│   # 路由配置,如果路由很多,可以再拆分模块文件
├───routes.ts
│ # 项目入口文件
└─main.ts

index.ts 是路由的入口文件,如果路由很少,那么可以只维护在这个文件里;项目稍微复杂时,可以拆出两个文件index.ts 和 routes.ts ,在 routes.ts 里维护路由树的结构,在 index.ts 导入路由树结构并激活路由,同时可以在该文件里配置路由钩子。项目更加复杂,例如做一个 Admin 后台,可以按照业务模块,再把 routes 拆分得更细,例如 game.ts / member.ts / order.ts 等业务模块,再统一导入到 index.ts 文件里。

路由的引入

Vue 3 的引入方式如下(其中 RouteRecordRaw 是路由项目的 TS 类型):

1
2
3
4
5
6
7
8
9
10
11
12
13
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routes: Array<RouteRecordRaw> = [
  // ...
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

export default router

 Vue 3 也可以配置一些额外的路由选项,比如:指定 router-link 为当前激活的路由所匹配的 className :

1
2
3
4
5
6
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  linkActiveClass: 'cur',
  linkExactActiveClass: 'cur',
  routes,
})

路由树的配置

当项目的路由很多的时候,文件会变得非常长,难以维护,这个时候可以集中到 routes.ts 或者更多的模块化文件管理,然后再 importindex.ts 里。暂且把 routes.ts 这个文件称为 “路由树”。

路由树的基础格式

在 TypeScript 里,路由文件的基础格式由三个部分组成:类型声明、数组结构、模块导出。之后就可以在 index.ts 里导入使用了。

1
2
3
4
5
6
7
8
9
10
11
12
// src/router/routes.ts

// 使用 TypeScript 时需要导入路由项目的类型声明
import type { RouteRecordRaw } from 'vue-router'

// 使用路由项目类型声明一个路由数组
const routes: Array<RouteRecordRaw> = [
  // ...
]

// 将路由数组导出给其他模块使用
export default routes

公共基础路径

在讲解使用 Vite 等工具创建项目时,都提到了一个 项目配置 的管理,以 Vite 项目的配置文件 vite.config.ts 为例,里面有一个选项 base ,base 的默认值是 /,也就是说,如果不配置它,那么所有的资源文件都是从域名根目录读取,如果项目部署在域名根目录那当然好,但是如果不是呢?那么就必须来配置它了。

配置很简单,只要把项目要上线的最终地址,去掉域名,剩下的那部分就是 base 的值。假设项目是部署在 https://example.com/vue3/ ,那么 base 就可以设置为 /vue3/

注意
如果路由只有一级,那么 base 也可以设置为相对路径 ./,这样可以把项目部署到任意地方。
如果路由不止一级,那么请准确地指定 base,并且确保是以 / 开头并以 / 结尾,例如 /foo/ 。

一级路由

项目地址后面只有一级 Path ,比如 https://example.com/home ,这里的 home 就是一级路由。

最基本的路由配置包含的字段有:

1
2
3
4
5
6
7
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'home',
    component: () => import('@views/home.vue'),
  },
]

其中 path 是路由的访问路径,像上面说的,如果的域名是 https://example.com, 配置为 /home,那么访问路径就是 https://example.com/home

name 是路由的名称,非必填,但是一般都会配置上去,这样可以很方便地用 name 来代替 path 实现路由的跳转,例如有时候开发环境和生产环境的路径不一致,或者说路径变更,通过 name 无需调整,但如果通过 path,可能就要修改很多文件里面的链接跳转目标了。

component 是路由的模板文件,指向一个 vue 组件,用于指定路由在浏览器端的视图渲染。指定使用哪个组件的方式有两种:同步组件异步组件

同步组件

字段 component 接收一个变量,变量的值就是对应的模板组件。

1
2
3
4
5
6
7
8
9
import Home from '@views/home.vue'

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'home',
    component: Home,
  },
]

在打包的时候,组件的所有代码都会被打包到一个文件里,对于大项目来说,这种方式的首屏加载是个灾难,要面对文件过大导致等待时间变长的问题。

所以现在都推荐使用第二种方式,可以实现路由懒加载

异步组件

字段 component 接收一个函数,在 return 的时候返回模板组件,同时组件里的代码在打包的时候都会生成独立的文件,并在访问到对应路由的时候按需引入。详见路由的懒加载

1
2
3
4
5
6
7
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'home',
    component: () => import('@views/home.vue'),
  },
]

多级路由

Vue 路由生态里,支持配置二级、三级、四级等多级路由,理论上没有上限,实际业务中用到的级数通常是三级到四级。

比如做一个美食类网站,打算在 “中餐” 大分类下配置一个 “饺子” 栏目,那么地址就是:https://example.com/chinese-food/dumplings。这种情况下,中餐 chinese-food 就是一级路由,饺子 dumplings 就是二级路由。

父子路由的关系,都是严格按照 JSON 的层级关系,子路由的信息配置到父级的 children 数组里面,孙路由也是按照一样的格式,配置到子路由的 children 里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const routes: Array<RouteRecordRaw> = [
  // 注意:这里是一级路由
  {
    path: '/lv1',
    name: 'lv1',
    component: () => import('@views/lv1.vue'),
    // 注意:这里是二级路由,在 `path` 的前面没有 `/`
    children: [
      {
        path: 'lv2',
        name: 'lv2',
        component: () => import('@views/lv2.vue'),
        // 注意:这里是三级路由,在 `path` 的前面没有 `/`
        children: [
          {
            path: 'lv3',
            name: 'lv3',
            component: () => import('@views/lv3.vue'),
          },
        ],
      },
    ],
  },
]

注释里提示了二级、三级路由的 path 字段前面没有 / ,这样路径前面才会有其父级路由的 path 以体现其层级关系,否则会从根目录开始。

路由懒加载

Vue 在 Webpack 的代码分割功能的基础上,推出了异步组件,可以把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,这样按需载入,很方便地实现路由组件的懒加载。

在这一段配置里面:

1
2
3
4
5
6
7
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'home',
    component: () => import('@views/home.vue'),
  },
]

起到懒加载配置作用的就是 component 接收的值 () => import('@views/home.vue') ,其中 @views/home.vue 就是路由的组件。在命令行运行 · 打包构建后,会看到控制台输出的打包结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
❯ npm run build

hello-vue3@0.0.0 build
vue-tsc --noEmit && vite build

vite v2.9.15 building for production...
✓ 42 modules transformed.
dist/index.html                       0.42 KiB
dist/assets/home.03ad1823.js          0.65 KiB / gzip: 0.42 KiB
dist/assets/HelloWorld.1322d484.js    1.88 KiB / gzip: 0.96 KiB
dist/assets/about.c2af6d65.js         0.64 KiB / gzip: 0.41 KiB
dist/assets/login.e9d1d9f9.js         0.65 KiB / gzip: 0.42 KiB
dist/assets/index.60726771.css        0.47 KiB / gzip: 0.29 KiB
dist/assets/login.bef803dc.css        0.12 KiB / gzip: 0.10 KiB
dist/assets/HelloWorld.b2638077.css   0.38 KiB / gzip: 0.19 KiB
dist/assets/home.ea56cd55.css         0.12 KiB / gzip: 0.10 KiB
dist/assets/about.a0917080.css        0.12 KiB / gzip: 0.10 KiB
dist/assets/index.19d6fb3b.js         79.94 KiB / gzip: 31.71 KiB

路由文件都按照 views 目录下的路由组件和 components 目录下的组件命名,输出了对应的 JS 文件和 CSS 文件,项目部署后, Vue 只会根据当前路由加载需要的文件,其他文件只做预加载,对于大型项目的访问体验非常友好。

如果不使用路由懒加载,所有的组件都被打包成了一个很大的 JS 文件和 CSS 文件,没有进行代码分割,对大型项目来说,这种方式打包出来的文件可能会有好几兆,首屏加载的速度就会非常缓慢。

1
2
3
4
5
6
7
8
9
10
 npm run build

> hello-vue3@0.0.0 build
> vue-tsc --noEmit && vite build

vite v2.9.15 building for production...
 41 modules transformed.
dist/index.html                  0.42 KiB
dist/assets/index.67b1ee4f.css   1.22 KiB / gzip: 0.49 KiB
dist/assets/index.f758ee53.js    78.85 KiB / gzip: 31.05 KiB

## 路由的渲染

所有路由组件,要在访问后进行渲染,都必须在父级组件里带有 <router-view /> 标签。<router-view /> 在哪里,路由组件的代码就渲染在哪个节点上,一级路由的父级组件,就是 src/App.vue 这个根组件。

其中最基础的配置就是 <template /> 里面直接就是写一个 <router-view /> ,整个页面就是路由组件。

1
2
3
<template>
  <router-view />
</template>

如果站点带有全局公共组件,比如有全站统一的页头、页脚,只有中间区域才是路由,那么可以这样配置:

1
2
3
4
5
6
7
8
9
10
<template>
  <!-- 全局页头 -->
  <Header />

  <!-- 路由 -->
  <router-view />

  <!-- 全局页脚 -->
  <Footer />
</template>

如果有一部分路由带公共组件,一部分没有,比如大部分页面都需要有侧边栏,但登录页、注册页不需要,就可以这么处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
  <!-- 登录 -->
  <Login v-if="route.name === 'login'" />

  <!-- 注册 -->
  <Register v-else-if="route.name === 'register'" />

  <!-- 带有侧边栏的其他路由 -->
  <div v-else>
    <!-- 固定在左侧的侧边栏 -->
    <Sidebar />

    <!-- 路由 -->
    <router-view />
  </div>
</template>

使用 route 获取路由信息

Vue 3 的组件里,Vue 实例既没有了 this,也没有了 $route。Vue 3 用啥都要导入,所以获取当前路由信息的正确用法是先导入路由API,再在 setup 里定义一个变量来获取当前路由:

1
2
3
4
// 导入路由API
import { useRoute } from 'vue-router'
// 定义变量获取当前路由
const route = useRoute()

要在 <template /> 里使用路由,需把 route 在 setup 里 return 出去。

1
2
3
4
5
// 获取路由名称
console.log(route.name)

// 获取路由参数
console.log(route.params.id)

Vue 3 的 route 和 Vue 2 的用法基本一致。如果要获取父级路由信息(比如在做面包屑功能的时候),可以改成下面这样,手动指定倒数第二个为父级信息:

1
2
3
4
5
6
7
8
// 获取路由记录
const matched = route.matched

// 获取该记录的路由个数
const max = matched.length

// 获取倒数第二个路由(也就是当前路由的父级路由)
const parentRoute = matched[max - 2]

如果有配置父级路由,那么 parentRoute 就是父级路由信息,否则会返回 undefined

使用router操作路由

和 route 一样,Vue 3 不能使用 this.$router ,必须通过导入路由 API 来使用。和 useRoute 一样, useRouter 也是一个函数,需要在 setup 里定义一个变量来获取路由信息。:

1
2
3
4
// 导入路由 API
import { useRouter } from 'vue-router'
//定义一个变量来获取路由信息
const router = useRouter()

接下来通过定义好的变量 router 去操作路由:

1
2
3
4
5
6
7
// 跳转首页
router.push({
  name: 'home',
})

// 返回上一页
router.back()

router-link 是一个全局组件,可直接在 <template /> 里直接使用,无需导入,基础的用法在 Vue 2 和 Vue 3 里是一样;默认会被转换为一个 a 标签,对比写死的 <a href="..."> ,使用 router-link 更加灵活。

基础跳转

最基础的用法就是把它当成一个 target="_self" 的 a 标签使用,但无需重新刷新页面,因为是路由跳转,它的体验和使用 router 去进行路由导航的效果完全一样。

1
2
3
<template>
  <router-link to="/home">首页</router-link>
</template>

等价于 routerpush

1
2
3
router.push({
  name: 'home',
})

可以写个 <div /> 标签绑定 Click 事件达到 router-link 的效果:

1
2
3
4
5
6
7
8
9
10
11
12
<template>
  <div
    class="link"
    @click="
      router.push({
        name: 'home',
      })
    "
  >
    <span>首页</span>
  </div>
</template>

带参数的跳转

使用 router 的时候,可以轻松地带上参数去那些有 ID 的内容页、用户资料页、栏目列表页等等。比如要访问一篇文章 https://example.com/article/123 ,用 push 的写法是:

1
2
3
4
5
6
router.push({
  name: 'article',
  params: {
    id: 123,
  },
})

从基础跳转的写法,很容易就能猜到在 router-link 里应该怎么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
  <router-link
    class="link"
    :to="{
      name: 'article',
      params: {
        id: 123,
      },
    }"
  >
    这是文章的标题
  </router-link>
</template>

不生成 a 标签

router-link 默认被转换为一个 a 标签,但根据业务场景,也可以把它指定为生成其他标签,比如 span 、 div 、 li 等等,这些标签因为不具备 href 属性,所以在跳转时都是通过 Click 事件去执行。在 Vue 3 ,tag 属性已被移除,需要通过 custom 和 v-slot 的配合将其渲染为其他标签。

比如要渲染为一个带有路由导航功能的其他标签:

1
2
3
4
5
<template>
  <router-link to="/home" custom v-slot="{ navigate }">
    <span class="link" @click="navigate"> 首页 </span>
  </router-link>
</template>

渲染后就是一个普通的 <span /> 标签,当该标签被点击的时候,会通过路由的导航跳转到指定的路由页:

1
2
<!-- 渲染后的标签 -->
<span class="link">首页</span>

关于这两个属性的参数说明如下:

  1. custom ,一个布尔值,用于控制是否需要渲染为 a 标签,当不包含 custom 或者把 custom 设置为 false 时,则依然使用 a 标签渲染。
  2. v-slot 是一个对象,用来决定标签的行为,它包含了:
字段 含义
href 解析后的 URL,将会作为一个 a 元素的 href 属性
route 解析后的规范化的地址
navigate 触发导航的函数,会在必要时自动阻止事件,和 router-link 同理
isActive 如果需要应用激活的 class 则为 true,允许应用一个任意的 class
isExactActive 如果需要应用精确激活的 class 则为 true,允许应用一个任意的 class

一般来说,v-slot 必备的只有 navigate ,用来绑定元素的点击事件,否则点击元素后不会有任何反应,其他的可以根据实际需求来添加。

注意
要渲染为非 a 标签,切记两个点:

  1. router-link 必须带上 custom 和 v-slot 属性
  2. 最终要渲染的标签,写在 router-link 里,包括对应的 className 和点击事件

在独立 TS/JS 文件里使用路由

比如要做一个带有用户系统的站点,登录的相关代码除了在 login.vue 里运用外,在注册页面 register.vue,用户注册成功还要帮用户执行一次自动登录。

登录完成还要记录用户的登录信息、 Token 、过期时间等等,有不少数据要做处理,以及需要帮助用户自动切去登录前的页面等行为,这是两个不同的组件,如果写两次几乎一样的代码,会大大提高维护成本。

这种情况下就可以通过抽离核心代码,封装成一个 login.ts 文件,在这个独立的 ts 文件里去操作路由。

1
2
3
4
5
6
7
// 导入路由
import router from '@/router'

// 执行路由跳转
router.push({
  name: 'home',
})

路由元信息配置

有时候项目需要一些个性化配置,比如:

  1. 给予每个路由独立的标题;
  2. 管理后台的路由,部分页面需要限制一些访问权限;
  3. 通过路由来自动生成侧边栏、面包屑;
  4. 部分路由的生命周期需要做缓存( Keep Alive );
  5. 其他更多业务场景…

无需维护很多套配置,在定义路由树的时候可以配置 meta 字段,比如下面就是包含了多种元信息的一个登录路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const routes: Array<RouteRecordRaw> = [
  {
    path: '/login',
    name: 'login',
    component: () => import('@views/login.vue'),
    meta: {
      title: '登录',
      isDisableBreadcrumbLink: true,
      isShowBreadcrumb: false,
      addToSidebar: false,
      sidebarIcon: '',
      sidebarIconAlt: '',
      isNoLogin: true,
    },
  },
]

比如下述的一些配置,主要的功能包括:

字段 类型 含义
title string 用于在渲染的时候配置浏览器标题;
isDisableBreadcrumbLink boolean 是否禁用面包屑链接(对一些没有内容的路由可以屏蔽访问);
isShowBreadcrumb boolean 是否显示面包屑(此处的登录页不需要面包屑);
addToSidebar boolean 是否加入侧边栏(此处的登录页不需要加入侧边栏);
sidebarIcon string 配置侧边栏的图标 className(默认);
sidebarIconAlt string 配置侧边栏的图标 className(展开状态);
isNoLogin boolean 是否免登录(后台默认强制登录,设置为 true 则可以免登录访问,此处的登录页不需要校验);

类似的,如果有其他需求,比如要增加对不同用户组的权限控制(比如有管理员、普通用户分组,部分页面只有管理员允许访问),都可以通过配置 Meta 里的字段,再配合路由拦截一起使用。

路由重定向

对一些已下线的页面,直接访问原来的地址会导致 404 ,为了避免这种情况出现,通常会配置重定向将其指向一个新的页面,或者跳转回首页。

基本用法

路由重定向是使用一个 redirect 字段进行配置到对应的路由里面去实现跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'home',
    component: () => import('@views/home.vue'),
    meta: {
      title: 'Home',
    },
  },
  // 访问这个路由会被重定向到首页
  {
    path: '/error',
    redirect: '/',
  },
]

通常来说,配置了 redirect 的路由,只需要指定 2 个字段即可,1 个是 path 该路由本身的路径,1 个是 redirect 目标路由的路径,其他字段可以忽略。

redirect 字段可以接收三种类型的值:

类型 填写的值
string 另外一个路由的 path
route 另外一个路由(类似 router.push
function 可以判断不同情况的重定向目标,最终 return 一个 path 或者 route

业务场景

路由重定向可以避免用户访问到一些无效路由页面:

  1. 比如项目上线了一段时间后,有个路由需要改名,或者调整路径层级,可以把旧路由重定向到新的,避免原来的用户从收藏夹等地方进来后找不到
  2. 一些容易打错的地址,比如通常个人资料页都是用 /profile,但是业务网站是使用 /account,那也可以把 /profile 重定向到 /account 去
  3. 对于一些有会员体系的站点,可以根据用户权限进行重定向,分别指向他们具备访问权限的页面
  4. 官网首页在 PC 端、移动端、游戏内嵌横屏版分别有 3 套页面,但希望能通过主域名来识别不同设备,帮助用户自动切换访问

了解了业务场景,接下来就能比较清晰地了解应该如何配置重定向了。

配置为 path

最常用的场景,恐怕就是首页的指向了,比如首页地址是 https://example.com/home,但是想让主域名 https://example.com/ 也能跳转到 /home。这是最简单的配置方式,把目标路由的 path 配置进来就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
const routes: Array<RouteRecordRaw> = [
  // 重定向到 `/home`
  {
    path: '/',
    redirect: '/home',
  },
  // 真正的首页
  {
    path: '/home',
    name: 'home',
    component: () => import('@views/home.vue'),
  },
]

配置为route

如果想要重定向后的路由地址带上一些参数,可以配置为 route

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const routes: Array<RouteRecordRaw> = [
  // 重定向到 `/home` ,并带上一个 `query` 参数
  {
    path: '/',
    redirect: {
      name: 'home',
      query: {
        from: 'redirect',
      },
    },
  },
  // 真正的首页
  {
    path: '/home',
    name: 'home',
    component: () => import('@views/home.vue'),
  },
]

最终访问的地址就是 https://example.com/home?from=redirect,像这样带有来路参数的,就可以在 “百度统计” 或者 “CNZZ 统计” 之类的统计站点查看来路的流量。

配置为 function

产品需要在访问网站主域名的时候,识别用户身份跳转不同的首页,那么就可以这样配置路由重定向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const routes: Array<RouteRecordRaw> = [
  // 访问主域名时,根据用户的登录信息,重定向到不同的页面
  {
    path: '/',
    redirect: () => {
      // `loginInfo` 是当前用户的登录信息
      // 可以从 `localStorage` 或者 `Pinia` 读取
      const { groupId } = loginInfo

      // 根据组别 ID 进行跳转
      switch (groupId) {
        // 管理员跳去仪表盘
        case 1:
          return '/dashboard'

        // 普通用户跳去首页
        case 2:
          return '/home'

        // 其他都认为未登录,跳去登录页
        default:
          return '/login'
      }
    },
  },
]

路由配置

路由别名配置

根据的业务需求,也可以为路由指定一个别名,与上面的 路由重定向 功能相似,但又有不同:配置了路由重定向,当用户访问 /a 时,URL 将会被替换成 /b,然后匹配的实际路由是 /b 。配置了路由别名,/a 的别名是 /b,当用户访问 /b 时,URL 会保持为 /b,但是路由匹配则为 /a,就像用户访问 /a 一样。

添加一个 alias 字段即可轻松实现:

1
2
3
4
5
6
7
8
const routes: Array<RouteRecordRaw> = [
  {
    path: '/home',
    alias: '/index',
    name: 'home',
    component: () => import('@views/home.vue'),
  },
]

404 路由页面配置

可以配置一个 404 路由来代替站内的 404 页面。

1
2
3
4
5
6
7
const routes: Array<RouteRecordRaw> = [
  {
    path: '/:pathMatch(.*)*',
    name: '404',
    component: () => import('@views/404.vue'),
  },
]

这样配置之后,只要访问到不存在的路由,就会显示为这个 404 模板。

注意
新版的路由不再支持直接配置通配符 * ,而是必须使用带有自定义正则表达式的参数进行定义,详见官网说明。

导航守卫

导航守卫的本质是专属的钩子函数,钩子函数的应用场景如下所述。

钩子的应用场景

对于导航守卫还不熟悉的开发者,可以从一些实际使用场景来加强印象,比如:

  1. 前面说的,在渲染的时候配置浏览器标题,由于 Vue 项目只有一个 HTML 文件,所以默认只有一个标题,但想在访问 /home 的时候标题显示为 “首页”,访问 /about 的时候标题显示为 “关于” 。
  2. 部分页面需要管理员才能访问,普通用户不允许进入到该路由页面。
  3. Vue 单页面项目,传统的 CNZZ / 百度统计等网站统计代码只会在页面加载的时候统计一次,但需要每次切换路由都上报一次 PV 数据。

这样的场景还有很多,导航守卫支持全局使用,也可以在 .vue 文件里单独使用,接下来看看具体的用法。

路由里的全局钩子

在创建 router 的时候进行全局的配置,只要配置了钩子,那么所有的路由在被访问到的时候,都会触发这些钩子函数。

可用钩子 含义 触发时机
beforeEach 全局前置守卫 在路由跳转前触发
beforeResolve 全局解析守卫 在导航被确认前,同时在组件内守卫和异步路由组件被解析后
afterEach 全局后置守卫 在路由跳转完成后触发

全局配置非常简单,在 src/router/index.ts 里,在创建路由之后、在导出去之前使用:

1
2
3
4
5
6
7
8
9
10
11
12
import { createRouter } from 'vue-router'

// 创建路由
const router = createRouter({ ... })

// 在这里调用导航守卫的钩子函数
router.beforeEach((to, from) => {
  // ...
})

// 导出去
export default router

beforeEach

全局前置守卫,这是导航守卫里面运用的最多的一个钩子函数,通常将其称为 “路由拦截”,就是在组件被渲染之前,做一些拦截操作。

参数 作用
to 即将要进入的路由对象
from 当前导航正要离开的路由

在进入路由之前,根据 Meta 路由元信息 的配置,设定路由的网页标题:

1
2
3
4
router.beforeEach((to, from) => {
  const { title } = to.meta
  document.title = title || '默认标题'
})

或者判断是否需要登录:

1
2
3
4
router.beforeEach((to, from) => {
  const { isNoLogin } = to.meta
  if (!isNoLogin) return '/login'
})

或者针对一些需要 ID 参数,但参数丢失的路由做拦截,比如:很多网站的文章详情页都是类似 https://example.com/article/123 这样格式的地址,是需要带有文章 ID 作为 URL 的一部分,如果只访问 https://example.com/article 则需要拦截掉。

这里是关于 article 路由的配置,是有要求 Params 要带上 ID 参数:

1
2
3
4
5
6
7
8
9
const routes: Array<RouteRecordRaw> = [
  // 这是一个配置了 `params` ,访问的时候必须带 `id` 的路由
  {
    path: '/article/:id',
    name: 'article',
    component: () => import('@views/article.vue'),
  },
  // ...
]

当路由的 params 丢失的时候,路由记录 matched 是一个空数组,针对这样的情况,就可以配置一个拦截,丢失参数时返回首页:

1
2
3
4
5
router.beforeEach((to) => {
  if (to.name === 'article' && to.matched.length === 0) {
    return '/'
  }
})

beforeResolve

全局解析守卫,它会在每次导航时触发,但是在所有组件内守卫和异步路由组件被解析之后,将在确认导航之前被调用。它通常会用在一些申请权限的环节,比如一些 H5 页面需要申请系统相机权限、一些微信活动需要申请微信的登录信息授权,获得权限之后才允许获取接口数据和给用户更多的操作,使用 beforeEach 时机太早,使用 afterEach 又有点晚,那么这个钩子的时机就刚刚好。

参数 作用
to 即将要进入的路由对象
from 当前导航正要离开的路由

用 Vue Router 官网的申请照相机权限的例子来举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// https://router.vuejs.org/zh/guide/advanced/navigation-guards.html

router.beforeResolve(async (to) => {
  // 如果路由配置了必须调用相机权限
  if (to.meta.requiresCamera) {
    // 正常流程,咨询是否允许使用照相机
    try {
      await askForCameraPermission()
    } catch (error) {
      // 容错
      if (error instanceof NotAllowedError) {
        // ... 处理错误,然后取消导航
        return false
      } else {
        // 如果出现意外,则取消导航并抛出错误
        throw error
      }
    }
  }
})

afterEach

全局后置守卫,这也是导航守卫里面用得比较多的一个钩子函数。每次切换路由都上报一次 PV 数据,类似这种每个路由都要执行一次,但又不必在渲染前操作的,都可以放到后置钩子里去执行。

参数 作用
to 即将要进入的路由对象
from 当前导航正要离开的路由

在组件内使用全局钩子

全局钩子虽然一般都是在路由文件里使用,但如果有需要,也可以在 .vue 文件里操作。

setup 里,定义一个 router 变量获取路由之后,就可以操作了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { defineComponent } from 'vue'
import { useRouter } from 'vue-router'

export default defineComponent({
  setup() {
    // 定义路由
    const router = useRouter()

    // 调用全局钩子
    router.beforeEach((to, from) => {
      // ...
    })
  },
})

路由里的独享钩子

介绍完全局钩子,如果只是有个别路由要做处理,可以使用路由独享的守卫,用来针对个别路由定制一些特殊功能,可以减少在全局钩子里面写一堆判断。

可用钩子 含义 触发时机
beforeEnter 路由独享前置守卫 在路由跳转前触发

注:路由独享的钩子,必须配置在 routes 的 JSON 树里面,挂在对应的路由下面(与 path、 namemeta 这些字段同级)。

beforeEnter

它和全局钩子 beforeEach 的作用相同,都是在进入路由之前触发,触发时机比 beforeResolve 要早。

顺序:beforeEach(全局) > beforeEnter(独享) > beforeResolve(全局)。

参数

参数 作用
to 即将要进入的路由对象
from 当前导航正要离开的路由

整个站点都默认以 “栏目标题” + “全站关键标题” 的格式作为网页的 Title ,例如 “项目经验 - 程沛权” ,但在首页的时候,想做一些不一样的定制,就可以通过 beforeEnter 来实现一些个别路由的单独定制:

1
2
3
4
5
6
7
8
9
10
11
const routes: Array<RouteRecordRaw> = [
  {
    path: '/home',
    name: 'home',
    component: () => import('@views/home.vue'),
    // 在这里添加单独的路由守卫
    beforeEnter: (to, from) => {
      document.title = '程沛权 - 养了三只猫'
    },
  },
]

组件内单独使用

组件里除了可以使用全局钩子外,还可以使用组件专属的路由钩子。

可用钩子 含义 触发时机
onBeforeRouteUpdate 组件内的更新守卫 在当前路由改变,但是该组件被复用时调用
onBeforeRouteLeave 组件内的离开守卫 导航离开该组件的对应路由时调用

onBeforeRouteUpdate

可以在当前路由改变但该组件被复用时,重新调用里面的一些函数,用来更新模板数据的渲染。

参数 作用
to 即将要进入的路由对象
from 当前导航正要离开的路由

比如一个内容网站,通常在文章详情页底部会有相关阅读推荐,这个时候就会有一个操作场景:从文章 A 跳转到文章 B。

比如从 https://example.com/article/111 切去 https://example.com/article/222 ,这种情况就属于 “路由改变,但是组件被复用” 的情况了。

这种情况下,原本放在 onMounted 里执行数据请求的函数就不会被调用,可以借助该钩子来实现渲染新的文章内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { defineComponent, onMounted } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'

export default defineComponent({
  setup() {
    // 其他代码略...

    // 查询文章详情
    async function queryArticleDetail(id: number) {
      // 请求接口数据
      const res = await axios({
        url: `/article/${id}`,
      })
      // ...
    }

    // 组件挂载完成后执行文章内容的请求
    // 注意这里是获取 `route` 的 `params`
    onMounted(async () => {
      const id = Number(route.params.id) || 0
      await queryArticleDetail(id)
    })

    // 组件被复用时重新请求新的文章内容
    onBeforeRouteUpdate(async (to, from) => {
      // ID 不变时减少重复请求
      if (to.params.id === from.params.id) return

      // 注意这里是获取 `to` 的 `params`
      const id = Number(to.params.id) || 0
      await queryArticleDetail(id)
    })
  },
})

onBeforeRouteLeave

可以在离开当前路由之前,实现一些离开前的判断拦截。

参数 作用
to 即将要进入的路由对象
from 当前导航正要离开的路由

这个离开守卫通常用来禁止用户在还未保存修改前突然离开,可以通过 return false 来取消用户离开当前路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { defineComponent } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

export default defineComponent({
  setup() {
    // 调用离开守卫
    onBeforeRouteLeave((to, from) => {
      // 弹出一个确认框
      const confirmText = '确认要离开吗?您的更改尚未保存!'
      const isConfirmLeave = window.confirm(confirmText)

      // 当用户点取消时,不离开路由
      if (!isConfirmLeave) {
        return false
      }
    })
  },
})

路由侦听

watch

侦听整个路由

可以跟以前一样,直接侦听整个路由的变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { defineComponent, watch } from 'vue'
import { useRoute } from 'vue-router'

export default defineComponent({
  setup() {
    const route = useRoute()

    // 侦听整个路由
    watch(route, (to, from) => {
      // 处理一些事情
      // ...
    })
  },
})

第一个参数传入整个路由;第二个参数是个 Callback ,可以获取 tofrom 来判断路由变化情况。

侦听路由的某个数据

如果只想侦听路由的某个数据变化,比如侦听一个 Query ,或者一个 Param ,可以采用这种方式:第一个参数传入一个 getter 函数, return 要侦听的值;第二个参数是个 Callback ,可以针对参数变化进行一些操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { defineComponent, watch } from 'vue'
import { useRoute } from 'vue-router'

export default defineComponent({
  setup() {
    const route = useRoute()

    // 侦听路由参数的变化
    watch(
      () => route.query.id,
      () => {
        console.log('侦听到 ID 变化')
      },
    )
  },
})

watchEffect

Vue 3 新出的一个侦听函数,可以简化 watch 的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { defineComponent, watchEffect } from 'vue'
import { useRoute } from 'vue-router'

export default defineComponent({
  setup() {
    const route = useRoute()

    // 从接口查询文章详情
    async function queryArticleDetail() {
      const id = Number(route.params.id) || 0
      console.log('文章 ID 是:', id)

      const res = await axios({
        url: `/article/${id}`,
      })
      // ...
    }

    // 直接侦听包含路由参数的那个函数
    watchEffect(queryArticleDetail)
  },
})

对比 watch 的使用, watchEffect 在操作上更加简单,把包含要被侦听数据的函数,当成它的入参传进去即可。

部署问题与服务端配置

通常使用路由的 Hash 模式,部署后有问题的情况很少,但是如果使用 History 模式,可能会遇到这样那样的问题。

常见部署问题

这部分详细内容可以参考文档:常见部署问题

服务端配置方案

如果使用的是 HTML5 的 History 模式,那么服务端也需要配置对应的支持,否则会出现路由跳转正常,但页面一刷新就 404 的情况。

Nginx

现在大部分公司的服务程序都在使用 Nginx ,可以将以下代码发给运维工程师参考,调整 Nginx 的配置:

1
2
3
location / {
  try_files $uri $uri/ /index.html;
}

Express

如果是前端工程师使用 Node.js 作服务端,并且使用了 Express 服务端框架,那么操作将变得更简单:

  1. 仅需要安装一个中间件
1
npm install connect-history-api-fallback
  1. 在服务启动入口文件里导入该中间件并激活
1
2
3
4
5
6
7
8
9
10
const express = require('express')
const history = require('connect-history-api-fallback')

// 创建 Express 实例
const app = express()
app
  // 启用 History 中间件
  .use(history())
  // 这里是读取打包后的页面文件目录
  .use('/', express.static(resolve('../dist')))

其他的诸如 Apache 、 IIS 、或者原生 Node 等等配置方案, Vue 官方都提供了对应的演示代码。

插件的开发和使用

插件的安装和引入

虽然对于个人开发者来说,有一个用的顺手的包管理器就足够日常开发了,但是还是有必要多了解一下不同的包管理器,因为未来可能会面对团队协作开发、为开源项目贡献代码等情况,需要遵循团队要求的包管理机制(例如使用 Monorepo 架构的团队会更青睐于 yarn 或 pnpm 的 Workspace 功能)。

通过 npm 安装

npm 是 Node.js 自带的包管理器,平时通过 npm install 命令来安装各种 npm 包(比如 npm install vue-router ),就是通过这个包管理器来安装的。

通过 cnpm 安装

它的安装命令和 npm 非常一致,通过 cnpm install 命令来安装(比如 cnpm install vue-router)。在使用它之前,需要通过 npm 命令进行全局安装。

yarn 也是一个常用的包管理工具,和 npm 十分相似, npmjs 上的包,也会同步到 yarnpkg 。但是安装命令上会有点不同, yarn 是用 add 代替 install ,用 remove 代替 uninstall

yarn 的 lock 文件是 yarn.lock ,如果有管理多人协作仓库的需求,可以根据实际情况把它添加至 .gitignore 文件,便于统一团队的包管理。

通过 pnpm 安装

pnpm 是包管理工具的一个后起之秀,主打快速的、节省磁盘空间的特色,用法跟其他包管理器很相似,没有太多的学习成本, npm 和 yarn 的命令它都支持,也是必须先全局安装它才可以使用。目前 pnpm 在开源社区的使用率越来越高,包括接触最多的 Vue / Vite 团队也在逐步迁移到 pnpm 来管理依赖。pnpm 的下载源使用的是 npm ,所以如果要绑定镜像源,按照 npm 的方式 处理就可以了。

通过 CDN 安装

大部分插件都会提供一个 CDN 版本,让可以在 .html 文件里通过 <script> 标签引入。比如:

1
<script src="https://unpkg.com/vue-router"></script>

除了 CDN 版本是直接可用之外,其他通过 npm 、 yarn 等方式安装的插件,都需要在入口文件 main.ts 或者要用到的 .vue 文件里引入,比如:

1
import { createRouter, createWebHistory } from 'vue-router'

Vue 专属插件

这里特指 Vue 插件,通过 Vue Plugins 设计规范 开发出来的插件,在 npm 上通常是以 vue-xxx 这样带有 vue 关键字的格式命名(比如 vue-baidu-analytics)。

专属插件通常分为 全局插件 和 单组件插件,区别在于,全局版本是在 main.ts 引入后 use,而单组件版本则通常是作为一个组件在 .vue 文件里引入使用。

全局插件的使用

全局插件的使用,就是在 main.ts 通过 import 引入,然后通过 use 来启动初始化。

use 方法支持两个参数:

参数 类型 作用  
plugin object function 插件,一般是在 import 时使用的名称
options object 插件的参数,有些插件在初始化时可以配置一定的选项  

基本写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.ts
import plugin1 from 'plugin1'
import plugin2 from 'plugin2'
import plugin3 from 'plugin3'
import plugin4 from 'plugin4'

createApp(App)
  .use(plugin1)
  .use(plugin2)
  .use(plugin3, {
    // plugin3's options
  })
  .use(plugin4)
  .mount('#app')

单组件插件的使用

单组件的插件,通常自己本身也是一个 Vue 组件(大部分情况下都会打包为 JS 文件,但本质上是一个 Vue 的 Component )。

单组件的引入,一般都是在需要用到的 .vue 文件里单独 import ,然后挂到 <template /> 里去渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
  <!-- 放置组件的渲染标签,用于显示组件 -->
  <ComponentExample />
</template>

<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import logo from '@/assets/logo.png'

// 引入单组件插件
import ComponentExample from 'a-component-example'

export default defineComponent({
  // 挂载组件模板
  components: {
    ComponentExample,
  },
})
</script>

通用 JS / TS 插件

也叫普通插件,指它们无需任何框架依赖,可以应用在任意项目中,属于独立的 Library ,比如 axios 、 qrcode 、md5 等等,在任何技术栈都可以单独引入使用,非 Vue 专属。

通用插件的使用非常灵活,既可以全局挂载,也可以在需要用到的组件里单独引入。组件里单独引入方式:

1
2
3
4
5
6
7
8
import { defineComponent } from 'vue'
import md5 from '@withtypes/md5'

export default defineComponent({
  setup() {
    const md5Msg = md5('message')
  },
})

全局挂载方法比较特殊,因为插件本身不是专属 Vue,没有 install 接口,无法通过 use 方法直接启动。

本地插件

封装的目的

如果不把这一块的业务代码抽离出来进行封装,则需要在每个用到的地方都写一次,不仅繁琐,而且以后一旦产品需求有改动,维护起来就十分困难。

常用的封装类型

常用的本地封装方式有两种:一种是以 通用 JS / TS 插件 的形式,一种是以 Vue 专属插件 的形式。

开发本地通用 JS / TS 插件

一般情况下会以通用类型比较常见,因为大部分都是一些比较小的功能,而且可以很方便的在不同项目之间进行复用。

项目结构

通常会把这一类文件都归类在 src 目录下的 libs 文件夹里,代表存放的是 Library 文件( JS 项目以 .js 文件存放, TS 项目以 .ts 文件存放)。

1
2
3
4
5
6
7
8
9
vue-demo
│ # 源码文件夹
├─src
│ │ # 本地通用插件
│ └─libs
│   ├─foo.ts
│   └─bar.ts
│ # 其他结构这里省略…
└─package.json

这样在调用的时候,可以通过 @/libs/foo 来引入,或者配置了 alias 别名,也可以使用别名导入,例如 @libs/foo

设计规范与开发案例

在设计本地通用插件的时候,需要遵循 ES Module 模块设计规范 ,并且做好必要的代码注释(用途、入参、返回值等)。

  • 只有一个默认功能:可以使用 export default 来默认导出一个函数

    案例:封装打招呼功能

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    // src/libs/greet.ts  
      
    /**  
    * 向对方打招呼  
    * @param name - 打招呼的目标人名  
    * @returns 向传进来的人名返回一句欢迎语  
    */  
    export default function greet(name: string): string {  
      return `Welcome, ${name}!`  
    }  
    

    在 Vue 组件里就可以这样使用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    <script lang="ts">  
    import { defineComponent } from 'vue'  
    // 导入本地插件  
    import greet from '@libs/greet'  
      
    export default defineComponent({  
      setup() {  
        // 导入的名称就是这个工具的方法名,可以直接调用  
        const message = greet('Petter')  
        console.log(message) // Welcome, Petter!  
      },  
    })  
    </script>  
    
  • 是一个小工具合集:如果有很多个作用相似的函数,那么建议放在一个文件里作为一个工具合集统一管理,使用 export 来导出里面的每个函数。

    案例:封装通过正则表达式判断表单输入内容是否符合要求的函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    // src/libs/regexp.ts  
      
    /**  
    * 手机号校验  
    * @param phoneNumber - 手机号  
    * @returns true=是手机号,false=不是手机号  
    */  
    export function isMob(phoneNumber: number | string): boolean {  
      return /^1[3456789]\d{9}$/.test(String(phoneNumber))  
    }  
      
    /**  
    * 邮箱校验  
    * @param email - 邮箱地址  
    * @returns true=是邮箱地址,false=不是邮箱地址  
    */  
    export function isEmail(email: string): boolean {  
      return /^[A-Za-z\d]+([-_.][A-Za-z\d]+)*@([A-Za-z\d]+[-.])+[A-Za-z\d]{2,4}$/.test(  
        email,  
      )  
    }  
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    <script lang="ts">  
    import { defineComponent } from 'vue'  
    // 需要用花括号 {} 来按照命名导出时的名称导入  
    import { isMob, isEmail } from '@libs/regexp'  
      
    export default defineComponent({  
      setup() {  
        // 判断是否是手机号  
        console.log(isMob('13800138000')) // true  
        console.log(isMob('123456')) // false  
      
        // 判断是否是邮箱地址  
        console.log(isEmail('example@example.com')) // true  
        console.log(isEmail('example')) // false  
      },  
    })  
    </script>  
    
  • 包含工具及辅助函数:如果主要提供一个独立功能,但还需要提供一些额外的变量或者辅助函数用于特殊的业务场景,那么可以用 export default 导出主功能,用 export 导出其他变量或者辅助函数。

    案例:封装打招呼功能外加偶尔的赞美

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    // src/libs/greet.ts  
      
    /**  
    * 称赞对方  
    * @param name - 要称赞的目标人名  
    * @returns 向传进来的人名发出一句赞美的话  
    */  
    export function praise(name: string): string {  
      return `Oh! ${name}! It's so kind of you!`  
    }  
    /**  
    * 向对方打招呼  
    * @param name - 打招呼的目标人名  
    * @returns 向传进来的人名返回一句欢迎语  
    */  
    export default function greet(name: string): string {  
      return `Welcome, ${name}!`  
    }  
    

    在 Vue 组件里就可以这样使用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    <script lang="ts">  
    import { defineComponent } from 'vue'  
    // 导入本地插件  
    import greet, { praise } from '@libs/greet'  
      
    export default defineComponent({  
      setup() {  
        // 导入的名称就是这个工具的方法名,可以直接调用  
        const message = greet('Petter')  
        console.log(message) // Welcome, Petter!  
        // 使用命名导出的赞美  
        const praiseMessage = praise('Petter')  
        console.log(praiseMessage) // Oh! Petter! It's so kind of you!  
      },  
    })  
    </script>  
    

开发本地 Vue 专属插件

这一类的插件只能给 Vue 使用,有时候自己的业务比较特殊,无法找到完全适用的 npm 包,那么就可以自己写一个!

项目结构

通常会把这一类文件都归类在 src 目录下的 plugins 文件夹里,代表存放的是 Plugin 文件( JS 项目以 .js 文件存放, TS 项目以 .ts 文件存放)。

1
2
3
4
5
6
7
8
9
vue-demo
│ # 源码文件夹
├─src
│ │ # 本地 Vue 插件
│ └─plugins
│   ├─foo.ts
│   └─bar.ts
│ # 其他结构这里省略…
└─package.json

这样在调用的时候,可以通过 @/plugins/foo 来引入,或者配置了 alias 别名,也可以使用别名导入,例如 @plugins/foo

设计规范

需要遵循 Vue 官方撰写的 Vue Plugins 设计规范 ,并且做好必要的代码注释,除了标明插件 API 的 “用途、入参、返回值” 之外,最好在注释内补充一个 Example 或者 Tips 说明,功能丰富的插件最好直接写个 README 文档。

具体的案例示范可在后续实际操作时参考:开发案例

全局 API 挂载

对于一些使用频率比较高的插件方法,如果觉得在每个组件里单独导入再用很麻烦,也可以考虑将其挂载到 Vue 上,使其成为 Vue 的全局变量。

在 Vue 3,如果想要挂载全局变量,需要通过全新的 globalProperties 来实现,在使用该方式之前,可以把 createApp 定义为一个变量再执行挂载。

定义全局 API

如上,在配置全局变量之前,可以把初始化时的 createApp 定义为一个变量(假设为 app ),然后把需要设置为全局可用的变量或方法,挂载到 appconfig.globalProperties 上面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import md5 from 'md5'

// 创建 Vue 实例
const app = createApp(App)

// 把插件的 API 挂载全局变量到实例上
app.config.globalProperties.$md5 = md5

// 也可以自己写一些全局函数去挂载
app.config.globalProperties.$log = (text: string): void => {
  console.log(text)
}

app.mount('#app')

全局 API 的替代方案

在 Vue 3 实际上并不是特别推荐使用全局变量,Vue 3 比较推荐按需引入使用,这也是在构建过程中可以更好的做到代码优化。

npm包的开发与发布

常用的构建工具

平时项目里用到的 npm 包,也可以理解为是一种项目插件,一些很简单的包,其实就和编写本地插件一样,假设包的入口文件是 index.js ,那么可以直接在 index.js 里编写代码,再进行模块化导出。其他项目里安装这个包之后就可以直接使用里面的方法了,这种方式适合非常非常简单的包,很多独立的工具函数包就是使用这种方式来编写包的源代码。

但这一类包通常是提供很基础的功能实现,更多时候需要自己开发的包更倾向于和框架、和业务挂钩,涉及到非 JavaScript 代码,例如 Vue 组件的编译、 Less 等 CSS 预处理器编译、 TypeScript 的编译等等,如果不通过构建工具来处理,那么发布后这个包的使用就会有诸多限制,需要满足和开发这个包时一样的开发环境才能使用,这对于使用者来说非常不友好。

因此大部分 npm 包的开发也需要用到构建工具来转换项目源代码,统一输出为一个兼容性更好、适用性更广的 JavaScript 文件,配合 .d.ts 文件的类型声明,使用者可以不需要特地配置就可以开箱即用,非常方便,非常友好。目前方便的包构建工具为Vite

项目结构与入口文件

初始化项目

首先初始化项目,这部分在之前的章节中有提,不再赘述

配置包信息

对一个 npm 包来说,最重要的文件莫过于 package.json 项目清单,其中有三个字段是必填的:

字段 是否必填 作用
name 必填 npm 包的名称,遵循项目名称的规则
version 必填 npm 包的版本号,遵循语义化版本号的规则
main 必填 项目的入口文件,通常指向构建产物所在目录的某个文件,该文件通常包含了所有模块的导出。如果只指定了 main 字段,则使用 require 和 import 以及浏览器访问 npm 包的 CDN 时,都将默认调用该字段指定的入口文件。如果有指定 module 和 browser 字段,则通常对应 cjs 格式的文件,对应 CommonJS 规范。
module 当项目使用 import 引入 npm 包时对应的入口文件,通常指向一个 es 格式的文件,对应 ES Module 规范。
browser 当项目使用了 npm 包的 CDN 链接,在浏览器访问页面时的入口文件,通常指向一个 umd 格式的文件,对应 UMD 规范。
types 一个 .d.ts 类型声明文件,包含了入口文件导出的方法 / 变量的类型声明,如果项目有自带类型文件,那么在使用者在使用 TypeScript 开发的项目里,可以得到友好的类型提示
files 指定发布到 npm 上的文件范围,格式为 string[] 支持配置多个文件名或者文件夹名称。通常可以只指定构建的输出目录,例如 dist 文件夹,如果不指定,则发布的时候会把所有源代码一同发布。

其中 main 、 module 和 browser 三个入口文件对应的文件格式和规范,通常都是交给构建工具处理,无需手动编写,开发者只需要维护一份源码即可编译出不同规范的 JS 文件, types 对应的类型声明文件也是由工具来输出,无需手动维护,而其他的字段可以根据项目的性质决定是否补充。

安装开发依赖

Vite本身对TypeScript进行了支持,因此只需要将Vite安装到开发依赖,则npm包可使用Vite进行构建,使用TypeScript编写源代码。

1
2
# 添加 -D 选项将其安装到 devDependencies
npm i -D vite

添加配置文件

在 配置包信息 的时候已提前配置了一个 npm run build 的命令,它将运行 Vite 来构建 npm 包的入口文件。由于 Vite 默认是构建入口文件为 HTML 的网页应用,而开发 npm 包时入口文件是 JS / TS 文件,因此需要添加一份配置文件来指定构建的选项。

以下是本次的基础配置,可以完成最基本的打包,它将输出三个不同格式的入口文件,分别对应 CommonJS 、 ES Module 和 UMD 规范,分别对应 package.json 里 main 、 module 和 browser 字段指定的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// vite.config.ts
import { defineConfig } from 'vite'

// https://cn.vitejs.dev/config/
export default defineConfig({
  build: {
    // 输出目录
    outDir: 'dist',
    // 构建 npm 包时需要开启 “库模式”
    lib: {
      // 指定入口文件
      entry: 'src/index.ts',
      // 输出 UMD 格式时,需要指定一个全局变量的名称
      name: 'hello',
      // 最终输出的格式,这里指定了三种
      formats: ['es', 'cjs', 'umd'],
      // 针对不同输出格式对应的文件名
      fileName: (format) => {
        switch (format) {
          // ES Module 格式的文件名
          case 'es':
            return 'index.mjs'
          // CommonJS 格式的文件名
          case 'cjs':
            return 'index.cjs'
          // UMD 格式的文件名
          default:
            return 'index.min.js'
        }
      },
    },
    // 压缩混淆构建后的文件代码
    minify: true,
  },
})

添加入口文件

最基础的准备工作已完成,接下来添加入口文件并尝试编译。在添加配置文件时已指定了入口文件为 src/index.ts ,因此需要对应的创建该文件,并写入一个简单的方法,将用它来测试打包结果:

1
2
3
4
// src/index.ts
export default function hello(name: string) {
  console.log(`Hello ${name}`)
}

在命令行执行 npm run build 命令,可以看到项目下生成了 dist 文件夹,以及三个 JavaScript 文件,此时目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
hello-lib
│ # 构建产物的输出文件夹
├─dist
│ ├─index.cjs
│ ├─index.min.js
│ └─index.mjs
│ # 依赖文件夹
├─node_modules
│ # 源码文件夹
├─src
│ │ # 入口文件
│ └─index.ts
│ # 项目清单信息
├─package-lock.json
├─package.json
│ # Vite 配置文件
└─vite.config.ts

开发 npm 包

编写 npm 包代码

在开发的过程中,需要遵循模块化开发的要求,当前这个演示包使用 TypeScript 编码,就需要 使用 ES Module 来设计模块 ,如果对模块化设计还没有足够的了解,请先回顾相关的内容。先在 src 目录下创建一个名为 utils.ts 的文件,写入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/utils.ts

/**
 * 生成随机数
 * @param min - 最小值
 * @param max - 最大值
 * @param roundingType - 四舍五入类型
 * @returns 范围内的随机数
 */
export function getRandomNumber(
  min: number = 0,
  max: number = 100,
  roundingType: 'round' | 'ceil' | 'floor' = 'round',
) {
  return Math[roundingType](Math.random() * (max - min) + min)
}

/**
 * 生成随机布尔值
 */
export function getRandomBoolean() {
  const index = getRandomNumber(0, 1)
  return [true, false][index]
}

这里导出了两个随机方法,其中 getRandomNumber 提供了随机数值的返回,而 getRandomBoolean 提供了随机布尔值的返回,在源代码方面, getRandomBoolean 调用了 getRandomNumber 获取随机索引。

这是一个很常见的 npm 工具包的开发思路,包里的函数都使用了细粒度的编程设计,每一个函数都是独立的功能,在必要的情况下,函数 B 可以调用函数 A 来减少代码的重复编写。

在这里, utils.ts 文件已开发完毕,接下来需要将它导出的方法提供给包的使用者,请删除入口文件 src/index.ts 原来的测试内容,并输入以下新代码:

1
2
// src/index.ts
export * from './utils'

这代表将 utils.ts 文件里导出的所有方法或者变量,再次导出去,如果有很多个 utils.ts 这样的文件, index.ts 将作为一个统一的入口,统一的导出给构建工具去编译输出。

接下来在命令行执行 npm run build ,分别观察 dist 目录下的文件变化。

对 npm 包进行本地调试

开发或者迭代了一个 npm 包之后,不建议直接发布,可以在本地进行测试,直到没有问题了再发布到 npmjs 上供其他人使用。npm 提供了一个 npm link 命令供开发者本地联调,假设 path/to/my-library 是一个 npm 包的项目路径, path/to/my-project 是一个调试项目的所在路径,那么通过以下步骤可以在 my-project 里本地调试 my-library 包。

  • 创建本地软链接:先在 my-library npm 包项目里执行 npm link 命令,创建 npm 包的本地软链接。运行了以上命令之后,意味着刚刚开发好的 npm 包,已经被成功添加到了 Node 的全局安装目录下,可以在命令行运行npm prefix -g命令查看全局安装目录的位置。假设 {prefix} 是全局安装目录,刚刚这个包在 package.json 里的包名称是 my-library ,那么在 {prefix}/node_modules/my-library 这个目录下可以看到被软链接了一份项目代码。自此已经对这个 npm 包完成了一次 “本地发布” ,接下来就要在调试项目里进行本地关联。

  • 关联本地软链接:在 my-project 调试项目里执行语法为 npm link [<package-spec>] 的 link 命令,关联 npm 包的本地软链接(这里的 [<package-spec>] 参数,可以是包名称,也可以是 npm 包项目所在的路径)。

至此,就完成了调试项目对该 npm 包在本地的 “安装” ,此时在 my-project 这个调试项目的 node_modules 目录下也会创建一个软链接,指向 my-library 所在的目录。

添加版权注释

很多知名项目在 Library 文件的开头都会有一段版权注释,它的作用除了声明版权归属之外,还会告知使用者关于项目的主页地址、版本号、发布日期、 BUG 反馈渠道等信息。npm 社区提供了非常多开箱即用的注入插件,通常可以通过 “当前使用的构建工具名称” 加上 “plugin banner” 这样的关键字,在 npmjs 网站上搜索是否有相关的插件,以当前使用的 Vite 为例,可以通过 vite-plugin-banner 实现版权注释的自动注入。

首先进行安装:

1
npm i -D vite-plugin-banner

根据插件的文档建议,打开 vite.config.ts 文件,将其导入,并通过读取 package.json 的信息来生成常用的版权注释信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// vite.config.ts
import { defineConfig } from 'vite'
// 导入版权注释插件
import banner from 'vite-plugin-banner'
// 导入 npm 包信息
import pkg from './package.json'

// https://cn.vitejs.dev/config/
export default defineConfig({
  // 其他选项保持不变
  // ...
  plugins: [
    // 新增 banner 插件的启用,传入 package.json 的字段信息
    banner(
      `/**\n * name: ${pkg.name}\n * version: v${pkg.version}\n * description: ${pkg.description}\n * author: ${pkg.author}\n * homepage: ${pkg.homepage}\n */`
    ),
  ],
})

再次运行 npm run build 命令,打开 dist 目录下的 Library 文件,可以看到都成功添加了一段版权注释,这样其他开发者如果在使用过程中遇到了问题,就可以找到插件作者的联系方式了。

生成 npm 包的类型声明

为什么需要类型声明

如果在上一小节 关联本地软链接 创建 Vue 调试项目时,也是使用了 TypeScript 版本的 Vue 项目,会遇到 VSCode 在下面这句代码上:

1
import { getRandomNumber } from '@learning-vue3/lib'

在包名称 '@learning-vue3/lib' 的位置提示了一个红色波浪线,把鼠标移上去会显示这么一段话:

无法找到模块 “@learning-vue3/lib” 的声明文件。 “D:/Project/demo/hello-lib/dist/index.cjs” 隐式拥有 “any” 类型。尝试使用npm i --save-dev @types/learning-vue3__lib(如果存在),或者添加一个包含declare module '@learning-vue3/lib';的新声明 (.d.ts) 文件 ts(7016)

此时在命令行运行 Vue 调试项目的打包命令 npm run build ,也会遇到打包失败的报错,控制台同样反馈了这个问题:缺少声明文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
❯ npm run build

hello-vue3@0.0.0 build
vue-tsc --noEmit && vite build

src/App.vue:8:30 - error TS7016: Could not find a declaration file for module '@learning-vue3/lib'. 'D:/Project/demo/hello-lib/dist/index.cjs' implicitly has an 'any' type.
  Try `npm i --save-dev @types/learning-vue3__lib` if it exists or add a new declaration (.d.ts) file containing `declare module '@learning-vue3/lib';`

8 import { getRandomNumber } from '@learning-vue3/lib'
                               ~~~~~~~~~~~~~~~~~~~~


Found 1 error in src/App.vue:8

虽然使用者可以按照报错提示,在调试项目下创建一个 d.ts 文件并写入以下内容来声明该 npm 包:

1
declare module '@learning-vue3/lib'

但这需要每个使用者,或者说每个使用到这个包的项目都声明一次,对于使用者来说非常不友好, declare module 之后虽然不会报错了,但也无法获得 VSCode 对 npm 包提供的 API 进行 TS 类型的自动推导与类型提示、代码补全等功能支持。

主流的做法

以 @vue/reactivity 这个包为例,如果项目下安装有这个 npm 包,可以在

1
2
# 基于项目根目录
./node_modules/@vue/reactivity/dist/reactivity.d.ts

这个文件里查看 Vue 3 响应式 API 的类型声明,也可以通过该文件的 CDN 地址访问到其内容:

1
https://cdn.jsdelivr.net/npm/@vue/reactivity@3.2.40/dist/reactivity.d.ts

生成 DTS 文件

请先全局安装 typescript 这个包:

1
npm install -g typescript

依然是在在命令行界面,回到 hello-lib 这个 npm 包项目的根目录,执行以下命令生成 tsconfig.json 文件:

1
tsc --init

打开 tsconfig.json 文件,生成的文件里会有很多默认被注释掉的选项,请将以下几个选项取消注释,同时在 compilerOptions 字段的同级新增 include 字段,这几个选项都修改为如下配置:

1
2
3
4
5
6
7
8
{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "declarationDir": "./dist"
  },
  "include": ["./src"]
}

其中 compilerOptions 三个选项的意思是: .ts 源文件不编译为 .js 文件,只生成 .d.ts 文件并输出到 dist 目录; include 选项则告诉 TypeScript 编译器,只处理 src 目录下的 TS 文件。

修改完毕后,在命令行执行以下命令,它将根据 tsconfig.json 的配置对项目进行编译:

1
tsc

可以看到现在的 dist 目录下多了 2 份 .d.ts 文件: index.d.ts 和 utils.d.ts 。

1
2
3
4
5
6
7
hello-lib
└─dist
  ├─index.cjs
  ├─index.d.ts
  ├─index.min.js
  ├─index.mjs
  └─utils.d.ts

打开 dist/index.d.ts ,可以看到它的内容和 src/index.ts 是一样的,因为作为入口文件,只提供了模块的导出:

1
2
// dist/index.d.ts
export * from './utils'

再打开 dist/utils.d.ts ,可以看到它的内容如下,对比 src/utils.ts 的文件内容,它去掉了具体的功能实现,并且根据代码逻辑,转换成了 TypeScript 的类型声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// dist/utils.d.ts
/**
 * 生成随机数
 * @param min - 最小值
 * @param max - 最大值
 * @param roundingType - 四舍五入类型
 * @returns 范围内的随机数
 */
export declare function getRandomNumber(
  min?: number,
  max?: number,
  roundingType?: 'round' | 'ceil' | 'floor',
): number
/**
 * 生成随机布尔值
 */
export declare function getRandomBoolean(): boolean

由于 hello-lib 项目的 package.json 已提前指定了类型声明文件指向,因此可以直接回到调试 npm 包的 Vue 项目,此时 VSCode 对那句 import 语句的红色波浪线报错信息已消失不见,鼠标移到 getRandomNumber 这个方法上,也可以看到 VSCode 出现了该方法的类型提示,非常方便。再次运行 npm run build 命令构建调试项目,这一次顺利通过编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
❯ npm run build

hello-vue3@0.0.0 build
vue-tsc --noEmit && vite build

vite v2.9.15 building for production...
✓ 42 modules transformed.
dist/assets/logo.03d6d6da.png             6.69 KiB
dist/index.html                           0.42 KiB
dist/assets/home.9a123f29.js              2.01 KiB / gzip: 1.01 KiB
dist/assets/logo.db8b6a93.js              0.12 KiB / gzip: 0.13 KiB
dist/assets/TransferStation.25db7d3e.js   0.29 KiB / gzip: 0.22 KiB
dist/assets/bar.0e9da4c4.js               0.53 KiB / gzip: 0.37 KiB
dist/assets/bar.09e673fa.css              0.22 KiB / gzip: 0.18 KiB
dist/assets/home.6bd02f2a.css             0.62 KiB / gzip: 0.33 KiB
dist/assets/index.60726771.css            0.47 KiB / gzip: 0.29 KiB
dist/assets/index.aebbe022.js             79.87 KiB / gzip: 31.80 KiB

生成 DTS Bundle

发布之前,先介绍另外一个生成 DTS 文件的方式,可以根据实际情况选择使用。这里使用了 DTS Bundle 来称呼类型声明文件,这是因为直接使用 tsc 命令生成的 DTS 文件,是和源码目录的文件数量挂钩的,可以留意到在上一小节使用 tsc 命令生成声明文件后,在 hello-lib 项目中:

  • src 源码目录有 index.ts 和 utils.ts 两个文件
  • dist 输出目录也对应生成了 index.d.ts 和 utils.d.ts 两个文件

在一个大型项目里,源码的目录和文件非常多,意味着 DTS 文件也是非常多,这样的输出结构并不是特别友好。在讲 npm 包对类型声明主流的做法的时候,提到了 Vue 响应式 API 的 npm 包是提供了一个完整的 DTS 文件,它包含了所有 API 的类型声明信息:

1
./node_modules/@vue/reactivity/dist/reactivity.d.ts

这种将多个模块的文件内容合并为一个完整文件的行为通常称之为 Bundle。这里选用问题最少的 dts-bundle-generator 进行开发演示,请先安装到 hello-lib 项目的 devDependencies :npm i -D dts-bundle-generator

在 hello-lib 的根目录下,创建一个与 src 源码目录同级的 scripts 目录,用来存储源码之外的脚本函数。将以下代码保存到 scripts 目录下,命名为 buildTypes.mjs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// scripts/buildTypes.mjs
import { writeFileSync } from 'fs'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import { generateDtsBundle } from 'dts-bundle-generator'

async function run() {
  // 默认情况下 `.mjs` 文件需要自己声明 __dirname 变量
  const __filename = fileURLToPath(import.meta.url)
  const __dirname = dirname(__filename)

  // 获取项目的根目录路径
  const rootPath = resolve(__dirname, '..')

  // 添加构建选项
  // 插件要求是一个数组选项,支持多个入口文件
  const options = [
    {
      filePath: resolve(rootPath, `./src/index.ts`),
      output: {
        noBanner: true,
      },
    },
  ]

  // 生成 DTS 文件内容
  // 插件返回一个数组,返回的文件内容顺序同选项顺序
  const dtses = generateDtsBundle(options, {
    preferredConfigPath: resolve(rootPath, `./tsconfig.json`),
  })
  if (!Array.isArray(dtses) || !dtses.length) return

  // 将 DTS Bundle 的内容输出成 `.d.ts` 文件保存到 dist 目录下
  // 当前只有一个文件要保存,所以只取第一个下标的数据
  const dts = dtses[0]
  const output = resolve(rootPath, `./dist/index.d.ts`)
  writeFileSync(output, dts)
}
run().catch((e) => {
  console.log(e)
})

接下来打开 hello-lib 的 package.json 文件,添加一个 build:types 的 script ,并在 build 命令中通过 && 符号设置为继发执行任务,当前所有的 scripts 如下:

1
2
3
4
5
6
{
  "scripts": {
    "build": "vite build && npm run build:types",
    "build:types": "node scripts/buildTypes.mjs"
  }
}

接下来再运行 npm run build 命令,将在执行完 Vite 的 build 任务之后,再继续执行 DTS Bundle 的文件生成,可以看到现在的 dist 目录变成了如下,只会生成一个 .d.ts 文件:

1
2
3
4
5
6
hello-lib
└─dist
  ├─index.cjs
  ├─index.d.ts
  ├─index.min.js
  └─index.mjs

现在 index.d.ts 文件已经集合了源码目录下所有的 TS 类型,变成了如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// dist/index.d.ts
/**
 * 生成随机数
 * @param min - 最小值
 * @param max - 最大值
 * @param roundingType - 四舍五入类型
 * @returns 范围内的随机数
 */
export declare function getRandomNumber(
  min?: number,
  max?: number,
  roundingType?: 'round' | 'ceil' | 'floor',
): number
/**
 * 生成随机布尔值
 */
export declare function getRandomBoolean(): boolean

export {}

对于大型项目,将 DTS 文件集合为 Bundle 输出是一种主流的管理方式,非常建议使用这种方式来为 npm 包生成类型文件。

npm包的说明与发布

这部分内容并不需要专业知识,在此就不作赘述。

本文由作者按照 CC BY 4.0 进行授权