前端开发整理(四): 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 或者更多的模块化文件管理,然后再 import 到 index.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 标签跳转
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>
等价于 router 的 push:
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>
关于这两个属性的参数说明如下:
custom,一个布尔值,用于控制是否需要渲染为a标签,当不包含custom或者把custom设置为false时,则依然使用a标签渲染。v-slot是一个对象,用来决定标签的行为,它包含了:
| 字段 | 含义 |
|---|---|
| href | 解析后的 URL,将会作为一个 a 元素的 href 属性 |
| route | 解析后的规范化的地址 |
| navigate | 触发导航的函数,会在必要时自动阻止事件,和 router-link 同理 |
| isActive | 如果需要应用激活的 class 则为 true,允许应用一个任意的 class |
| isExactActive | 如果需要应用精确激活的 class 则为 true,允许应用一个任意的 class |
一般来说,v-slot 必备的只有 navigate ,用来绑定元素的点击事件,否则点击元素后不会有任何反应,其他的可以根据实际需求来添加。
注意
要渲染为非a标签,切记两个点:
router-link必须带上custom和v-slot属性- 最终要渲染的标签,写在
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',
})
路由元信息配置
有时候项目需要一些个性化配置,比如:
- 给予每个路由独立的标题;
- 管理后台的路由,部分页面需要限制一些访问权限;
- 通过路由来自动生成侧边栏、面包屑;
- 部分路由的生命周期需要做缓存( Keep Alive );
- 其他更多业务场景…
无需维护很多套配置,在定义路由树的时候可以配置 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 |
业务场景
路由重定向可以避免用户访问到一些无效路由页面:
- 比如项目上线了一段时间后,有个路由需要改名,或者调整路径层级,可以把旧路由重定向到新的,避免原来的用户从收藏夹等地方进来后找不到
- 一些容易打错的地址,比如通常个人资料页都是用
/profile,但是业务网站是使用/account,那也可以把/profile重定向到/account去 - 对于一些有会员体系的站点,可以根据用户权限进行重定向,分别指向他们具备访问权限的页面
- 官网首页在 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 模板。
注意
新版的路由不再支持直接配置通配符*,而是必须使用带有自定义正则表达式的参数进行定义,详见官网说明。
导航守卫
导航守卫的本质是专属的钩子函数,钩子函数的应用场景如下所述。
钩子的应用场景
对于导航守卫还不熟悉的开发者,可以从一些实际使用场景来加强印象,比如:
- 前面说的,在渲染的时候配置浏览器标题,由于 Vue 项目只有一个 HTML 文件,所以默认只有一个标题,但想在访问
/home的时候标题显示为 “首页”,访问/about的时候标题显示为 “关于” 。 - 部分页面需要管理员才能访问,普通用户不允许进入到该路由页面。
- 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、 name、meta 这些字段同级)。
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 ,可以获取 to 和 from 来判断路由变化情况。
侦听路由的某个数据
如果只想侦听路由的某个数据变化,比如侦听一个 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
npm install connect-history-api-fallback
- 在服务启动入口文件里导入该中间件并激活
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 ),然后把需要设置为全局可用的变量或方法,挂载到 app 的 config.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包的说明与发布
这部分内容并不需要专业知识,在此就不作赘述。