- Vue3 + TS 入门
- 前言
- 起步准备
- 了解构建工具
- 本章结语
- 升级与配置
- 单组件的编写
- 路由的使用
- 插件的使用
- 组件之间的通信
- 全局状态的管理
- 高效开发
Vue3 + TS 入门
前言
这是一个关于 Vue 3.0 + TypeScript 的起步学习教程,适合完全的 Vue 新手和 Vue 2.0 的老手,在官方文档的基础上融入自己的一些实践经验。
起步准备
这一章是为刚刚迈入前端工程化、或者还没有接触过前端工程化的同学准备的。
如果你刚从传统的用 HTML + CSS + JS 手写页面的认知阶段走过来,这一章的内容对你下阶段的学习应该很有用。
了解前端工程化
现在前端的工作中,实际业务里的前端开发,和你刚接触时的前端开发已经完全不同了。
刚接触前端的时候,做一个页面,是先创建 HTML 页面文件写页面结构,再在里面写 CSS 代码美化页面,再根据需要写一些 JavaScript 代码增加交互功能,需要几个页面就创建几个页面,相信大家的前端起步都是从这个模式开始的。
而实际上的前端开发工作,早已进入了前端工程化开发的时代,已经充满了各种现代化框架、预处理器、代码编译…
最终的产物也不再单纯是多个 HTML 页面,经常能看到 SPA / SSR / SSG 等词汇的身影。
名词 | 全称 | 中文 |
---|---|---|
SPA | Single Page Application | 单页面应用 |
SSR | Server-Side Rendering | 服务端渲染 |
SSG | Static Site Generator | 静态站点生成器 |
传统开发的弊端
在了解什么是前端工程化之前,我们先回顾一下传统开发存在的弊端,这样更能知道我们为什么需要它。
在传统的前端开发模式下,前端工程师是在 HTML 文件里直接编写代码,所需要的 JavaScript 代码是通过 script
标签以内联或者文件引用的形式放到 HTML 代码里的。
例如这样:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- 引入 JS 文件 -->
<script src="./js/lib-1.js"></script>
<script src="./js/lib-2.js"></script>
<!-- 引入 JS 文件 -->
</body>
</html>
如演示代码,虽然可以把代码分成多个文件来维护,这样可以有效降低代码维护成本,但在实际开发过程中,还是会存在代码运行时的一些问题。
一个案例
我们继续用上面的演示代码,来看一个最简单的一个例子。
先在 lib-1.js
文件里,我们声明一个变量:
var foo = 1
然后在 lib-2.js
文件里,我们也声明一个变量(没错,也是 foo
):
var foo = 2
然后在 HTML 代码里追加一个 script
,打印这个值:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- 引入 JS 文件 -->
<script src="./js/lib-1.js"></script>
<script src="./js/lib-2.js"></script>
<!-- 引入 JS 文件 -->
<!-- 假设这里是实际的业务代码 -->
<script>
console.log(foo)
</script>
<!-- 假设这里是实际的业务代码 -->
</body>
</html>
你猜会输出什么? —— 答案是 2
。
如果你不知道 lib-2.js
里也声明了一个 foo
变量,如果在后面的代码里预期了 foo + 2 === 3
,那么这样就得不到你想要的结果(因为 lib-1.js
里的 foo
是 1
, 1 + 2
等于 3
) 。
原因是 JavaScript 的加载顺序是从上到下,当使用 var
声明变量时,如果命名有重复,那么后加载的变量会覆盖掉先加载的变量。
这是使用 var
声明的情况,它允许使用相同的名称来重复声明,那么换成 let
或者 const
呢?
虽然不会出现重复声明的情况,但你会收获一段报错:
Uncaught SyntaxError: Identifier 'foo' has already been declared (at lib-2.js:1:1)
你的程序这次直接崩溃了,因为 let
和 const
无法重复声明,从而抛出这个错误,程序依然无法正确运行。
更多问题
以上只是一个最简单的案例,就暴露出了传统开发很大的弊端,然而并不止于此,实际上,存在了诸如以下这些的问题:
- 如本案例,可能存在同名的变量声明,引起变量冲突
- 引入多个资源文件时,比如有多个 JS 文件,在其中一个 JS 文件里面使用了在别处声明的变量,无法快速找到是在哪里声明的,大型项目难以维护
- 类似第 1 、 2 点提到的问题无法轻松预先感知,很依赖开发人员人工定位原因
- 大部分代码缺乏分割,比如一个工具函数库,很多时候需要整包引入到 HTML 里,文件很大,然而实际上只需要用到其中一两个方法
- 由第 4 点大文件延伸出的问题,
script
的加载从上到下,容易阻塞页面渲染 - 不同页面的资源引用都需要手动管理,容易造成依赖混乱,难以维护
- 如果你要压缩 CSS 、混淆 JS 代码,也是要人力操作使用工具去一个个处理后替换,容易出错
当然,实际上还会有更多的问题会遇到。
工程化带来的优势
为了解决传统开发的弊端,前端也开始引入工程化开发的概念,借助工具来解决人工层面的繁琐事情。
- 引入了模块化和包的概念,作用域隔离,解决了代码冲突的问题
- 按需导出和导入机制,让编码过程更容易定位问题
- 自动化的代码检测流程,有问题的代码在开发过程中就可以被发现
- 编译打包机制可以让你使用开发效率更高的编码方式,比如 Vue 组件、 CSS 的各种预处理器
- 引入了代码兼容处理的方案( e.g. Babel ),可以让你自由使用更先进的 JavaScript 语句,而无需顾忌浏览器兼容性,因为最终会帮你转换为浏览器兼容的实现版本
- 引入了 Tree Shaking 机制,清理没有用到的代码,减少项目构建后的体积
还有非常多的体验提升!列举不完!而对应的工具,根据用途也会有非常多的选择。
如何实践工程化
基于 Vue 3 的项目,最主流的工程化组合拳有以下两种:
常用方案 | Runtime | 构建工具 | 框架 |
---|---|---|---|
方案一 | Node | Webpack | Vue |
方案二 | Node | Vite | Vue |
当你技术成熟的时候,还可以选择更喜欢的方案自行组合,例如用 Deno 来代替 Node ,但前期我们还是按照主流的方案来进入工程化的学习。
下面的内容我们将根据 Vue 3 的工程化开发,逐一讲解涉及到常用的工具,了解它们的用途和用法。
命令行工具
命令行界面( Command-line Interface ,缩写 CLI ),是一种通过命令行来实现人机交互的工具。
在工程化开发过程中,前端开发已离不开各种命令行操作,所以请先提前准备好命令行工具。
如果你有所留意,会发现很多工具都可以实现命令行操作,比如:命令行界面( CLI )、终端( Terminal )、 Shell 、控制台( Console )等等。
从完整功能看,它们之间确实有许多区别,不过对于前端开发者来说,日常的命令行交互需要用到的功能不会特别多,所以后面我们会统一一些名词,减少理解上的偏差。
交互行为 | 统一代替名词 | 代替名词解释 |
---|---|---|
输入 | 命令行 | 需要输入命令的时候,会统一用 ”命令行“ 来指代。 |
输出 | 控制台 | 鉴于前端开发者更多接触的是浏览器的 Console 控制台, 所以也是会用 ”控制台“ 来指代。 |
Windows
在 Windows 平台,你可以使用自带的 CMD 或者 Windows PowerShell 工具。
但为了更好的开发体验,推荐使用以下工具(需要下载安装),可以根据自己的喜好选择其一:
名称 | 简介 | 下载 |
---|---|---|
Windows Terminal | 由微软推出的强大且高效的 Windows 终端 | 前往 GitHub 下载open in new window |
CMDer | 一款体验非常好的 Windows 控制台模拟器 | 前往 GitHub 下载open in new window |
我现在在我的 Windows 台式机上是使用 Windows Terminal 比较多,在此之前是用 CMDer ,两者的设计和体验都非常优秀,当然,还有颜值。
了解 Node.js
只要你在近几年有接触过前端开发,哪怕你没有实际使用过,也应该有听说过 Node.js ,那么它是一个什么样的存在?
什么是 Node.js
Node.js (简称 Node ) 是一个基于 Chrome V8 引擎构建的 JS 运行时( JavaScript Runtime )。
它让 JavaScript 代码不再局限于网页上,还可以跑在客户端、服务端等场景,极大的推动了前端开发的发展,现代的前端开发几乎都离不开 Node 。
什么是 Runtime
Runtime ,可以叫它 “运行时” 或者 “运行时环境” ,这个概念是指,你的代码在哪里运行,哪里就是运行时。
传统的 JavaScript 只能跑在浏览器上,每个浏览器都为 JS 提供了一个运行时环境,你可以简单的把浏览器当成一个 Runtime ,明白了这一点,相信你就能明白什么是 Node 。
Node 就是一个让 JS 可以脱离浏览器运行的环境,当然,这里并不是说 Node 就是浏览器。
Node 和浏览器的区别
虽然 Node 也是基于 Chrome V8 引擎构建,但它并不是一个浏览器,它提供了一个完全不一样的运行时环境,没有 Window 、没有 Document 、没有 DOM 、没有 Web API ,没有 UI 界面…
但它提供了很多浏览器做不到的能力,比如和操作系统的交互,例如 “文件读写” 这样的操作在浏览器有诸多的限制,而在 Node 则轻轻松松。
对于前端开发者来说, Node 的巨大优势在于,使用一种语言就可以编写所有东西(前端和后端),不再花费很多精力去学习各种各样的开发语言。
哪怕你仅仅只做 Web 开发,也不再需要顾虑新的语言特性在浏览器上的兼容性( e.g. ES6 、 ES7 、 ES8 、 ES9 …), Node 配合构建工具,以及诸如 Babel 这样的代码编译器,可以帮你转换为浏览器兼容性最高的 ES5 。
当然还有很多工程化方面的好处,总之一句话,使用 Node ,你的开发体验会非常好。
下载和安装 Node
在 Node.js 官网提供了安装包的下载,不论你是使用 Windows 系统还是 MacOS 系统, Node 都提供了对应的安装包,直接下载安装包并运行即可安装到你的电脑里,就可以用来开发你的项目了。
点击访问:Node.js 官网下载open in new window
安装后,打开你的 命令行工具 ,输入以下命令即可查看是否安装成功:
node -v
如果已成功安装,会在控制台输出当前的 Node 版本号。
版本之间的区别
你可以看到官网标注了 LTS 和 Current 两个系列,并且对应了不同的版本号。
LTS ,全称 Long Time Support ,长期维护版本,这个系列代表着稳定,建议首次下载以及后续的每次升级都选择 LTS 版本,减少开发过程中的未知问题出现,大版本号都是偶数( e.g. v16.x.x )。
Current 是最新发布版本,或者叫 “尝鲜版” ,你可以在这个系列体验到最新的功能,但也可能会有一些意想不到的问题和兼容性要处理,大版本号都是奇数( e.g. v17.x.x )。
不论是 LTS 还是 Current ,每个系列下面都还有不同的大版本和小版本,是不是每次都必须及时更新到最新版呢?
当然不是,你完全可以依照你的项目技术栈依赖的最低 Node 版本去决定是否需要升级,不过如果条件允许,还是建议至少要把大版本升级到最新的 LTS 版本。
了解 Node 项目
在安装和配置完 Node.js 之后,我们接下来来了解 Node 项目的一些基础组成,这有助于我们开启前端工程化开发大门。
初始化一个项目
如果想让一个项目成为 Node 项目,只需要在命令行 cd
到项目所在的目录,执行初始化命令:
npm init
之后命令行会输出一些提示,以及一些问题,可以根据你的实际情况填写项目信息,例如:
package name: (demo) node-demo
以上面这个问题为例:
冒号左边的 package name
是问题的题干,会询问你要输入什么内容。
冒号右边的括号内容 (demo)
是 Node 为你推荐的答案(不一定会出现这个推荐值),如果你觉得 OK ,可以直接按回车确认,进入下一道题。
冒号右边的 node-demo
是你输入的答案(如果你选择了推荐的答案,则这里为空),这个答案会写入到项目信息文件里。
当你回答完所有问题之后,会把你填写的信息输出到控制台,确认无误后,回车完成初始化的工作。
{
"name": "node-demo",
"version": "1.0.0",
"description": "A demo about Node.js.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "chengpeiquan",
"license": "MIT"
}
Is this OK? (yes)
如果你觉得问题太多,太繁琐了,可以直接加上 -y
参数,这样会以 Node 推荐的答案帮你快速生成项目信息。
npm init -y
了解 package.json
在完成 项目的初始化 之后,你会发现在项目的根目录下出现了一个名为 package.json
的 JSON 文件。
这是 Node 项目的清单,里面记录了这个项目的基础信息、依赖信息、开发过程的脚本行为、发布相关的信息等等,未来你将在很多项目里看到它的身影。
如果你是按照上面初始化一节的操作得到的这个文件,打开它之后,你会发现里面存储了你在初始化过程中,根据问题确认下来的那些答案,例如:
{
"name": "node-demo",
"version": "1.0.0",
"description": "A demo about Node.js.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "chengpeiquan",
"license": "MIT"
}
package.json 的字段并非全部必填,唯一的要求就是,必须是一个 JSON 文件,所以你也可以仅仅写入以下内容:
{}
但在实际的项目中,往往需要填写更完善的项目信息,除了手动维护这些信息之外,你在安装 npm 包等操作时, Node 也会帮你写入数据到这个文件里,我们来了解一些常用字段的含义:
字段名 | 含义 |
---|---|
name | 项目名称,如果你打算发布成 npm 包,它将作为包的名称 |
version | 项目版本号,如果你打算发布成 npm 包,这个字段是必须的,遵循 语义化版本号 的要求 |
description | 项目的描述 |
keywords | 关键词,用于在 npm 网站上进行搜索 |
homepage | 项目的官网 URL |
main | 项目的入口文件 |
scripts | 指定运行脚本的命令缩写,常见的如 npm run build 等命令就在这里配置,详见 脚本命令的配置 |
author | 作者信息 |
license | 许可证信息,可以选择适当的许可证进行开源 |
dependencies | 记录当前项目的生产依赖,安装 npm 包时会自动生成,详见:了解包和插件 |
devDependencies | 记录当前项目的开发依赖,安装 npm 包时会自动生成,详见:了解包和插件 |
type | 配置 Node 对 CJS 和 ESM 的支持 |
其中最后的 type 字段是涉及到模块规范的支持,它有两个可选值: commonjs
和 module
,其默认值为 commonjs
。
- 当不设置或者设置为
commonjs
时,扩展名为.js
和.cjs
的文件都是 CommonJS 规范的模块,如果要使用 ES Module 规范,需要使用.mjs
扩展名 - 当不设置或者设置为
module
时,扩展名为.js
和.mjs
的文件都是 ES Module 规范的模块,如果要使用 CommonJS 规范,需要使用.cjs
扩展名
关于模块规范可以在 了解模块化设计 一节了解更多。
关于 package.json 的完整的选项可以在 npm Docsopen in new window 上查阅。
如果你打算发布成 npm 包,它将作为包的名称,可以是普通包名,也可以是范围包的包名。
类型 | 释义 | 例子 |
---|---|---|
范围包 | 具备 @scope/project-name 格式,一般有一系列相关的开发依赖之间会以相同的 scope 进行命名 |
如 @vue/cli 、 @vue/cli-service 就是一系列相关的范围包 |
普通包 | 其他命名都属于普通包 | 如 vue 、 vue-router |
包名有一定的书写规则:
- 名称必须保持在 1 ~ 214 个字符之间(包括范围包的
@scope/
部分) - 只允许使用小写字母、下划线、短横线、数字、小数点(并且只有范围包可以以点或下划线开头)
- 包名最终成为 URL 、命令行参数或者文件夹名称的一部分,所以名称不能包含任何非 URL 安全字符
TIP
了解这一点有助于你在后续工作中,在需要查找技术栈相关包的时候,可以知道如何在 npmjs 上找到它们。
如果你打算发布 npm 包,可以通过 npm view <package-name>
命令查询包名是否已存在,如果存在就会返回该包的相关信息。
比如我们查询 vue
这个包名,会返回它的版本号、许可证、描述等信息:
npm view vue
vue@3.2.33 | MIT | deps: 5 | versions: 372
The progressive JavaScript framework for building modern web UI.
https://github.com/vuejs/core/tree/main/packages/vue#readme
keywords: vue
# 后面太多信息这里就省略...
如果查询一个不存在的包名,则会返回 404 信息:
npm view vue123456
npm ERR! code E404
npm ERR! 404 Not Found - GET https://registry.npmjs.org/vue123456 - Not found
npm ERR! 404
npm ERR! 404 'vue123456@latest' is not in this registry.
npm ERR! 404 You should bug the author to publish it (or use the name yourself!)
npm ERR! 404
npm ERR! 404 Note that you can also install from a
npm ERR! 404 tarball, folder, http url, or git url.
# 后面太多信息这里就省略...
语义化版本号管理
Node 项目遵循 语义化版本号open in new window 的规则,例如 1.0.0
、 1.0.1
、 1.1.0
这样的版本号,本教材的主角 Vue 也是遵循了语义化版本号的发布规则。
建议开发者在入门前端工程化的时候就应该熟悉这套规则,后续的项目开发中,你会使用到很多外部依赖,它们也是使用版本号控制来管理代码的发布,每个版本之间可能会有一些兼容性问题,如果不了解版本号的通用规则,很容易在你的开发中带来困扰。
TIP
现在有很多 CI/CD 流水线作业具备了根据 Git 的 Commit 记录来自动升级版本号,它们也是遵循了语义化版本号规则,版本号的语义化在前端工程里有重大的意义。
基本格式与升级规则
版本号的格式为: Major.Minor.Patch
(简称 X.Y.Z
),它们的含义和升级规则如下:
英文 | 中文 | 含义 |
---|---|---|
Major | 主版本号 | 当项目作了大量的变更,与旧版本存在一定的不兼容问题 |
Minor | 次版本号 | 做了向下兼容的功能改动或者少量功能更新 |
Patch | 修订号 | 修复上一个版本的少量 BUG |
一般情况下,三者均为正整数,并且从 0
开始,遵循这三条注意事项:
- 当主版本号升级时,次版本号和修订号归零
- 当次版本号升级时,修订号归零,主版本号保持不变
- 当修订号升级时,主版本号和次版本号保持不变
下面以一些常见的例子帮助你快速理解版本号的升级规则:
- 如果不打算发布,可以默认为
0.0.0
,代表它并不是一个进入发布状态的包 - 在正式发布之前,你可以将其设置为
0.1.0
发布第一个测试版本,自此,代表已进入发布状态,但还处于初期开发阶段,这个阶段你可能经常改变 API ,但不需要频繁的更新主版本号 - 在
0.1.0
发布后,修复了 BUG ,下一个版本号将设置为0.1.1
,即更新了一个修订号 - 在
0.1.1
发布后,有新的功能发布,下一个版本号可以升级为0.2.0
,即更新了一个次版本号 - 当你觉得这个项目已经功能稳定、没有什么 BUG 了,决定正式发布并给用户使用时,那么就可以进入了
1.0.0
正式版了
版本标识符
以上是一些常规的版本号升级规则,你也可以通过添加 “标识符” 来修饰你的版本更新:
格式为: Major.Minor.Patch-Identifier.1
,其中的 Identifier
代表 “标识符” ,它和版本号之间使用 -
短横线来连接,后面的 .1
代表当前标识符的第几个版本,每发布一次,这个数字 +1 。
标识符 | 含义 |
---|---|
alpha | 内部版本,代表当前可能有很大的变动 |
beta | 测试版本,代表版本已开始稳定,但可能会有比较多的问题需要测试和修复 |
rc | 即将作为正式版本发布,只需做最后的验证即可发布正式版 |
脚本命令的配置
在工作中,你会频繁接触到 npm run dev
启动开发环境、 npm run build
构建打包等操作,这些操作其实是对命令行的一种别名。
它在 package.json 里是存放于 scripts
字段,以 [key: string]: string
为格式的键值对存放数据( key: value
)。
{
"scripts": {
// ...
}
}
其中:
key
是命令的缩写,也就是npm run xxx
里的xxx
,如果一个单词不足以表达,可以用冒号:
拼接多个单词,例如mock:list
、mock:detail
等等value
是完整的执行命令内容,多个命令操作用&&
连接,例如git add . && git commit
以 Vue CLI 创建的项目为例,它的项目 package.json 文件里就会包括了这样的命令:
{
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
}
}
这里的名字是可以自定义的,比如你可以把 serve
改成你更喜欢的 dev
:
{
"scripts": {
"dev": "vue-cli-service serve",
"build": "vue-cli-service build"
}
}
这样运行 npm run dev
也可以相当于运行了 vue-cli-service serve
。
据我所了解,有不少开发者曾经对不同的 Vue CLI 版本提供的 npm run serve
和 npm run dev
有什么区别有过疑问,看到这里应该都明白了吧,可以说没有区别,因为这取决于它对应的命令,而不是取决于它起什么名称。
TIP
如果 value
部分包含了双引号 "
,必须使用转义符 \
来避免格式问题,例如: \"
。
可以阅读 npm 关于 scripts 的 完整文档open in new window 了解更多用法。
Hello Node
看到这里,对于 Node 项目的基本创建流程和关键信息都有所了解了吧!我们来写一个 demo ,实际体验一下如何从初始化项目到打印一个 Hello World
到控制台的过程。
请先启动你的命令行工具,然后创建一个项目文件夹,这里使用 mkdir
命令:
# 语法是 mkdir <dir-name>
mkdir node-demo
使用 cd
命令进入刚刚创建好的项目目录:
# 语法是 cd <dir-path>
cd node-demo
执行项目初始化,可以回答问题,也可以添加 -y
参数来使用默认配置:
npm init -y
来到这里我们就得到了一个具有 package.json 的 Node 项目了。
在项目下创建一个 index.js
的 JS 文件,可以像平时一样书写 JavaScript ,我们输入以下内容并保存:
console.log('Hello World')
然后打开 package.json 文件,修改 scripts 部分如下,也就是配置了一个 "dev": "node index"
命令:
{
"name": "demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "node index"
},
"keywords": [],
"author": "",
"license": "ISC"
}
在命令行执行 npm run dev
,可以看到控制台打印出了 Hello World
:
npm run dev
> demo@1.0.0 dev
> node index
Hello World
这等价于我们直接在命令行执行 node index.js
命令,其中 node
是 Node.js 运行文件的命令, index
是文件名,相当于 index.js
,因为 JS 文件名后缀可以省略。
了解模块化设计
在了解 Node 项目之后,就要开始编码加强你对 Node.js 的熟悉程度了,但在开始使用之前,你还需要了解一些概念。
在未来的日子里(不限于本教程,与你在前端工程化相关的工作内容息息相关),你会频繁的接触到两个词:模块( Module )和包( Package )。
模块和包是 Node 开发最重要的组成部分,不管你是全部自己实现一个项目,还是会依赖各种第三方轮子来协助你的开发,项目的构成都离不开这两者。
解决了什么问题
在软件工程的设计原则里,有一个原则叫 “单一职责” 。
假设一个代码块负责了多个职责的功能支持,在后续的迭代过程中,维护成本会极大的增加,虽然只需要修改这个代码块,但需要兼顾职责 1 、职责 2 、职责 3 … 等多个职责的兼容性,稍不注意就会引起工程运行的崩溃。
“单一职责” 的目的就是减少功能维护带来的风险,把代码块的职责单一化,让代码的可维护性更高。
一个完整业务的内部实现,不应该把各种代码都耦合在一起,而应该按照职责去划分好代码块,再进行组合,形成一个 “高内聚,低耦合” 的工程设计。
模块化就是由此而来,在前端工程里,每个单一职责的代码块,就叫做模块( Module ) ,模块有自己的作用域,功能与业务解耦,非常方便复用和移植。
TIP
模块化还可以解决我们本章开头所讲述的 传统开发的弊端 里提到的大部分问题,随着下面内容一步步深入,你将一步步的理解它。
如何实现模块化
在前端工程的发展过程中,不同时期诞生了很多不同的模块化机制,最为主流的有以下几种:
模块化方案 | 全称 | 适用范围 |
---|---|---|
CJS | CommonJS | Node 端 |
AMD | Async Module Definition | 浏览器 |
CMD | Common Module Definition | 浏览器 |
UMD | Universal Module Definition | Node 端和浏览器 |
ESM | ES Module | Node 端和浏览器 |
其中 AMD 、CMD 、 UMD 都已经属于有点过去式的模块化方案了,在新的业务里,结合各种编译工具,可以直接用最新的 ESM 方案来实现模块化,所以可以在后续有接触的时候再了解。
ESM ( ES Module ) 是 JavaScript 在 ES6( ECMAScript 2015 )版本推出的模块化标准,旨在成为浏览器和服务端通用的模块解决方案。
CJS ( CommonJS ) 原本是服务端的模块化标准(设计之初也叫 ServerJS ),是为 JavaScript 设计的用于浏览器之外的一个模块化方案, Node 默认支持了该规范,在 Node 12 之前也只支持 CJS ,但从 Node 12 开始,已经同时支持 ES Module 的使用。
至此,不论是 Node 端还是浏览器端, ES Module 是统一的模块化标准了!
但由于历史原因, CJS 在 Node 端依然是非常主流的模块化写法,所以还是值得进行了解,因此下面的内容将主要介绍 CJS 和 ESM 这两种模块化规范是如何实际运用。
TIP
在开始体验模块化的编写之前,你需要先在你的电脑里 安装好 Node.js ,然后打开 命令行工具 ,通过 cd
命令进入你平时管理项目的目录路径,然后 初始化一个 Node 项目 。
另外,在 CJS 和 ESM ,一个独立的文件就是一个模块,该文件内部的变量必须通过导出才能被外部访问到,而外部文件想访问这些变量,需要导入对应的模块才能生效。
用 CommonJS 设计模块
虽然现在推荐使用 ES Module 作为模块化标准,但是日后你在工作的过程中,还是不免会遇到要维护一些老项目,因此了解 CommonJS 还是非常有必要的。
以下简称 CJS 代指 CommonJS 规范。
准备工作
延续我们在 Hello Node 部分创建的 Node.js demo 项目,先调整一下目录结构:
- 删掉
index.js
文件 - 创建一个
src
文件夹,在里面再创建一个cjs
文件夹 - 在
cjs
文件夹里面创建两个文件:index.cjs
和module.cjs
TIP
注意这里我使用了 .cjs
文件扩展名,其实它也是 JS 文件,但这个扩展名是 Node 专门为 CommonJS 规范设计的,可以在 了解 package.json 部分的内容了解更多。
此时目录结构应该如下:
node-demo
│ # 源码文件夹
├─src
│ │ # 业务文件夹
│ └─cjs
│ │ # 入口文件
│ ├─index.cjs
│ │ # 模块文件
│ └─module.cjs
│ # 项目清单
└─package.json
这是一个常见的 Node 项目目录结构,通常我们的源代码都会放在 src
文件夹里面统一管理。
然后我们再修改一下 package.json 里面的 scripts 部分,改成如下:
{
"scripts": {
"dev:cjs": "node src/cjs/index.cjs"
}
}
后面我们在命令行执行 npm run dev:cjs
就可以测试我们的 CJS 模块了。
基本语法
CJS 使用 module.exports
语法导出模块,可以导出任意合法的 JavaScript 类型,例如:字符串、布尔值、对象、数组、函数等等。
使用 require
导入模块,在导入的时候,当文件扩展名是 .js
时,可以只写文件名,而此时我们使用的是 .cjs
扩展名,所以需要完整的书写。
默认导出和导入
默认导出的意思是,一个模块只包含一个值;而导入默认值则意味着,导入时声明的变量名就是对应模块的值。
我们在 src/cjs/module.cjs
文件里,写入以下代码,导出一句 Hello World
信息:
// src/cjs/module.cjs
module.exports = 'Hello World'
TIP
自己在写入代码的时候,不需要包含文件路径那句注释,这句注释只是为了方便阅读时能够区分代码属于哪个文件,以下代码均如此。
在 src/cjs/index.cjs
文件里,写入以下代码,导入我们刚刚编写的模块。
// src/cjs/index.cjs
const m = require('./module.cjs')
console.log(m)
在命令行输入 npm run dev:cjs
,可以看到成功输出了 Hello World
信息:
npm run dev:cjs
> demo@1.0.0 dev:cjs
> node src/cjs/index.cjs
Hello World
可以看到,在导入模块时,声明的 m
变量拿到的值,就是整个模块的内容,可以直接使用,此例子中它是一个字符串。
我们再改动一下,把 src/cjs/module.cjs
改成如下,这次我们导出一个函数:
// src/cjs/module.cjs
module.exports = function foo() {
console.log('Hello World')
}
相应的,这次变成了导入一个函数,所以我们可以执行它:
// src/cjs/index.cjs
const m = require('./module.cjs')
m()
得到的结果也是打印一句 Hello World
,不同的是,这一次的打印行为是在模块里定义的,入口文件只是执行模块里的函数。
npm run dev:cjs
> demo@1.0.0 dev:cjs
> node src/cjs/index.cjs
Hello World
命名导出和导入
默认导出的时候,一个模块只包含一个值,有时候你想把很多相同分类的函数进行模块化集中管理,例如想做一些 utils 类的工具函数文件、或者是维护项目的配置文件,全部使用默认导出的话,你会有非常多的文件要维护。
那么就可以用到命名导出,这样既可以导出多个数据,又可以统一在一个文件里维护管理,命名导出是先声明多个变量,然后通过 {}
对象的形式导出。
我们再来修改一下 src/cjs/module.cjs
文件,这次我们改成如下:
// src/cjs/module.cjs
function foo() {
console.log('Hello World from foo.')
}
const bar = 'Hello World from bar.'
module.exports = {
foo,
bar,
}
这个时候你通过原来的方式去拿模块的值,会发现无法直接获取到函数体或者字符串的值,因为打印出来的也是一个对象。
// src/cjs/index.cjs
const m = require('./module.cjs')
console.log(m)
控制台输出:
npm run dev:cjs
> demo@1.0.0 dev:cjs
> node src/cjs/index.cjs
{ foo: [Function: foo], bar: 'Hello World from bar.' }
需要通过 m.foo()
、 m.bar
的形式才可以拿到值。
此时你可以用一种更方便的方式,利用 ES6 的对象解构来直接拿到变量:
// src/cjs/index.cjs
const { foo, bar } = require('./module.cjs')
foo()
console.log(bar)
这样子才可以直接调用变量拿到对应的值。
导入时重命名
以上都是基于非常理想的情况下使用模块,有时候不同的模块之间也会存在相同命名导出的情况,我们来看看模块化是如何解决这个问题的。
我们的模块文件保持不变,依然导出这两个变量:
// src/cjs/module.cjs
function foo() {
console.log('Hello World from foo.')
}
const bar = 'Hello World from bar.'
module.exports = {
foo,
bar,
}
这次在入口文件里也声明一个 foo
变量,我们在导入的时候对模块里的 foo
进行了重命名操作。
// src/cjs/index.cjs
const {
foo: foo2, // 这里进行了重命名
bar,
} = require('./module.cjs')
// 就不会造成变量冲突
const foo = 1
console.log(foo)
// 用新的命名来调用模块里的方法
foo2()
// 这个不冲突就可以不必处理
console.log(bar)
再次运行 npm run dev:cjs
,可以看到打印出来的结果完全符合预期:
npm run dev:cjs
> demo@1.0.0 dev:cjs
> node src/cjs/index.cjs
1
Hello World from foo.
Hello World from bar.
这是利用了 ES6 解构对象的 给新的变量名赋值open in new window 技巧。
以上是针对命名导出时的重命名方案,如果是默认导出,那么在导入的时候用一个不冲突的变量名来声明就可以了。
用 ES Module 设计模块
ES Module 是新一代的模块化标准,它是在 ES6( ECMAScript 2015 )版本推出的,是原生 JavaScript 的一部分。
不过因为历史原因,如果你要直接在浏览器里使用该方案,在不同的浏览器里会有一定的兼容问题,一般都需要借助构建工具来开发,工具会帮你抹平这些差异。
很多新推出的构建工具都默认只支持该方案( e.g. Vite 、 Rollup ),要兼容 CJS 反而需要自己引入插件单独配置。
后面我们会全程使用 TypeScript 来写 Vue3 ,也是需要使用 ES Module ,因此了解它对你非常重要。
以下简称 ESM 代指 ES Module 规范。
TIP
在阅读本小节之前,建议先阅读 用 CommonJS 设计模块 以了解前置内容,本小节会在适当的内容前后与 CJS 的写法进行对比。
准备工作
继续使用我们在 用 CommonJS 设计模块 时使用的 Hello Node 项目作为 demo ,当然你也可以重新创建一个新的。
一样的,先调整一下目录结构:
- 在
src
文件夹里面创建一个esm
文件夹 - 在
esm
文件夹里面创建两个 MJS 文件:index.mjs
和module.mjs
TIP
注意这里我使用了 .mjs
文件扩展名,因为默认情况下, Node 需要使用该扩展名才会支持 ES Module 规范。
你也可以在 package.json 里增加一个 "type": "module"
的字段来使 .js
文件支持 ESM ,但对应的,原来使用 CommonJS 规范的文件需要从 .js
扩展名改为 .cjs
才可以继续使用 CJS 。
为了减少理解上的门槛,这里选择了使用 .mjs
新扩展名便于入门,可以在 了解 package.json 部分的内容了解更多。
此时目录结构应该如下:
node-demo
│ # 源码文件夹
├─src
│ │ # 上次用来测试 CommonJS 的相关文件
│ ├─cjs
│ │ ├─index.cjs
│ │ └─module.cjs
│ │
│ │ # 这次要用的 ES Module 测试文件
│ └─esm
│ │ # 入口文件
│ ├─index.mjs
│ │ # 模块文件
│ └─module.mjs
│
│ # 项目清单
└─package.jso
同样的,源代码放在 src
文件夹里面管理。
然后我们再修改一下 package.json 里面的 scripts 部分,参照上次配置 CSJ 的格式,增加一个 ESM 版本的 script ,改成如下:
{
"scripts": {
"dev:cjs": "node src/cjs/index.cjs",
"dev:esm": "node src/esm/index.mjs"
}
}
后面我们在命令行执行 npm run dev:esm
就可以测试我们的 ESM 模块了。
TIP
注意, script 里的 .mjs
扩展名不能省略。
另外,在实际项目中,你可能不需要做这些处理,因为很多工作脚手架已经帮你处理过了,比如我们的 Vue3 项目。
基本语法
ESM 使用 export default
(默认导出)和 export
(命名导出)这两个语法导出模块,和 CJS 一样, ESM 也可以导出任意合法的 JavaScript 类型,例如:字符串、布尔值、对象、数组、函数等等。
使用 import ... from ...
导入模块,在导入的时候,如果文件扩展名是 .js
则可以省略文件名后缀,否则需要把扩展名也完整写出来。
默认导出和导入
ESM 的默认导出也是一个模块只包含一个值,导入时声明的变量名,它对应的数据就是对应模块的值。
我们在 src/esm/module.mjs
文件里,写入以下代码,导出一句 Hello World
信息:
// src/esm/module.mjs
export default 'Hello World'
在 src/esm/index.mjs
文件里,写入以下代码,导入我们刚刚编写的模块。
// src/esm/index.mjs
import m from './module.mjs'
console.log(m)
在命令行输入 npm run dev:esm
,可以看到成功输出了 Hello World
信息:
npm run dev:esm
> demo@1.0.0 dev:esm
> node src/esm/index.mjs
Hello World
可以看到,在导入模块时,声明的 m
变量拿到的值,就是整个模块的内容,可以直接使用,此例子中它是一个字符串。
像在 CJS 的例子里一样,我们也来再改动一下,把 src/esm/module.mjs
改成导出一个函数:
// src/esm/module.mjs
export default function foo() {
console.log('Hello World')
}
同样的,这次也是变成了导入一个函数,我们可以执行它:
// src/esm/index.mjs
import m from './module.mjs'
m()
一样可以从模块里的函数得到一句 Hello World
的打印信息。
npm run dev:esm
> demo@1.0.0 dev:esm
> node src/esm/index.mjs
Hello World
TIP
可以看到, CJS 和 ESM 的默认导出是非常相似的,在未来如果有老项目需要从 CJS 往 ESM 迁移,大部分情况下你只需要把 module.exports
改成 export default
即可。
命名导出和导入
虽然默认导出的时候, CJS 和 ESM 的写法非常相似,但命名导出却完全不同!
在 CJS ,命名导出后的模块数据默认是一个对象,你可以导入模块后通过 m.foo
这样的方式去调用,或者在导入的时候直接解构:
// CJS 支持导入的时候直接解构
const { foo } = require('./module.cjs')
但 ES Module 不是对象,如果你这样导出,其实也是默认导出:
// 在 ESM ,通过这样导出的数据也是属于默认导出
export default {
foo: 1,
}
无法通过这样导入:
// ESM 无法通过这种方式对默认导出的数据进行 “解构”
import { foo } from './module.mjs'
会报错:
import { foo } from './module.mjs'
^^^
SyntaxError:
The requested module './module.mjs' does not provide an export named 'foo'
正确的方式应该是通过 export
来对数据进行命名导出,我们修改一下 src/esm/module.mjs
文件:
// src/esm/module.mjs
export function foo() {
console.log('Hello World from foo.')
}
export const bar = 'Hello World from bar.'
现在你才可以通过它们的命名进行导入:
// src/esm/index.mjs
import { foo, bar } from './module.mjs'
foo()
console.log(bar)
TIP
切记,和 CJS 不同, ESM 模块不是对象,命名导出之后只能使用花括号 {}
来导入名称。
导入时重命名
接下来我们来看看 ESM 是如何处理相同命名导出的问题,我们的模块文件依然保持不变,还是导出两个变量:
// src/esm/module.mjs
export function foo() {
console.log('Hello World from foo.')
}
export const bar = 'Hello World from bar.'
入口文件里面,也声明一个 foo
变量,然后导入的时候对模块里的 foo
进行重命名操作:
// src/esm/index.mjs
import {
foo as foo2, // 这里进行了重命名
bar
} from './module.mjs'
// 就不会造成变量冲突
const foo = 1
console.log(foo)
// 用新的命名来调用模块里的方法
foo2()
// 这个不冲突就可以不必处理
console.log(bar)
可以看到,在 ESM 的重命名方式和 CJS 是完全不同的,它是使用 as
关键字来操作,语法为 <old-name> as <new-name>
。
现在我们再次运行 npm run dev:esm
,可以看到打印出来的结果也是完全符合预期了:
npm run dev:esm
> demo@1.0.0 dev:esm
> node src/esm/index.mjs
1
Hello World from foo.
Hello World from bar.
以上是针对命名导出时的重命名方案,如果是默认导出,和 CJS 一样,在导入的时候用一个不冲突的变量名来声明就可以了。
了解组件化设计
了解完模块化设计之后,未来在 Vue 的工程化开发过程中,你还会遇到一个新的概念,那就是 “组件” 。
什么是组件化
模块化属于 JavaScript 的概念,但作为一个页面,我们都知道它是由 HTML + CSS + JS 三部分组成的,既然 JS 代码可以按照不同的功能、需求划分成模块,那么页面是否也可以呢?
答案是肯定的!组件化就是由此而来。
在前端工程项目里,页面可以理解为一个积木作品,组件则是用来搭建这个作品的一块又一块积木。
解决了什么问题
模块化属于 JavaScript 的概念,把代码块的职责单一化,一个函数、一个类都可以独立成一个模块。
但这只解决了逻辑部分的问题,一个页面除了逻辑,还有骨架( HTML )和样式( CSS ),组件就是把一些可复用的 HTML 结构和 CSS 样式再做一层抽离,然后再放置到需要展示的位置。
常见的组件有:页头、页脚、导航栏、侧边栏… 甚至小到一个用户头像也可以抽离成组件,因为头像可能只是尺寸、圆角不同而已。
每个组件都有自己的 “作用域” , JavaScript 部分利用 模块化 来实现作用域隔离, HTML 和 CSS 代码则借助 Style Scoped 来生成独有的 hash ,避免全局污染,这些方案组合起来,使得组件与组件之间的代码不会互相影响。
如何实现组件化
在 Vue ,是通过 Single-File Component (简称 SFC , .vue
单组件文件)来实现组件化开发。
一个 Vue 组件是由三部分组成的:
<template>
<!-- HTML 代码 -->
</template>
<script>
// JavaScript 代码
</script>
<style scoped>
/* CSS 代码 */
</style>
在后面的 单组件的编写 一章中,我们会详细介绍如何编写一个 Vue 组件。
了解包和插件
在实际业务中,经常会用到各种各样的插件,插件在 Node 项目里的体现是一个又一个的依赖包。
虽然你也可以把插件的代码文件手动放到你的源码文件夹里引入,但并不是一个最佳的选择,本节内容将带你了解 Node 的依赖包。
什么是包
在 Node 项目里,包可以简单理解为模块的集合,一个包可以只提供一个模块的功能,也可以作为多个模块的集合集中管理。
包通常是发布在官方的包管理平台 npmjs 上面,开发者需要使用的时候,可以通过包管理器安装到项目里,并在你的代码里引入,开箱即用(详见: 依赖包的管理 )。
使用 npm 包可以减少你在项目中重复造轮子,提高项目的开发效率,也可以极大的缩小项目源码的体积(详见:什么是 node_modules)。
包管理平台官网:https://www.npmjs.comopen in new window
什么是 node_modules
node_modules 是 Node 项目下用于存放已安装的依赖包的目录,如果不存在,会自动创建。
如果是本地依赖,会存在于项目根目录下,如果是全局依赖,会存在于环境变量关联的路径下,详见下方的管理依赖部分内容的讲解。
TIP
一般在提交项目代码到 Git 仓库或者你的服务器上时,都需要排除 node_modules 文件夹的提交,因为它非常大。
如果托管在 Git 仓库,可以在 .gitignore 文件里添加 node_modules
作为要排除的文件夹名称。
什么是包管理器
包管理器( Package Manager )是用来管理依赖包的工具,比如:发布、安装、更新、卸载等等。
Node 默认提供了一个包管理器 npm
,在安装 Node.js 的时候,默认会一起安装 npm 包管理器,可以通过以下命令查看它是否正常。
npm -v
如果正常,将会输出相应的版本号。
依赖包的管理
接下来我们会以 npm 作为默认的包管理器,来了解如何在项目里管理依赖包。
配置镜像源
在国内,直接使用 npm 会比较慢,可以通过绑定 npm Mirror 中国镜像站open in new window 的镜像源来提升依赖包的下载速度。
你可以先在命令行输入以下命令查看当前的 npm 配置:
npm config get registry
# https://registry.npmjs.org/
默认情况下,会输出 npm 官方的资源注册表地址,接下来我们在命令行上输入以下命令,进行镜像源的绑定:
npm config set registry https://registry.npmmirror.com
可以再次运行查询命令来查看是否设置成功:
npm config get registry
# https://registry.npmmirror.com/
可以看到已经成功更换为中国镜像站的地址了,之后在安装 npm 包的时候,速度会有很大的提升!
如果需要删除自己配置的镜像源,可以输入以下命令进行移除,移除后会恢复默认设置:
npm config rm registry
TIP
如果你之前已经绑定过 npm.taobao
系列域名,也请记得更换成 npmmirror
这个新的域名!
随着新的域名已经正式启用,老 npm.taobao.org
和 registry.npm.taobao.org
域名在 2022 年 05 月 31 日零时后不再提供服务。
本地安装
项目的依赖建议优先选择本地安装,这是因为本地安装可以把依赖列表记录到 package.json 里,多人协作的时候可以减少很多问题出现,特别是当本地依赖与全局依赖版本号不一致的时候。
生产依赖
执行 npm install
的时候,添加 --save
或者 -S
选项可以将依赖安装到本地,并列为生产依赖。
TIP
需要提前在命令行 cd
到你的项目目录下再执行安装。
另外, --save
或者 -S
选项在实际使用的时候可以省略,因为它是默认选项。
npm install --save <package-name>
可以在项目的 package.json
文件里的 dependencies
字段查看是否已安装成功,例如:
// package.json
{
// 会安装到这里
"dependencies": {
// 以 "包名":"版本号" 的格式写入
"vue-router": "^4.0.14"
},
}
生产依赖包会被安装到项目根目录下的 node_modules
目录里。
项目在上线后仍需用到的包,就需要安装到生产依赖里,比如 Vue 的路由 vue-router
就需要以这个方式安装。
开发依赖
执行 npm install
的时候,如果添加 --save-dev
或者 -D
选项,可以将依赖安装到本地,并写入开发依赖里。
TIP
需要提前在命令行 cd
到你的项目目录下再执行安装。
npm install --save-dev <package-name>
可以在项目的 package.json
文件里的 devDependencies
字段查看是否已安装成功,例如:
// package.json
{
// 会安装到这里
"devDependencies": {
// 以 "包名":"版本号" 的格式写入
"eslint": "^8.6.0"
},
}
开发依赖包也是会被安装到项目根目录下的 node_modules
目录里。
和生产依赖包不同的点在于,只在开发环境生效,构建部署到生产环境时可能会被抛弃,一些只在开发环境下使用的包,就可以安装到开发依赖里,比如检查代码是否正确的 ESLint
就可以用这个方式安装。
全局安装
执行 npm install
的时候,如果添加 --global
或者 -g
选项,可以将依赖安装到全局,它们将被安装在 配置环境变量 里配置的全局资源路径里。
npm install --global <package-name>
TIP
Mac 用户需要使用 sudo
来提权才可以完成全局安装。
另外,可以通过 npm root -g
查看全局包的安装路径。
一般情况下,类似于 @vue/cli
之类的脚手架会提供全局安装的服务,安装后,你就可以使用 vue create xxx
等命令直接创建 Vue 项目了。
但不是每个 npm 包在全局安装后都可以正常使用,请阅读 npm 包的主页介绍和使用说明。
版本控制
有时候一些包的新版本不一定适合你的老项目,因此 npm 也提供了版本控制功能,支持通过指定的版本号或者 Tag 安装。
语法如下,在包名后面紧跟 @
符号,再紧跟版本号或者 Tag 名称。
npm install <package-name>@<version | tag>
例如:
现阶段 Vue 默认为 3.x 的版本了,如果你想安装 Vue 2 ,可以通过指定版本号的方式安装:
npm install vue@2.6.14
或者通过对应的 Tag 安装:
npm install vue@legacy
TIP
版本号或者 Tag 名称可以在 npmjs 网站上的包详情页查询。
版本升级
一般来说,直接重新安装依赖包可以达到更新的目的,但也可以通过 npm update
命令来更新。
语法如下,可以更新全部的包:
npm update
也可以更新指定的包:
npm update <package-name>
npm 会检查是否有满足版本限制的更新版本。
卸载
可以通过 npm uninstall
命令来卸载指定的包,和安装一样,卸载也区分了卸载本地依赖包和卸载全局包,不过只有在卸载全局包的时候才需要添加选项,默认只卸载当前项目下的本地包。
本地卸载:
npm uninstall <package-name>
全局卸载:
npm uninstall --global <package-name>
TIP
Mac 用户需要使用 sudo
来提权才可以完成全局卸载。
如何使用包
在了解了 npm 包的常规操作之后,我们通过一个简单的例子来了解如何在项目里使用 npm 包。
继续使用我们的 Hello Node demo ,或者你也可以重新创建一个 demo 。
首先在 命令行工具 通过 cd
命令进入项目所在的目录,我们用本地安装的方式来把 md5 包open in new window 添加到生产依赖,这是一个为我们提供开箱即用的哈希算法的包,在未来的实际工作中,你可能也会用到它,在这里使用它是因为足够简单,哈哈!
输入以下命令并回车执行:
npm install md5
可以看到控制台提示一共安装了 4 个包,这是因为 md5 这个 npm 包还引用了其他的包作为依赖,需要同时安装才可以正常工作。
# 这是安装 md5 之后控制台的信息返回
added 4 packages, and audited 5 packages in 2s
found 0 vulnerabilities
此时项目目录下会出现一个 node_modules 文件夹和一个 package-lock.json 文件:
node-demo
│ # 依赖文件夹
├─node_modules
│ # 源码文件夹
├─src
│ # 锁定安装依赖的版本号
├─package-lock.json
│ # 项目清单
└─package.json
我们先打开 package.json ,可以看到已经多出了一个 dependencies
字段,这里记录了我们刚刚安装的 md5 包信息。
{
"name": "demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev:cjs": "node src/cjs/index.cjs",
"dev:esm": "node src/esm/index.mjs"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"md5": "^2.3.0"
}
}
来到这里你可能会有一连串的疑问:
- 为什么只安装了一个 md5 ,但控制台提示安装了 4 个包?
- 为什么 package.json 又只记录了 1 个 md5 包信息?
- 为什么提示审核了 5 个包,哪里来的第 5 个包?
不要着急,请先打开 package-lock.json 文件,这个文件是记录了锁定安装依赖的版本号信息(由于篇幅原因,这里的展示省略了一些包的细节):
{
"name": "demo",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "demo",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"md5": "^2.3.0"
}
},
"node_modules/charenc": {
"version": "0.0.2",
// ...
},
"node_modules/crypt": {
"version": "0.0.2",
// ...
},
"node_modules/is-buffer": {
"version": "1.1.6",
// ...
},
"node_modules/md5": {
"version": "2.3.0",
// ...
}
},
"dependencies": {
"charenc": {
"version": "0.0.2",
// ...
},
"crypt": {
"version": "0.0.2",
// ...
},
"is-buffer": {
"version": "1.1.6",
// ...
},
"md5": {
"version": "2.3.0",
// ...
"requires": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
}
}
}
可以看到这个文件的 dependencies
字段除了 md5 之外,还有另外 3 个包信息,它们就是 md5 包所依赖的另外 3 个 npm 包了,这就解答了为什么一共安装了 4 个 npm 包。
在 node_modules 文件夹下你也可以看到以这 4 个包名为命名的文件夹,这些文件夹存放的就是各个包项目发布在 npmjs 平台上的文件。
我们再看 packages
字段,这里除了罗列出 4 个 npm 包的信息之外,还把项目的信息也列了进来,这就是为什么是提示审核了 5 个包,原因是除了 4 个依赖包,你的项目本身也是一个包。
TIP
package-lock.json 文件并不是一成不变的,假如以后 md5 又引用了更多的包,这里记录的信息也会随之增加。
并且不同的包管理器,它的 lock 文件也会不同,如果是使用 yarn 作为包管理器的话,它是生成一个 yarn.lock 文件,而不是 package-lock.json ,有关更多的包管理器,详见 插件的使用 一章。
现在我们已经安装好 md5 包了,接下来看看具体如何使用它。
通常在包的 npmjs 主页上会有 API 和用法的说明,只需要根据说明操作,我们打开 src/esm/index.mjs
文件,首先需要导入这个包。
包的导入和我们在 了解模块化设计 一节了解到的模块导入用法是一样的,只是把 from
后面的文件路径换成了包名。
// src/esm/index.mjs
import md5 from 'md5'
然后根据 md5 的用法,我们来编写一个小例子,先声明一个原始字符串变量,然后再声明一个使用 md5 加密过的字符串变量,并打印它们:
// src/esm/index.mjs
import md5 from 'md5'
const before = 'Hello World'
const after = md5(before)
console.log({ before, after })
在命令行输入 npm run dev:esm
,可以在控制台看到输出了这些内容,我们成功获得了转换后的结果:
npm run dev:esm
> demo@1.0.0 dev:esm
> node src/esm/index.mjs
{ before: 'Hello World', after: 'b10a8db164e0754105b7a99be72e3fe5' }
是不是非常简单,其实包的用法和我们在导入模块的用法可以说是完全一样的,区别主要在于,包是需要你安装了才能用,而模块是需要自己编写。
了解 TypeScript
本章内容看到这里,相信你已经对 Node 工程项目有了足够的认识了,在此之前我们的所有代码都是使用 JavaScript 编写的,接下来这一节,我将带你认识 TypeScript ,这是一门新的语言,但是上手非常简单。
TypeScript 简称 TS ,既是一门新语言,也是 JS 的一个超集,它是在 JavaScript 的基础上增加了一套类型系统,它支持所有的 JS 语句,为工程化开发而生,最终在编译的时候去掉类型和特有的语法,生成 JS 代码。
虽然带有类型系统的前端语言不止 TypeScript (例如 Facebook 推出的 Flow.jsopen in new window ),但从目前整个 开源社区的流行趋势open in new window 看, TypeScript 无疑是更好的选择。
而且只要你本身已经学会了 JS ,并且经历过很多协作类的项目,那么使用 TS 编程是一个很自然而然的过程。
为什么需要类型系统
要想知道自己为什么要用 TypeScript ,得先从 JavaScript 有什么不足说起,举一个非常小的例子:
function getFirstWord(msg) {
console.log(msg.split(' ')[0])
}
getFirstWord('Hello World') // 输出 Hello
getFirstWord(123) // TypeError: msg.split is not a function
这里定义了一个用空格切割字符串的方法,并打印出第一个单词:
- 第一次执行时,字符串支持
split
方法,所以成功获取到了第一个单词Hello
- 第二次执行时,由于数值不存在
split
方法,所以传入123
引起了程序崩溃
这就是 JavaScript 的弊端,过于灵活,没有类型的约束,很容易因为类型的变化导致一些本可避免的 BUG 出现,而且这些 BUG 通常需要在程序运行的时候才会被发现,很容易引发生产事故。
虽然可以在执行 split
方法之前执行一层判断或者转换,但很明显增加了很多工作量。
TypeScript 的出现,在编译的时候就可以执行检查来避免掉这些问题,而且配合 VSCode 等编辑器的智能提示,可以很方便的知道每个变量对应的类型。
Hello TypeScript
我们将继续使用 Hello Node 这个 demo ,或者你可以再建一个新 demo ,依然是在 src
文件夹下,创建一个 ts
文件夹归类本次的测试文件,然后创建一个 index.ts
文件在 ts
文件夹下。
TIP
TypeScript 语言对应的文件扩展名是 .ts
。
然后在命令行通过 cd
命令进入项目所在的目录路径,安装 TypeScript 开发的两个主要依赖包:
- typescriptopen in new window 这个包是用 TypeScript 编程的语言依赖包
- ts-nodeopen in new window 是让 Node 可以运行 TypeScript 的执行环境
npm install -D typescript ts-node
这次我们添加了一个 -D
参数,因为 TypeScript 和 TS-Node 是开发过程中使用的依赖,所以我们将其添加到 package.json 的 devDependencies
字段里。
然后修改 scripts 字段,增加一个 dev:ts
的 script :
{
"name": "demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev:cjs": "node src/cjs/index.cjs",
"dev:esm": "node src/esm/index.mjs",
"dev:ts": "ts-node src/ts/index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"md5": "^2.3.0"
},
"devDependencies": {
"ts-node": "^10.7.0",
"typescript": "^4.6.3"
}
}
准备工作完毕!
TIP
请注意, dev:ts
这个 script 我是用了 ts-node
来代替原来在用的 node
,因为使用 node
无法识别 TypeScript 语言。
我们把 为什么需要类型系统 里面提到的例子放到 src/ts/index.ts
里:
// src/ts/index.ts
function getFirstWord(msg) {
console.log(msg.split(' ')[0])
}
getFirstWord('Hello World')
getFirstWord(123)
然后在命令行运行 npm run dev:ts
来看看这次的结果:
TSError: ⨯ Unable to compile TypeScript:
src/ts/index.ts:1:23 - error TS7006: Parameter 'msg' implicitly has an 'any' type.
1 function getFirstWord(msg) {
~~~
这是告知 getFirstWord
的入参 msg
带有隐式 any 类型,这个时候你可能还不了解 any 代表什么意思,没关系,我们来看下如何修正这段代码:
// src/ts/index.ts
function getFirstWord(msg: string) {
console.log(msg.split(' ')[0])
}
getFirstWord('Hello World')
getFirstWord(123)
留意到没有,现在函数的入参 msg
已经变成了 msg: string
,这是 TypeScript 指定参数为字符串类型的一个写法。
现在再运行 npm run dev:ts
,上一个错误提示已经不再出现,取而代之的是一个新的报错:
TSError: ⨯ Unable to compile TypeScript:
src/ts/index.ts:7:14 - error TS2345:
Argument of type 'number' is not assignable to parameter of type 'string'.
7 getFirstWord(123)
~~~
这次的报错代码是在 getFirstWord(123)
这里,告诉我们 number
类型的数据不能分配给 string
类型的参数,也就是我们故意传入一个会报错的数值进去,被 TypeScript 检查出来了!
你可以再仔细留意一下控制台的信息,你会发现没有报错的 getFirstWord('Hello World')
也没有打印出结果,这是因为 TypeScript 需要先被编译成 JavaScript ,然后再执行。
这个机制让我们的代码问题能够及早发现,一旦代码出现问题,编译阶段就会失败。
我们移除会报错的那行代码,只保留如下:
// src/ts/index.ts
function getFirstWord(msg: string) {
console.log(msg.split(' ')[0])
}
getFirstWord('Hello World')
再次运行 npm run dev:ts
,这次完美运行!
npm run dev:ts
> demo@1.0.0 dev:ts
> ts-node src/ts/index.ts
Hello
在这个例子里,相信你已经感受到 TypeScript 的魅力了!接下来我们来认识一下不同的 JavaScript 类型,在 TypeScript 里面应该如何定义。
常用的 TS 类型定义
在 Hello TypeScript 的体验中,相信能够感受到 TypeScript 编程带来的好处了,代码的健壮性得到了大大的提升!
并且应该也能够大致了解到, TS 类型并不会给你的编程带来非常高的门槛或者说开发阻碍,它是以一种非常小的成本换取大收益的行为。
TIP
如果你还没有体验这个 demo ,建议先按教程跑一下,然后我们来讲解不同的 JavaScript 类型应该如何在 TypeScript 里定义,接下来的时间里,你可以一边看,一边在 demo 里实践。
原始数据类型
原始数据类型open in new window 是一种既非对象也无方法的数据,刚才我们演示代码里,函数的入参使用的字符串 String 就是原始数据类型之一。
除了 String ,另外还有数值 Number 、布尔值 Boolean 等等,它们在 TypeScript 都有统一的表达方式,我们列个表格对比,能够更直观的了解:
原始数据类型 | JavaScript | TypeScript |
---|---|---|
字符串 | String | string |
数值 | Number | number |
布尔值 | Boolean | boolean |
大整数 | BigInt | bigint |
符号 | Symbol | symbol |
不存在 | Null | null |
未定义 | Undefined | undefined |
有没有发现窍门?对! TypeScript 对原始数据的类型定义真的是超级简单,就是转为全小写即可!
举几个例子:
// 字符串
const str: string = 'Hello World'
// 数值
const num: number = 1
// 布尔值
const bool: boolean = true
不过在实际的编程过程中,原始数据类型的类型定义是可以省略的,因为 TypeScript 会根据你声明变量时赋值的类型,自动帮你推导变量类型,也就是可以跟平时写 JavaScript 一样:
// 这样也不会报错,因为 TS 会帮你推导它们的类型
const str = 'Hello World'
const num = 1
const bool = true
数组
除了原始数据类型之外, JavaScript 还有引用类型,数组 Array 就是其中的一种。
之所以先讲数组,是因为它在 TS 类型定义的写法上面,可能是最接近原始数据的一个类型了,为什么这么说?我们还是列个表格,来看一下如何定义数组:
数组里的数据 | 类型写法 1 | 类型写法 2 |
---|---|---|
字符串 | string[] | Array |
数值 | number[] | Array |
布尔值 | boolean[] | Array |
大整数 | bigint[] | Array |
符号 | symbol[] | Array |
不存在 | null[] | Array |
未定义 | undefined[] | Array |
是吧!就只是在原始数据类型的基础上变化了一下书写格式,就成为了数组的定义!
我个人最常用的就是 string[]
这样的格式,只需要追加一个方括号 []
,另外一种写法是基于 TS 的泛型 Array<T>
,两种方式定义出来的类型其实是一样的。
举几个例子:
// 字符串数组
const strs: string[] = ['Hello World', 'Hi World']
// 数值数组
const nums: number[] = [1, 2, 3]
// 布尔值数组
const bools: boolean[] = [true, true, false]
在实际的编程过程中,如果你的数组一开始就有初始数据(数组长度不为 0 ),那么 TypeScript 也会根据数组里面的项目类型,正确自动帮你推导这个数组的类型,这种情况下也可以省略类型定义:
// 这种有初始项目的数组, TS 也会帮你推导它们的类型
const strs = ['Hello World', 'Hi World']
const nums = [1, 2, 3]
const bools = [true, true, false]
但是!如果一开始是 []
,那么就必须显式的指定数组类型(取决于你的 tsconfig.json 的配置,可能会引起报错):
// 这个时候会认为是 any[] 或者 never[] 类型
const nums = []
// 这个时候再 push 一个 number 数据进去,也不会使其成为 number[]
nums.push(1)
而对于复杂的数组,比如数组里面的 item 都是对象,其实格式也是一样,只不过把原始数据类型换成 对象的类型 即可,例如 UserItem[]
表示这是一个关于用户的数组列表。
对象(接口)
看完数组咱们就来看对象了,对象也是引用类型,在 数组 的最后我提到了一个 UserItem[]
的写法,这里的 UserItem
就是一个对象的类型定义。
如果你熟悉 JavaScript ,那么就知道对象的 “键值对” 里面的值,可能是由原始数据、数组、对象组成的,所以在 TypeScript ,类型定义也是需要根据值的类型来确定它的类型,因此定义对象的类型应该是第一个比较有门槛的地方。
如何定义对象的类型
对象的类型定义有两个语法支持: type
和 interface
。
先看看 type
的写法:
type UserItem = {
// ...
}
再看看 interface
的写法:
interface UserItem {
// ...
}
可以看到它们表面上的区别是一个有 =
号,一个没有,事实上在一般的情况下也确实如此,两者非常接近,但是在特殊的时候也有一定的区别。
了解接口的使用
为了降低学习门槛,我们统一使用 interface
来做入门教学,它的写法与 Object 更为接近,事实上它也被用的更多。
对象的类型 interface
也叫做接口,用来描述对象的结构。
TIP
对象的类型定义通常采用 Upper Camel Case 大驼峰命名法,也就是每个单词的首字母大写,例如 UserItem
、 GameDetail
,这是为了跟普通变量进行区分(变量通常使用 Lower Camel Case 小驼峰写法,也就是第一个单词的首字母小写,其他首字母大写,例如 userItem
)。
这里我通过一些举例来带你举一反三,你随时可以在 demo 里进行代码实践。
我们以这个用户信息为例子,比如你要描述 Petter 这个用户,他的最基础信息就是姓名和年龄,那么定义为接口就是这么写:
// 定义用户对象的类型
interface UserItem {
name: string
age: number
}
// 在声明变量的时候将其关联到类型上
const petter: UserItem = {
name: 'Petter',
age: 20,
}
如果你需要添加数组、对象等类型到属性里,按照这样继续追加即可。
可选的接口属性
注意,上面这样定义的接口类型,表示 name
和 age
都是必选的属性,不可以缺少,一旦缺少,代码运行起来就会报错!
我们在 src/ts/index.ts
里敲入以下代码,也就是在声明变量的时候故意缺少了 age
属性,来看看会发生什么:
// 注意!这是一段会报错的代码
interface UserItem {
name: string
age: number
}
const petter: UserItem = {
name: 'Petter',
}
运行 npm run dev:ts
,你会看到控制台给你的报错信息,缺少了必选的属性 age
:
src/ts/index.ts:6:7 - error TS2741:
Property 'age' is missing in type '{ name: string; }' but required in type 'UserItem'.
6 const petter: UserItem = {
~~~~~~
src/ts/index.ts:3:3
3 age: number
~~~
'age' is declared here.
在实际的业务中,有可能会出现一些属性并不是必须的,就像这个年龄,你可以将其设置为可选属性,通过添加 ?
来定义。
请注意下面代码的第三行, age
后面紧跟了一个 ?
号再接 :
号,这是 TypeScript 对象对于可选属性的一个定义方式,这一次这段代码是可以成功运行的!
interface UserItem {
name: string
// 这个属性变成了可选
age?: number
}
const petter: UserItem = {
name: 'Petter',
}
调用自身接口的属性
如果一些属性的结构跟本身一致,也可以直接引用,比如下面例子里的 friendList
属性,用户的好友列表,它就可以继续使用 UserItem
这个接口作为数组的类型:
interface UserItem {
name: string
age: number
enjoyFoods: string[]
// 这个属性引用了本身的类型
friendList: UserItem[]
}
const petter: UserItem = {
name: 'Petter',
age: 18,
enjoyFoods: ['rice', 'noodle', 'pizza'],
friendList: [
{
name: 'Marry',
age: 16,
enjoyFoods: ['pizza', 'ice cream'],
friendList: [],
},
{
name: 'Tom',
age: 20,
enjoyFoods: ['chicken', 'cake'],
friendList: [],
}
],
}
接口的继承
接口还可以继承,比如你要对用户设置管理员,管理员信息也是一个对象,但要比普通用户多一个权限级别的属性,那么就可以使用继承,它通过 extends
来实现:
interface UserItem {
name: string
age: number
enjoyFoods: string[]
friendList: UserItem[]
}
// 这里继承了 UserItem 的所有属性类型,并追加了一个权限等级属性
interface Admin extends UserItem {
permissionLevel: number
}
const admin: Admin = {
name: 'Petter',
age: 18,
enjoyFoods: ['rice', 'noodle', 'pizza'],
friendList: [
{
name: 'Marry',
age: 16,
enjoyFoods: ['pizza', 'ice cream'],
friendList: [],
},
{
name: 'Tom',
age: 20,
enjoyFoods: ['chicken', 'cake'],
friendList: [],
}
],
permissionLevel: 1,
}
如果你觉得这个 Admin
类型不需要记录这么多属性,也可以在继承的过程中舍弃某些属性,通过 Omit
帮助类型来实现,Omit
的类型如下:
type Omit<T, K extends string | number | symbol>
其中 T
代表已有的一个对象类型, K
代表要删除的属性名,如果只有一个属性就直接是一个字符串,如果有多个属性,用 |
来分隔开,下面的例子就是删除了两个不需要的属性:
interface UserItem {
name: string
age: number
enjoyFoods: string[]
friendList?: UserItem[]
}
// 这里在继承 UserItem 类型的时候,删除了两个多余的属性
interface Admin extends Omit<UserItem, 'enjoyFoods' | 'friendList'> {
permissionLevel: number
}
// 现在的 admin 就非常精简了
const admin: Admin = {
name: 'Petter',
age: 18,
permissionLevel: 1,
}
看到这里并实际体验过的话,在业务中常见的类型定义已经难不倒你了!
类
类是 JavaScript ES6 推出的一个概念,通过 class
关键字,你可以定义一个对象的模板,如果你对类还比较陌生的话,可以先阅读一下阮一峰老师的 ES6 文章:Class 的基本语法。
在 TypeScript ,通过类得到的变量,它的类型就是这个类,可能这句话看起来有点难以理解,我们来看个例子,你可以在 demo 里运行它:
// 定义一个类
class User {
// constructor 上的数据需要先这样定好类型
name: string
// 入参也要定义类型
constructor(userName: string) {
this.name = userName
}
getName() {
console.log(this.name)
}
}
// 通过 new 这个类得到的变量,它的类型就是这个类
const petter: User = new User('Petter')
petter.getName() // Petter
类与类之间可以继承:
// 这是一个基础类
class UserBase {
name: string
constructor(userName: string) {
this.name = userName
}
}
// 这是另外一个类,继承自基础类
class User extends UserBase {
getName() {
console.log(this.name)
}
}
// 这个变量拥有上面两个类的所有属性和方法
const petter: User = new User('Petter')
petter.getName()
类也可以提供给接口去继承:
// 这是一个类
class UserBase {
name: string
constructor(userName: string) {
this.name = userName
}
}
// 这是一个接口,可以继承自类
interface User extends UserBase {
age: number
}
// 这样这个变量就必须同时存在两个属性
const petter: User = {
name: 'Petter',
age: 18,
}
如果类上面本身有方法存在,接口在继承的时候也要相应的实现,当然也可以借助在 对象(接口) 提到的 Omit
帮助类型来去掉这些方法。
class UserBase {
name: string
constructor(userName: string) {
this.name = userName
}
// 这是一个方法
getName() {
console.log(this.name)
}
}
// 接口继承类的时候也可以去掉类上面的方法
interface User extends Omit<UserBase, 'getName'> {
age: number
}
// 最终只保留数据属性,不带有方法
const petter: User = {
name: 'Petter',
age: 18,
}
联合类型
阅读到这里,对 JavaScript 的数据和对象如何在 TypeScript 定义类型相信没有太大问题了吧!
所以这里我先插入一个知识点,在介绍 对象(接口) 和 类 的类型定义时,提到 Omit
的帮助类型,它的类型里面有一个写法是 string | number | symbol
,这其实是 TypeScript 的一个联合类型。
当一个变量可能出现多种类型的值的时候,你可以使用联合类型来定义它,类型之间用 |
符号分隔。
举一个简单的例子,下面这个函数接收一个代表 “计数” 的入参,并拼接成一句话打印到控制台,因为最终打印出来的句子是字符串,所以参数没有必要非得是数值,传字符串也是可以的,所以我们就可以使用联合类型:
// 你可以在 demo 里运行这段代码
function counter(count: number | string) {
console.log(`The current count is: ${count}.`)
}
// 不论传数值还是字符串,都可以达到我们的目的
counter(1) // The current count is: 1.
counter('2') // The current count is: 2.
在实际的业务场景中,例如 Vue 的路由在不同的数据结构里也有不同的类型,有时候我们需要通过路由实例来判断是否符合要求的页面,也需要用到这种联合类型:
// 注意:这不是完整的代码,只是一个使用场景示例
import type { RouteRecordRaw, RouteLocationNormalizedLoaded } from 'vue-router'
function isArticle(
route: RouteRecordRaw | RouteLocationNormalizedLoaded
): boolean {
// ...
}
再举个例子,我们是用 Vue 做页面,你会涉及到子组件或者 DOM 的操作,当它们还没有渲染出来时,你获取到的是 null ,渲染后你才能拿到组件或者 DOM 结构,这种场景我们也可以使用联合类型:
// querySelector 拿不到 DOM 的时候返回 null
const ele: HTMLElement | null = document.querySelector('.main')
最后这个使用场景在 Vue 单组件的 DOM 元素与子组件 一节里我们也有相关的讲解。
当你决定使用联合类型的时候,大部分情况下你可能需要对变量做一些类型判断再写逻辑,当然有时候也可以无所谓,就像我们第一个例子拼接字符串那样。
这一小节在这里我们做简单了解即可,因为下面我们会继续配合不同的知识点把这个联合类型再次拿出来讲,比如 函数的重载 部分。
函数
函数是 JavaScript 里最重要的成员之一,我们所有的功能实现都是基于函数。
函数的基本的写法
在 JavaScript ,函数有很多种写法:
// 注意:这是 JavaScript 代码
// 写法一:函数声明
function sum1(x, y) {
return x + y
}
// 写法二:函数表达式
const sum2 = function(x, y) {
return x + y
}
// 写法三:箭头函数
const sum3 = (x, y) => x + y
// 写法四:对象上的方法
const obj = {
sum4(x, y) {
return x + y
}
}
// 还有很多……
但其实离不开两个最核心的操作:输入与输出,也就是对应函数的 “入参” 和 “返回值” ,在 TypeScript ,函数本身和 TS 类型有关系的也是在这两个地方。
函数的入参是把类型写在参数后面,返回值是写在圆括号后面,我们把上面在 JavaScript 的这几个写法,转换成 TypeScript 看看区别在哪里:
// 注意:这是 TypeScript 代码
// 写法一:函数声明
function sum1(x: number, y: number): number {
return x + y
}
// 写法二:函数表达式
const sum2 = function(x: number, y: number): number {
return x + y
}
// 写法三:箭头函数
const sum3 = (x: number, y: number): number => x + y
// 写法四:对象上的方法
const obj = {
sum4(x: number, y: number): number {
return x + y
}
}
// 还有很多……
是不是一下子 Get 到了技巧!函数的类型定义也是非常的简单,掌握这个技巧可以让你解决大部分常见的函数。
函数的可选参数
实际业务中会遇到有一些函数入参是可选,可以用和 对象(接口) 一样,用 ?
来定义:
// 注意 isDouble 这个入参后面有个 ? 号,表示可选
function sum(x: number, y: number, isDouble?: boolean): number {
return isDouble ? (x + y) * 2 : x + y
}
// 这样传参都不会报错,因为第三个参数是可选的
sum(1, 2) // 3
sum(1, 2, true) // 6
TIP
需要注意的是,可选参数必须排在必传参数的后面。
无返回值的函数
除了有返回值的函数,我们更多时候是不带返回值的,例如下面这个例子,这种函数我们用 void
来定义它的返回,也就是空。
// 注意这里的返回值类型
function sayHi(name: string): void {
console.log(`Hi, ${name}!`)
}
sayHi('Petter') // Hi, Petter!
需要注意的是, void
和 null
、 undefined
不可以混用,如果你的函数返回值类型是 null
,那么你是真的需要 return
一个 null
值:
// 只有返回 null 值才能定义返回类型为 null
function sayHi(name: string): null {
console.log(`Hi, ${name}!`)
return null
}
有时候你要判断参数是否合法,不符合要求时需要提前终止执行(比如在做一些表单校验的时候),这种情况下你也可以用 void
:
function sayHi(name: string): void {
// 这里判断参数不符合要求则提前终止运行,但它没有返回值
if (!name) return
// 否则正常运行
console.log(`Hi, ${name}!`)
}
异步函数的返回值
对于异步函数,你需要用 Promise<T>
类型来定义它的返回值,这里的 T
是泛型,取决于你的函数最终返回一个什么样的值( async / await
也适用这个类型)。
例如这个例子,这是一个异步函数,会 resolve
一个字符串,所以它的返回类型是 Promise<string>
(假如你没有 resolve
数据,那么就是 Promise<void>
)。
// 注意这里的返回值类型
function queryData(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Hello World')
}, 3000)
})
}
queryData().then((data) => console.log(data))
函数本身的类型
细心的同学可能会有个疑问,通过函数表达式或者箭头函数声明的函数,这样写好像只对函数体的类型进行了定义,而左边的变量并没有指定。
没错,我们确实是没有为这个变量指定类型:
// 这里的 sum ,我们确实是没有指定类型
const sum = (x: number, y: number): number => x + y
这是因为,通常 TypeScript 会根据函数体帮我们自动推导,所以可以省略这里的定义。
如果确实有必要,你可以这样来定义等号左边的类型:
const sum: (x: number, y: number) => number = (x: number, y: number): number => x + y
这里出现了 2 个箭头 =>
,注意第一个箭头是 TypeScript 的,第二个箭头是 JavaScript ES6 的。
实际上上面这句代码是分成了三部分:
const sum: (x: number, y: number) => number
是这个函数的名称和类型= (x: number, y: number)
这里是指明了函数的入参和类型: number => x + y
这里是函数的返回值和类型
第 2 和 3 点相信你从上面的例子已经能够理解了,所以我们注意力放在第一点:
TypeScript 的函数类型是以 () => void
这样的形式来写的:左侧圆括号是函数的入参类型,如果没有参数,就只有一个圆括号,如果有参数,就按照参数的类型写进去;右侧则是函数的返回值。
事实上由于 TypeScript 会帮你推导函数类型,所以我们很少会显式的去写出来,除非你在给对象定义方法:
// 对象的接口
interface Obj {
// 上面的方法就需要你显式的定义出来
sum: (x: number, y: number) => number
}
// 声明一个对象
const obj: Obj = {
sum(x: number, y: number): number {
return x + y
}
}
函数的重载
在未来的实际开发中,你可能会接触到一个 API 有多个 TS 类型的情况,比如 Vue 的 watch API 。
Vue 的这个 watch API 在被调用时,需要接收一个数据源参数,当监听单个数据源时,它匹配了类型 1 ,当传入一个数组监听多个数据源时,它匹配了类型 2 。
这个知识点其实就是 TypeScript 里的函数重载。
我们先来看下不用重载的时候,我们的代码应该怎么写:
// 对单人或者多人打招呼
function greet(name: string | string[]): string | string[] {
if (Array.isArray(name)) {
return name.map((n) => `Welcome, ${n}!`)
}
return `Welcome, ${name}!`
}
// 单个问候语
const greeting = greet('Petter')
console.log(greeting) // Welcome, Petter!
// 多个问候语
const greetings = greet(['Petter', 'Tom', 'Jimmy'])
console.log(greetings) // [ 'Welcome, Petter!', 'Welcome, Tom!', 'Welcome, Jimmy!' ]
TIP
注意这里的入参和返回值使用了 TypeScript 的 联合类型 ,不了解的话请先重温知识点。
虽然代码逻辑部分还是比较清晰的,区分了入参的数组类型和字符串类型,返回不同的结果,但是,在入参和返回值的类型这里,却显得非常乱。
并且这样子写,下面在调用函数时,定义的变量也无法准确的获得它们的类型:
// 此时这个变量依然可能有多个类型
const greeting: string | string[]
如果你要强制确认类型,需要使用 TS 的 类型断言 (留意后面的 as
关键字):
const greeting = greet('Petter') as string
const greetings = greet(['Petter', 'Tom', 'Jimmy']) as string[]
这无形的增加了编码时的心智负担。
此时,利用 TypeScript 的函数重载就非常有用!我们来看一下具体如何实现:
// 这一次用了函数重载
function greet(name: string): string // TS 类型
function greet(name: string[]): string[] // TS 类型
function greet(name: string | string[]) {
if (Array.isArray(name)) {
return name.map((n) => `Welcome, ${n}!`)
}
return `Welcome, ${name}!`
}
// 单个问候语
const greeting = greet('Petter') // 此时只有一个类型 string
console.log(greeting) // Welcome, Petter!
// 多个问候语
const greetings = greet(['Petter', 'Tom', 'Jimmy']) // 此时只有一个类型 string[]
console.log(greetings) // [ 'Welcome, Petter!', 'Welcome, Tom!', 'Welcome, Jimmy!' ]
上面是利用函数重载优化后的代码,可以看到我一共写了 3 行 function greet …
,区别如下:
第 1 行是函数的 TS 类型,告知 TypeScript ,当入参为 string
类型时,返回值也是 string
;
第 2 行也是函数的 TS 类型,告知 TypeScript ,当入参为 string[]
类型时,返回值也是 string[]
;
第 3 行开始才是真正的函数体,这里的函数入参需要把可能涉及到的类型都写出来,用以匹配前两行的类型,并且这种情况下,函数的返回值类型可以省略,因为在第 1 、 2 行里已经定义过返回类型了。
任意值
如果你实在不知道应该如何定义一个变量的类型, TypeScript 也允许你使用任意值。
还记得我们在 为什么需要类型系统 的用的那个例子吗?我们再次放到 src/ts/index.ts
里:
// 这段代码在 TS 里运行会报错
function getFirstWord(msg) {
console.log(msg.split(' ')[0])
}
getFirstWord('Hello World')
getFirstWord(123)
运行 npm run dev:ts
的时候,会得到一句报错 Parameter 'msg' implicitly has an 'any' type.
,意思是这个参数带有隐式 any 类型。
这里的 any 类型,就是 TypeScript 任意值。
既然报错是 “隐式” ,那我们 “显式” 的指定就可以了,当然,为了程序能够正常运行,我们还提高一下函数体内的代码健壮性:
// 这里的入参显式指定了 any
function getFirstWord(msg: any) {
// 这里使用了 String 来避免程序报错
console.log(String(msg).split(' ')[0])
}
getFirstWord('Hello World')
getFirstWord(123)
这次就不会报错了,不论是传 string
还是 number
还是其他类型,都可以正常运行。
TIP
使用 any 的目的是让你在开发的过程中,可以不必在无法确认类型的地方消耗太多时间,不代表不需要注意代码的健壮性。
一旦使用了 any ,代码里的逻辑请务必考虑多种情况进行判断或者处理兼容。
npm 包
虽然现在从 npm 安装的包都基本自带 TS 类型了,不过也存在一些包没有默认支持 TypeScript ,比如我们前面提到的 md5 。
你在 TS 文件里导入并使用这个包的时候,会编译失败,比如在我们前面的 Hello TypeScript demo 里敲入以下代码:
// src/ts/index.ts
import md5 from 'md5'
console.log(md5('Hello World'))
在命令行执行 npm run dev:ts
之后,你会得到一段报错信息:
src/ts/index.ts:1:17 - error TS7016:
Could not find a declaration file for module 'md5'.
'D:/Project/demo/node-demo/node_modules/md5/md5.js' implicitly has an 'any' type.
Try `npm i --save-dev @types/md5` if it exists
or add a new declaration (.d.ts) file containing `declare module 'md5';`
1 import md5 from 'md5'
~~~~~
这是因为缺少 md5 这个包的类型定义,我们根据命令行的提示,安装 @types/md5
这个包。
这是因为这些包是很早期用 JavaScript 编写的,因为功能够用作者也没有进行维护更新,所以缺少相应的 TS 类型,因此开源社区推出了一套 @types 类型包,专门处理这样的情况。
@types 类型包的命名格式为 @types/<package-name>
,也就是在原有的包名前面拼接 @types
,日常开发要用到的知名 npm 包都会有响应的类型包,只需要将其安装到 package.json 的 devDependencies
里即可解决该问题。
我们来安装一下 md5 的类型包:
npm install -D @types/md5
再次运行就不会报错了!
npm run dev:ts
> demo@1.0.0 dev:ts
> ts-node src/ts/index.ts
b10a8db164e0754105b7a99be72e3fe5
类型断言
在讲解 函数的重载 的时候,我提到了一个用法:
const greeting = greet('Petter') as string
这里的 值 as 类型
就是 TypeScript 类型断言的语法,它还有另外一个语法是 <类型>值
。
当一个变量应用了 联合类型 时,在某些时候如果不显式的指明其中的一种类型,可能会导致后续的代码运行报错。
这个时候你就可以通过类型断言强制指定其中一种类型,以便程序顺利运行下去。
常见的使用场景
我们把函数重载时最开始用到的那个例子,也就是下面的代码放到 src/ts/index.ts
里:
// 对单人或者多人打招呼
function greet(name: string | string[]): string | string[] {
if (Array.isArray(name)) {
return name.map((n) => `Welcome, ${n}!`)
}
return `Welcome, ${name}!`
}
// 虽然你知道此时应该是 string[] ,但 TypeScript 还是会认为这是 string | string[]
const greetings = greet(['Petter', 'Tom', 'Jimmy'])
// 会导致无法使用 join 方法
const greetingSentence = greetings.join(' ')
console.log(greetingSentence)
执行 npm run dev:ts
,可以清楚的看到报错原因,因为 string
类型不具备 join
方法。
src/ts/index.ts:11:31 - error TS2339:
Property 'join' does not exist on type 'string | string[]'.
Property 'join' does not exist on type 'string'.
11 const greetingStr = greetings.join(' ')
~~~~
此时利用类型断言就可以达到目的:
// 对单人或者多人打招呼
function greet(name: string | string[]): string | string[] {
if (Array.isArray(name)) {
return name.map((n) => `Welcome, ${n}!`)
}
return `Welcome, ${name}!`
}
// 你知道此时应该是 string[] ,所以用类型断言将其指定为 string[]
const greetings = greet(['Petter', 'Tom', 'Jimmy']) as string[]
// 现在可以正常使用 join 方法
const greetingSentence = greetings.join(' ')
console.log(greetingSentence)
需要注意的事情
但是,请不要滥用类型断言,只在你能够确保代码正确的情况下去使用它,我们来看一个反例:
// 原本要求 age 也是必须的属性之一
interface User {
name: string
age: number
}
// 但是类型断言过程中,你遗漏了
const petter = {} as User
petter.name = 'Petter'
// TypeScript 依然可以运行下去,但实际上你的数据是不完整的
console.log(petter) // { name: 'Petter' }
TIP
使用类型断言可以让 TypeScript 不检查你的代码,它会认为你是对的。
所以,请务必保证自己的代码真的是对的!
类型推论
还记得我在讲 原始数据类型 的时候,最后提到的:
不过在实际的编程过程中,原始数据类型的类型定义是可以省略的,因为 TypeScript 会根据你声明变量时赋值的类型,自动帮你推导变量类型
这其实是 TypeScript 的类型推论功能,当你在声明变量的时候可以确认它的值,那么 TypeScript 也可以在这个时候帮你推导它的类型,这种情况下你就可以省略一些代码量。
下面这个变量这样声明是 OK 的,因为 TypeScript 会帮你推导 msg
是 string
类型。
// 相当于 msg: string
let msg = 'Hello World'
// 所以要赋值为 number 类型时会报错
msg = 3 // Type 'number' is not assignable to type 'string'
下面这段代码也是可以正常运行的,因为 TypeScript 会根据 return
的结果推导 getRandomNumber
的返回值是 number
类型,从而推导变量 num
也是 number
类型。
// 相当于 getRandomNumber(): number
function getRandomNumber() {
return Math.round(Math.random() * 10)
}
// 相当于 num: number
const num = getRandomNumber()
类型推论的前提是变量在声明时有明确的值,如果一开始没有赋值,那么会被默认为 any
类型。
// 此时相当于 foo: any
let foo
// 所以可以任意改变类型
foo = 1 // 1
foo = true // true
类型推论可以帮你节约很多书写工作量,在确保变量初始化有明确的值的时候,你可以省略其类型,但必要的时候,该写上的还是要写上。
如何编译为 JavaScript 代码
学习到这里,对于 TypeScript 的入门知识已经学到了吧!
前面我们学习的时候,一直是基于 dev:ts
命令,它调用的是 ts-node
来运行我们的 TS 文件:
{
// ...
"scripts": {
// ...
"dev:ts": "ts-node src/ts/index.ts"
},
// ...
}
但我们最终可能需要的是一个 JS 文件,比如你要通过 <script src>
来放到 HTML 页面里,这就涉及到对 TypeScript 的编译。
我们来看看如何把一个 TS 文件编译成 JS 文件,让其从 TypeScript 变成 JavaScript 代码。
编译单个文件
我们先在 package.json 里增加一个 build script :
{
"name": "demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev:cjs": "node src/cjs/index.cjs",
"dev:esm": "node src/esm/index.mjs",
"dev:ts": "ts-node src/ts/index.ts",
"build": "tsc src/ts/index.ts --outDir dist"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"md5": "^2.3.0"
},
"devDependencies": {
"@types/md5": "^2.3.2",
"ts-node": "^10.7.0",
"typescript": "^4.6.3"
}
}
这样我们在命令行运行 npm run build
的时候,就会把 src/ts/index.ts
这个 TS 文件编译,并输出到项目下与 src 文件夹同级的 dist 目录下。
其中 tsc
是 TypeScript 用来编译文件的命令, --outDir
是它的一个选项,用来指定输出目录,如果不指定,则默认生成到源文件所在的目录下面。
我们把之前在 函数的重载 用过的这个例子放到 src/ts/index.ts
文件里,因为它是一段比较典型的、包含了多个知识点的 TypeScript 代码:
// 对单人或者多人打招呼
function greet(name: string): string
function greet(name: string[]): string[]
function greet(name: string | string[]) {
if (Array.isArray(name)) {
return name.map((n) => `Welcome, ${n}!`)
}
return `Welcome, ${name}!`
}
// 单个问候语
const greeting = greet('Petter')
console.log(greeting)
// 多个问候语
const greetings = greet(['Petter', 'Tom', 'Jimmy'])
console.log(greetings)
你可以先执行 npm run dev:ts
测试它的可运行性,当然,如果期间你的代码运行有问题,在编译阶段也会给你报错。
我们现在来编译它,现在在命令行输入 npm run build
并回车执行。
我们可以看到多了一个 dist 文件夹,里面多了一个 index.js
文件。
node-demo
│ # 构建产物
├─dist
│ │ # 编译后的 JS 文件
│ └─index.js
│ # 依赖文件夹
├─node_modules
│ # 源码文件夹
├─src
│ # 锁定安装依赖的版本号
├─package-lock.json
│ # 项目清单
└─package.json
index.js
文件里面的代码如下:
function greet(name) {
if (Array.isArray(name)) {
return name.map(function (n) { return "Welcome, ".concat(n, "!"); });
}
return "Welcome, ".concat(name, "!");
}
// 单个问候语
var greeting = greet('Petter');
console.log(greeting);
// 多个问候语
var greetings = greet(['Petter', 'Tom', 'Jimmy']);
console.log(greetings);
可以看到已经成功把 TypeScript 代码编译成 JavaScript 代码了。
我们在命令行执行 node dist/index.js
,像我们之前测试 JS 文件一样使用 node
命令,运行 dist 目录下的 index.js
文件,它可以正确运行:
node dist/index.js
Welcome, Petter!
[ 'Welcome, Petter!', 'Welcome, Tom!', 'Welcome, Jimmy!' ]
编译多个模块
刚才我们只是编译一个 index.ts
文件,如果 index.ts
里引入了其他模块,此时 index.ts
是作为入口文件,入口文件 import
进来使用的模块也会被 TypeScript 一并编译。
我们拆分一下模块,把 greet
函数单独抽离成一个模块文件 src/ts/greet.ts
:
// src/ts/greet.ts
function greet(name: string): string
function greet(name: string[]): string[]
function greet(name: string | string[]) {
if (Array.isArray(name)) {
return name.map((n) => `Welcome, ${n}!`)
}
return `Welcome, ${name}!`
}
export default greet
在 src/ts/index.ts
这边,把这个模块导进来:
// src/ts/index.ts
import greet from './greet'
// 单个问候语
const greeting = greet('Petter')
console.log(greeting)
// 多个问候语
const greetings = greet(['Petter', 'Tom', 'Jimmy'])
console.log(greetings)
我们的 build script 无需修改,依然只编译 index.ts
,但因为导入了 greet.ts
,所以 TypeScript 也会一并编译,我们来试一下运行 npm run build
, 现在 dist 目录下有两个文件了:
node-demo
│ # 构建产物
├─dist
│ ├─greet.js # 多了这个文件
│ └─index.js
│
│ # 其他文件这里省略...
└─package.json
我们来看看这一次的编译结果:
先看看 greet.js
:
"use strict";
exports.__esModule = true;
function greet(name) {
if (Array.isArray(name)) {
return name.map(function (n) { return "Welcome, ".concat(n, "!"); });
}
return "Welcome, ".concat(name, "!");
}
exports["default"] = greet;
再看看 index.js
:
"use strict";
exports.__esModule = true;
var greet_1 = require("./greet");
// 单个问候语
var greeting = (0, greet_1["default"])('Petter');
console.log(greeting);
// 多个问候语
var greetings = (0, greet_1["default"])(['Petter', 'Tom', 'Jimmy']);
console.log(greetings);
这个代码风格有没有觉得似曾相识?是的,就是我们前面提到的 CommonJS 模块代码。
其实在 编译单个文件 代码的时候,它也是 CommonJS ,只不过因为只有一个文件,没有涉及到模块化,所以你第一眼看不出来。
我们还是在命令行执行 node dist/index.js
,虽然也是运行 dist 目录下的 index.js
文件,但这次它的作用是充当一个入口文件了,引用到的 greet.js
模块文件也会被调用。
这次一样可以得到正确的结果:
node dist/index.js
Welcome, Petter!
[ 'Welcome, Petter!', 'Welcome, Tom!', 'Welcome, Jimmy!' ]
修改编译后的 JS 版本
我们还可以修改编译配置,让 TypeScript 编译成不同的 JavaScript 版本。
我们修改 package.json 里的 build script ,在原有的命令后面增加一个 --target
选项:
{
// ...
"scripts": {
// ...
"build": "tsc src/ts/index.ts --outDir dist --target es6"
},
// ...
}
--target
选项的作用是控制编译后的 JavaScript 版本,可选的值目前有: es3
, es5
, es6
, es2015
, es2016
, es2017
, es2018
, es2019
, es2020
, es2021
, es2022
, esnext
,分别对应不同的 JS 规范(所以未来的可选值会根据 JS 规范一起增加)。
之前编译出来的 JavaScript 是 CommonJS 规范 ,我们本次配置的是 es6
,这是支持 ES Module 规范 的版本。
TIP
通常还需要配置一个 --module
选项,用于决定编译后是 CJS 规范还是 ESM 规范,但如果缺省,会根据 --target
来决定。
再次在命令行运行 npm run build
,这次看看变成了什么:
先看看 greet.js
:
function greet(name) {
if (Array.isArray(name)) {
return name.map((n) => `Welcome, ${n}!`);
}
return `Welcome, ${name}!`;
}
export default greet;
再看看 index.js
:
import greet from './greet';
// 单个问候语
const greeting = greet('Petter');
console.log(greeting);
// 多个问候语
const greetings = greet(['Petter', 'Tom', 'Jimmy']);
console.log(greetings);
这次编译出来的都是基于 ES6 的 JavaScript 代码,因为涉及到 ESM 模块,所以你不能直接在 node 运行它了,你可以手动改一下扩展名,改成 .mjs
(包括 index 文件里 import
的 greet 文件名也要改),然后再运行 node dist/index.mjs
。
其他事项
在尝试 编译单个文件 和 编译多个模块 的时候,我相信你应该没有太大的疑问。
但是来到 修改编译后的 JS 版本 这里,事情就开始变得复杂了起来,你应该能感觉到编译的选项和测试成本都相应的增加了很多。
事实上我们刚才编译的 JS 文件,因为涉及到模块化,是无法直接在 HTML 页面里使用的(单个文件可以,因为没有涉及模块),实际的项目中,需要借助 构建工具 来帮我们处理很多编译过程中的兼容性问题。
而我们刚才用到的诸如 --target
这样的选项,可以用一个更简单的方式来管理,类似于 package.json 项目清单, TypeScript 也有一份适用于项目的配置清单,请看 了解 tsconfig.json 部分。
了解 tsconfig.json
TypeScript 项目一般都会有一个 tsconfig.json 文件,放置于项目的根目录下,这个文件的作用是用来管理 TypeScript 在编译过程中的一些选项配置。
在开始之前,我们需要全局安装一下 TypeScript :
npm install -g typescript
这样我们就可以使用 TypeScript 提供的全局功能,可以直接在命令行里使用 tsc
命令了(之前本地安装的时候,需要封装成 package.json 的 script 才能调用它)。
依然是用我们的 Hello TypeScript demo ,记得先通过 cd
命令进入项目所在的目录。
在命令行输入 tsc --init
,这是 TypeScript 提供的初始化功能,会帮你生成一个默认的 tsconfig.json 文件。
tsc --init
Created a new tsconfig.json with:
TS
target: es2016
module: commonjs
strict: true
esModuleInterop: true
skipLibCheck: true
forceConsistentCasingInFileNames: true
You can learn more at https://aka.ms/tsconfig.json
现在我们的目录结构是这样子,多了一个 tsconfig.json 文件:
node-demo
│ # 构建产物
├─dist
│ # 依赖文件夹
├─node_modules
│ # 源码文件夹
├─src
│ # 锁定安装依赖的版本号
├─package-lock.json
│ # 项目清单
├─package.json
│ # TypeScript 配置
└─tsconfig.json
每一个 tsc
的命令行的选项,都可以作为这个 JSON 的一个字段来管理,例如我们刚才的 --outDir
和 --target
选项,在这个 JSON 文件里对应的就是:
{
"compilerOptions": {
"target": "es6",
"module": "es6",
"outDir": "./dist",
}
}
你可以直接在生成的 tsconfig.json 上面修改。
我们来试试效果,这一次不需要用到 package.json 里的 build script 了,直接在命令行运行 tsc
,它现在会根据你配置的 tsconfig.json 文件,按照你的要求来编译。
可以看到它依然按照要求在 dist 目录下生成编译后的 JS 文件,而且这一次的编译结果,和我们在 build script 里使用的 tsc src/ts/index.ts --outDir dist --target es6
这一长串命令是一样的。
所以正常工作中,我们都是使用 tsconfig.json 来管理 TypeScript 的配置的。
完整的选项可以查看 TypeScript 官网: tsconfig - typescriptlang
不过实际工作中,我们的项目都是通过一些脚手架创建的,例如 Vue CLI ,或者现在的 Create Vue 或者 Create Preset ,都会在创建项目模板的时候,帮你提前配置好通用的选项,你只需要在不满足条件的情况下去调整。
了解构建工具
在前端开发领域,构建工具可以帮我们解决很多问题:
- 新版本的 JS 代码好用,但有兼容问题,我们可以通过构建工具去转换成低版本 JS 的实现
- 项目好多代码可以复用,我们可以直接抽离成 模块 、 组件 ,交给构建工具去合并打包
- TypeScript 的类型系统和代码检查真好用,我们也可以放心写,交给构建工具去编译
- CSS 写起来好烦,我们可以使用 Sass 、 Less 等 CSS 预处理器 ,交给构建工具去编译
- 海量的 npm 包 开箱即用,剩下的工作交给构建工具去按需抽离与合并
- 项目上线前代码要混淆,人工处理太费劲,交给构建工具自动化处理
- 写不完的其他场景…
目前已经有很多流行的构建工具,例如: Gruntopen in new window 、 Gulpopen in new window 、 Webpackopen in new window 、 Snowpackopen in new window 、 Parcelopen in new window 、 Rollupopen in new window 、 Viteopen in new window … 每一个工具都有自己的特色。
基于我们主要开发 Vue 项目,在这里只介绍两个流行且强相关的工具: Webpack 和 Vite 。
Webpack
Webpack 是一个老牌的构建工具,前些年可以说几乎所有的项目都是基于 Webpack 构建的,生态最庞大,各种各样的插件最全面,对旧版本的浏览器支持程度也最全面。
点击访问:Webpack 官网open in new window
在升级与配置一章里的 使用 @vue/cli 创建项目 会指导你如何使用 Vue CLI 创建一个基于 Webpack 的 Vue 项目。
Vite
Vite 的作者也是我们熟悉的 Vue 作者尤雨溪,它是一个基于 ESM 实现的构建工具,主打更轻、更快的开发体验,主要面向现代浏览器,于 2021 年推出 2.x 版本之后,进入了一个飞速发展的时代,目前市场上的 npm 包基本都对 Vite 做了支持,用来做业务已经没有问题了。
毫秒级的开发服务启动和热重载,对 TypeScript 、 CSS 预处理器等常用开发工具都提供了开箱即用的支持,也兼容海量的 npm 包,如果你是先用 Webpack 再用的 Vite ,你会很快就喜欢上它!
点击访问:Vite 官网open in new window
在升级与配置一章里的 使用 Vite 创建项目 会指导你如何使用流行脚手架创建一个基于 Vite 的 Vue 项目。
两者的区别
在开发流程上, Webpack 会先打包,再启动开发服务器,访问开发服务器时,会把打包好的结果直接给过去,下面是 Webpack 使用的 bundler 机制的工作流程。
Vite 是基于浏览器原生的 ES Module ,所以不需要预先打包,而是直接启动开发服务器,请求到对应的模块的时候再进行编译,下面是 Vite 使用的 ESM 机制的工作流程。
所以当项目体积越大的时候,在开发启动速度上, Vite 和 Webpack 的差距会越来越大。
你可以点击 Vite 官网的这篇文章: 为什么选 Vite了解更多的技术细节。
构建方面,为了更好的加载体验,以及 Tree Shaking 按需打包 、懒加载和 Chunk 分割利于缓存,两者都需要进行打包;但由于 Vite 是面向现代浏览器,所以如果你的项目有兼容低版本浏览器的需求的话,建议还是用 Webpack 来打包,否则, Vite 是目前的更优解。
开发环境和生产环境
在使用构建工具的时候,需要了解一下 “环境” 的概念,对构建工具而言,会有 “开发环境( development )” 和 “生产环境( production )” 之分。
TIP
需要注意的是,这和业务上的 “测试 -> 预发 -> 生产” 那几个环境的概念是不一样的,业务上线流程的这几个环境,对于项目来说,都属于 “生产环境” ,因为需要打包部署。
开发环境
我们前面在编写 Hello TypeScript 这个 demo 的时候,使用了 npm run dev:ts
这样的命令来测试 TypeScript 代码的可运行性,你可以把这个阶段认为是我们的一个 “测试环境” ,这个时候代码不管怎么写,它都是 TypeScript 代码,不是最终要编译出来的 JavaScript 。
如果基于 Webpack 或者 Vite 这样的构建工具,测试环境提供了更多的功能,例如:
- 可以使用 TypeScript 、 CSS 预处理器之类的需要编译的语言提高开发效率
- 提供了热重载( Hot Module Replacement , 简称 HMR ),当你修改了代码之后,无需重新运行或者刷新页面,构建工具会检测你的修改自动帮你更新
- 代码不会压缩,并有 Source Mapping 源码映射,方便 BUG 调试
- 默认提供局域网服务,无需自己做本地部署
- 更多 …
生产环境
我们在 Hello TypeScript demo 最后配置的一个 npm run build
命令,将 TypeScript 代码编译成了 JavaScript ,这个时候 dist 文件夹下的代码文件就处于 “生产环境” 了,因为之后不论源代码怎么修改,都不会直接影响到它们,直到再次执行 build 编译。
可以看出生产环境和开发环境最大的区别就是稳定!除非你再次打包发布,否则不会影响到已部署的代码。
- 代码会编译为浏览器最兼容的版本,一些不兼容的新语法会进行 Polyfill
- 稳定,除非重新发布,否则不会影响到已部署的代码
- 打包的时候代码会进行压缩混淆,缩小项目的体积,也降低源码被直接曝光的风险
环境判断
在 Webpack ,你可以使用 process.env.NODE_ENV
来区分开发环境( development )还是生产环境( production ),它会返回当前所处环境的名称。
在 Vite ,你还可以通过判断 import.meta.env.DEV
为 true
时是开发环境,判断 import.meta.env.PROD
为 true
时是生产环境(这两个值永远相反)。
有关环境变量的问题可以查阅以下文档:
工具 | 文档 |
---|---|
Webpack | 模式open in new window |
Vite | 环境变量和模式open in new window |
本章结语
如果你之前很少接触或者完全没有接触过前端工程化的开发,直接通过构建工具上手 Vue 开发应该还是有一定门槛的,这一章主要是帮助你解决一些入门知识点方面的问题。
如果你有兴趣,可以根据知识点自行 Google 更多资料去延申阅读,在前端工程化,每个知识点都很值得深入研究。
升级与配置
截止至 2022 年 2 月 7 日, Vue 3 已成为新的默认版本,新老用户请先阅读下方 全新的 Vue 版本 一节,以了解默认版本带来的注意事项!
全新的 Vue 版本
Vue 3 被指定为默认版本之后,有一些注意事项需要留意:
使用 Vue 3
在 NPM 的 vue 版本主页 上面,会看到当前已使用 3.2.30
作为默认 latest
版本(也就是运行 npm i vue
默认会安装 Vue 3 了,无需再通过指定 next
版本)。
包括 vue-router
、 vuex
、vue-loader
和 @vue/test-utils
等相关的生态,同样不需要指定 next 版本了,都配合 Vue 3 指定了新的 latest 默认版本。
所有的文档和官方站点将默认切换到 Vue 3 版本,请查看 官方文档 一节了解最新的官方资源站点。
使用 Vue 2
如果还要用 Vue 2 ,需要手动指定 legacy
版本,也就是通过 npm i vue@legacy
才能安装到 Vue 2 。
Vue 2 相关的生态目前没有打 legacy
的 Tag,所以需要显式的指定版本号才可以安装到配套的程序,比如通过 npm i vue-router@3.5.3
才能安装到 Vue 2 配套的 Router 版本。
如果之前使用了 latest
标签或 *
从 npm 安装 Vue 或其他官方库,请确保项目的 package.json
能够明确使用兼容 Vue 2 的版本。
{
"dependencies": {
- "vue": "latest",
+ "vue": "^2.6.14",
- "vue-router": "latest",
+ "vue-router": "^3.5.3",
- "vuex": "latest"
+ "vuex": "^3.6.2"
},
"devDependencies": {
- "vue-loader": "latest",
+ "vue-loader": "^15.9.8",
- "@vue/test-utils": "latest"
+ "@vue/test-utils": "^1.3.0"
}
}
使用 Vite 创建项目
Vite 从 2021 年 1 月份发布 2.0 版本以来,发展非常快,我也在第一时间参与贡献了一些文档和插件,并且在 2021 年期间,个人项目已经全面切换到 Vite ,公司业务也在 2021 年底开始用 Vite 来跑新项目,整体情况非常稳定和乐观。
关于是否使用 Vite 和安利团队使用 Vue 3 ,可以看我在 2022 年春节前写的 Markdown工程师的一周 一文,我是非常推荐升级技术栈的。
在这里我推荐以下这几种创建 Vite 项目的方式:Create Vite 、 Create Vue 和 Create Preset 。
Create Vite
create-vite 是 Vite 官方推荐的一个脚手架工具,可以创建基于 Vite 的不同技术栈基础模板。
npm create vite
然后按照命令行的提示操作(选择 vue
技术栈进入),即可创建一个基于 Vite 的基础空项目。
:::tip
不过这里的项目非常基础,啥也没有,如果你要用到 Router 、 Vuex 、 ESLint 等程序,都需要再自己安装和配置,所以推荐使用 Create Preset 。
:::
Create Vue
create-vue 是 Vue 官方推出的一个新脚手架,可以创建基于 Vite 的 Vue 基础模板。
npm init vue@3
然后根据命令行的提示操作。
Create Preset
create-preset 是 Awesome Starter 的 CLI 脚手架,提供快速创建预设项目的能力,可以创建一些有趣实用的项目启动模板,也可以用来管理你的常用项目配置。
npm create preset
也是按照命令行的提示操作(选择 vue
技术栈进入,选择 vue3-ts-vite 或者其他社区模板),即可创建基于 Vite 的模板项目。
你也可以像使用 @vue/cli
一样,全局安装到本地,通过 preset init
命令来创建项目。
# 全局安装
npm install -g create-preset
# 查看是否安装成功(成功则输出版本号)
preset -v
# 创建项目
preset i
点击 Create Preset 官方文档 查看完整使用教程。
注意事项
虽然 Vite 和 Webpack 在开发体验上差不多,但本质存在很大的差异,特别是依赖包只能使用 ESM 版本,开发期间请多参考 Vite 官网 的资料,也可以发邮件和我交流。
使用 @vue/cli 创建项目
如果你不习惯 Vite ,依然可以使用 Vue CLI 作为开发脚手架。
更新 CLI 脚手架
老规矩,还是全局安装,把脚手架更新到最新版本(最低版本要求在 4.5.6
以上才能支持 Vue 3.0 )。
npm install -g @vue/cli
使用 CLI 创建 3.x 项目
还是熟悉的 create
命令。
vue create hello-vue3
由于我们要使用 TS ,所以需要选择最后一个选项来进行自定义搭配。
Vue CLI v5.0.4
? Please pick a preset:
Default ([Vue 3] babel, eslint)
Default ([Vue 2] babel, eslint)
> Manually select features
然后我们按空格选中需要的依赖,总共选择了下面这些:
Vue CLI v5.0.4
? Please pick a preset: Manually select features
? Check the features needed for your project: (Press <space> to select,
<a> to toggle all, <i> to invert selection, and <enter> to proceed)
(*) Babel
(*) TypeScript
( ) Progressive Web App (PWA) Support
(*) Router
(*) Vuex
(*) CSS Pre-processors
>(*) Linter / Formatter
( ) Unit Testing
( ) E2E Testing
选择 Vue 版本,我们要用 Vue 3 所以需要选择 3.x 。
? Choose a version of Vue.js that you want to start the project with (Use arrow keys)
> 3.x
2.x
是否选择 class 语法的模板,虽然这个选项是针对 TypeScript 的,在 2.x 版本为了更好的写 TS ,通常需要使用 class 语法,但是因为 Vue 3 有了对 TypeScript 支持度更高的 Composition API ,所以我们选择 n
,也就是 “否” 。
? Use class-style component syntax? (y/N) n
Babel 可以把一些现代版本的代码转换为兼容性更好的 JS 版本,所以选 y
确认。
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills,
transpiling JSX)? (Y/n) y
路由模式( Hash 还是 History ),这个根据自己项目情况选择,你可以先选 y
确认,回头遇到部署的问题可以在 “路由” 一章的 部署问题与服务端配置 小节查看怎么处理。
? Use history mode for router? (Requires proper server setup for index fallback
in production) (Y/n) y
选择一个 CSS 预处理器,可以根据自己的喜好选择,不过鉴于目前开源社区组件常用的都是 Lessopen in new window ,所以也建议先选 Less 作为预处理器的入门。
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported
by default):
Sass/SCSS (with dart-sass)
> Less
Stylus
Lint 规则,用来代码检查,写 TypeScript 离不开 Lint ,可以根据自己喜好选择,也可以先选择默认,后面在 添加协作规范 一节也有说明如何配置规则,这里我们先默认第一个。
? Pick a linter / formatter config: (Use arrow keys)
> ESLint with error prevention only
ESLint + Airbnb config
ESLint + Standard config
ESLint + Prettier
Lint 的校验时机,一个是在保存时校验,一个是在提交 commit 的时候才校验,这里我们也选默认。
? Pick additional lint features: (Press <space> to select,
<a> to toggle all, <i> to invert selection, and <enter> to proceed)
>(*) Lint on save
( ) Lint and fix on commit
项目配置文件,我习惯独立文件。
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
> In dedicated config files
In package.json
是否保存为未来的项目配置,存起来方便以后快速创建。
? Save this as a preset for future projects? Yes
? Save preset as: vue-3-ts-config
至此,项目创建完成!
你可以跟原来一样,通过 npm run serve
开启热更进行开发调试,通过 npm run build
构建打包上线。
添加项目配置
用脚手架最重要的一个配置文件就是 vue.config.js
了,你可以拷贝你之前项目下的这个文件过来,就立即可以用。
具体的各个选项说明和调整可以参考官网的说明文档:配置参考 | Vue CLI
调整 TS Config
如果你在 vite.config.ts
或者 vue.config.js
设置了 alias 的话,因为 TypeScript 不认识里面配置的 alias 别名,所以需要再对 tsconfig.json
做一点调整,增加对应的 path ,否则 TS 不认识。
比如引入 @cp/HelloWorld.vue
的时候, TypeScript 不知道等价于 src/components/HelloWorld.vue
,从而会报错找不到该模块。
假设你在 vite.config.ts
里配置了这些 alias :
export default defineConfig({
// ...
resolve: {
alias: {
'@': resolve('src'), // 源码根目录
'@img': resolve('src/assets/img'), // 图片
'@less': resolve('src/assets/less'), // 预处理器
'@libs': resolve('src/libs'), // 本地库
'@plugins': resolve('src/plugins'), // 本地插件
'@cp': resolve('src/components'), // 公共组件
'@views': resolve('src/views'), // 路由组件
},
},
// ...
})
那么在你的 tsconfig.json 就需要相应的加上这些 paths :
{
"compilerOptions": {
// ...
"paths": {
"@/*": ["src/*"],
"@img/*": ["src/assets/img/*"],
"@less/*": ["src/assets/less/*"],
"@libs/*": ["src/libs/*"],
"@plugins/*": ["src/plugins/*"],
"@cp/*": ["src/components/*"],
"@views/*": ["src/views/*"]
},
// ...
},
// ...
}
添加协作规范
考虑到后续可能会有团队协作,我们最好是能够统一编码风格,所以建议在项目根目录下再增加一个 .editorconfig
文件。
这个文件的作用是强制编辑器以该配置来进行编码,比如缩进统一为空格而不是 Tab ,每次缩进都是 2 个空格而不是 4 个等等。
文件内容如下:
# http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 80
trim_trailing_whitespace = true
[*.md]
max_line_length = 0
trim_trailing_whitespace = false
具体的参数说明可参考:项目代码风格统一神器 editorconfig的作用与配置说明
:::tip
部分编辑器可能需要安装对应的插件才可以支持该配置。
例如 VSCode 需要安装 EditorConfig for VS Code。
:::
添加 VSCode 插件
要问现在前端用的最多的编辑器是哪个,肯定是 VS Code 了,这里推荐几个非常舒服的 VS Code 插件,可以通过插件中心安装,也可以通过官方应用市场下载。
Prettier
Prettier是目前最流行的代码格式化工具,可以约束你的代码风格不会乱七八糟,目前你所知道的知名项目(如 Vue 、 Vite 、 React 等)和大厂团队(谷歌、微软、阿里、腾讯等)都在使用 Prettier 来格式化代码。
通过脚手架创建的项目很多都内置了 Prettier 功能集成(例如 Create Preset ,参考了主流的格式化规范,比如 2 个空格的缩进、无需写分号结尾、数组 / 对象每一项都带有尾逗号等等)。
如果需要手动增加功能支持,请在项目根目录下创建一个 .prettierrc
文件,写入以下内容:
{
"semi": false,
"singleQuote": true
}
这代表 JavaScript / TypeScript 代码一般情况下不需要加 ;
分号结尾,然后使用 ''
单引号来定义字符串等变量。
这里只需要写入与默认配置不同的选项即可,如果和默认配置一致,可以省略,完整的配置选项以及默认值可以在 Prettier 官网的 Options Docs 查看。
配合 VSCode 的 VSCode Prettier 扩展,可以在编辑器里使用这个规则来格式化文件。
如果你开启了 ESLint ,配合 ESLint 的代码提示,可以更方便的体验格式化排版,详见 ESLint 一节的说明。
TIP
配合 VSCode Prettier 扩展 ,这份配置直接在 VSCode 里生效,如果配合 ESLint 使用,需要安装 prettieropen in new window 依赖。
ESLint
ESLint是一个查找 JS / TS 代码问题并提供修复建议的工具,换句话说就是可以约束你的代码不会写出一堆 BUG ,它是代码强健性的重要保障。
虽然大部分前端开发者都不愿意接受这些约束(当年我入坑的时候也是),但说实话,经过 ESLint 检查过的代码质量真的高了很多,如果你不愿意总是做一个游兵散勇,建议努力让自己习惯被 ESLint 检查,大厂和大项目都是有 ESLint 检查的。
特别是写 TypeScript ,配合 ESLint 的检查实在太爽了(字面意思,真的很舒服)。
通过脚手架创建的项目通常都会帮你配置好 ESLint 规则,如果有一些项目是一开始没有,后面想增加,你也可以手动配置。
这里以一个 Vite + TypeScript + Prettier 的 Vue 3 项目为例,在项目根目录下创建一个 .eslintrc.js
文件,写入以下内容:
module.exports = {
root: true,
env: {
node: true,
browser: true,
},
extends: ['plugin:vue/vue3-essential', 'eslint:recommended', 'prettier'],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'prettier'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'prettier/prettier': 'warn',
'vue/multi-word-component-names': 'off',
},
globals: {
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly',
},
}
然后安装对应的依赖(记得添加 -D
参数添加到 devDependencies
,因为都是开发环境下使用的):
- eslintopen in new window
- eslint-config-prettieropen in new window
- eslint-plugin-prettieropen in new window
- eslint-plugin-vueopen in new window
- @typescript-eslint/eslint-pluginopen in new window
- @typescript-eslint/parseropen in new window
- prettieropen in new window
就可以在项目中生效了,一旦代码有问题, ESLint 就会帮你检查出来并反馈具体的报错原因,久而久之你的代码就会越写越规范。
更多的选项可以在 ESLint 官网的 Configuring ESLintopen in new window 查阅。
如果有一些文件需要排除检查,可以再创建一个 .eslintignore
文件在项目根目录下,里面添加要排除的文件或者文件夹名称:
dist/*
更多的排除规则可以在 ESLint 官网的 The .eslintignore Fileopen in new window 一文查阅。
安装 VSCode
要问现在前端工程师用的最多的代码编辑器是哪个,肯定是 Visual Studio Code 了!
与其他的编辑器相比,有这些优点:
- 背靠 Microsoft ,完全免费并且开源,开箱即用
- 可以通过简单的配置调整来满足你之前在其他编辑器上的习惯( e.g. Sublime Text )
- 轻量级但功能强大,内置了对 JavaScript、TypeScript 和 Node.js 的支持,
- 丰富的插件生态,可以根据你的需要,安装提高编码效率的功能支持,以及其他的语言扩展
- 智能的代码补全、类型推导、代码检查提示、批量编辑、引用跳转、比对文件等功能支持
- 登录你的 GitHub 账号即可实现配置自动同步,在其他电脑上直接使用你的最习惯配置和插件
当然,还有非常多优点,欢迎体验!
点击下载:Visual Studio Codeopen in new window
一般情况下开箱即用,无门槛,你也可以阅读官方文档了解一些个性化的配置。
添加 VSCode 插件
VSCode 本身是轻量级的,也就是只提供最基础的功能,更优秀的体验或者个性化体验,是需要我们通过插件来启用的。
这里推荐几个非常舒服的 VSCode 插件,可以通过插件中心安装,也可以通过官方应用市场下载。
Chinese (Simplified)
VSCode 安装后默认是英文本,需要自己进行汉化配置, VSCode 的特色就是插件化处理各种功能,语言方面也一样。
安装该插件并启用,即可让 VSCode 显示为简体中文。
点击下载:Chinese (Simplified)
Volar
Vue 官方推荐的 VSCode 扩展,用以代替 Vue 2 时代的 Vetur ,提供了 Vue 3 的语言支持、 TypeScript 支持、基于 vue-tsc的类型检查等功能。
点击下载:Volar
Vue VSCode Snippets
从实际使用 Vue 的角度提供 Vue 代码片段的生成,可以通过简单的命令,在 .vue 文件里实现大篇幅的代码片段生成,最新版本已基于 Volar 构建。
e.g.
- 输入
ts
可以快速创建一个包含了template
+script
+style
的 Vue 模板(可选 2.x 、3.x 以及 class 风格的模板) - 也可以通过输入带有
v3
开头的指令来快速生成 Vue 3 的 API 。
下面是输入了 ts
两个字母之后,用箭头选择 vbase-3-ts
自动生成的一个模板片段,在开发过程中非常省事:
<template>
<div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
return {}
}
})
</script>
<style scoped>
</style>
点击下载:Vue VSCode Snippetsopen in new window
TIP
为啥我要推荐这个 vue-vscode-snippets
,而不是 Vue3snippets
,原因可以看我之前记录的一段揪心的经历…一言难尽,太惨了……
解决vscode保存vue文件时 压缩stylus代码为一行以及无法注释template的问题open in new window
Auto Close Tag
可以快速帮你完成 HTML 标签的闭合,除非你熟悉 jsx
/ tsx
,否则在写 template
的时候肯定用得上。
点击下载:Auto Close Tagopen in new window
Auto Rename Tag
假如你要把 div
修改为 section
,不需要再把 <div>
然后找到代码尾部的 </div>
才能修改,只需要选中前面的半个标签,直接修改,插件会自动帮你把闭合部分也同步修改,对于篇幅比较长的代码调整非常有帮助。
点击下载:Auto Rename Tagopen in new window
EditorConfig for VSCode
一个可以让编辑器遵守协作规范的插件,详见 添加协作规范 。
点击下载:EditorConfig for VSCodeopen in new window
VSCode Prettier
这是 Prettier 在 VSCode 的一个扩展,不论你的项目有没有安装 Pretter 依赖,安装该扩展之后,单纯在 VSCode 也可以使用 Pretter 来进行代码格式化。
点击下载:Prettier - Code formatteropen in new window
点击访问:Prettier 官网open in new window 了解更多配置。
VSCode ESLint
这是 ESLint 在 VSCode 的一个扩展, TypeScript 项目基本都开了 ESLint ,编辑器也建议安装该扩展支持。
点击下载:VSCode ESLintopen in new window
点击访问:ESLint 官网open in new window 了解更多配置。
其他插件
其他的比如预处理器相关的,Git 相关的,可以根据自己的需求到插件市场里搜索安装。
项目初始化
至此,脚手架已经帮我们搭好了一个可直接运行的基础项目,已经可以正常的 serve
和 build
了,项目配置和编辑器也都弄好了,是不是可以开始写代码了?
不急,还需要了解一点东西,就是如何初始化一个 3.x 项目。
因为在实际开发过程中,我们还会用到各种 NPM 包,像 UI 框架、插件的引入都是需要在初始化阶段处理。
甚至有时候还要脱离脚手架,采用 CDN 引入的方式来开发,所以开始写组件之前,我们还需要了解一下在 3.x 项目中,初始化阶段的一些变化。
入口文件
项目的初始化都是在入口文件集中处理,3.x 的目录结构对比 2.x 没变化,入口文件依然还是 main.ts
但 3.x 在初始化的时候,做了不少的调整,可以说是面目全非,但是这次改动我认为是好的,因为统一了使用方式,不再跟 2.x 那样很杂。
回顾 2.x
先回顾一下 2.x,在 2.x,在导入各种依赖之后,通过 new Vue
来执行 Vue 的初始化;相关的 Vue 生态和插件,有的使用 Vue.use
来进行初始化,有的是作为 new Vue
的入参。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import xxx from 'xxx'
Vue.use(xxx);
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
了解 3.x
在 3.x ,是通过 createApp
来执行 Vue 的初始化,另外不管是 Vue 生态里的东西,还是外部插件、 UI 框架,统一都是由 use
来激活初始化,非常统一和简洁。
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import xxx from 'xxx'
createApp(App)
.use(store)
.use(router)
.use(xxx)
.mount('#app')
本章结语
这一章就到这里了,对比 2.x 来说,大体上还是很相似的,但是也有个别调整需要注意了解,比如上面最后提到的入口文件,对于后续的开发工作是非常重要的。
其他的变化,会在每一节涉及到的内容里面,再单独和 2.x 进行对比,这样比较能加深各个功能模块的记忆。
单组件的编写
项目搭好了,第一个要了解的肯定是组件的变化,由于这部分篇幅会非常大,所以会分成很多个小节,一部分一部分按照开发顺序来逐步了解。
btw: 出于对 Vue 3.0 的尊敬,以及前端的发展趋势,我们这一次是打算直接使用 TypeScript
来编写组件,对 TS 不太熟悉的同学,建议先对 TS 有一定的了解,然后一边写一边加深印象。
全新的 setup 函数
在开始编写组件之前,我们需要了解两个全新的前置知识点:setup
与 defineComponent
。
了解 setup
Vue 3.x 的 composition api
系列里,推出了一个全新的 setup
函数,它是一个组件选项,在创建组件之前执行,一旦 props 被解析,并作为组合式 API 的入口点。
:::tip
说的通俗一点,就是使用 Vue 3.x 的生命周期的情况下,整个组件相关的业务代码,都可以丢到 setup
里编写。
因为在 setup
之后,其他的生命周期才会被启用(点击了解:组件的生命周期)。
:::
基本语法:
import { defineComponent } from 'vue'
export default defineComponent({
setup (props, context) {
// 业务代码写这里...
return {
// 需要给template用的数据、函数放这里return出去...
}
}
})
这里我还写了一个 defineComponent
,也是本次的新东西,可以点击 了解 defineComponent 。
:::warning
使用 setup
的情况下,请牢记一点:不能再用 this
来获取 Vue 实例,也就是无法通过 this.xxx
、 this.fn()
这样来获取实例上的数据,或者执行实例上的方法。
全新的 3.x 组件编写,请继续往下看,会一步一步做说明。
:::
setup 的参数使用
setup
函数包含了两个入参:
参数 | 类型 | 含义 | 是否必传 |
---|---|---|---|
props | object | 由父组件传递下来的数据 | 否 |
context | object | 组件的执行上下文 | 否 |
第一个参数 **props**
:
它是响应式的(只要你不解构它,或者使用 toRef / toRefs 进行响应式解构),当传入新的 prop 时,它将被更新。
第二个参数 **context**
:
context
只是一个普通的对象,它暴露三个组件的 property:
属性 | 类型 | 作用 |
---|---|---|
attrs | 非响应式对象 | props 未定义的属性都将变成 attrs |
slots | 非响应式对象 | 插槽 |
emit | 方法 | 触发事件 |
因为 context
只是一个普通对象,所以你可以直接使用 ES6 解构。
平时使用可以通过直接传入 { emit }
,即可用 emit('xxx')
来代替使用 context.emit('xxx')
,另外两个功能也是如此。
但是 attrs
和 slots
请保持 attrs.xxx
、slots.xxx
来使用他们数据,不要解构这两个属性,因为他们虽然不是响应式对象,但会随组件本身的更新而更新。
两个参数的具体使用,可以详细了解可查阅 组件之间的通信 一章。
了解 defineComponent
这是 Vue 3.x 推出的一个全新 API ,defineComponent
可以用于 TypeScript
的类型推导,帮你简化掉很多编写过程中的类型定义。
比如,你原本需要这样才可以使用 setup
:
import { Slots } from 'vue'
// 声明props和return的数据类型
interface Data {
[key: string]: unknown
}
// 声明context的类型
interface SetupContext {
attrs: Data
slots: Slots
emit: (event: string, ...args: unknown[]) => void
}
// 使用的时候入参要加上声明,return也要加上声明
export default {
setup(props: Data, context: SetupContext): Data {
// ...
return {
// ...
}
}
}
是不是很繁琐?(肯定是啊!不用否定……
使用了 defineComponent
之后,你就可以省略这些类型定义:
import { defineComponent } from 'vue'
export default defineComponent({
setup (props, context) {
// ...
return {
// ...
}
}
})
而且不只适用于 setup
,只要是 Vue 本身的 API ,defineComponent
都可以自动帮你推导。
在编写组件的过程中,你只需要维护自己定义的数据类型就可以了,专注于业务。
组件的生命周期
在了解了两个前置知识点之后,也还不着急写组件,我们还需要先了解组件的生命周期,你才能够灵活的把控好每一处代码的执行结果达到你的预期。
升级变化
从 2.x 升级到 3.x,在保留对 2.x 的生命周期支持的同时,3.x 也带来了一定的调整。
:::tip
3.x 依然支持 2.x 的生命周期,但是不建议混搭使用,前期你可以继续使用 2.x 的生命周期,但还是建议尽快熟悉并完全使用 3.x 的生命周期来编写你的组件。
:::
生命周期的变化,可以直观的从下表了解:
2.x 生命周期 | 3.x 生命周期 | 执行时间说明 |
---|---|---|
beforeCreate | setup | 组件创建前执行 |
created | setup | 组件创建后执行 |
beforeMount | onBeforeMount | 组件挂载到节点上之前执行 |
mounted | onMounted | 组件挂载完成后执行 |
beforeUpdate | onBeforeUpdate | 组件更新之前执行 |
updated | onUpdated | 组件更新完成之后执行 |
beforeDestroy | onBeforeUnmount | 组件卸载之前执行 |
destroyed | onUnmounted | 组件卸载完成后执行 |
errorCaptured | onErrorCaptured | 当捕获一个来自子孙组件的异常时激活钩子函数 |
其中,在3.x,setup
的执行时机比 2.x 的 beforeCreate
和 created
还早,可以完全代替原来的这 2 个钩子函数。
另外,被包含在 <keep-alive>
中的组件,会多出两个生命周期钩子函数:
2.x 生命周期 | 3.x 生命周期 | 执行时间说明 |
---|---|---|
activated | onActivated | 被激活时执行 |
deactivated | onDeactivated | 切换组件后,原组件消失前执行 |
使用 3.x 的生命周期
在 3.x ,每个生命周期函数都要先导入才可以使用,并且所有生命周期函数统一放在 setup
里运行。
如果你需要在达到 2.x 的 beforeCreate
和 created
目的的话,直接把函数执行在 setup
里即可。
比如:
import { defineComponent, onBeforeMount, onMounted } from 'vue'
export default defineComponent({
setup () {
console.log(1);
onBeforeMount( () => {
console.log(2);
});
onMounted( () => {
console.log(3);
});
console.log(4);
return {}
}
})
最终将按照生命周期的顺序输出:
// 1
// 4
// 2
// 3
组件的基本写法
btw:官网的例子片段挺多,使用 JavaScript
基本上没啥问题,故这里只讲述如何通过 TypeScript
来编写一个组件。
如果你是从 2.x 就开始写 TS 的话,应该知道在 2.x 的时候就已经有了 extend
和 class component
的基础写法;3.x 在保留 class 写法的同时,还推出了 defineComponent
+ composition api
的新写法。
加上视图部分又有 template
和 tsx
的写法、以及 3.x 对不同版本的生命周期兼容,累计下来,在 Vue 里写 TS ,至少有 9 种不同的组合方式(我的认知内,未有更多的尝试),堪比孔乙己的回字(甚至吊打回字……
我们先来回顾一下这些写法组合分别是什么,了解一下 3.x 最好使用哪种写法:
回顾 2.x
在 2.x ,为了更好的 TS 推导,用的最多的还是 class component
的写法。
适用版本 | 基本写法 | 视图写法 |
---|---|---|
2.x | Vue.extend | template |
2.x | class component | template |
2.x | class component | tsx |
了解 3.x
目前 3.x 从官方对版本升级的态度来看, defineComponent
就是为了解决之前 2.x 对 TS 推导不完善等问题而推出的,尤大也是更希望大家习惯 defineComponent
的使用。
适用版本 | 基本写法 | 视图写法 | 生命周期版本 | 官方是否推荐 |
---|---|---|---|---|
3.x | class component | template | 2.x | × |
3.x | defineComponent | template | 2.x | × |
3.x | defineComponent | template | 3.x | √ |
3.x | class component | tsx | 2.x | × |
3.x | defineComponent | tsx | 2.x | × |
3.x | defineComponent | tsx | 3.x | √ |
btw: 我本来还想把每种写法都演示一遍,但写到这里,看到这么多种组合,我累了……
所以从接下来开始,都会以 defineComponent
+ composition api
+ template
的写法,并且按照 3.x 的生命周期来作为示范案例。
接下来,使用 composition api
来编写组件,先来实现一个最简单的 Hello World!
。
:::warning
在 3.x ,只要你的数据要在 template
中使用,就必须在 setup
里return出来。
当然,只在函数中调用到,而不需要渲染到模板里的,则无需 return 。
:::
<template>
<p class="msg">{{ msg }}</p>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
const msg = 'Hello World!';
return {
msg
}
}
})
</script>
<style lang="stylus" scoped>
.msg
font-size 14px
</style>
和 2.x 一样,都是 template
+ script
+ style
三段式组合,上手非常简单。
template
和 2.x 可以说是完全一样(会有一些不同,比如 router-link
移除了 tag
属性等等,后面讲到了会说明)
style
则是根据你熟悉的预处理器或者原生 CSS 来写的,完全没有变化。
变化最大的就是 script
部分了。
响应式数据的变化
响应式数据是 MVVM 数据驱动编程的特色,相信大部分人当初入坑 MVVM 框架,都是因为响应式数据编程比传统的操作 DOM 要来得方便,而选择 Vue ,则是方便中的方便。
设计上的变化
作为最重要的一个亮点, Vue 3 的响应式数据在设计上和 Vue 2 有着很大的不同。
回顾 Vue 2
Vue 2 是使用了 Object.defineProperty 的 getter/setter 来实现数据的响应性,这个方法的具体用法可以参考 MDN 的文档: Object.defineProperty - MDN 。
这里我们用这个方法来实现一个简单的双向绑定 demo ,亲自试一下可以有更多的理解:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DefineProperty Demo</title>
</head>
<body>
<!-- 输入框和按钮 -->
<div>
<input type="text" id="input" />
<button onclick="vm.text = 'Hello World'">设置为 Hello World</button>
</div>
<!-- 输入框和按钮 -->
<!-- 文本展示 -->
<div id="output"></div>
<!-- 文本展示 -->
<script>
// 定义一个响应式数据
const vm = {}
Object.defineProperty(vm, 'text', {
set(value) {
document.querySelector('#input').value = value
document.querySelector('#output').innerText = value
}
})
// 处理输入行为
document.querySelector('#input').oninput = function(e) {
vm.text = e.target.value
}
</script>
</body>
</html>
这个小 demo 实现了这两个功能:
- 输入框的输入行为只修改 vm.text 的数据,但会同时更新 output 标签的文本内容
- 点击按钮修改 vm.text 的数据,也会触发输入框和 output 文本的更新
当然 Vue 做了非常多的工作,而非只是简单的调用了 Object.defineProperty ,可以查阅 Vue 2 的官网文档了解更多关于 2.x 的响应式原理。
可以在 深入 Vue 2 的响应式原理 了解更多这部分的内容。
了解 Vue 3
Vue 3 是使用了 Proxy 的 getter/setter 来实现数据的响应性,这个方法的具体用法可以参考 MDN 的文档: Proxy - MDN。
同样的,我们也来实现一个简单的双向绑定 demo ,这次用 Proxy 来实现:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Proxy Demo</title>
</head>
<body>
<!-- 输入框和按钮 -->
<div>
<input type="text" id="input" />
<button onclick="vm.text = 'Hello World'">设置为 Hello World</button>
</div>
<!-- 输入框和按钮 -->
<!-- 文本展示 -->
<div id="output"></div>
<!-- 文本展示 -->
<script>
// 定义一个响应式数据
const vm = new Proxy({}, {
set(obj, key, value) {
document.querySelector('#input').value = value
document.querySelector('#output').innerText = value
}
})
// 处理输入行为
document.querySelector('#input').oninput = function(e) {
vm.text = e.target.value
}
</script>
</body>
</html>
实现的功能和 Object.defineProperty 的 demo 是完全一样的,而且也都是基于 setter 行为来完成我们的实现,那么为什么 Vue 3 要舍弃 Object.defineProperty ,换成 Proxy 呢?
主要原因在于 Object.defineProperty 有以下的不足:
- 无法监听数组下标的变化,通过 arr[i] = newValue 这样的操作无法实时响应
- 无法监听数组长度的变化,例如通过 arr.length = 10 去修改数组长度,无法响应
- 只能监听对象的属性,对于整个对象需要遍历,特别是多级对象更是要通过嵌套来深度监听
- 使用 Object.assign() 等方法给对象添加新属性时,也不会触发更新
- 更多细节上的问题 …
这也是为什么 Vue 2 要提供一个 Vue.set API的原因,你可以在 Vue 2 中更改检测的注意事项 了解更多说明。
而这些问题在 Proxy 都可以得到解决。
可以在 深入 Vue 3 的响应式原理了解更多这部分的内容。
用法上的变化
本指南只使用 Composition API 来编写组件,这是使用 Vue 3 的最大优势。
TIP
虽然官方文档做了一定的举例,但实际用起来还是会有一定的坑,比如可能你有些数据用着用着就失去了响应……
这些情况不是 bug ,(:з)∠)而是你用的姿势不对……
相对来说官方文档并不会那么细致的去提及各种场景的用法,包括在 TypeScript 中的类型定义,所以本章节主要通过踩坑心得的思路来复盘一下这些响应式数据的使用。
相对于 2.x 在 data 里定义后即可通过 this.xxx 来调用响应式数据,3.x 的生命周期里取消了 Vue 实例的 this,你要用到的比如 ref 、reactive 等响应式 API ,都必须通过导入才能使用,然后在 setup 里定义。
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup () {
const msg = ref<string>('Hello World!');
return {
msg
}
}
})
由于新的 API 非常多,但有些使用场景却不多,所以当前暂时只对常用的几个 API 做使用和踩坑说明,更多的 API 可以在官网查阅。
响应式 API 之 ref
ref
是最常用的一个响应式 API,它可以用来定义所有类型的数据,包括 Node 节点。
没错,在 2.x 常用的 this.$refs.xxx
来取代 document.querySelector('.xxx')
获取 Node 节点的方式,也是用这个 API 来取代。
类型声明
在开始使用 API 之前,要先了解一下在 TypeScript
中,ref
需要如何进行类型声明。
平时我们在定义变量的时候,都是这样给他们进行类型声明的:
// 单类型
const msg: string = 'Hello World!';
// 多类型
const phoneNumber: number | string = 13800138000;
但是在使用 ref
时,不能这样子声明,会报错,正确的声明方式应该是使用 <>
来包裹类型定义,紧跟在 ref
API 之后:
// 单类型
const msg = ref<string>('Hello World!');
// 多类型
const phoneNumber = ref<number | string>(13800138000);
变量的定义
了解了如何进行类型声明之后,对变量的定义就没什么问题了,前面说了它可以用来定义所有类型的数据,包括 Node 节点,但不同类型的值之间还是有少许差异和注意事项,具体可以参考如下。
基本类型
对字符串、布尔值等基本类型的定义方式,比较简单:
// 字符串
const msg = ref<string>('Hello World!');
// 数值
const count = ref<number>(1);
// 布尔值
const isVip = ref<boolean>(false);
引用类型
对于对象、数组等引用类型也适用,比如要定义一个对象:
// 声明对象的格式
interface Member {
id: number,
name: string
};
// 定义一个成员对象
const userInfo = ref<Member>({
id: 1,
name: 'Tom'
});
定义一个普通数组:
// 数字数组
const uids = ref<number[]>([ 1, 2, 3 ]);
// 字符串数组
const names = ref<string[]>([ 'Tom', 'Petter', 'Andy' ]);
定义一个对象数组:
// 声明对象的格式
interface Member {
id: number,
name: string
};
// 定义一个成员组
const memberList = ref<Member[]>([
{
id: 1,
name: 'Tom'
},
{
id: 2,
name: 'Petter'
}
]);
DOM 元素与子组件
除了可以定义数据,ref
也有我们熟悉的用途,就是用来挂载节点,也可以挂在子组件上。
对于 2.x 常用的 this.$refs.xxx
来获取 DOM 元素信息,该 API 的使用方式也是同样:
模板部分依然是熟悉的用法,把 ref 挂到你要引用的 DOM 上。
<template>
<!-- 挂载DOM元素 -->
<p ref="msg">
留意该节点,有一个ref属性
</p>
<!-- 挂载DOM元素 -->
<!-- 挂载子组件 -->
<Child ref="child" />
<!-- 挂载子组件 -->
</template>
script
部分有三个最基本的注意事项:
:::tip
- 定义挂载节点后,也是必须通过
xxx.value
才能正确操作到挂载的 DOM 元素或组件(详见下方的变量的读取与赋值); - 请保证视图渲染完毕后再执行 DOM 或组件的相关操作(需要放到生命周期的
onMounted
或者nextTick
函数里,这一点在 2.x 也是一样); - 该变量必须
return
出去才可以给到template
使用(这一点是 3.x 生命周期的硬性要求,子组件的数据和方法如果要给父组件操作,也要return
出来才可以)。
:::
配合上面的 template
,来看看 script
部分的具体例子:
import { defineComponent, onMounted, ref } from 'vue'
import Child from '@cp/Child.vue'
export default defineComponent({
components: {
Child
},
setup () {
// 定义挂载节点,声明的类型详见下方附表
const msg = ref<HTMLElement | null>(null);
const child = ref<typeof Child | null>(null);
// 请保证视图渲染完毕后再执行节点操作 e.g. onMounted / nextTick
onMounted( () => {
// 比如获取DOM的文本
console.log(msg.value.innerText);
// 或者操作子组件里的数据
child.value.isShowDialog = true;
});
// 必须return出去才可以给到template使用
return {
msg,
child
}
}
})
关于 DOM 和子组件的 TS 类型声明,可参考以下规则:
节点类型 | 声明类型 | 参考文档 |
---|---|---|
DOM 元素 | 使用 HTML 元素接口 | HTML 元素接口 |
子组件 | 使用 typeof 获取子组件的类型 | typeof 操作符 |
另外,关于这一小节,有一个可能会引起 TS 编译报错的情况是,新版本的脚手架创建出来的项目会默认启用 --strictNullChecks
选项,会导致案例中的代码无法正常运行(报错 TS2531: Object is possibly 'null'.
)。
原因是:默认情况下 null
和 undefined
是所有类型的子类型,但开启了 strictNullChecks
选项之后,会使 null
和 undefined
只能赋值给 void
和它们各自,这虽然是个更为严谨的选项,但因此也会带来一些影响赶工期的额外操作。
有以下几种解决方案可以参考:
- 在涉及到相关操作的时候,对节点变量增加一个判断:
if ( child.value ) {
// 读取子组件的数据
console.log(child.value.num);
// 执行子组件的方法
child.value.sayHi('use if in onMounted');
}
- 通过 TS 的可选符 ? 来将目标设置为可选,避免出现错误(这个方式不能直接修改子组件数据的值)
// 读取子组件的数据
console.log(child.value?.num);
// 执行子组件的方法
child.value?.sayHi('use ? in onMounted');
- 在项目根目录下的
tsconfig.json
文件里,显式的关闭strictNullChecks
选项,关闭后,由自己来决定是否需要对null
进行判断:
{
"compilerOptions": {
// ...
"strictNullChecks": false
},
// ...
}
- 使用 any 类型来代替,但是写 TS 还是尽量不要使用 any ,满屏的 AnyScript 不如写回 JS 。
变量的读取与赋值
被 ref
包裹的变量会全部变成对象,不管你定义的是什么类型的值,都会转化为一个 ref 对象,其中 ref 对象具有指向内部值的单个 property .value
。
:::tip
读取任何 ref 对象的值都必须通过 xxx.value
才可以正确获取到。
:::
请牢记上面这句话,初拥 3.x 的同学很多 bug 都是由于这个问题引起的(包括我……
对于普通变量的值,读取的时候直接读变量名即可:
// 读取一个字符串
const msg: string = 'Hello World!';
console.log('msg的值', msg);
// 读取一个数组
const uids: number[] = [ 1, 2, 3 ];
console.log('第二个uid', uids[1]);
对 ref 对象的值的读取,切记!必须通过 value !
// 读取一个字符串
const msg = ref<string>('Hello World!');
console.log('msg的值', msg.value);
// 读取一个数组
const uids = ref<number[]>([ 1, 2, 3 ]);
console.log('第二个uid', uids.value[1]);
普通变量都必须使用 let
才可以修改值,由于 ref 对象是个引用类型,所以可以在 const
定义的时候,直接通过 .value
来修改。
// 定义一个字符串变量
const msg = ref<string>('Hi!');
// 1s后修改它的值
setTimeout(() => {
msg.value = 'Hello!'
}, 1000);
因此你在对接接口数据的时候,可以自由的使用 forEach
、map
、filter
等遍历函数来操作你的 ref 数组,或者直接重置它。
const data = ref<string[]>([]);
// 提取接口的数据
data.value = api.data.map( (item: any) => item.text );
// 重置数组
data.value = [];
问我为什么突然要说这个?因为涉及到下一部分的知识,关于 reactive
的。
响应式 API 之 reactive
reactive
是继 ref
之后最常用的一个响应式 API 了,相对于 ref
,它的局限性在于只适合对象、数组。
:::tip
使用 reactive
的好处就是写法跟平时的对象、数组几乎一模一样,但它也带来了一些特殊注意点,请留意赋值部分的特殊说明。
:::
类型声明与定义
reactive
的声明方式,以及定义方式,没有 ref
的变化那么大,就是和普通变量一样。
reactive 对象:
// 声明对象的格式
interface Member {
id: number,
name: string
};
// 定义一个成员对象
const userInfo: Member = reactive({
id: 1,
name: 'Tom'
});
reactive 数组:
// 普通数组
const uids: number[] = [ 1, 2, 3];
// 对象数组
interface Member {
id: number,
name: string
};
// 定义一个成员对象数组
const userList: Member[] = reactive([
{
id: 1,
name: 'Tom'
},
{
id: 2,
name: 'Petter'
},
{
id: 3,
name: 'Andy'
}
]);
变量的读取与赋值
reactive 对象在读取字段的值,或者修改值的时候,与普通对象是一样的。
reactive 对象:
// 声明对象的格式
interface Member {
id: number,
name: string
};
// 定义一个成员对象
const userInfo: Member = reactive({
id: 1,
name: 'Tom'
});
// 读取用户名
console.log(userInfo.name);
// 修改用户名
userInfo.name = 'Petter';
但是对于 reactive 数组,和普通数组会有一些区别。
先看看普通数组,重置,或者改变值,都是可以直接轻松的进行操作:
// 定义一个普通数组
let uids: number[] = [ 1, 2, 3 ];
// 从另外一个对象数组里提取数据过来
uids = api.data.map( item => item.id );
// 合并另外一个数组
let newUids: number[] = [ 4, 5, 6 ];
uids = [...uids, ...newUids];
// 重置数组
uids = [];
在 2.x 的时候,你在操作数组时,完全可以和普通数组那样随意的处理数据的变化,依然能够保持响应性。
但在 3.x ,如果你使用 reactive
定义数组,则不能这么搞了,必须只使用那些不会改变引用地址的操作。
:::tip
按照原来的思维去使用 reactive
数组,会造成数据变了,但模板不会更新的 bug ,如果你遇到类似的情况,可以从这里去入手排查问题所在。
:::
举个例子,比如你要从接口读取翻页数据的时候,通常要先重置数组,再异步添加数据:
如果你使用常规的重置,会导致这个变量失去响应性:
/**
* 不推荐使用这种方式
* 异步添加数据后,模板不会响应更新
*/
let uids: number[] = reactive([ 1, 2, 3 ]);
// 丢失响应性的步骤
uids = [];
// 异步获取数据后,模板依然是空数组
setTimeout( () => {
uids.push(1);
}, 1000);
要让模板那边依然能够保持响应性,则必须在关键操作时,不破坏响应性 API 的存在。
let uids: number[] = reactive([ 1, 2, 3 ]);
// 不会破坏响应性
uids.length = 0;
// 异步获取数据后,模板可以正确的展示
setTimeout( () => {
uids.push(1);
}, 1000);
特别注意
不要对通过 reactive
定义的对象进行解构,解构后得到的变量会失去响应性。
比如这些情况,在 2s 后都得不到新的 name 信息:
import { defineComponent, reactive } from 'vue'
interface Member {
id: number,
name: string
};
export default defineComponent({
setup () {
// 定义一个带有响应性的成员对象
const userInfo: Member = reactive({
id: 1,
name: 'Petter'
});
// 2s后更新userInfo
setTimeout( () => {
userInfo.name = 'Tom';
}, 2000);
// 这个变量在2s后不会同步更新
const newUserInfo: Member = {...userInfo};
// 这个变量在2s后不会再同步更新
const { name } = userInfo;
// 这样return出去给模板用,在2s后也不会同步更新
return {
...userInfo
}
}
})
响应式 API 之 toRef 与 toRefs
看到这里之前,应该对 ref
和 reactive
都有所了解了,为了方便开发者,Vue 3.x 还推出了 2 个与之相关的 API ,用于 reactive
向 ref
转换。
各自的作用
两个 API 的拼写非常接近,顾名思义,一个是只转换一个字段,一个是转换所有字段。
API | 作用 |
---|---|
toRef | 创建一个新的ref变量,转换 reactive 对象的某个字段为ref变量 |
toRefs | 创建一个新的对象,它的每个字段都是 reactive 对象各个字段的ref变量 |
我们先定义好一个 reactive
变量:
interface Member {
id: number,
name: string
};
const userInfo: Member = reactive({
id: 1,
name: 'Petter'
});
然后来看看这 2 个 API 应该怎么使用。
使用 toRef
toRef
接收 2 个参数,第一个是 reactive
对象, 第二个是要转换的 key
。
在这里我们只想转换 userInfo
里的 name
,只需要这样操作:
const name: string = toRef(userInfo, 'name');
这样就成功创建了一个 ref
变量。
之后读取和赋值就使用 name.value
,它会同时更新 name
和 userInfo.name
。
:::tip
在 toRef
的过程中,如果使用了原对象上面不存在的 key
,那么定义出来的变量的 value
将会是 undefined
。
如果你对这个不存在的 key
的 ref
变量,进行了 value
赋值,那么原来的对象也会同步增加这个 key
,其值也会同步更新。
:::
使用 toRefs
toRefs
接收 1 个参数,是一个 reactive
对象。
const userInfoRefs: Member = toRefs(userInfo);
这个新的 userInfoRefs
,本身是个普通对象,但是它的每个字段,都是与原来关联的 ref
变量。
为什么要进行转换
关于为什么要出这么 2 个 API ,官方文档没有特别说明,不过经过自己的一些实际使用,以及在写上一节 reactive
的 特别注意,可能知道一些使用理由。
ref
和 reactive
这两者的好处就不重复了,但是在使用的过程中,各自都有各自不方便的地方:
ref
虽然在template
里使用起来方便,但比较烦的一点是在script
里进行读取/赋值的时候,要一直记得加上.value
,否则bug就来了reactive
虽然在使用的时候,因为你知道它本身是一个Object
类型,所以你不会忘记foo.bar
这样的格式去操作,但是在template
渲染的时候,你又因此不得不每次都使用foo.bar
的格式去渲染
那么有没有办法,既可以在编写 script
的时候不容易出错,在写 template
的时候又比较简单呢?
于是, toRef
和 toRefs
因此诞生。
什么场景下比较适合使用它们
从便利性和可维护性来说,最好只在功能单一、代码量少的组件里使用,比如一个表单组件,通常表单的数据都放在一个对象里。
当然你也可以更猛一点就是把所有的数据都定义到一个 data
里,然后你再去 data
里面取…但是没有必要为了转换而转换。
在业务中的具体运用
这一部分我一直用 userInfo
来当案例,那就继续以一个用户信息表的小 demo 来做这个的演示吧。
在 **script**
部分:
- 先用
reactive
定义一个源数据,所有的数据更新,都是修改这个对象对应的值,按照对象的写法去维护你的数据 - 再通过
toRefs
定义一个给template
用的对象,它本身不具备响应性,但是它的字段全部是ref
变量 - 在
return
的时候,对toRefs
对象进行解构,这样导出去就是各个字段对应的ref
变量,而不是一整个对象
import { defineComponent, reactive, toRefs } from 'vue'
interface Member {
id: number,
name: string,
age: number,
gender: string
};
export default defineComponent({
setup () {
// 定义一个reactive对象
const userInfo = reactive({
id: 1,
name: 'Petter',
age: 18,
gender: 'male'
})
// 定义一个新的对象,它本身不具备响应性,但是它的字段全部是ref变量
const userInfoRefs = toRefs(userInfo);
// 2s后更新userInfo
setTimeout( () => {
userInfo.id = 2;
userInfo.name = 'Tom';
userInfo.age = 20;
}, 2000);
// 在这里结构toRefs对象才能继续保持响应式
return {
...userInfoRefs
}
}
})
在 **template**
部分:
由于 return
出来的都是 ref
变量,所以你在模板里直接使用 userInfo
各个字段的 key
即可。
<template>
<ul class="user-info">
<li class="item">
<span class="key">ID:</span>
<span class="value">{{ id }}</span>
</li>
<li class="item">
<span class="key">name:</span>
<span class="value">{{ name }}</span>
</li>
<li class="item">
<span class="key">age:</span>
<span class="value">{{ age }}</span>
</li>
<li class="item">
<span class="key">gender:</span>
<span class="value">{{ gender }}</span>
</li>
</ul>
</template>
需要注意的问题
请注意是否有相同命名的变量存在,比如上面在 return
给 template
使用时,解构 userInfoRefs
的时候已经包含了一个 name
字段,此时如果还有一个单独的变量也叫 name
。
:::tip
那么他们谁会生效,取决于谁排在后面。
因为 return
出去的其实是一个对象,在对象里,如果存在相同的 key
,则后面那个会覆盖前面的。
:::
这种情况下,会以单独定义的 name
为渲染数据。
return {
...userInfoRefs,
name
}
这种情况下,则是以 userInfoRefs
里的 name
为渲染数据。
return {
name,
...userInfoRefs
}
所以当你决定使用 toRef
和 toRefs
的时候,请注意这个特殊情况!
函数的定义和使用
在了解了响应式数据如何使用之后,接下来就要开始了解函数了。
在 2.x,函数都是放在 methods
对象里定义,然后再在 mounted
等生命周期或者模板里通过 click
使用。
但在 3.x 的生命周期里,和数据的定义一样,都是通过 setup
来完成。
:::tip
- 你可以在
setup
里定义任意类型的函数(普通函数、class 类、箭头函数、匿名函数等等) - 需要自动执行的函数,执行时机需要遵循生命周期
- 需要暴露给模板去通过
click
、change
等行为来触发的函数,需要把函数名在setup
里进行return
才可以在模板里使用
:::
简单写一下例子:
<template>
<p>{{ msg }}</p>
<!-- 在这里点击执行return出来的方法 -->
<button @click="changeMsg">修改MSG</button>
<!-- 在这里点击执行return出来的方法 -->
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
export default defineComponent({
setup () {
const msg = ref<string>('Hello World!');
// 这个要暴露给模板使用,必须return才可以使用
function changeMsg () {
msg.value = 'Hi World!';
}
// 这个要在页面载入时执行,无需return出去
const init = () => {
console.log('init');
}
// 在这里执行init
onMounted( () => {
init();
});
return {
// 数据
msg,
// 方法
changeMsg
}
}
})
</script>
数据的监听
监听数据变化也是组件里的一项重要工作,比如监听路由变化、监听参数变化等等。
Vue 3.x 在保留原来的 watch
功能之外,还新增了一个 watchEffect
帮助我们更简单的进行监听。
watch
在 Vue 3 ,新版的 watch
和 Vue 2 的旧版写法对比,在使用方式上变化非常大!
回顾 2.x
在 Vue 2 是这样用的,和 data
、 methods
都在同级配置:
export default {
data() {
return {
// ...
}
},
// 注意这里,放在 data 、 methods 同个级别
watch: {
// ...
},
methods: {
// ...
}
}
并且类型繁多,选项式 API 的类型如下:
watch: { [key: string]: string | Function | Object | Array}
联合类型过多,意味着用法复杂,下面是个很好的例子,虽然出自 官网 的用法介绍,但也反映出来对初学者不太友好,初次接触可能会觉得一头雾水:
export default {
data() {
return {
a: 1,
b: 2,
c: {
d: 4
},
e: 5,
f: 6
}
},
watch: {
// 侦听顶级 property
a(val, oldVal) {
console.log(`new: ${val}, old: ${oldVal}`)
},
// 字符串方法名
b: 'someMethod',
// 该回调会在任何被侦听的对象的 property 改变时被调用,不论其被嵌套多深
c: {
handler(val, oldVal) {
console.log('c changed')
},
deep: true
},
// 侦听单个嵌套 property
'c.d': function (val, oldVal) {
// do something
},
// 该回调将会在侦听开始之后被立即调用
e: {
handler(val, oldVal) {
console.log('e changed')
},
immediate: true
},
// 你可以传入回调数组,它们会被逐一调用
f: [
'handle1',
function handle2(val, oldVal) {
console.log('handle2 triggered')
},
{
handler: function handle3(val, oldVal) {
console.log('handle3 triggered')
}
/* ... */
}
]
},
methods: {
someMethod() {
console.log('b changed')
},
handle1() {
console.log('handle 1 triggered')
}
}
}
当然肯定也会有人觉得这样选择多是个好事,选择适合自己的就好,但我个人还是感觉,这种写法对于初学者来说不是那么友好,有些过于复杂化,如果一个用法可以适应各种各样的场景,岂不是更妙?
:::tip
另外需要注意的是,不能使用箭头函数来定义 watcher 函数 (例如 searchQuery: newValue => this.updateAutocomplete(newValue)
)。
因为箭头函数绑定了父级作用域的上下文,所以 this
将不会按照期望指向组件实例, this.updateAutocomplete
将是 undefined
。
:::
Vue 2 也可以通过 this.$watch()
这个 API 的用法来实现对某个数据的监听,它接受三个参数: source
、 callback
和 options
。
export default {
data() {
return {
a: 1,
}
},
// 生命周期钩子
mounted() {
this.$watch('a', (newVal, oldVal) => {
// ...
})
}
}
由于 this.$watch
的用法和 Vue 3 比较接近,所以这里不做过多的回顾,请直接看 了解 3.x 部分。
了解 3.x
在 Vue 3 的组合式 API 写法, watch
是一个可以接受 3 个参数的函数(保留了 Vue 2 的 this.$watch
这种用法),在使用层面上简单了好多。
import { watch } from 'vue'
// 一个用法走天下
watch(
source, // 必传,要监听的数据源
callback, // 必传,监听到变化后要执行的回调函数
// options // 可选,一些监听选项
)
下面的内容都基于 Vue 3 的组合式 API 用法展开讲解。
API 的 TS 类型
在了解用法之前,先对它的 TS 类型定义做一个简单的了解, watch 作为组合式 API ,根据使用方式有两种类型定义:
- 基础用法的 TS 类型,详见 基础用法 部分
// watch 部分的 TS 类型
// ...
export declare function watch<T, Immediate extends Readonly<boolean> = false>(
source: WatchSource<T>,
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
options?: WatchOptions<Immediate>
): WatchStopHandle
// ...
- 批量监听的 TS 类型,详见 批量监听 部分
// watch 部分的 TS 类型
// ...
export declare function watch<
T extends MultiWatchSources,
Immediate extends Readonly<boolean> = false
>(
sources: [...T],
cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
options?: WatchOptions<Immediate>
): WatchStopHandle
// MultiWatchSources 是一个数组
declare type MultiWatchSources = (WatchSource<unknown> | object)[];
// ...
但是不管是基础用法还是批量监听,可以看到这个 API 都是接受 3 个入参:
参数 | 是否可选 | 含义 |
---|---|---|
source | 必传 | 数据源(详见:要监听的数据源 ) |
callback | 必传 | 监听到变化后要执行的回调函数(详见:监听后的回调函数 ) |
options | 可选 | 一些监听选项(详见:监听的选项 ) |
并返回一个可以用来停止监听的函数(详见:停止监听)。
要监听的数据源
在上面 API 的 TS 类型 已经对 watch API 的组成有一定的了解了,这里先对数据源的类型和使用限制做下说明。
:::tip
如果不提前了解,在使用的过程中可能会遇到 “监听了但没有反应” 的情况出现。
另外,这部分内容会先围绕基础用法展开说明,批量监听会在 批量监听 部分单独说明。
:::
watch API 的第 1 个参数 source
是要监听的数据源,它的 TS 类型如下:
// watch 第 1 个入参的 TS 类型
// ...
export declare type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)
// ...
可以看到能够用于监听的数据,是通过 响应式 API 定义的变量( Ref<T>
),或者是一个 计算数据 ( ComputedRef<T>
),或者是一个 getter 函数 ( () => T
)。
所以要想定义的 watch 能够做出预期的行为,数据源必须具备响应性或者是一个 getter ,如果只是通过 let
定义一个普通变量,然后去改变这个变量的值,这样是无法监听的。
:::tip
如果要监听响应式对象里面的某个值(这种情况下对象本身是响应式,但它的 property 不是),需要写成 getter 函数,简单的说就是需要写成有返回值的函数,这个函数 return 你要监听的数据, e.g. () => foo.bar
,可以结合下方 基础用法 的例子一起理解。
:::
监听后的回调函数
在上面 API 的 TS 类型 介绍了 watch API 的组成,和数据源一样,先了解一下回调函数的定义。
:::tip
和数据源部分一样,回调函数的内容也是会先围绕基础用法展开说明,批量监听会在 批量监听 部分单独说明。
:::
watch API 的第 2 个参数 callback
是监听到数据变化时要做出的行为,它的 TS 类型如下:
// watch 第 2 个入参的 TS 类型
// ...
export declare type WatchCallback<V = any, OV = any> = (
value: V,
oldValue: OV,
onCleanup: OnCleanup
) => any
// ...
乍一看它有三个参数,但实际上这些参数不是你自己定义的,而是 watch API 传给你的,所以不管你用或者不用,它们都在那里:
参数 | 作用 |
---|---|
value | 变化后的新值,类型和数据源保持一致 |
oldValue | 变化前的旧值,类型和数据源保持一致 |
onCleanup | 注册一个清理函数,详见 监听效果清理 部分 |
注意:第一个参数是新值,第二个才是原来的旧值!
如同其他 JS 函数,在使用 watch 的回调函数时,可以对这三个参数任意命名,比如把 value
命名为你觉得更容易理解的 newValue
。
:::tip
如果监听的数据源是一个 引用类型 时( e.g. Object
、 Array
、 Date
… ), value
和 oldValue
是完全相同的,因为指向同一个对象。
:::
另外,默认情况下,watch
是惰性的,也就是只有当被监听的数据源发生变化时才执行回调。
基础用法
来到这里,对 2 个必传的参数都有一定的了解了,我们先看看基础的用法,也就是日常最常编写的方案,我们只需要先关注前 2 个必传的参数。
// 不要忘了导入要用的 API
import { defineComponent, reactive, watch } from 'vue'
export default defineComponent({
setup() {
// 定义一个响应式数据
const userInfo = reactive({
name: 'Petter',
age: 18,
})
// 2s后改变数据
setTimeout(() => {
userInfo.name = 'Tom'
}, 2000)
/**
* 可以直接监听这个响应式对象
* callback 的参数如果不用可以不写
*/
watch(userInfo, () => {
console.log('监听整个 userInfo ', userInfo.name)
})
/**
* 也可以监听对象里面的某个值
* 此时数据源需要写成 getter 函数
*/
watch(
// 数据源,getter 形式
() => userInfo.name,
// 回调函数 callback
(newValue, oldValue) => {
console.log('只监听 name 的变化 ', userInfo.name)
console.log('打印变化前后的值', { oldValue, newValue })
}
)
},
})
一般的业务场景,基础用法足以面对。
如果你有多个数据源要监听,并且监听到变化后要执行的行为一样,那么可以使用 批量监听 。
特殊的情况下,你可以搭配 监听的选项 做一些特殊的用法,详见下面部分的内容。
批量监听
如果你有多个数据源要监听,并且监听到变化后要执行的行为一样,第一反应可能是这样来写:
- 抽离相同的处理行为为公共函数
- 然后定义多个监听操作,传入这个公共函数
import { defineComponent, ref, watch } from 'vue'
export default defineComponent({
setup() {
const message = ref<string>('')
const index = ref<number>(0)
// 2s后改变数据
setTimeout(() => {
// 来到这里才会触发 watch 的回调
message.value = 'Hello World!'
index.value++
}, 2000)
// 抽离相同的处理行为为公共函数
const handleWatch = (
newValue: string | number,
oldValue: string | number
): void => {
console.log({ newValue, oldValue })
}
// 然后定义多个监听操作,传入这个公共函数
watch(message, handleWatch)
watch(index, handleWatch)
},
})
这样写其实没什么问题,不过除了抽离公共代码的写法之外, watch API 还提供了一个批量监听的用法,和 基础用法 的区别在于,数据源和回调参数都变成了数组的形式。
数据源:以数组的形式传入,里面每一项都是一个响应式数据。
回调参数:原来的 value
和 newValue
也都变成了数组,每个数组里面的顺序和数据源数组排序一致。
可以看下面的这个例子更为直观:
import { defineComponent, ref, watch } from 'vue'
export default defineComponent({
setup() {
// 定义多个数据源
const message = ref<string>('')
const index = ref<number>(0)
// 2s后改变数据
setTimeout(() => {
message.value = 'Hello World!'
index.value++
}, 2000)
watch(
// 数据源改成了数组
[message, index],
// 回调的入参也变成了数组,每个数组里面的顺序和数据源数组排序一致
([newMessage, newIndex], [oldMessage, oldIndex]) => {
console.log('message 的变化', { newMessage, oldMessage })
console.log('index 的变化', { newIndex, oldIndex })
}
)
},
})
什么情况下可能会用到批量监听呢?比如一个子组件有多个 props ,当有任意一个 prop 发生变化时,都需要执行初始化函数重置组件的状态,那么这个时候就可以用上这个功能啦!
:::tip
在适当的业务场景,你也可以使用 watchEffect 来完成批量监听,但请留意 功能区别 部分的说明。
:::
监听的选项
在 API 的 TS 类型 里提到, watch API 还接受第 3 个参数 options
,可选的一些监听选项。
它的 TS 类型如下:
// watch 第 3 个入参的 TS 类型
// ...
export declare interface WatchOptions<Immediate = boolean>
extends WatchOptionsBase {
immediate?: Immediate
deep?: boolean
}
// ...
// 继承的 base 类型
export declare interface WatchOptionsBase extends DebuggerOptions {
flush?: 'pre' | 'post' | 'sync'
}
// ...
// 继承的 debugger 选项类型
export declare interface DebuggerOptions {
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
}
// ...
options
是一个对象的形式传入,有以下几个选项:
选项 | 类型 | 默认值 | 可选值 | 作用 |
---|---|---|---|---|
deep | boolean | false | true | false | 是否进行深度监听 |
immediate | boolean | false | true | false | 是否立即执行监听回调 |
flush | string | ‘pre’ | ‘pre’ | ‘post’ | ‘sync’ | 控制监听回调的调用时机 |
onTrack | (e) => void | 在数据源被追踪时调用 | ||
onTrigger | (e) => void | 在监听回调被触发时调用 |
其中 onTrack
和 onTrigger
的 e
是 debugger 事件,建议在回调内放置一个 debugger 语句 以调试依赖,这两个选项仅在开发模式下生效。
:::tip
deep 默认是 false
,但是在监听 reactive 对象或数组时,会默认为 true
,详见 监听选项之 deep。
:::
监听选项之 deep
deep
选项接受一个布尔值,可以设置为 true
开启深度监听,或者是 false
关闭深度监听,默认情况下这个选项是 false
关闭深度监听的,但也存在特例。
设置为 false
的情况下,如果直接监听一个响应式的 引用类型 数据(e.g. Object
、 Array
… ),虽然它的属性的值有变化,但对其本身来说是不变的,所以不会触发 watch 的 callback 。
下面是一个关闭了深度监听的例子:
import { defineComponent, ref, watch } from 'vue'
export default defineComponent({
setup() {
// 定义一个响应式数据,注意我是用的 ref 来定义
const nums = ref<number[]>([])
// 2s后给这个数组添加项目
setTimeout(() => {
nums.value.push(1)
// 可以打印一下,确保数据确实变化了
console.log('修改后', nums.value)
}, 2000)
// 但是这个 watch 不会按预期执行
watch(
nums,
// 这里的 callback 不会被触发
() => {
console.log('触发监听', nums.value)
},
// 因为关闭了 deep
{
deep: false,
}
)
},
})
类似这种情况,你需要把 deep
设置为 true
才可以触发监听。
可以看到我的例子特地用了 ref API ,这是因为通过 reactive API 定义的对象无法将 deep
成功设置为 false
(这一点在目前的官网文档未找到说明,最终是在 watch API 的源码 上找到了答案)。
// ...
if (isReactive(source)) {
getter = () => source
deep = true // 被强制开启了
}
// ...
这个情况就是我说的 “特例” ,你可以通过 isReactive
API 来判断是否需要手动开启深度监听。
// 导入 isReactive API
import { defineComponent, isReactive, reactive, ref } from 'vue'
export default defineComponent({
setup() {
// 监听这个数据时,会默认开启深度监听
const foo = reactive({
name: 'Petter',
age: 18,
})
console.log(isReactive(foo)) // true
// 监听这个数据时,不会默认开启深度监听
const bar = ref({
name: 'Petter',
age: 18,
})
console.log(isReactive(bar)) // false
},
})
监听选项之 immediate
在 监听后的回调函数 部分有了解过, watch 默认是惰性的,也就是只有当被监听的数据源发生变化时才执行回调。
这句话是什么意思呢?来看一下这段代码,为了减少 deep 选项的干扰,我们换一个类型,换成 string
数据来演示,请留意我的注释:
import { defineComponent, ref, watch } from 'vue'
export default defineComponent({
setup() {
// 这个时候不会触发 watch 的回调
const message = ref<string>('')
// 2s后改变数据
setTimeout(() => {
// 来到这里才会触发 watch 的回调
message.value = 'Hello World!'
}, 2000)
watch(message, () => {
console.log('触发监听', message.value)
})
},
})
可以看到,数据在初始化的时候并不会触发监听回调,如果有需要的话,通过 immediate
选项来让它直接触发。
immediate
选项接受一个布尔值,默认是 false
,你可以设置为 true
让回调立即执行。
我们改成这样,请留意高亮的代码部分和新的注释:
import { defineComponent, ref, watch } from 'vue'
export default defineComponent({
setup() {
// 这一次在这里可以会触发 watch 的回调了
const message = ref<string>('')
// 2s后改变数据
setTimeout(() => {
// 这一次,这里是第二次触发 watch 的回调,不再是第一次
message.value = 'Hello World!'
}, 2000)
watch(
message,
() => {
console.log('触发监听', message.value)
},
// 设置 immediate 选项
{
immediate: true,
}
)
},
})
注意,在带有 immediate 选项时,不能在第一次回调时取消该数据源的监听,详见 停止监听 部分。
监听选项之 flush
flush
选项是用来控制 监听回调 的调用时机,接受指定的字符串,可选值如下,默认是 'pre'
。
可选值 | 回调的调用时机 | 使用场景 |
---|---|---|
‘pre’ | 将在渲染前被调用 | 允许回调在模板运行前更新了其他值 |
‘sync’ | 在渲染时被同步调用 | 目前来说没什么好处,可以了解但不建议用… |
‘post’ | 被推迟到渲染之后调用 | 如果要通过 ref 操作 DOM 元素与子组件 ,需要使用这个值来启用该选项,以达到预期的执行效果 |
对于 'pre'
和 'post'
,回调使用队列进行缓冲。回调只被添加到队列中一次。
即使观察值变化了多次,值的中间变化将被跳过,不会传递给回调,这样做不仅可以提高性能,还有助于保证数据的一致性。
更多关于 flush 的信息,请参阅 副作用刷新时机 。
停止监听
如果你在 setup 或者 script-setup 里使用 watch 的话, 组件被卸载 的时候也会一起被停止,一般情况下不太需要关心如何停止监听。
不过有时候你可能想要手动取消, Vue 3 也提供了方法。
:::tip
随着组件被卸载一起停止的前提是,侦听器必须是 同步语句 创建的,这种情况下侦听器会绑定在当前组件上。
如果放在 setTimeout
等 异步函数 里面创建,则不会绑定到当前组件,因此组件卸载的时候不会一起停止该侦听器,这种时候你就需要手动停止监听。
:::
在 API 的 TS 类型 有提到,当你在定义一个 watch 行为的时候,它会返回一个用来停止监听的函数。
这个函数的 TS 类型如下:
export declare type WatchStopHandle = () => void;
用法很简单,做一下简单了解即可:
// 定义一个取消观察的变量,它是一个函数
const unwatch = watch(message, () => {
// ...
})
// 在合适的时期调用它,可以取消这个监听
unwatch()
但是也有一点需要注意的是,如果你启用了 immediate 选项 ,不能在第一次触发监听回调时执行它。
// 注意:这是一段错误的代码,运行会报错
const unwatch = watch(
message,
// 监听的回调
() => {
// ...
// 在这里调用会有问题 ❌
unwatch()
},
// 启用 immediate 选项
{
immediate: true,
}
)
你会收获一段报错,告诉你 unwatch
这个变量在初始化前无法被访问:
Uncaught ReferenceError: Cannot access 'unwatch' before initialization
目前有两种方案可以让你实现这个操作:
方案一:使用 var
并判断变量类型,利用 var 的变量提升 来实现目的。
// 这里改成 var ,不要用 const 或 let
var unwatch = watch(
message,
// 监听回调
() => {
// 这里加一个判断,是函数才执行它
if (typeof unwatch === 'function') {
unwatch()
}
},
// 监听选项
{
immediate: true,
}
)
不过 var
已经属于过时的语句了,建议用方案二的 let
。
方案二:使用 let
并判断变量类型。
// 如果不想用 any ,可以导入 TS 类型
import type { WatchStopHandle } from 'vue'
// 这里改成 let ,但是要另起一行,先定义,再赋值
let unwatch: WatchStopHandle
unwatch = watch(
message,
// 监听回调
() => {
// 这里加一个判断,是函数才执行它
if (typeof unwatch === 'function') {
unwatch()
}
},
// 监听选项
{
immediate: true,
}
)
监听效果清理
在 监听后的回调函数 部分提及到一个参数 onCleanup
,它可以帮你注册一个清理函数。
有时 watch 的回调会执行异步操作,当 watch 到数据变更的时候,需要取消这些操作,这个函数的作用就用于此,会在以下情况调用这个清理函数:
- watcher 即将重新运行的时候
- watcher 被停止(组件被卸载或者被手动 停止监听 )
TS 类型:
declare type OnCleanup = (cleanupFn: () => void) => void;
用法方面比较简单,传入一个回调函数运行即可,不过需要注意的是,需要在停止监听之前注册好清理行为,否则不会生效。
我们在 停止监听 里的最后一个 immediate 例子的基础上继续添加代码,请注意注册的时机:
let unwatch: WatchStopHandle
unwatch = watch(
message,
(newValue, oldValue, onCleanup) => {
// 需要在停止监听之前注册好清理行为
onCleanup(() => {
console.log('监听清理ing')
// 根据实际的业务情况定义一些清理操作 ...
})
// 然后再停止监听
if (typeof unwatch === 'function') {
unwatch()
}
},
{
immediate: true,
}
)
watchEffect
如果一个函数里包含了多个需要监听的数据,一个一个数据去监听太麻烦了,在 Vue 3 ,你可以直接使用 watchEffect API 来简化你的操作。
API 的 TS 类型
这个 API 的类型如下,使用的时候需要传入一个副作用函数(相当于 watch 的 监听后的回调函数 ),也可以根据你的实际情况传入一些可选的 监听选项 。
和 watch API 一样,它也会返回一个用于 停止监听 的函数。
// watchEffect 部分的 TS 类型
// ...
export declare type WatchEffect = (onCleanup: OnCleanup) => void
export declare function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase
): WatchStopHandle
// ...
副作用函数也会传入一个清理回调作为参数,和 watch 的 监听效果清理 一样的用法。
你可以理解为它是一个简化版的 watch ,具体简化在哪里呢?请看下面的用法示例。
用法示例
它立即执行传入的一个函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。
import { defineComponent, ref, watchEffect } from 'vue'
export default defineComponent({
setup () {
// 单独定义两个数据,后面用来分开改变数值
const name = ref<string>('Petter');
const age = ref<number>(18);
// 定义一个调用这两个数据的函数
const getUserInfo = (): void => {
console.log({
name: name.value,
age: age.value
});
}
// 2s后改变第一个数据
setTimeout(() => {
name.value = 'Tom';
}, 2000);
// 4s后改变第二个数据
setTimeout(() => {
age.value = 20;
}, 4000);
// 直接监听调用函数,在每个数据产生变化的时候,它都会自动执行
watchEffect(getUserInfo);
}
})
和 watch 的区别
虽然理论上 watchEffect
是 watch
的一个简化操作,可以用来代替 批量监听 ,但它们也有一定的区别:
watch
可以访问侦听状态变化前后的值,而watchEffect
没有。watch
是在属性改变的时候才执行,而watchEffect
则默认会执行一次,然后在属性改变的时候也会执行。
第二点的意思,看下面这段代码可以有更直观的理解:
使用 watch :
export default defineComponent({
setup() {
const foo = ref<string>('')
setTimeout(() => {
foo.value = 'Hello World!'
}, 2000)
function bar() {
console.log(foo.value)
}
// 使用 watch 需要先手动执行一次
bar()
// 然后当 foo 有变动时,才会通过 watch 来执行 bar()
watch(foo, bar)
},
})
使用 watchEffect :
export default defineComponent({
setup() {
const foo = ref<string>('')
setTimeout(() => {
foo.value = 'Hello World!'
}, 2000)
function bar() {
console.log(foo.value)
}
// 可以通过 watchEffect 实现 bar() + watch(foo, bar) 的效果
watchEffect(bar)
},
})
可用的监听选项
虽然用法和 watch 类似,但也简化了一些选项,它的监听选项 TS 类型如下:
// 只支持 base 类型
export declare interface WatchOptionsBase extends DebuggerOptions {
flush?: 'pre' | 'post' | 'sync'
}
// ...
// 继承的 debugger 选项类型
export declare interface DebuggerOptions {
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
}
// ...
对比 watch API ,它不支持 deep 和 immediate ,请记住这一点,其他的用法是一样的。
flush
选项的使用详见 监听选项之 flush ,onTrack
和 onTrigger
详见 监听的选项 部分内容。
watchPostEffect
watchEffect API 使用 flush: 'post'
选项时的别名,具体区别详见 监听选项之 flush 部分。
watchSyncEffect
watchEffect API 使用 flush: 'sync'
选项时的别名,具体区别详见 监听选项之 flush 部分。
数据的计算
和 Vue 2.0 一样,数据的计算也是使用 computed
API ,它可以通过现有的响应式数据,去通过计算得到新的响应式变量,用过 Vue 2.0 的同学应该不会太陌生,但是在 Vue 3.0 ,在使用方式上也是变化非常大!
:::tip
这里的响应式数据,可以简单理解为通过 ref API 、 reactive API 定义出来的数据,当然 Vuex 、Vue Router 等 Vue 数据也都具备响应式,可以戳 响应式数据的变化 了解。
:::
用法变化
我们先从一个简单的用例来看看在 Vue 新旧版本的用法区别:
假设你定义了两个分开的数据 firstName
名字和 lastName
姓氏,但是在 template 展示时,需要展示完整的姓名,那么你就可以通过 computed
来计算一个新的数据:
回顾 2.x
在 Vue 2.0 ,computed
和 data
在同级配置,并且不可以和 data
里的数据同名重复定义:
// 在 Vue 2 的写法:
export default {
data() {
return {
firstName: 'Bill',
lastName: 'Gates',
}
},
// 注意这里定义的变量,都要通过函数的形式来返回它的值
computed: {
// 普通函数可以直接通过熟悉的 this 来拿到 data 里的数据
fullName() {
return `${this.firstName} ${this.lastName}`
},
// 箭头函数则需要通过参数来拿到实例上的数据
fullName2: (vm) => `${vm.firstName} ${vm.lastName}`,
}
}
这样你在需要用到全名的地方,只需要通过 this.fullName
就可以得到 Bill Gates
。
了解 3.x
在 Vue 3.0 ,跟其他 API 的用法一样,需要先导入 computed
才能使用:
// 在 Vue 3 的写法:
import { defineComponent, ref, computed } from 'vue'
export default defineComponent({
setup() {
// 定义基本的数据
const firstName = ref<string>('Bill')
const lastName = ref<string>('Gates')
// 定义需要计算拼接结果的数据
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// 2s 后改变某个数据的值
setTimeout(() => {
firstName.value = 'Petter'
}, 2000)
// template 那边在 2s 后也会显示为 Petter Gates
return {
fullName,
}
},
})
你可以把这个用法简单的理解为,传入一个回调函数,并 return
一个值,对,它需要有明确的返回值。
:::tip
需要注意的是:
- 定义出来的
computed
变量,和ref
变量的用法一样,也是需要通过.value
才能拿到它的值 - 但是区别在于,
computed
的value
是只读的
原因详见下方的 类型定义 。
:::
类型定义
我们之前说过,在 defineComponent 里,会自动帮我们推导 Vue API 的类型,所以一般情况下,你是不需要显式的去定义 computed
出来的变量类型的。
在确实需要手动指定的情况下,你也可以导入它的类型然后定义:
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
// 注意这里添加了类型定义
const fullName: ComputedRef<string> = computed(
() => `${firstName.value} ${lastName.value}`
)
你要返回一个字符串,你就写 ComputedRef<string>
;返回布尔值,就写 ComputedRef<boolean>
;返回一些复杂对象信息,你可以先定义好你的类型,再诸如 ComputedRef<UserInfo>
去写。
// 这是 ComputedRef 的类型定义:
export declare interface ComputedRef<T = any> extends WritableComputedRef<T> {
readonly value: T;
[ComoutedRefSymbol]: true;
}
优势对比和注意事项
在继续往下看之前,我们先来了解一下这个 API 的一些优势和注意事项(如果在 Vue 2.x 已经有接触过的话,可以跳过这一段,因为优势和需要注意的东西比较一致)。
优势对比
看到这里,相信刚接触的同学可能会有疑问,既然 computed
也是通过一个函数来返回值,那么和普通的 function
有什么区别,或者说优势?
- 性能优势
这一点在 官网文档 其实是有提到的:
数据的计算是基于它们的响应依赖关系缓存的,只在相关响应式依赖发生改变时它们才会重新求值。
也就是说,只要原始数据没有发生改变,多次访问 computed
,都是会立即返回之前的计算结果,而不是再次执行函数;而普通的 function
调用多少次就执行多少次,每调用一次就计算一次。
至于为何要如此设计,官网文档也给出了原因:
我们为什么需要缓存?假设我们有一个性能开销比较大的计算数据 list,它需要遍历一个巨大的数组并做大量的计算。然后我们可能有其他的计算数据依赖于 list。如果没有缓存,我们将不可避免的多次执行 list 的 getter!如果你不希望有缓存,请用 function 来替代。
:::tip
在这部分内容里,我把官方文档的一些用词做了更换,比如把 method 都替换成了 function ,也把 “计算属性” 都换成了 “计算数据”,原因在于官网很多地方是基于 Options API 的写法去描述,而本文档是基于 Composition API 。
点击了解: 如何理解 JavaScript 中方法(method)和函数(function)的区别?
:::
- 书写统一
我们假定 foo1 是 ref
变量, foo2 是 computed
变量, foo3 是普通函数返回值
看到这里的同学应该都已经清楚 Vue 3 的 ref
变量是通过 foo1.value
来拿到值的,而 computed
也是通过 foo2.value
,并且在 template 里都可以省略 .value
,在读取方面,他们是有一致的风格和简洁性。
而 foo3 不管是在 script 还是 template ,都需要通过 foo3()
才能拿到结果,相对来说会有那么一丢丢别扭。
当然,关于这一点,如果涉及到的数据不是响应式数据,那么还是老老实实的用函数返回值吧,原因请见下面的 注意事项 。
注意事项
有优势当然也就有一定的 “劣势” ,当然这也是 Vue 框架的有意为之,所以在使用上也需要注意一些问题:
- 只会更新响应式数据的计算
假设你要获取当前的时间信息,因为不是响应式数据,所以这种情况下你就需要用普通的函数去获取返回值,才能拿到最新的时间。
const nowTime = computed(() => new Date())
console.log(nowTime.value)
// 输出 Sun Nov 14 2021 21:07:00 GMT+0800 (GMT+08:00)
// 2s 后依然是跟上面一样的结果
setTimeout(() => {
console.log(nowTime.value)
// 还是输出 Sun Nov 14 2021 21:07:00 GMT+0800 (GMT+08:00)
}, 2000)
- 数据是只读的
通过 computed 定义的数据,它是只读的,这一点在 类型定义 已经有所了解。
如果你直接赋值,不仅无法变更数据,而且会收获一个报错。
TS2540: Cannot assign to 'value' because it is a read-only property.
虽然无法直接赋值,但是在必要的情况下,你依然可以通过 computed
的 setter
来更新数据。
点击了解:computed 的 setter 用法
setter 的使用
通过 computed 定义的变量默认都是只读的形式(只有一个 getter ),但是在必要的情况下,你也可以使用其 setter 属性来更新数据。
基本格式
当你需要用到 setter 的时候, computed
就不再是一个传入 callback 的形式了,而是传入一个带有 2 个方法的对象。
// 注意这里computed接收的入参已经不再是函数
const foo = computed({
// 这里需要明确的返回一个值
get() {
// ...
},
// 这里接收一个参数,代表修改 foo 时,赋值下来的新值
set(newValue) {
// ...
},
})
这里的 get
就是 computed
的 getter ,跟原来传入 callback 的形式一样,是用于 foo.value
的读取,所以这里你必须有明确的返回值。
这里的 set
就是 computed
的 setter ,它会接收一个参数,代表新的值,当你通过 foo.value = xxx
赋值的时候,赋入的这个值,就会通过这个入参来传递进来,你可以根据你的业务需要,把这个值,赋给相关的数据源。
:::tip
请注意,必须使用 get
和 set
这 2 个方法名,也只接受这 2 个方法。
:::
在了解了基本格式后,可以查看下面的例子来了解具体的用法。
使用示范
官网的 例子 是一个 Options API 的案例,这里我们改成 Composition API 的写法来演示:
// 还是这2个数据源
const firstName = ref<string>('Bill')
const lastName = ref<string>('Gates')
// 这里我们配合setter的需要,改成了另外一种写法
const fullName = computed({
// getter我们还是返回一个拼接起来的全名
get() {
return `${firstName.value} ${lastName.value}`
},
// setter这里我们改成只更新firstName,注意参数也定义TS类型
set(newFirstName: string) {
firstName.value = newFirstName
},
})
console.log(fullName.value) // 输出 Bill Gates
// 2s后更新一下数据
setTimeout(() => {
// 对fullName的赋值,其实更新的是firstName
fullName.value = 'Petter'
// 此时firstName已经得到了更新
console.log(firstName.value) // 会输出 Petter
// 当然,由于firstName变化了,所以fullName的getter也会得到更新
console.log(fullName.value) // 会输出 Petter Gates
}, 2000)
应用场景
计算 API 的作用,官网文档只举了一个非常简单的例子,那么在实际项目中,什么情况下用它会让我们更方便呢?
简单举几个比较常见的例子吧,加深一下对 computed
的理解。
数据的拼接和计算
如上面的案例,与其每个用到的地方都要用到 firstName + ' ' + lastName
这样的多变量拼接,不如用一个 fullName
来的简单。
当然,不止是字符串拼接,数据的求和等操作更是合适,比如说你做一个购物车,购物车里有商品列表,同时还要显示购物车内的商品总金额,这种情况就非常适合用计算数据。
复用组件的动态数据
在一个项目里,很多时候组件会涉及到复用,比如说:“首页的文章列表 vs 列表页的文章列表 vs 作者详情页的文章列表” ,特别常见于新闻网站等内容资讯站点,这种情况下,往往并不需要每次都重新写 UI 、数据渲染等代码,仅仅是接口 URL 的区别。
这种情况你就可以通过路由名称来动态获取你要调用哪个列表接口:
const route = useRoute()
// 定义一个根据路由名称来获取接口URL的计算数据
const apiUrl = computed(() => {
switch (route.name) {
// 首页
case 'home':
return '/api/list1'
// 列表页
case 'list':
return '/api/list2'
// 作者页
case 'author':
return '/api/list3'
// 默认是随机列表
default:
return '/api/random'
}
})
// 请求列表
const getArticleList = async (): Promise<void> => {
// ...
articleList.value = await axios({
method: 'get',
url: apiUrl.value,
// ...
})
// ...
}
当然,这种情况你也可以在父组件通过 props
传递接口 URL ,如果你已经学到了 组件通讯 一章的话。
获取多级对象的值
你应该很经常的遇到要在 template 显示一些多级对象的字段,但是有时候又可能存在某些字段不一定有,需要做一些判断的情况,虽然有 v-if
,但是嵌套层级一多,你的模板会难以维护。
如果你把这些工作量转移给计算数据,结合 try / catch
,这样就无需在 template 里处理很多判断了。
// 例子比较极端,但在 Vuex 这种大型数据树上,也不是完全不可能存在
const foo = computed(() => {
// 正常情况下返回需要的数据
try {
return store.state.foo3.foo2.foo1.foo
}
// 处理失败则返回一个默认值
catch (e) {
return ''
}
})
这样你在 template 里要拿到 foo 的值,完全不需要关心中间一级又一级的字段是否存在,只需要区分是不是默认值。
不同类型的数据转换
有时候你会遇到一些需求类似于,让用户在输入框里,按一定的格式填写文本,比如用英文逗号 ,
隔开每个词,然后保存的时候,是用数组的格式提交给接口。
这个时候 computed
的 setter 就可以妙用了,只需要一个简单的 computed
,就可以代替 input
的 change
事件或者 watch
监听,可以减少很多业务代码的编写。
<template>
<input
type="text"
v-model="tagsStr"
placeholder="请输入标签,多个标签用英文逗号隔开"
/>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from 'vue'
export default defineComponent({
setup() {
// 这个是最终要用到的数组
const tags = ref<string[]>([])
// 因为input必须绑定一个字符串
const tagsStr = computed({
// 所以通过getter来转成字符串
get() {
return tags.value.join(',')
},
// 然后在用户输入的时候,切割字符串转换回数组
set(newValue: string) {
tags.value = newValue.split(',')
},
})
return {
tagsStr,
}
},
})
</script>
指令
指令是 Vue 模板语法里的特殊标记,在使用上和 HTML 的 data-*属性十分相似,统一以 v-
开头( e.g. v-html
)。
它以简单的方式实现了常用的 JavaScript 表达式功能,当表达式的值改变的时候,响应式地作用到 DOM 上。
内置指令
Vue 提供了一些内置指令可以直接使用,例如:
<template>
<!-- 渲染一段文本 -->
<span v-text="msg"></span>
<!-- 渲染一段文本 -->
<!-- 渲染一段 HTML -->
<div v-html="html"></div>
<!-- 渲染一段 HTML -->
<!-- 循环创建一个列表 -->
<ul v-if="items.length">
<li v-for="(item, index) in items" :key="index">
<span>{{ item }}</span>
</li>
</ul>
<!-- 循环创建一个列表 -->
<!-- 一些事件( @ 等价于 v-on ) -->
<button @click="hello">Hello</button>
<!-- 一些事件( @ 等价于 v-on ) -->
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup() {
const msg = ref<string>('Hello World!')
const html = ref<string>('<p>Hello World!</p>')
const items = ref<string[]>(['a', 'b', 'c', 'd'])
function hello() {
console.log(msg.value)
}
return {
msg,
html,
items,
hello,
}
},
})
</script>
内置指令在使用上都非常的简单,可以在 指令 - API 参考open in new window 查询完整的指令列表和用法,在模板上使用时,请了解 指令的模板语法open in new window 。
TIP
其中有 2 个指令有别名:
v-on
的别名是@
,使用@click
等价于v-on:click
v-bind
的别名是:
,使用:src
等价于v-bind:src
自定义指令
如果 Vue 的内置指令不能满足业务需求,还可以开发自定义指令。
相关的 TS 类型
在开始编写代码之前,先了解一下自定义指令相关的 TypeScript 类型。
自定义指令有两种实现形式,一种是作为一个对象,其中的写法比较接近于 Vue 组件,除了 getSSRPropsopen in new window 和 deep 选项 外,其他的每一个属性都是一个 钩子函数 ,下一小节会介绍钩子函数的内容。
// 对象式写法的 TS 类型
// ...
export declare interface ObjectDirective<T = any, V = any> {
created?: DirectiveHook<T, null, V>
beforeMount?: DirectiveHook<T, null, V>
mounted?: DirectiveHook<T, null, V>
beforeUpdate?: DirectiveHook<T, VNode<any, T>, V>
updated?: DirectiveHook<T, VNode<any, T>, V>
beforeUnmount?: DirectiveHook<T, null, V>
unmounted?: DirectiveHook<T, null, V>
getSSRProps?: SSRDirectiveHook
deep?: boolean
}
// ...
另外一种是函数式写法,只需要定义成一个函数,但这种写法只在 mounted
和 updated
这两个钩子生效,并且触发一样的行为。
// 函数式写法的 TS 类型
// ...
export declare type FunctionDirective<T = any, V = any> = DirectiveHook<T, any, V>
// ...
这是每个钩子函数对应的类型,它有 4 个入参:
// 钩子函数的 TS 类型
// ...
export declare type DirectiveHook<
T = any,
Prev = VNode<any, T> | null,
V = any
> = (
el: T,
binding: DirectiveBinding<V>,
vnode: VNode<any, T>,
prevVNode: Prev
) => void
// ...
钩子函数第二个参数的类型:
// 钩子函数第二个参数的 TS 类型
// ...
export declare interface DirectiveBinding<V = any> {
instance: ComponentPublicInstance | null
value: V
oldValue: V | null
arg?: string
modifiers: DirectiveModifiers
dir: ObjectDirective<any, V>
}
// ...
可以看到自定义指令最核心的就是 “钩子函数” 了,接下来我们来了解这部分的知识点。
钩子函数
和 组件的生命周期 类似,自定义指令里的逻辑代码也有一些特殊的调用时机,在这里称之为钩子函数:
钩子函数 | 调用时机 |
---|---|
created | 在绑定元素的 attribute 或事件监听器被应用之前调用 |
beforeMount | 当指令第一次绑定到元素并且在挂载父组件之前调用 |
mounted | 在绑定元素的父组件被挂载后调用 |
beforeUpdate | 在更新包含组件的 VNode 之前调用 |
updated | 在包含组件的 VNode 及其子组件的 VNode 更新后调用 |
beforeUnmount | 在卸载绑定元素的父组件之前调用 |
unmounted | 当指令与元素解除绑定且父组件已卸载时,只调用一次 |
TIP
因为自定义指令的默认写法是一个对象,所以在代码风格上是遵循 Options API 的生命周期命名,而非 Vue 3 的 Composition API 风格。
钩子函数在用法上就是这样子:
const myDirective = {
created(el, binding, vnode, prevVnode) {
// ...
},
mounted(el, binding, vnode, prevVnode) {
// ...
},
// 其他钩子...
}
在 相关的 TS 类型 我们已了解,每个钩子函数都有 4 个入参:
参数 | 作用 |
---|---|
el | 指令绑定的 DOM 元素,可以直接操作它 |
binding | 一个对象数据,见下方的单独说明 |
vnode | el 对应在 Vue 里的虚拟节点信息 |
prevVNode | Update 时的上一个虚拟节点信息,仅在 beforeUpdate 和 updated 可用 |
其中用的最多是 el
和 binding
了。
el
的值就是我们通过document.querySelector
拿到的那个 DOM 元素。binding
是一个对象,里面包含了以下属性: | 属性 | 作用 | | —- | —- | | value | 传递给指令的值,例如v-foo="bar"
里的bar
,支持任意有效的 JS 表达式 | | oldValue | 指令的上一个值,仅对beforeUpdate
和updated
可用 | | arg | 传给指令的参数,例如v-foo:bar
里的bar
| | modifiers | 传给指令的修饰符,例如v-foo.bar
里的bar
| | instance | 使用指令的组件实例 | | dir | 指令定义的对象(就是上面的const myDirective = { /* ... */ }
这个对象) |
在了解了指令的写法和参数作用之后,我们来看看如何注册一个自定义指令。
局部注册
自定义指令可以在单个组件内定义并使用,通过和 setup 函数 同级别的 directives
选项进行定义,可以参考下面的例子和注释:
<template>
<!-- 这个使用默认值 unset -->
<div v-highlight>{{ msg }}</div>
<!-- 这个使用默认值 unset -->
<!-- 这个使用传进去的黄色 -->
<div v-highlight="`yellow`">{{ msg }}</div>
<!-- 这个使用传进去的黄色 -->
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
// 自定义指令在这里编写,和 setup 同级别
directives: {
// directives 下的每个字段名就是指令名称
highlight: {
// 钩子函数
mounted(el, binding) {
el.style.backgroundColor =
typeof binding.value === 'string' ? binding.value : 'unset'
},
},
},
setup() {
const msg = ref<string>('Hello World!')
return {
msg,
}
},
})
</script>
上面是对象式的写法,你也可以写成函数式:
export default defineComponent({
directives: {
highlight(el, binding) {
el.style.backgroundColor =
typeof binding.value === 'string' ? binding.value : 'unset'
},
},
})
TIP
局部注册的自定义指令,默认在子组件内生效,子组件内无需重新注册即可使用父组件的自定义指令。
全局注册
自定义指令也可以注册成全局,这样就无需在每个组件里定义了,只要在入口文件 main.ts
里启用它,任意组件里都可以使用自定义指令。
请查看 开发本地 Vue 专属插件 一节的内容了解如何注册一个全局的自定义指令插件。
deep 选项
除了 钩子函数 ,在 相关的 TS 类型 里还可以看到有一个 deep 选项,它是一个布尔值,作用是:
如果自定义指令用于一个有嵌套属性的对象,并且需要在嵌套属性更新的时候触发 beforeUpdate
和 updated
钩子,那么需要将这个选项设置为 true
才能够生效。
<template>
<div v-foo="foo"></div>
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue'
export default defineComponent({
directives: {
foo: {
beforeUpdate(el, binding) {
console.log('beforeUpdate', binding)
},
updated(el, binding) {
console.log('updated', binding)
},
mounted(el, binding) {
console.log('mounted', binding)
},
// 需要设置为 true ,如果是 false 则不会触发
deep: true,
},
},
setup() {
// 定义一个有嵌套属性的对象
const foo = reactive({
bar: {
baz: 1,
},
})
// 2s 后修改其中一个值,会触发 beforeUpdate 和 updated
setTimeout(() => {
foo.bar.baz = 2
console.log(foo)
}, 2000)
return {
foo,
}
},
})
</script>
插槽
Vue 在使用子组件的时候,子组件在 template 里类似一个 HTML 标签,你可以在这个子组件标签里传入任意模板代码以及 HTML 代码,这个功能就叫做 “插槽” 。
默认插槽
默认情况下,子组件使用 <slot />
标签即可渲染父组件传下来的插槽内容,例如:
在父组件这边:
<template>
<!-- 注意这里,子组件标签里面传入了 HTML 代码 -->
<Child>
<p>这是插槽内容</p>
</Child>
<!-- 注意这里,子组件标签里面传入了 HTML 代码 -->
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Child from '@cp/Child.vue'
export default defineComponent({
components: {
Child,
},
})
</script>
在子组件这边:
<template>
<slot />
</template>
默认插槽非常简单,一个 <slot />
就可以了。
具名插槽
有时候你可能需要指定多个插槽,例如一个子组件里有 “标题” 、 “作者”、 “内容” 等预留区域可以显示对应的内容,这时候就需要用到具名插槽来指定不同的插槽位。
子组件通过 name
属性来指定插槽名称:
<template>
<!-- 显示标题的插槽内容 -->
<div class="title">
<slot name="title" />
</div>
<!-- 显示标题的插槽内容 -->
<!-- 显示作者的插槽内容 -->
<div class="author">
<slot name="author" />
</div>
<!-- 显示作者的插槽内容 -->
<!-- 其他插槽内容放到这里 -->
<div class="content">
<slot />
</div>
<!-- 其他插槽内容放到这里 -->
</template>
父组件通过 template
标签绑定 v-slot:name
格式的属性,来指定传入哪个插槽里:
<template>
<Child>
<!-- 传给标题插槽 -->
<template v-slot:title>
<h1>这是标题</h1>
</template>
<!-- 传给标题插槽 -->
<!-- 传给作者插槽 -->
<template v-slot:author>
<h1>这是作者信息</h1>
</template>
<!-- 传给作者插槽 -->
<!-- 传给默认插槽 -->
<p>这是插槽内容</p>
<!-- 传给默认插槽 -->
</Child>
</template>
v-slot:name
有一个别名 #name
语法,上面父组件的代码也相当于:
<template>
<Child>
<!-- 传给标题插槽 -->
<template #title>
<h1>这是标题</h1>
</template>
<!-- 传给标题插槽 -->
<!-- 传给作者插槽 -->
<template #author>
<h1>这是作者信息</h1>
</template>
<!-- 传给作者插槽 -->
<!-- 传给默认插槽 -->
<p>这是插槽内容</p>
<!-- 传给默认插槽 -->
</Child>
</template>
TIP
在使用具名插槽的时候,子组件如果不指定默认插槽,那么在具名插槽之外的内容将不会被渲染。
默认内容
你可以给 slot
标签添加内容,例如 <slot>默认内容</slot>
,当父组件没有传入插槽内容时,会使用默认内容来显示,默认插槽和具名插槽均支持该功能。
注意事项
有一条规则需要记住:
- 父组件里的所有内容都是在父级作用域中编译的
- 子组件里的所有内容都是在子作用域中编译的
CSS 样式与预处理器
Vue 组件的 CSS 样式部分,3.x 保留着和 2.x 完全一样的写法。
编写组件样式表
最基础的写法,就是在 Vue 文件里创建一个 style
标签,即可在里面写 CSS 代码了。
<style>
.msg {
width: 100%;
}
.msg p {
color: #333;
font-size: 14px;
}
</style>
动态绑定 CSS
动态绑定 CSS ,在 Vue 2.x 就已经存在了,在此之前常用的是 :class
和 :style
,现在在 Vue 3.x ,还可以通过 v-bind
来动态修改了。
其实这一部分主要是想说一下 3.x 新增的 <style> v-bind
功能,不过既然写到这里,就把另外两个动态绑定方式也一起提一下。
使用 :class 动态修改样式名
它是绑定在 DOM 元素上面的一个属性,跟 class
同级别,它非常灵活!
:::tip
使用 :class
是用来动态修改样式名,也就意味着你必须提前把样式名对应的样式表先写好!
:::
假设我们已经提前定义好了这几个变量:
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
const activeClass = 'active-class'
const activeClass1 = 'active-class1'
const activeClass2 = 'active-class2'
const isActive = true
return {
activeClass,
activeClass1,
activeClass2,
isActive,
}
}
})
</script>
如果只想绑定一个单独的动态样式,你可以传入一个字符串:
<template>
<p :class="activeClass">Hello World!</p>
</template>
如果有多个动态样式,也可以传入一个数组:
<template>
<p :class="[activeClass1, activeClass2]">Hello World!</p>
</template>
你还可以对动态样式做一些判断,这个时候传入一个对象:
<template>
<p :class="{ 'active-class': isActive }">Hello World!</p>
</template>
多个判断的情况下,记得也用数组套起来:
<template>
<p
:class="[
{ activeClass1: isActive },
{ activeClass2: !isActive }
]"
>
Hello World!
</p>
</template>
那么什么情况下会用到 :class
呢?
最常见的场景,应该就是导航、选项卡了,比如你要给一个当前选中的选项卡做一个突出高亮的状态,那么就可以使用 :class
来动态绑定一个样式。
<template>
<ul class="list">
<li
class="item"
:class="{ cur: index === curIndex }"
v-for="(item, index) in 5"
:key="index"
@click="curIndex = index"
>
{{ item }}
</li>
</ul>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup () {
const curIndex = ref<number>(0)
return {
curIndex,
}
}
})
</script>
<style scoped>
.cur {
color: red;
}
</style>
这样就简单实现了一个点击切换选项卡高亮的功能。
使用 :style 动态修改内联样式
如果你觉得使用 :class
需要提前先写样式,再去绑定样式名有点繁琐,有时候只想简简单单的修改几个样式,那么你可以通过 :style
来处理。
默认的情况下,我们都是传入一个对象去绑定:
key
是符合 CSS 属性名的 “小驼峰式” 写法,或者套上引号的短横线分隔写法(原写法),例如在 CSS 里,定义字号是font-size
,那么你需要写成fontSize
或者'font-size'
作为它的键。value
是 CSS 属性对应的 “合法值”,比如你要修改字号大小,可以传入13px
、0.4rem
这种带合法单位字符串值,但不可以是13
这样的缺少单位的值,无效的 CSS 值会被过滤不渲染。
<template>
<p
:style="{
fontSize: '13px',
'line-height': 2,
color: '#ff0000',
textAlign: 'center'
}"
>
Hello World!
</p>
</template>
如果有些特殊场景需要绑定多套 style
,你需要在 script
先定义好各自的样式变量(也是符合上面说到的那几个要求的对象),然后通过数组来传入:
<template>
<p
:style="[style1, style2]"
>
Hello World!
</p>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
const style1 = {
fontSize: '13px',
'line-height': 2,
}
const style2 = {
color: '#ff0000',
textAlign: 'center',
}
return {
style1,
style2,
}
}
})
</script>
使用 v-bind 动态修改 style
当然,以上两种形式都是关于 <script />
和 <template />
部分的相爱相杀,如果你觉得会给你的模板带来一定的维护成本的话,不妨考虑这个新方案,将变量绑定到 <style />
部分去。
:::tip
请注意这是一个在 3.2.0
版本之后才被归入正式队列的新功能!
如果需要使用它,请确保你的 vue
和 @vue/compiler-sfc
版本号在 3.2.0
以上,最好是保持最新的 @next
版本。
:::
我们先来看看基本的用法:
<template>
<p class="msg">Hello World!</p>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup () {
const fontColor = ref<string>('#ff0000')
return {
fontColor,
}
}
})
</script>
<style scoped>
.msg {
color: v-bind(fontColor);
}
</style>
如上面的代码,你将渲染出一句红色文本的 Hello World!
这其实是利用了现代浏览器支持的 CSS 变量来实现的一个功能(所以如果你打算用它的话,需要提前注意一下兼容性噢,点击查看:CSS Variables 兼容情况)
它渲染到 DOM 上,其实也是通过绑定 style
来实现,我们可以看到渲染出来的样式是:
<p
class="msg"
data-v-7eb2bc79=""
style="--7eb2bc79-fontColor:#ff0000;"
>
Hello World!
</p>
对应的 CSS 变成了:
.msg[data-v-7eb2bc79] {
color: var(--7eb2bc79-fontColor);
}
理论上 v-bind
函数可以在 Vue 内部支持任意的 JS 表达式,但由于可能包含在 CSS 标识符中无效的字符,因此官方是建议在大多数情况下,用引号括起来,如:
.text {
font-size: v-bind('theme.font.size');
}
由于 CSS 变量的特性,因此对 CSS 响应式属性的更改不会触发模板的重新渲染(这也是和 :class
与 :style
的最大不同)。
:::tip
不管你有没有开启 ,使用 v-bind
渲染出来的 CSS 变量,都会带上 scoped
的随机 hash 前缀,避免样式污染(永远不会意外泄漏到子组件中),所以请放心使用!
:::
如果你对 CSS 变量的使用还不是很了解的话,可以先阅读一下相关的基础知识点。
相关阅读:使用CSS自定义属性(变量) - MDN
样式表的组件作用域
CSS 不像 JS ,是没有作用域的概念的,一旦写了某个样式,直接就是全局污染。所以 BEM 命名法 等规范才应运而生。
但在 Vue 组件里,有两种方案可以避免出现这种污染问题。
一个是 Vue 2.x 就有的 <style scoped>
,一个是 Vue 3.x 新推出的 <style module>
。
style scoped
Vue 组件在设计的时候,就想到了一个很优秀的解决方案,通过 scoped
来支持创建一个 CSS 作用域,使这部分代码只运行在这个组件渲染出来的虚拟 DOM 上。
使用方式很简单,只需要在 style
后面带上 scoped
属性。
<style scoped>
.msg {
width: 100%;
}
.msg p {
color: #333;
font-size: 14px;
}
</style>
编译后,虚拟 DOM 都会带有一个 data-v-xxxxx
这样的属性,其中 xxxxx
是一个随机生成的 hash ,同一个组件的 hash 是相同并且唯一的:
<div class="msg" data-v-7eb2bc79>
<p data-v-7eb2bc79>Hello World!</p>
</div>
而 CSS 则也会带上与 HTML 相同的属性,从而达到样式作用域的目的。
.msg[data-v-7eb2bc79] {
width: 100%;
}
.msg p[data-v-7eb2bc79] {
color: #333;
font-size: 14px;
}
使用 scoped
可以有效的避免全局样式污染,你可以在不同的组件里面都使用相同的 className,而不必担心会相互覆盖,不必再定义很长很长的样式名来防止冲突了。
:::tip
添加 scoped
生成的样式,只作用于当前组件中的元素,并且权重高于全局 CSS ,可以覆盖全局样式
:::
style module
这是在 Vue 3.x 才推出的一个新方案,和 <style scoped>
不同,scoped 是通过给 DOM 元素添加自定义属性的方式来避免冲突,而 <style module>
则更为激进,将会编译成 CSS Modules 。
对于 CSS Modules 的处理方式,我们也可以通过一个小例子来更直观的了解它:
/* 编译前 */
.title {
color: red;
}
/* 编译后 */
._3zyde4l1yATCOkgn-DBWEL {
color: red;
}
可以看出,是通过比较 “暴力” 的方式,把我们编写的 “好看的” 样式名,直接改写成一个随机 hash 样式名,来避免样式互相污染。
上面的案例来自阮老师的博文 CSS Modules 用法教程
所以我们回到 Vue 这边,看看 <style module>
是怎么操作的。
<template>
<p :class="$style.msg">Hello World!</p>
</template>
<style module>
.msg {
color: #ff0000;
}
</style>
于是,你将渲染出一句红色文本的 Hello World!
。
:::tip
- 使用这个方案,需要了解如何 使用 :class 动态修改样式名
- 如果单纯只使用
<style module>
,那么在绑定样式的时候,是默认使用$style
对象来操作的 - 必须显示的指定绑定到某个样式,比如
$style.msg
,才能生效 - 如果单纯的绑定
$style
,并不能得到 “把全部样式名直接绑定” 的期望结果 - 如果你指定的 className 是短横杆命名,比如
.user-name
,那么需要通过$style['user-name']
去绑定
:::
你也可以给 module
进行命名,然后就可以通过你命名的 “变量名” 来操作:
<template>
<p :class="classes.msg">Hello World!</p>
</template>
<style module="classes">
.msg {
color: #ff0000;
}
</style>
:::tip
需要注意的一点是,一旦开启 <style module>
,那么在 <style module>
里所编写的样式,都必须手动绑定才能生效,没有被绑定的样式,会被编译,但不会主动生效到你的 DOM 上。
原因是编译出来的样式名已经变化,而你的 DOM 未指定对应的样式名,或者指定的是编译前的命名,所以并不能匹配到正确的样式。
:::
useCssModule
这是一个全新的 API ,面向在 script 部分操作 CSS Modules 。
在上面的 CSS Modules 部分可以知道,你可以在 style
定义好样式,然后在 template
部分通过变量名来绑定样式。
那么如果有一天有个需求,你需要通过 v-html
来渲染 HTML 代码,那这里的样式岂不是凉凉了?当然不会!
Vue 3.x 提供了一个 Composition API useCssModule
来帮助你在 setup
函数里操作你的 CSS Modules (对,只能在 setup 或者 script setup 里使用)。
基本用法:
我们绑定多几个样式,再来操作:
<template>
<p :class="$style.msg">
<span :class="$style.text">Hello World!</span>
</p>
</template>
<script lang="ts">
import { defineComponent, useCssModule } from 'vue'
export default defineComponent({
setup () {
const style = useCssModule()
console.log(style)
}
})
</script>
<style module>
.msg {
color: #ff0000;
}
.text {
font-size: 14px;
}
</style>
可以看到打印出来的 style
是一个对象:
key
是你在<style modules>
里定义的原始样式名value
则是编译后的新样式名
{
msg: 'home_msg_37Xmr',
text: 'home_text_2woQJ'
}
所以我们来配合 模板字符串 的使用,看看刚刚说的,要通过 v-html
渲染出来的内容应该如何绑定样式:
<template>
<div v-html="content"></div>
</template>
<script lang="ts">
import { defineComponent, useCssModule } from 'vue'
export default defineComponent({
setup () {
// 获取样式
const style = useCssModule()
// 编写模板内容
const content = `<p class="${style.msg}">
<span class="${style.text}">Hello World! —— from v-html</span>
</p>`
return {
content,
}
}
})
</script>
<style module>
.msg {
color: #ff0000;
}
.text {
font-size: 14px;
}
</style>
是不是也非常简单?可能刚开始不太习惯,但写多几次其实也蛮好玩的这个功能!
另外,需要注意的是,如果你是指定了 modules 的名称,那么必须传入对应的名称作为入参才可以正确拿到这些样式:
比如指定了一个 classes 作为名称:
<style module="classes">
/* ... */
</style>
那么需要通过传入 classes 这个名称才能拿到样式,否则会是一个空对象:
const style = useCssModule('classes')
:::tip
在 const style = useCssModule()
的时候,命名是随意的,跟你在 <style module="classes">
这里指定的命名没有关系。
:::
深度操作符
在 样式表的组件作用域 部分我们了解到,使用 scoped 后,父组件的样式将不会渗透到子组件中,但也不能直接修改子组件的样式。
如果确实需要进行修改子组件的样式,必须通过 ::v-deep
(完整写法) 或者 :deep
(快捷写法) 操作符来实现。
:::tip
- 旧版的深度操作符是
>>>
、/deep/
和::v-deep
,现在>>>
和/deep/
已进入弃用阶段(虽然暂时还没完全移除) - 同时需要注意的是,旧版
::v-deep
的写法是作为组合器的方式,写在样式或者元素前面,如:::v-deep .class-name { /* ... */ }
,现在这种写法也废弃了。
:::
现在不论是 ::v-deep
还是 :deep
,使用方法非常统一,我们来假设 .b 是子组件的样式名:
<style scoped>
.a :deep(.b) {
/* ... */
}
</style>
编译后:
.a[data-v-f3f3eg9] .b {
/* ... */
}
:::tip
可以看到,新的 deep 写法是作为一个类似 JS “函数” 那样去使用,需要深度操作的样式或者元素名,作为 “入参” 去传入。
:::
同理,如果你使用 Less 或者 Stylus 这种支持嵌套写法的预处理器,也是可以这样去深度操作的:
.a {
:deep(.b) {
/* ... */
}
}
另外,除了操作子组件的样式,那些通过 v-html
创建的 DOM 内容,也不受作用域内的样式影响,也可以通过深度操作符来实现样式修改。
使用 CSS 预处理器
在工程化的现在,可以说前端都几乎不写 CSS 了,都是通过 sass
、less
、stylus
等 CSS 预处理器来完成样式的编写。
为什么要用 CSS 预处理器?放一篇关于三大预处理器的点评,新同学可以做个简单了解,具体的用法在对应的官网上有非常详细的说明。
可以查看了解:浅谈css预处理器,Sass、Less和Stylus
在 Vue 组件里使用预处理器非常简单,Vue CLI
内置了 stylus
,如果打算使用其他的预处理器,需要先安装。
这里以 stylus
为例,只需要通过 lang="stylus"
属性来指定使用哪个预处理器,即可直接编写对应的代码:
<style lang="stylus">
// 定义颜色变量
$color-black = #333
$color-red = #ff0000
// 编写样式
.msg
width 100%
p
color $color-black
font-size 14px
span
color $color-red
</style>
编译后的css代码:
.msg {
width: 100%;
}
.msg p {
color: #333;
font-size: 14px;
}
.msg p span {
color: #ff0000;
}
预处理器也支持 scoped
,用法请查阅 样式表的组件作用域 部分。
本章结语
来到这里,关于组件的基础构成和写法风格,以及数据、函数的定义和使用,相信大家基本上有一定的了解了。
这一章内容不多,但非常重要,了解组件的基础写法关系着你后续在开发过程中,能否合理的掌握数据获取和呈现之间的关系,以及什么功能应该放在哪个生命周期调用等等,所谓磨刀不误砍柴工。
路由的使用
在传统的 Web 开发过程中,当你需要实现多个站内页面时,以前你需要写很多个 html 页面,然后通过 a 标签来实现互相跳转。
在如今 SPA 当道的时代,像 Vue 工程,可以轻松的通过配置一个生态组件,来实现只用一个 html ,却能够完成多个站内页面渲染、跳转的功能。
这个生态组件,就是路由。
:::tip
从这里开始,所有包含到 .vue 文件引入的地方,可能会看到 @xx/xx.vue
这样的写法。
@views
是 src/views
的路径别名,@cp
是 src/components
的路径别名。
路径别名可以在 vue.config.js
里配置 alias
,点击了解:添加项目配置
:::
路由的目录结构
3.x 引入路由的方式和 2.x 一样,如果你也是在创建 Vue 项目的时候选择了带上路由,那么会自动帮你在 src
文件夹下创建如下的目录结构。
如果创建时没有选择,那么也可以按照这个结构自己创建对应的文件。
src
├─router
├───index.ts
├───routes.ts
└─main.ts
其中 index.ts
是路由的入口文件,系统安装的时候也只有这个文件,routes.ts
是我自己加的,主要用于集中管理路由,index.ts
只用于编写路由的创建、拦截等逻辑功能。
因为大型项目来说,路由树是很粗壮的,往往需要配置上二级、三级路由,逻辑和配置都放到一个文件的话,太臃肿了。
:::tip
需要注意的是,与 Vue 3.x 配套的路由版本是 vue-router 4.x 以上,也就是如果一开始创建没有选择路由的话,后续自己安装,需要选择 vue-router@4
或者 vue-router@latest
才可以正确匹配。
:::
在项目里引入路由
不管是 Vue 2.x 还是 Vue 3.x ,引入路由都是在 index.js
/ index.ts
文件里,但是版本升级带来的变化很大,由于我们的 Vue 3.0 是写 TypeScript
,所以这里只做一个 TS 的变化对比。
回顾 2.x
Vue 2.x 的引入方式如下(其中 RouteConfig
是路由项目的 TS 类型定义)。
import Vue from 'vue'
import VueRouter, { RouteConfig } from 'vue-router'
Vue.use(VueRouter)
const routes: Array<RouteConfig> = [
// ...
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
里面一些选项的功能说明:
routes
是路由树的配置,当你的路由很粗壮的时候你可以集中到routes.ts
管理然后再import
进来(具体的配置请看后面的 路由配置部分 说明)。mode
决定访问路径模式,可配置为hash
或者history
,hash 模式是这种http://abc.com/#/home
这样带 # 号的地址,支持所有浏览器,history 模式是http://abc.com/home
这样不带 # 号,不仅美观,而且体验更好,但需要服务端做一些配置支持,也只对主流浏览器支持。
相关阅读:后端配置例子 - HTML5 History 模式
base
是 history 模式在进行路由切换时的基础路径,默认是/
根目录,如果你的项目不是部署在根目录下,而是二级目录、三级目录等多级目录,就必须指定这个 base ,不然路由切换会有问题。
了解 3.x
Vue 3.x 的引入方式如下(其中 RouteRecordRaw
是路由项目的 TS 类型定义)。
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw> = [
// ...
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router
在 Vue 3.x (也就是 vue-router 4.x) 里,路由简化了一些配置项,里面一些选项的功能说明:
routes
和 2.x 一样,是路由树的配置。history
和 2.x 有所不同,在 3.x ,使用history
来代替 2.x 的mode
,但功能是一样的,也是决定访问路径模式是hash
模式 还是history
模式,同时合并了 Vue 2.x (也就是 vue-router 3.x) 的base
选项作为模式函数的入参。
:::tip
当然,和在使用 Vue 2.x 的时候一样,你还可以配置一些额外的路由选项。
:::
比如:指定 router-link
针对活动路由所匹配的 className
:
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
linkActiveClass: 'cur',
linkExactActiveClass: 'cur',
routes
})
更多的配置项可以参考官网: RouterOptions - Vue Router
路由树的配置
在 引入路由 部分有说到,当你的路由很粗壮的时候,你可以集中到 routes.ts
管理然后再 import
到 index.ts
里。
我们暂且把 routes.ts
这个文件称为“路由树”,因为它像一棵大树一样,不仅可以以一级路由为树干去生长,还可以添加二级、三级等多级路由来开枝散叶。
那我们来看看 routes.ts
应该怎么写:
基础格式
在TS里,路由文件的基础格式由三个部分组成:
// TS需要引入每个路由的类型定义
import { RouteRecordRaw } from 'vue-router'
// 定义一个路由数组
const routes: Array<RouteRecordRaw> = [
// ...
];
// 暴露定义好的路由数据
export default routes;
之后就可以在 index.ts
里导入使用了。
那么里面的路由数组又是怎么写呢?这里就涉及到了 一级路由 和 多级路由 的编写。
公共路径
在配置路由之前,需要先了解公共路径(publicPath)的概念,在 添加项目配置 部分,我们里面有一个参数,叫 publicPath
,其实就是用来控制路由的公共路径,那么它有什么用呢?
publicPath
的默认值是 /
,也就是说,如果你不配置它,那么所有的资源文件都是从域名根目录读取,如果你的项目部署在域名根目录那当然好,但是如果不是呢?那么就必须来配置它了。
配置很简单,只要把项目要上线的最终地址,去掉域名,剩下的那部分就是 publicPath
。
:::tip
如果你的路由只有一级,那么 publicPath
也可以设置为相对路径 ./
,这样你可以把项目部署到任意地方。
如果路由不止一级,那么请准确的指定 publicPath
,并且保证它是以 /
开头, /
结尾。
:::
假设你的项目是部署在 https://chengpeiquan.com/vue3/
,那么 publicPath
就可以设置为 /vue3/
。
通常我们开发环境,也就是本机ip访问的时候,都是基于根目录,但上线后的就不一定是根目录了,那么你在 vue.config.js
里可以通过环境变量来指定不同环境使用不同的 publicPath
。
const IS_DEV = process.env.NODE_ENV === 'development' ? true : false;
module.exports = {
publicPath: IS_DEV ? '/' : '/vue3/'
}
一级路由
一级路由,顾名思义,就是在我们的项目地址后面,只有一级path,比如 https://chengpeiquan.com/home
这里的 home
就是一级路由。
我们来看一下最基本的路由配置应该包含哪些字段:
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'home',
component: () => import(/* webpackChunkName: "home" */ '@views/home.vue')
}
];
path
是路由的访问路径,像上面说的,如果你的域名是https://chengpeiquan.com/
, 配置为/home
,那么访问路径就是https://chengpeiquan.com/home
:::tip
一级路由的path都必须是以 /
开头,比如: /home
、/setting
;
如果你的项目首页不想带上 home
之类的尾巴,只想要 https://chengpeiquan.com/
这样的域名直达 ,其实也是配置一级路由,只需要把路由的 path
指定为 /
即可。
:::
name
是路由的名称,非必填,但是一般都会配置上去,这样可以很方便的通过name
来代替path
实现路由的跳转,因为像有时候你的开发环境和生产环境的路径不一致,或者说路径变更,通过name
无需调整,但如果通过path
,可能就要修改很多文件里面的链接跳转目标了。component
是路由的模板文件,指向一个vue组件,用于指定路由在浏览器端的视图渲染,这里有两种方式来指定使用哪个组件:
同步组件
字段 component
接收一个变量,变量的值就是对应的模板组件。
在打包的时候,会把组件的所有代码都打包到一个文件里,对于大项目来说,这种方式的首屏加载是个灾难,要面对文件过大带来等待时间变长的问题。
import Home from '@/components/home.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'home',
component: Home
}
];
所以现在都推荐使用第二种方式,可以实现 路由懒加载 。
异步组件
字段 component
接收一个函数,在return的时候返回模板组件,同时还可以指定要生成的chunk,组件里的代码都会生成独立的文件,按需引入。
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'home',
component: () => import(/* webpackChunkName: "home" */ '@views/home.vue')
}
];
关于这部分的更多说明,可以查看 路由懒加载。
多级路由
在Vue路由生态里,支持配置二级、三级、四级等多级路由,理论上没有上限,实际业务中用到的级数通常是三级到四级。
比如你做一个美食类网站,打算在 “中餐” 大分类下配置一个 “饺子” 栏目,那么地址就是:
https://chengpeiquan.com/chinese-food/dumplings
这种情况下,中餐 chinese-food
就是一级路由,饺子 dumplings
就是二级路由。
如果你想再细化一下,“饺子” 下面再增加一个 “韭菜” 、“白菜” 等不同馅料的子分类:
https://chengpeiquan.com/chinese-food/dumplings/chives
这里的韭菜 chives
就是饺子 dumplings
的子路由,也就是三级路由。
在了解了子路由的概念后,来看一下具体如何配置,以及注意事项。
:::tip
父子路由的关系,都是严格按照JSON的层级关系,子路由的信息配置到父级的 children
数组里面,孙路由也是按照一样的格式,配置到子路由的 children
里。
:::
这是一个简单的子路由示范:
const routes: Array<RouteRecordRaw> = [
// 注意:这里是一级路由
{
path: '/lv1',
name: 'lv1',
component: () => import(/* webpackChunkName: "lv1" */ '@views/lv1.vue'),
// 注意:这里是二级路由
children: [
{
path: 'lv2',
name: 'lv2',
component: () => import(/* webpackChunkName: "lv2" */ '@views/lv2.vue'),
// 注意:这里是三级路由
children: [
{
path: 'lv3',
name: 'lv3',
component: () => import(/* webpackChunkName: "lv3" */ '@views/lv3.vue')
}
]
}
]
}
];
最终线上的访问地址,比如要访问三级路由:
https://chengpeiquan.com/lv1/lv2/lv3
路由懒加载
在上面我们提过,路由在配置 同步组件 的时候,构建出来的文件都集中在一起,大的项目的文件会变得非常大,影响页面加载。
所以Vue在Webpack的代码分割功能的基础上,推出了 异步组件,可以把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,这样按需载入,很方便的实现路由组件的懒加载。
在这一段配置里面:
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'home',
component: () => import(/* webpackChunkName: "home" */ '@views/home.vue')
}
];
起到懒加载配置作用的就是 component
接收的值:
() => import(/* webpackChunkName: "home" */ '@views/home.vue')
其中 @views/home.vue
不必说,就是路由的组件。
而前面的“注释” /* webpackChunkName: "home" */
起到的作用就是为切割后的代码文件命名。
在命令行对项目执行 npm run build
打包,构建后,会看到控制台输出的打包结果:
File Size Gzipped
dist\static\js\chunk-vendors.1fd4afd3.js 137.27 KiB 48.66 KiB
dist\static\js\login.730a2ef8.js 69.65 KiB 23.06 KiB
dist\static\js\app.82ec2bee.js 4.32 KiB 1.94 KiB
dist\static\js\home.5988a746.js 1.00 KiB 0.54 KiB
dist\static\js\about.a73d5b8f.js 0.38 KiB 0.28 KiB
dist\static\css\login.f107fbdb.css 0.33 KiB 0.19 KiB
dist\static\css\home.12026f88.css 0.13 KiB 0.13 KiB
dist\static\css\app.b1cc4f11.css 0.04 KiB 0.06 KiB
而如果你不使用路由懒加载,build出来的文件是这样的:
File Size Gzipped
dist\static\js\chunk-vendors.389391d2.js 203.98 KiB 71.02 KiB
dist\static\js\app.634c584f.js 6.56 KiB 2.40 KiB
dist\static\css\app.beea0177.css 0.41 KiB 0.23 KiB
单纯看js文件:
使用代码切割,当你访问 home
路由的时候,分割后你首次会加载 app
、chunk-vendors
、home
这3个文件,加起来142.59k。
而不分割则需要加载210.54k,整整多出接近50%的体积,这只是一个非常小的demo,大型项目会更夸张!
两者哪个更适合大项目,高下立见!!!
路由的渲染
所有路由组件,要在访问后进行渲染,都必须在父级组件里带有 <router-view />
标签。
<router-view />
在哪里,路由组件的代码就渲染在哪个节点上。
一级路由的父级组件,当然就是 src
下的 App.vue
。
最基础的配置:
最简单的基础格式,就是 template
里面直接就是 <router-view />
,整个页面就是路由组件。
<template>
<router-view />
</template>
带有全局的公共组件:
比如有全站统一的页头、页脚,只有中间区域才是路由。
<template>
<!-- 全局页头 -->
<Header />
<!-- 路由 -->
<router-view />
<!-- 全局页脚 -->
<Footer />
</template>
部分路由全局,部分路由带公共组件:
比如大部分页面都需要有侧边栏,但登录页、注册页不能带。
<template>
<!-- 登录 -->
<Login v-if="route.name === 'login'" />
<!-- 注册 -->
<Register v-else-if="route.name === 'register'" />
<!-- 带有侧边栏的其他路由 -->
<div v-else>
<!-- 固定在左侧的侧边栏 -->
<Sidebar />
<!-- 路由 -->
<router-view />
</div>
</template>
使用 route 获取路由信息
和 2.x 可以直接在组件里使用 this.$route
来获取当前路由信息不同,在3.x 的组件里,Vue实例既没有了 this
,也没有了 $route
。
要牢记一个事情就是,3.x 用啥都要导入,所以,获取当前路由信息的正确用法是:
1、导入路由组件
import { useRoute } from 'vue-router'
2、定义路由变量
刚刚导入的 useRoute
是一个函数,需要在 setup
里定义一个变量来获取路由信息。
const route = useRoute();
3、读取路由信息
接下来就可以通过定义好的变量 route
去获取当前路由信息了。
当然,如果要在 template
里使用路由,记得把 route
在 setup
里return出去。
// 获取路由名称
console.log(route.name);
// 获取路由参数
console.log(route.params.id);
3.x 的 route
和 2.x 的用法基本一致,日常使用应该很快能上手。
:::warning
但是 3.x 的新路由也有一些小变化,有一些属性是被移除了,比如之前获取父级路由信息,很喜欢用的 parent
属性,现在已经没有了 点击查看原因 。
:::
类似被移除的 parent
,如果要获取父级路由信息(比如你在做面包屑功能的时候),可以改成下面这样,手动指定倒数第二个为父级信息:
// 获取路由记录
const MATCHED = route.matched;
// 获取该记录的路由个数
const LEN = MATCHED.length;
// 获取倒数第二个路由(也就是当前路由的父级路由)
const ROUTE_PARENT = MATCHED[LEN - 2];
如果有配置父级路由,那么刚刚的 ROUTE_PARENT
就是父级路由信息了
使用 router 操作路由
和 route
一样,在 3.x 也不再存在 this.$router
,也必须通过导入路由组件来使用。
1、导入路由组件
import { useRouter } from 'vue-router'
2、定义路由变量
和 useRoute
一样, useRouter
也是一个函数,需要在 setup
里定义一个变量来获取路由信息。
const router = useRouter();
3、操作路由
接下来就可以通过定义好的变量 router
去操作路由了。
// 跳转首页
router.push({
name: 'home'
})
// 返回上一页
router.back();
使用 router-link 标签跳转
router-link
是一个路由组件,可直接在 template
里使用,基础的用法在 2.x 和 3.x 一样。
默认会被转换为一个 a
标签,对比写死的 <a href="...">
,使用 router-link
会更加灵活。
基础跳转
最基础的用法就是把它当成一个 target="_self"
的a标签使用,但无需重新刷新页面,因为是路由跳转,它的体验和使用 router
去进行路由导航的效果完全一样。
<template>
<router-link to="/home">首页</router-link>
</template>
等价于 router
的 push
:
router.push({
name: 'home'
})
你可以写个 span
然后绑定 click
事件来达到 router-link
的效果(但你看是不是麻烦很多emm…
<template>
<span
class="link"
@click="router.push({
name: 'home'
})"
>
首页
</span>
</template>
带参数的跳转
使用 router
的时候,可以轻松的带上参数去那些有id的内容页、用户资料页、栏目列表页等等。
比如你要访问一篇文章 https://chengpeiquan.com/article/123
,用 push
的写法是:
router.push({
name: 'article',
params: {
id: 123
}
})
同理,从基础跳转的写法,很容易就能get到在 router-link
里应该怎么写:
<template>
<router-link
class="link"
:to="{
name: 'article',
params: {
id: 123
}
}"
>
这是文章的标题
</router-link>
</template>
不生成 a 标签
router-link
默认是被转换为一个 a
标签,但根据业务场景,你也可以把它指定为生成其他标签,比如 span
、 div
、 li
等等,这些标签因为不具备 href
属性,所以在跳转时都是通过 click
事件去执行。
在 2.x,指定为其他标签只需要一个 tag
属性即可:
<template>
<router-link tag="span" to="/home">首页</router-link>
</template>
但在 3.x ,tag
属性已被移除,需要通过 custom
和 v-slot
的配合来渲染为其他标签。
比如要渲染为一个带有路由导航功能的 div
:
<template>
<router-link
to="/home"
custom
v-slot="{ navigate }"
>
<span
class="link"
@click="navigate"
>
首页
</span>
</router-link>
</template>
渲染后就是一个普通的 span
标签,当你点击的时候,它会通过路由的导航把你带到指定的路由页:
<span class="link">首页</span>
关于这2个属性,他们的参数说明如下:
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
,用来绑定元素的点击事件,否则元素点击后不会有任何反应,其他的可以根据实际需求来添加。
:::tip
要渲染为非 a
标签,切记两个点:
router-link
必须带上custom
和v-slot
属性- 最终要渲染的标签,写在
router-link
里,包括对应的className
和点击事件
:::
在独立 TS/JS 文件里使用路由
除了可以在 .vue
文件里使用路由之外,你也可以在单独的 .ts
、.js
里使用。
比如你要做一个带有用户系统的站点,登录的相关代码除了在 login.vue
里运用外,在注册页面 register.vue
,用户注册成功还要帮用户执行一次自动登录。
登录完成还要记录用户的登录信息、token、过期时间等等,有不少数据要做处理,以及需要帮助用户自动切去他登录前的页面等行为。
这是两个不同的组件,让我来写2次几乎一样的代码,我是拒绝的!
这种情况下你就可以通过抽离核心代码,封装成一个 login.ts
文件,在这个独立的 ts
文件里去操作路由。
// 导入路由
import router from '@/router'
// 执行路由跳转
router.push({
name: 'home'
})
路由元信息配置
有时候你的项目需要一些个性化配置,比如:
- 每个路由给予独立的标题;
- 管理后台的路由,部分页面需要限制一些访问权限;
- 通过路由来自动生成侧边栏、面包屑;
- 部分路由的生命周期需要做缓存(keep alive);
- and so on……
无需维护很多套配置,定义路由的时候可以配置 meta 字段,比如下面就是包含了多种元信息的一个登录路由:
const routes: Array<RouteRecordRaw> = [
{
path: '/login',
name: 'login',
component: () => import(/* webpackChunkName: "login" */ '@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后,会校验登录状态,此处的登录页不需要校验); |
这些功能都是我在项目里需要操控到路由的功能,通过这样的一些字段来达到路由的控制。
:::tip
路由 meta
字段的内容没有要求,按需配置,一些功能可以配合 路由拦截 一起使用。
:::
类似的,你如果有其他需求,比如要增加对不同用户组的权限控制(比如有管理员、普通用户分组,部分页面只有管理员允许访问),都可以通过路由元信息来配置,然后在对应的地方进行读取操作。
路由重定向
这个是我们的老朋友了,路由重定向是使用一个 redirect
字段,配置到对应的路由里面去实现跳转。
:::tip
通常来说,配置了 redirect
的路由,只需要指定2个字段即可,1个是 path
自己的路径,1个是 redirect
目标路由的路径,其他诸如 name
、component
等字段可以忽略,因为根本不会访问到。
:::
redirect
字段可以接收三种类型的值:
类型 | 填写的值 |
---|---|
string | 另外一个路由的 path |
route | 另外一个路由(类似 router.push ) |
function | 可以判断不同情况的重定向目标,最终 return 一个 path 或者 route |
业务场景
路由重定向可以避免用户访问到一些无效路由页面:
- 比如项目上线了一段时间后,有个路由需要改名,或者调整路径层级,可以把旧路由重定向到新的,避免原来的用户从收藏夹等地方进来后找不到
- 一些容易打错的地址,比如通常个人资料页都是用
profile
,但是你的这个网站是用account
,那也可以把profile
重定向到account
去 - 对于一些有会员体系的站点,可以根据用户权限进行重定向,分别指向他们具备访问权限的页面
- 官网首页在PC端、移动端、游戏内嵌横屏版分别有3套页面,但希望能通过主域名来识别不同设备,帮助用户自动切换访问
了解了业务场景,接下来就能比较清晰的了解应该如何配置重定向了。
配置为 path
最常用的场景,恐怕就是首页的指向了,比如首页地址是 https://chengpeiquan.com/home
,但是想让主域名 https://chengpeiquan.com/
也能跳转到 /home
,可以这么配置:
这是最简单的配置方式,把目标路由的 path
丢进来就可以了:
const routes: Array<RouteRecordRaw> = [
// 重定向到home
{
path: '/',
redirect: '/home'
},
// 真正的首页
{
path: '/home',
name: 'home',
component: () => import(/* webpackChunkName: "home" */ '@views/home.vue')
}
]
但缺点也显而易见,只能针对那些不带参数的路由。
配置为 route
如果你想要重定向后的路由地址带上一些参数,可以配置为 route
:
const routes: Array<RouteRecordRaw> = [
// 重定向到home,并带上一个query
{
path: '/',
redirect: {
name: 'home',
query: {
from: 'redirect'
}
}
},
// 真正的首页
{
path: '/home',
name: 'home',
component: () => import(/* webpackChunkName: "home" */ '@views/home.vue')
}
]
最终访问的地址就是 https://chengpeiquan.com/home?from=redirect
, 像这样带有来路参数的,你就可以在 “百度统计” 或者 “CNZZ统计” 之类的统计站点查看来路的流量。
配置为 function
结合业务场景来解释是最直观的,比如你的网站有3个用户组,一个是管理员,一个是普通用户,还有一个是游客(未登录),他们的网站首页是不一样的。
管理员的首页具备各种数据可视化图表、最新的网站数据、一些最新的用户消息等等。
普通用户的首页可能只有一些常用模块的入口链接。
未登录用户则直接跳转到登录页面。
产品需要在访问网站主域名的时候,识别他们的身份来跳转不同的首页,那么就可以来配置我们的路由重定向了:
const routes: Array<RouteRecordRaw> = [
// 访问主域名时,根据用户的登录信息,重定向到不同的页面
{
path: '/',
redirect: () => {
// LOGIN_INFO是当前用户的登录信息,你可以从localStorage或者Vuex读取
const GROUP_ID: number = LOGIN_INFO.groupId;
// 根据组别id进行跳转
switch (GROUP_ID) {
// 管理员,跳去仪表盘
case 1:
return '/dashboard';
// 普通用户,跳去首页
case 2:
return '/home';
// 其他都认为未登录,跳去登录页
default:
return '/login'
}
}
}
]
路由别名配置
根据你的业务需求,你也可以为路由指定一个别名,与上面的 路由重定向 功能相似,但又有不同:
配置了路由重定向,当用户访问 /a
时,URL 将会被替换成 /b
,然后匹配的实际路由是 /b
。
配置了路由别名,/a
的别名是 /b
,当用户访问 /b
时,URL 会保持为 /b
,但是路由匹配则为 /a
,就像用户访问 /a
一样。
配置方法
添加一个 alias
字段即可轻松实现:
const routes: Array<RouteRecordRaw> = [
{
path: '/home',
alias: '/index',
name: 'home',
component: () => import(/* webpackChunkName: "home" */ '@views/home.vue')
}
]
如上的配置,即可实现可以通过 /home
访问首页,也可以通过 /index
访问首页。
404路由页面配置
你可以配置一个404路由来代替站内的404页面。
配置方法
const routes: Array<RouteRecordRaw> = [
{
path: '/:pathMatch(.*)*',
name: '404',
component: () => import(/* webpackChunkName: "404" */ '@views/404.vue')
}
]
这样配置之后,只要访问到不存在的路由,就会显示为这个404模板。
:::warning
新版的路由不再支持直接配置通配符 *
,而是必须使用带有自定义正则表达式的参数进行定义。
官方说明:Removed * (star or catch all) routes
:::
导航守卫
和 2.x 时使用的路由一样, 3.x 也支持导航守卫,并且用法基本上是一样的。
导航守卫这个词对初次接触的同学来说应该会有点云里雾里,其实就是几个专属的钩子函数,我们先来看一下使用场景,大致理解一下这个东西是啥,有什么用。
钩子的应用场景
对于导航守卫还不熟悉的同学,可以从一些实际使用场景来加强印象,比如:
- 前面说的,在渲染的时候配置浏览器标题,Vue项目只要一个Html文件,默认只有一个标题,但你想在访问
home
的时候标题显示为 “首页”,访问about
的时候标题显示为 “关于我们”; - 部分页面需要管理员才能访问,普通用户不允许进入到该路由页面;
- Vue单页面项目,传统的CNZZ/百度统计等网站统计代码只会在页面加载的时候统计一次,但你需要每次切换路由都上报一次PV数据
场景,还有很多…
导航守卫支持全局使用,也可以在 .vue
文件里单独使用,我们来看下具体的用法。
路由里的全局钩子
顾名思义,是在创建 router
的时候进行全局的配置,也就是说,只要你配置了钩子,那么所有的路由在调用到的时候,都会触发这些钩子函数。
可用钩子 | 含义 | 触发时机 |
---|---|---|
beforeEach | 全局前置守卫 | 在路由跳转前触发 |
beforeResolve | 全局解析守卫 | 在导航被确认前,同时在组件内守卫和异步路由组件被解析后 |
afterEach | 全局后置守卫 | 在路由跳转完成后触发 |
全局配置非常简单,在 src/router/index.ts
里,创建路由之后、在暴露出去之前使用:
import { createRouter } from 'vue-router'
// 创建路由
const router = createRouter({ ... })
// 在这里调用导航守卫的钩子函数
router.beforeEach((to, from) => {
// ...
})
// 暴露出去
export default router
beforeEach
全局前置守卫,这是导航守卫里面运用的最多的一个钩子函数,我习惯把它叫成 “路由拦截”。
拦截这个词,顾名思义,就是在 XXX 目的达到之前,把它拦下来,所以路由的目的就是渲染指定的组件嘛,路由拦截就是在它渲染之前,做一些拦截操作。
参数
参数 | 作用 |
---|---|
to | 即将要进入的路由对象 |
from | 当前导航正要离开的路由 |
:::tip
和 2.x 不同,2.x 的 beforeEach
是默认三个参数,第三个参数是 next
,用来操作路由接下来的跳转。
但在新版本路由里,已经通过 RFC 将其删除,虽然目前还是作为可选参数使用,但以后不确定是否会移除,不建议继续使用,点击查看原因。
新版本路由可以通过 return
来代替 next
。
:::
用法
比如在进入路由之前,根据 meta
信息,设定路由的网页标题:
router.beforeEach( (to, from) => {
const TITLE: string = to.meta.title;
document.title = TITLE || '默认title';
})
或者判断是否需要登录(需要在 meta信息 里配置相关的参数):
router.beforeEach( (to, from) => {
if ( to.meta && !to.meta.isNoLogin ) {
return '/login';
}
})
或者针对一些需要id参数,但参数丢失的路由做拦截:
比如:文章详情页 https://chengpeiquan/article/123
这样的地址,是需要带有文章id的,如果只访问 https://chengpeiquan/article
则需要拦截掉。
这里是关于 article
路由的配置,是有要求params要带上id参数:
const routes: Array<RouteRecordRaw> = [
// 这是一个配置了params,访问的时候必须带id的路由
{
path: '/article/:id',
name: 'article',
component: () => import(/* webpackChunkName: "article" */ '@views/article.vue'),
}
// ...
]
当路由的 params
丢失的时候,路由记录 matched
是一个空数组,针对这样的情况,你就可以配置一个拦截,丢失参数时返回首页:
router.beforeEach( (to, from) => {
if ( to.matched.length === 0 ) {
return '/';
}
})
beforeResolve
全局解析守卫,它会在每次导航时触发,但是在所有组件内守卫和异步路由组件被解析之后,将在确认导航之前被调用。
这个钩子用的比较少,因为它和 beforeEach
非常相似,相信大部分同学都是会用 beforeEach
来代替它。
那么它有什么用?
它通常会用在一些申请权限的环节,比如一些H5页面需要申请系统相机权限、一些微信活动需要申请微信的登录信息授权,获得权限之后才允许获取接口数据和给用户更多的操作,使用 beforeEach
时机太早,使用 afterEach
又有点晚,那么这个钩子的时机就刚刚好。
参数
参数 | 作用 |
---|---|
to | 即将要进入的路由对象 |
from | 当前导航正要离开的路由 |
用法
我就拿目前英文官网的一个申请照相机权限的例子来举例(官网传送门):
router.beforeResolve(async to => {
// 如果路由配置了必须调用相机权限
if ( to.meta.requiresCamera ) {
// 正常流程,咨询是否允许使用照相机
try {
await askForCameraPermission()
}
// 容错
catch (error) {
if ( error instanceof NotAllowedError ) {
// ... 处理错误,然后取消导航
return false
} else {
// 如果出现意外,则取消导航并抛出错误
throw error
}
}
}
})
afterEach
全局后置守卫,这也是导航守卫里面用的比较多的一个钩子函数。
参数
参数 | 作用 |
---|---|
to | 即将要进入的路由对象 |
from | 当前导航正要离开的路由 |
用法
在刚刚的 钩子的应用场景 里面我有个例子,就是每次切换路由都上报一次PV数据,类似这种每个路由都要执行一次,但又不必在渲染前操作的,都可以放到后置钩子里去执行。
我之前有写过2个插件:Vue版CNZZ统计、Vue版百度统计,就是用的这个后置钩子来实现自动上报数据。
router.afterEach( (to, from) => {
// 上报流量的操作
// ...
})
在组件内使用全局钩子
上面所讲的都是全局钩子,虽然一般都是在路由文件里使用,但如果有需要,也可以在 .vue
文件里操作。
:::tip
和路由的渲染不同,渲染是父级路由组件必须带有 <router-view />
标签才能渲染,但是使用全局钩子不受此限制。
建议只在一些入口文件里使用,比如 App.vue
,或者是一些全局的 Header.vue
、Footer.vue
里使用,方便后续维护。
:::
在 setup
里,定义一个 router
变量获取路由之后,就可以操作了:
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 | 当前导航正要离开的路由 |
:::tip
和 beforeEach
一样,也是取消了 next
,可以通过 return
来代替。
:::
用法
比如:整个站点的默认标题都是 “项目经验 - 程沛权” 这样,以 “栏目标题” + “全站关键标题” 的格式作为网页的title,但在首页的时候,你想做一些不一样的定制。
const routes: Array<RouteRecordRaw> = [
{
path: '/home',
name: 'home',
component: () => import(/* webpackChunkName: "home" */ '@views/home.vue'),
// 在这里添加单独的路由守卫
beforeEnter: (to, from) => {
document.title = '程沛权 - 养了三只猫';
}
}
];
你就可以通过 beforeEnter
来实现一些个别路由的单独定制。
:::tip
需要注意的是,只有从不同的路由切换进来,才会触发该钩子。
针对同一个路由,但是不同的params或者query、hash,都不会重复触发该钩子。
比如从 https://chengpeiquan.com/article/123
切换到 https://chengpeiquan.com/article/234
是不会触发的。
:::
其他的用法和 beforeEach
可以说是一样的。
组件内单独使用
组件里除了可以使用全局钩子外,还可以使用组件专属的路由钩子。
可用钩子 | 含义 | 触发时机 |
---|---|---|
onBeforeRouteUpdate | 组件内的更新守卫 | 在当前路由改变,但是该组件被复用时调用 |
onBeforeRouteLeave | 组件内的离开守卫 | 导航离开该组件的对应路由时调用 |
:::tip
1、组件内钩子的入参,也都是取消了 next
,可以通过 return
来代替。
2、在 setup
里使用时,需要遵循 Vue 3.0
的规范要求,先 import
再操作。
:::
和旧版路由不同,新版的 composition api
移除了 beforeRouteEnter
这个钩子了(查看详情)
onBeforeRouteUpdate
可以在当前路由改变,但是该组件被复用时,重新调用里面的一些函数用来更新模板数据的渲染。
参数
参数 | 作用 |
---|---|
to | 即将要进入的路由对象 |
from | 当前导航正要离开的路由 |
用法
比如一个内容网站,通常在文章详情页底部会有相关阅读推荐,这个时候就会有一个操作场景是,从文章A跳转到文章B。
比如从 https://chengpeiquan.com/article/111
切去 https://chengpeiquan.com/article/222
,这种情况就属于 “路由改变,但是组件被复用” 的情况了。
这种情况下,原本放在 onMounted
里执行数据请求的函数就不会被调用,可以借助该钩子来实现渲染新的文章内容。
import { defineComponent, onMounted } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
export default defineComponent({
setup () {
const route = useRoute();
// 获取文章详情
const getArticleDetail = (articleId: number): void => {
// 请求文章内容
// 此处略...
}
// 组件挂载完成后执行文章内容的请求
onMounted( () => {
const ARTICLE_ID: number = Number(route.params.id) || 0;
getArticleDetail(ARTICLE_ID);
})
// 组件被复用时重新请求新的文章内容(注意:要获取的是to的params)
onBeforeRouteUpdate( (to, from) => {
const NEW_ARTICLE_ID: number = Number(to.params.id) || 0;
getArticleDetail(NEW_ARTICLE_ID);
})
}
})
onBeforeRouteLeave
可以在离开当前路由之前,实现一些离开前的判断拦截。
参数
参数 | 作用 |
---|---|
to | 即将要进入的路由对象 |
from | 当前导航正要离开的路由 |
用法
这个离开守卫通常用来禁止用户在还未保存修改前突然离开,可以通过 return false
来取消用户离开当前路由。
import { defineComponent } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
export default defineComponent({
setup () {
// 调用离开守卫
onBeforeRouteLeave( (to, from) => {
// 弹出一个确认框
const CONFIRM_TEXT: string = '确认要离开吗?您的更改尚未保存!';
const IS_CONFIRM_LEAVE: boolean = window.confirm(CONFIRM_TEXT);
// 当用户点取消时,不离开路由
if ( !IS_CONFIRM_LEAVE ) {
return false
}
})
}
})
路由监听
路由的监听,可以延续以往的 watch
大法,也可以用全新的 watchEffect
。
watch
在 Vue 2.x
的时候,监听路由变化用的最多的就是 watch
了,Vue 3.x
的 watch
使用更简单。
1. 监听整个路由
你可以跟以前一样,直接监听整个路由的变化:
import { defineComponent, watch } from 'vue'
import { useRoute } from 'vue-router'
export default defineComponent({
setup () {
const route = useRoute();
// 监听整个路由
watch( route, (to, from) => {
// 处理一些事情
// ...
})
}
})
第一个参数传入整个路由;
第二个参数是个callback,可以获取to和from来判断路由变化情况。
2. 监听路由的某个数据
如果只想监听路由的某个数据变化,比如监听一个 query
,或者一个 param
,你可以采用这种方式:
import { defineComponent, watch } from 'vue'
import { useRoute } from 'vue-router'
export default defineComponent({
setup () {
const route = useRoute();
// 监听路由参数的变化
watch(
() => route.query.id,
() => {
console.log('监听到query变化');
}
)
}
})
第一个参数传入一个函数,return
你要监听的值;
第二个参数是个callback,可以针对参数变化进行一些操作。
watchEffect
这是 Vue 3.x
新出的一个监听函数,可以简化 watch
的行为。
比如你定义了一个函数,通过路由的参数来获取文章id,然后请求文章内容:
import { defineComponent, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
export default defineComponent({
setup () {
const route = useRoute();
// 获取文章详情
const getArticleDetail = (): void => {
// 直接通过路由的参数来获取文章id
const ARTICLE_ID: number = Number(route.params.id) || 0;
console.log('文章id是:', ARTICLE_ID);
// 请求文章内容
// 此处略...
}
// 直接监听包含路由参数的那个函数
watchEffect(getArticleDetail);
}
})
对比 watch
的使用, watchEffect
在操作上更加简单,把包含要被监听数据的函数,当成它的入参丢进去即可。
本章结语
路由在我们的实际项目里,是非常重要的一个部分,Vue 3.x 相对 2.x 来说,新版路由带来的变化不算特别多,但是那些变化足以让人一开始摸不着头脑(比如以前直接通过 this.$route
来操作路由,现在必须通过 useRoute
等等),还是要慢慢习惯下。
插件的使用
在构建 Vue 项目的过程中,离不开各种开箱即用的插件支持,用以快速完成需求,避免自己造轮子。
关于插件
关于插件的定义,摘选一段 官方plugins文档 的描述:
:::tip
插件是自包含的代码,通常向 Vue 添加全局级功能。它可以是一个带有公开 install()
方法的 object
,也可以是 一个function
插件的功能范围没有严格的限制,一般有下面几种:
添加全局方法或者 property
。如:vue-custom-element
添加全局资源:指令/过滤器/过渡等,例如:vue-touch
通过全局混入来添加一些组件选项,例如:vue-router
添加全局实例方法,通过把它们添加到 config.globalProperties
上实现。
一个库,提供自己的 API,同时提供上面提到的一个或多个功能。如 vue-router
:::
不同的实现方法,也会有不同的使用方式,下面按照使用方式的不同,把插件按照三类划分,单独讲解他们之间的区别和如何使用。
插件的安装和引入
我们的脚手架都是基于 Node.js
,所以提供了多种多样的安装方式。
通过 NPM 安装
NPM 是 Node.js
自带的一个包管理工具,在前端工程化十分普及的今天,可以说几乎所有你要用到的插件,都可以在npm上搜到。
通过 npm install
命令来安装各种npm包(比如 npm install vue-router
)。
附:NPM 官网
:::ti
NPM 在国内访问速度会比较慢,建议有梯子的用户使用。
我自己在用的是 Shadowfly,目前已经稳定用了有2年+ 。
:::
通过 CNPM 安装
由于一些不可描述的原因, NPM 在国内可能访问速度比较慢,你可以通过绑定淘宝镜像,通过 CNPM 源来下载包,CNPM 是完全同步 NPM 的。
它的安装命令和 NPM 非常一致,通过 cnpm install
命令来安装(比如 cnpm install vue-router
)。
在使用它之前,你需要通过 NPM 命令将其绑定到你的 node
上。
npm install -g cnpm
附:可以在 CNPM 官网 中国 NPM 镜像 了解更多使用方法。
:::tip
如果你之前已经绑定过 npm.taobao
系列域名,也请记得更换成 npmmirror
这个新的域名!
随着新的域名已经正式启用,老 npm.taobao.org
和 registry.npm.taobao.org
域名在 2022 年 05 月 31 日零时后不再提供服务。
详见:【望周知】淘宝 NPM 镜像站喊你切换新域名啦
:::
通过 YARN 安装
YARN 也是一个常用的包管理工具,和 NPM 十分相似,NPM 上的包,也会同步到 YARN ,通过 yarn add
命令来安装即可(比如 yarn add vue-router
)。
如果你没有日常翻墙,也可以考虑用 YARN 来代替 NPM,当然,在使用之前,你也必须先安装它才可以,一般情况下,需要添加 -g
或者 --global
参数来全局安装。
npm install -g yarn
但是安装命令上会有点不同, yarn 是用 add 代替 install ,用 remove 代替 uninstall ,例如:
# 安装单个包
yarn add vue-router
# 安装全局包
yarn global add typescript
# 卸载包
yarn remove vue-router
而且在运行脚本的时候,可以直接用 yarn 来代替 npm run ,例如 yarn dev 相当于 npm run dev 。
yarn 默认绑定的是 https://registry.yarnpkg.com 的下载源,如果包的下载速度太慢,也可以配置镜像源,但是命令有所差异:
# 查看镜像源
yarn config get registry
# 绑定镜像源
yarn config set registry https://registry.npmmirror.com
# 删除镜像源(注意这里是 delete )
yarn config delete registry
附:YARN 官网
不知道选择哪个?可以戳:npm和yarn的区别,我们该如何选择?
通过 PNPM 安装
PNPM 是包管理工具的一个后起之秀,用法跟其他包管理器很相似,没有太多的学习成本, NPM 和 YARN 的命令它都支持。
也是必须先全局安装它才可以使用:
npm install -g pnpm
目前 PNPM 在开源社区的使用率越来越高,包括我们接触最多的 Vue / Vite 团队也在逐步迁移到 PNPM 来管理依赖。
相关阅读:
通过 CDN 安装
大部分插件都会提供一个 CDN 版本,让你可以在 html
通过 script
标签引入。
比如:
<script src="https://unpkg.com/vue-router"></script>
插件的引入
除了 CDN 版本是直接可用之外,其他通过 NPM、YARN 等方式安装的插件,都需要在入口文件 main.js
或者要用到的 .vue
文件里引入,比如:
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
因为本教程都是基于工程化开发,使用的 CLI 脚手架,所以这些内容暂时不谈及 CDN 的使用方式。
通常来说会有细微差别,但影响不大,插件作者也会在插件仓库的 README 或者使用文档里进行告知。
Vue 专属插件
这里特指 Vue 插件,通过 Vue Plugins 设计规范 开发出来的插件,在npm上通常是以 vue-xxx
这样带有 vue 关键字的格式命名(比如 vue-baidu-analytics)。
专属插件通常分为 全局插件 和 单组件插件,区别在于,全局版本是在 main.ts
引入后 use
,而单组件版本则通常是作为一个组件在 .vue
文件里引入使用。
全局插件的使用
在本教程最最前面的时候,我有特地说了一个内容就是 项目初始化 - 升级与配置 ,在这里有提到过就是需要通过 use
来初始化框架、插件。
全局插件的使用,就是在 main.ts
通过 import
引入,然后通过 use
来启动初始化。
在 2.x ,全局插件是通过 Vue.use(xxxxxx)
来启动,而现在,则需要通过 createApp
的 use
,use
方法,既可以单独一行一个 use ,也可以直接链式 use 下去。
参数
use
方法支持两个参数:
参数 | 类型 | 作用 |
---|---|---|
plugin | object | function | 插件,一般是你在 import 时使用的名称 |
options | object | 插件的参数,有些插件在初始化时可以配置一定的选项 |
基本的写法就是像下面这样:
// 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')
大部分插件到这里就可以直接启动了,个别插件可能需要通过插件 API 去手动触发,在 npm package
的详情页上,作者一般会告知使用方法,按照说明书操作即可。
单组件插件的使用
单组件的插件,通常自己本身也是一个 Vue 组件(大部分情况下都会打包为 JS 文件,但本质上是一个 Vue 的 component )。
单组件的引入,一般都是在需要用到的 .vue
文件里单独 import
,然后挂到 template
里去渲染。
我放一个我之前打包的单组件插件 vue-picture-cropper 做案例,理解起来会比较直观:
<template>
<!-- 放置组件的渲染标签,用于显示组件 -->
<vue-picture-cropper
:boxStyle="{
width: '100%',
height: '100%',
backgroundColor: '#f8f8f8',
margin: 'auto'
}"
:img="pic"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 16 / 9,
}"
/>
<!-- 放置组件的渲染标签,用于显示组件 -->
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
// 引入单组件插件
import VuePictureCropper, { cropper } from 'vue-picture-cropper'
export default defineComponent({
// 挂载组件模板
components: {
VuePictureCropper
},
// 在这里定义一些组件需要用到的数据和函数
setup () {
const pic = ref<string>('');
onMounted( () => {
pic.value = require('@/assets/logo.png');
})
return {
pic
}
}
})
</script>
哈哈哈哈参考上面的代码,还有注释,应该能大概了解如何使用单组件插件了吧!
通用 JS 插件
也叫普通插件,这个 “普通” 不是指功能平平无奇,而是指它们无需任何框架依赖,可以应用在任意项目中,属于独立的 JS Library ,比如 axios 、 qrcode 、md5 等等,在任何技术栈都可以单独引入使用,非 Vue 专属。
通用 JS 插件的使用非常灵活,既可以全局挂载,也可以在需要用到的组件里单独引入。
组件里单独引入方式:
import { defineComponent } from 'vue'
import md5 from 'md5'
export default defineComponent({
setup () {
const MD5_MSG: string = md5('message');
}
})
全局挂载方法比较特殊,因为插件本身不是专属 Vue,没有 install
接口,无法通过 use
方法直接启动,下面有一 part 单独讲这一块的操作,详见 全局 API 挂载。
本地的一些工具插件
插件也不全是来自于网上,有时候针对自己的业务,涉及到一些经常用到的功能模块,你也可以抽离出来封装成项目专用的本地插件。
为什么要封装本地插件
举个例子,比如在做一个具备用户系统的网站时,会涉及到手机短信验证码模块,你在开始写代码之前,需要先要考虑到这些问题:
- 很多操作都涉及到下发验证码的请求,比如 “登录” 、 “注册” 、 “修改手机绑定” 、 “支付验证” 等等,代码雷同,只是接口 URL 或者参数不太一样
- 都是需要对手机号是否有传入、手机号的格式正确性验证等一些判断
- 需要对接口请求成功和失败的情况做一些不同的数据返回,但要处理的数据很相似,都是用于告知调用方当前是什么情况
- 返回一些 Toast 告知用户当前的交互结果
:::tip
如果不把这一块的业务代码抽离出来,你需要在每个用到的地方都写一次,不仅繁琐,而且以后一旦产品需求有改动,维护起来就惨了。
:::
如何封装一个本地插件
一般情况下,都是封装成一个 JS Library,或者一个 Vue Component 单组件插件就可以了。
我以上面提到的获取手机短信验证码模块为例子,我当时是这么处理的,把判断、请求、结果返回、Toast 都抽离出来,将其封装成一个 getVerCode.ts
放到 src/libs
目录下:
import axios from '@libs/axios'
import message from '@libs/message'
import regexp from '@libs/regexp'
/**
* 获取验证码
* @param phoneNumber - 手机号
* @param mode - 获取模式:login=登录,reg=注册
* @param params - 请求的参数
* @return verCode - 验证码:success=验证码内容,error=空值
*/
const getVerCode = (
phoneNumber: string | number | undefined,
mode: string,
params: any = {}
): Promise<string> => {
return new Promise( (resolve, reject) => {
let apiUrl = '';
/**
* 校验参数
*/
if ( !phoneNumber ) {
message.error('请输入手机号');
return false;
}
if ( !regexp.isMob(phoneNumber) ) {
message.error('手机号格式不正确');
return false;
}
if ( !mode ) {
message.error('验证码获取模式未传入');
return false;
}
/**
* 判断当前是请求哪种验证码
*/
switch (mode) {
case 'login':
apiUrl = `/api/sms/login/${phoneNumber}`;
break;
case 'reg':
apiUrl = `/api/sms/register/${phoneNumber}`;
break;
case 'rebind':
apiUrl = `/api/sms/authentication/${phoneNumber}`;
break;
default:
message.error('验证码获取模式传入错误');
return false;
}
/**
* 请求验证码
*/
axios({
isNoToken: true,
isNoRefresh: true,
method: 'get',
url: apiUrl,
params: params
}).then( (data: any) => {
// 异常拦截
const CODE: number = data.code || 0;
const MSG: string = data.msg || '';
if ( CODE !== 0 ) {
message.error(MSG);
if ( MSG === '验证码发送过频繁' ) {
reject('频繁');
return false;
}
reject('');
return false;
}
// 返回验证码成功标识
message.success('验证码已发送,请查收手机短信');
const RESULT: string = data.msg || '';
resolve(RESULT);
}).catch( (err: any) => {
message.error('网络异常,获取验证码失败');
reject('');
});
})
}
export default getVerCode;
然后你在需要用到的 .vue
组件里,就可以这样去获取验证码了,一句代码走天下:
// 导入验证码插件
import getVerCode from '@libs/getVerCode'
// 获取登录验证码
getVerCode(13800138000, 'login');
// 获取注册验证码
getVerCode(13800138000, 'reg');
因为是 Promise
,如果还需要做一些别的回调操作,还可以使用 async / await
或者 then / catch
去处理。
全局 API 挂载
对于一些使用频率比较高的插件方法,如果你觉得在每个组件里单独导入再用很麻烦,你也可以考虑将其挂载到 Vue 上,使其成为 Vue 的全局变量。
注:接下来的全局变量,都是指 Vue 环境里的全局变量,非 Window 下的全局变量。
回顾 2.x
在 2.x ,可以通过 prototype
的方式来挂载全局变量,然后通过 this
关键字来从 Vue 原型上调用该方法。
我以 md5
插件为例,在 main.ts
里进行全局 import
,然后通过 prototype
去挂到 Vue 上。
import Vue from 'vue'
import md5 from 'md5'
Vue.prototype.$md5 = md5;
之后在 .vue
文件里,你就可以这样去使用 md5
。
const MD5_MSG: string = this.$md5('message');
了解 3.x
在 3.x ,已经不再支持 prototype
这样使用了,在 main.ts
里没有了 Vue
,在组件的生命周期里也没有了 this
。
如果你依然想要挂载全局变量,需要通过全新的 globalProperties 来实现,在使用该方式之前,可以把 createApp
定义为一个变量再执行挂载。
定义全局 API
如上,在配置全局变量之前,你可以把初始化时的 createApp
定义为一个变量(假设为 app
),然后把需要设置为全局可用的变量或方法,挂载到 app
的 config.globalProperties
上面。
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.x 的 生命周期 无法取得实例的 this
来操作,需要通过全新的 getCurrentInstance 组件来进行处理。
// 导入 getCurrentInstance 组件
import { defineComponent, getCurrentInstance } from 'vue'
export default defineComponent({
setup () {
// 获取当前实例
const app = getCurrentInstance();
// 增加这层判断的原因见下方说明
if ( app ) {
// 调用全局的 MD5 API 进行加密
const MD5_STR: string = app.appContext.config.globalProperties.$md5('Hello World!');
console.log(MD5_STR);
// 调用刚刚挂载的打印函数
app.appContext.config.globalProperties.$log('Hello World!');
}
}
})
由于使用了 defineComponent ,它会帮我们自动推导 getCurrentInstance()
的类型为 ComponentInternalInstance
或 null
。
所以如果你的项目下的 TS 开启了 --strictNullChecks
选项,需要对实例变量做一层判断才能正确运行程序(可参考 DOM 元素与子组件 一节)。
:::tip
需要注意的是, getCurrentInstance
只能在 setup 函数或者 Vue 3.0 的 生命周期 钩子中调用。
如需在 setup
或生命周期钩子外使用,需要先在 setup
中调用 const app = getCurrentInstance();
获取实例变量,然后再通过 app
变量去使用。
:::
全局 API 的替代方案
在 Vue 3.x 实际上并不是特别推荐使用全局变量,3.x 比较推荐按需引入使用(从使用方式上也可以看得出,这类全局 API 的用法还真的挺麻烦的…)。
特别是针对 TypeScript ,尤大对于全局 API 的相关 PR 说明: Global API updates,也是不建议在 TS 里使用。
那么确实是需要用到一些全局 API 怎么办?
对于一般的数据和方法,建议采用 provide / inject 方案,在根组件(通常是 App.vue )把需要作为全局使用的数据 / 方法 provide 下去,在需要用到的组件里通过 inject 即可获取到,或者使用 EventBus 和 Vuex 等全局通信方案来处理。
本章结语
插件的使用基本上就涉及到这些点了,很多同学之所以还不敢在业务中使用 Vue 3.0,应该也是顾虑于 3.0 是不是有很多插件不能用,影响业务的开发效率(之前有问过不同公司的一些朋友,大部分都是出于这个考虑)。
相信经过这一章的说明,心里应该有底了,在缺少针对性的 Vue 专属插件的情况下,不妨也试一下通用的原生 JS Library 。
组件之间的通信
经过前面的那几部分的阅读,相信搭一个基础的 Vue 3.0 项目应该没什么问题了!
但实际业务开发过程中,还会遇到一些组件之间的通信问题,父子组件通信、兄弟组件通信、爷孙组件通信,还有一些全局通信的场景。
父子组件通信
父子组件通信是指,B 组件引入到 A 组件里渲染,此时 A 是 B 的父级;B 组件的一些数据需要从A组件拿,B 组件有时也要告知 A 组件一些数据变化情况。
他们之间的关系如下,Child.vue
是直接挂载在 Father.vue
下面:
Father.vue
└─Child.vue
常用的方法有:
方案 | 父组件向子组件 | 子组件向父组件 |
---|---|---|
props / emits | props | emits |
v-model / emits | v-model | emits |
ref / emits | ref | emits |
provide / inject | provide | inject |
EventBus | emit / on | emit / on |
Vuex | - | - |
为了方便阅读,下面的父组件统一叫 Father.vue
,子组件统一叫 Child.vue
。
:::warning
在 2.x,有的同学可能喜欢用 $attrs / $listeners
来进行通信,但该方案在 3.x 已经移除了,详见 移除 $listeners
:::
props / emits
这是Vue跨组件通信最常用,也是基础的一个方案,它的通信过程是:
Father.vue
通过prop
向Child.vue
传值(可包含父级定义好的函数)Child.vue
通过emit
向Father.vue
触发父组件的事件执行
下发 props
下发的过程是在 Father.vue
里完成的,父组件在向子组件下发 props
之前,需要导入子组件并启用它作为自身的模板,然后在 setup
里处理好数据,return 给 template
用。
在 Father.vue
的 script
里:
import { defineComponent } from 'vue'
import Child from '@cp/Child.vue'
interface Member {
id: number,
name: string
};
export default defineComponent({
// 需要启用子组件作为模板
components: {
Child
},
// 定义一些数据并return给template用
setup () {
const userInfo: Member = {
id: 1,
name: 'Petter'
}
// 不要忘记return,否则template拿不到数据
return {
userInfo
}
}
})
然后在 Father.vue
的 template
这边拿到 return 出来的数据,把要传递的数据通过属性的方式绑定在 template
的组件标签上。
<template>
<Child
title="用户信息"
:index="1"
:uid="userInfo.id"
:user-name="userInfo.name"
/>
</template>
这样就完成了 props
数据的下发。
:::tip
- 在
template
绑定属性这里,如果是普通的字符串,比如上面的title
,则直接给属性名赋值就可以 - 如果是变量,或者其他类型如
Number
、Object
等,则需要通过属性动态绑定的方式来添加,使用v-bind:
或者:
符号进行绑定 - 官方建议 prop 在
template
统一采用短横线分隔命名 (详见:Prop 的大小写命名),但实际上你采用驼峰也是可以正确拿到值,因为 Vue 的源码里有做转换
:::
接收 props
接收的过程是在 Child.vue
里完成的,在 script
部分,子组件通过与 setup
同级的 props
来接收数据。
它可以是一个数组,每个 item
都是 String
类型,把你要接受的变量名放到这个数组里,直接放进来作为数组的 item
:
export default defineComponent({
props: [
'title',
'index',
'userName',
'uid'
]
})
但这种情况下,使用者不知道这些属性到底是什么类型的值,是否必传。
带有类型限制的 props
注:这一小节的步骤是在
Child.vue
里操作。
既然我们最开始在决定使用 Vue 3.0 的时候,为了更好的类型限制,已经决定写 TypeScript
,那么我们最好不要出现这种使用情况。
推荐的方式是把 **props**
定义为一个对象,以对象形式列出 **prop**
,每个 **property**
的名称和值分别是 **prop**
各自的名称和类型,只有合法的类型才允许传入。
:::tip
注意,和 TS 的类型定义不同, props
这里的类型,首字母需要大写。
:::
支持的类型有:
类型 | 含义 |
---|---|
String | 字符串 |
Number | 数值 |
Boolean | 布尔值 |
Array | 数组 |
Object | 对象 |
Date | 日期数据,e.g. new Date() |
Function | 函数,e.g. 普通函数、箭头函数、构造函数 |
Promise | Promise 类型的函数 |
Symbol | Symbol 类型的值 |
于是我们把 props
再改一下,加上类型限制:
export default defineComponent({
props: {
title: String,
index: Number,
userName: String,
uid: Number
}
})
这样我们如果传入不正确的类型,程序就会抛出警告信息,告知开发者必须正确传值。
如果你需要对某个 prop
允许多类型,比如这个 uid
字段,它可能是数值,也可能是字符串,那么可以在类型这里,使用一个数组,把允许的类型都加进去。
export default defineComponent({
props: {
// 单类型
title: String,
index: Number,
userName: String,
// 这里使用了多种类型
uid: [ Number, String ]
}
})
可选以及带有默认值的 props
注:这一小节的步骤是在
Child.vue
里操作。
有时候我们想对一些 prop
设置为可选,然后提供一些默认值,还可以再将 prop
再进一步设置为对象,支持的字段有:
字段 | 类型 | 含义 |
---|---|---|
type | string | prop 的类型 |
required | boolean | 是否必传,true=必传,false=可选 |
default | any | 与 type 字段的类型相对应的默认值,如果 required 是 false ,但这里不设置默认值,则会默认为 undefined |
validator | function | 自定义验证函数,需要 return 一个布尔值,true=校验通过,false=校验不通过,当校验不通过时,控制台会抛出警告信息 |
我们现在再对 props
改造一下,对部分字段设置为可选,并提供默认值:
export default defineComponent({
props: {
// 可选,并提供默认值
title: {
type: String,
required: false,
default: '默认标题'
},
// 默认可选,单类型
index: Number,
// 添加一些自定义校验
userName: {
type: String,
// 在这里校验用户名必须至少3个字
validator: v => v.length >= 3
},
// 默认可选,但允许多种类型
uid: [ Number, String ]
}
})
使用 props
注:这一小节的步骤是在
Child.vue
里操作。
在 template
部分,3.x 的使用方法和 2.x 是一样的,比如要渲染我们上面传入的 props
:
<template>
<p>标题:{{ title }}</p>
<p>索引:{{ index }}</p>
<p>用户id:{{ uid }}</p>
<p>用户名:{{ userName }}</p>
</template>
但是 **script**
部分,变化非常大!
在 2.x ,只需要通过 this.uid
、this.userName
就可以使用父组件传下来的 prop
。
但是 3.x 没有了 this
, 需要给 setup
添加一个入参才可以去操作。
export default defineComponent({
props: {
title: String,
index: Number,
userName: String,
uid: Number
},
// 在这里需要添加一个入参
setup (props) {
// 该入参包含了我们定义的所有props
console.log(props);
}
})
:::tip
prop
是只读,不允许修改setup
的第一个入参,包含了我们定义的所有props(如果在Child.vue
里未定义,但 父组件Father.vue
那边非要传过来的,不会拿到,且控制台会有警告信息)- 该入参可以随意命名,比如你可以写成一个下划线
_
,通过_.uid
也可以拿到数据,但是语义化命名,是一个良好的编程习惯。
:::
传递非 Prop 的 Attribute
上面的提示里有提到一句:
如果在
Child.vue
里未定义,但 父组件Father.vue
那边非要传过来的,不会拿到,且控制台会有警告信息
但并不意味着你不能传递任何未定义的属性数据,在父组件,除了可以给子组件绑定 props,你还可以根据实际需要去绑定一些特殊的属性。
比如给子组件设置 class
、id
,或者 data-xxx
之类的一些自定义属性,如果 **Child.vue**
组件的 **template**
只有一个根节点,这些属性默认自动继承,并渲染在 node 节点上。
在 Father.vue
里,对 Child.vue
传递了 class
、id
和 data-hash
:
<template>
<Child
class="child"
keys="aaaa"
data-hash="afJasdHGUHa87d688723kjaghdhja"
/>
</template>
渲染后(2个 data-v-xxx
是父子组件各自的 css scoped
标记):
<div
class="child"
keys="aaaa"
data-hash="afJasdHGUHa87d688723kjaghdhja"
data-v-2dcc19c8=""
data-v-7eb2bc79=""
>
<!-- Child的内容 -->
</div>
你可以在 Child.vue
配置 inheritAttrs
为 false
,来屏蔽这些自定义属性的渲染。
export default defineComponent({
inheritAttrs: false,
setup () {
// ...
}
})
获取非 Prop 的 Attribute
想要拿到这些属性,原生操作需要通过 element.getAttribute
,但 Vue 也提供了相关的 API :
在 Child.vue
里,可以通过 setup
的第二个参数 context
里的 attrs
来获取到这些属性。
export default defineComponent({
setup (props, { attrs }) {
// attrs 是个对象,每个 Attribute 都是它的 key
console.log(attrs.class);
// 如果传下来的 Attribute 带有短横线,需要通过这种方式获取
console.log(attrs['data-hash']);
}
})
:::tip
attr
和prop
一样,都是只读的- 不管
inheritAttrs
是否设置,都可以通过attrs
拿到这些数据,但是element.getAttribute
则只有inheritAttrs
为true
的时候才可以。
:::
Vue 3.x 的 template
还允许多个根节点,多个根节点的情况下,无法直接继承这些属性,需要在 Child.vue
指定继承在哪个节点上,否则会有警告信息。
<template>
<!-- 指定继承 -->
<p v-bind="attrs"></p>
<!-- 指定继承 -->
<!-- 这些不会自动继承 -->
<p></p>
<p></p>
<p></p>
<!-- 这些不会自动继承 -->
</template>
当然,前提依然是,setup
里要把 attrs
给 return
出来。
查看详情:多个根节点上的 Attribute 继承
绑定 emits
最开始有介绍到,子组件如果需要向父组件告知数据更新,或者执行某些函数时,是通过 emits 来进行的。
每个 emit
都是事件,所以需要先由父组件先给子组件绑定,子组件才能知道应该怎么去调用。
:::tip
当然,父组件也是需要先在 setup
里进行定义并 return
,才能够在 template
里绑定给子组件。
:::
比如要给 Child.vue
绑定一个更新用户年龄的方法,那么在 Father.vue
里需要这么处理:
先看 script
部分(留意注释部分):
import { defineComponent, reactive } from 'vue'
import Child from '@cp/Child.vue'
interface Member {
id: number,
name: string,
age: number
};
export default defineComponent({
components: {
Child
},
setup () {
const userInfo: Member = reactive({
id: 1,
name: 'Petter',
age: 0
})
// 定义一个更新年龄的方法
const updateAge = (age: number): void => {
userInfo.age = age;
}
return {
userInfo,
// return给template用
updateAge
}
}
})
再看 template
部分(为了方便阅读,我把之前绑定的 props 先去掉了):
<template>
<Child
@update-age="updateAge"
/>
</template>
:::tip
- 动态绑定
props
是用:
,绑定emit
是用@
- 关于绑定的这个
@
符号,其实很好记忆,因为在 Vue 的template
里,所有的事件绑定都是通过@
,比如@click
、@change
等等 - 同样的,在绑定
emit
时,也需要使用短横线写法(详见:事件名)
:::
接收 emits
注:这一小节的步骤是在
Child.vue
里操作。
和 props
一样,你可以指定是一个数组,把要接收的 emit
名称写进去:
export default defineComponent({
emits: [
'update-age'
]
})
其实日常这样配置就足够用了。
:::tip
- 这里的
emit
名称指Father.vue
在给Child.vue
绑定事件时,template
里面给子组件指定的@aaaaa="bbbbb"
里的aaaaa
- 当在 emits 选项中定义了原生事件 (如
click
) 时,将使用组件中的事件替代原生事件侦听器
:::
接收 emits 时做一些校验
当然你也可以对这些事件做一些验证,配置为对象,然后把这个 emit
名称作为 key
, value
则配置为一个方法。
比如上面的更新年龄,只允许达到成年人的年龄才会去更新父组件的数据:
export default defineComponent({
emits: {
// 需要校验
'update-age': (age: number) => {
// 写一些条件拦截,记得返回false
if ( age < 18 ) {
console.log('未成年人不允许参与');
return false;
}
// 通过则返回true
return true;
},
// 一些无需校验的,设置为null即可
'update-name': null
}
})
调用 emits
注:这一小节的步骤是在
Child.vue
里操作。
和 props
一样,也需要在 setup
的入参里引入 emit
,才允许操作。
setup
的第二个入参 expose
是一个对象,你可以完整导入 expose
然后通过 expose.emit
去操作,也可以按需导入 { emit }
(推荐这种方式):
export default defineComponent({
emits: [
'update-age'
],
setup (props, { emit }) {
// 2s 后更新年龄
setTimeout( () => {
emit('update-age', 22);
}, 2000);
}
})
:::tipemit
的第二个参数开始是父组件那边要接收的自定义数据,为了开发上的便利,建议如果需要传多个数据的情况下,直接将第二个参数设置为一个对象,把所有数据都放到对象里,传递和接收起来都会方便很多。
:::
v-model / emits
对比 props / emits
,这个方式更为简单:
- 在
Father.vue
,通过v-model
向Child.vue
传值 Child.vue
通过自身设定的 emits 向Father.vue
通知数据更新
v-model
的用法和 props
非常相似,但是很多操作上更为简化,但操作简单带来的 “副作用” ,就是功能上也没有 props
那么多。
绑定 v-model
它的和下发 props 的方式类似,都是在子组件上绑定 Father.vue
定义好并 return
出来的数据。
:::tip
- 和 2.x 不同, 3.x 可以直接绑定
v-model
,而无需在子组件指定model
选项。 - 另外,3.x 的
v-model
需要使用:
来指定你要绑定的属性名,同时也开始支持绑定多个v-model
:::
我们来看看具体的操作:
<template>
<Child
v-model:user-name="userInfo.name"
/>
</template>
如果你要绑定多个数据,写多个 v-model
即可
<template>
<Child
v-model:user-name="userInfo.name"
v-model:uid="userInfo.id"
/>
</template>
看到这里应该能明白了,一个 v-model
其实就是一个 prop
,它支持的数据类型,和 prop
是一样的。
所以,子组件在接收数据的时候,完全按照 props
去定义就可以了。
点击回顾:接收 props ,了解在 Child.vue
如何接收 props
,以及相关的 props
类型限制等部分内容。
配置 emits
注:这一小节的步骤是在
Child.vue
里操作。
虽然 v-model
的配置和 prop
相似,但是为什么出这么两个相似的东西?自然是为了简化一些开发上的操作。
使用 props / emits,如果要更新父组件的数据,还需要在父组件定义好方法,然后 return
给 template
去绑定事件给子组件,才能够更新。
而使用 v-model / emits
,无需如此,可以在 Child.vue
直接通过 “update:属性名” 的格式,直接定义一个更新事件:
export default defineComponent({
props: {
userName: String,
uid: Number
},
emits: [
'update:userName',
'update:uid'
]
})
btw: 这里的 update 后面的属性名,支持驼峰写法,这一部分和 2.x 的使用是相同的。
这里也可以对数据更新做一些校验,配置方式和 接收 emits 时做一些校验 是一样的。
调用自身的 emits
注:这一小节的步骤是在
Child.vue
里操作。
在 Child.vue
配置好 emits 之后,就可以在 setup
里直接操作数据的更新了:
export default defineComponent({
// ...
setup (props, { emit }) {
// 2s 后更新用户名
setTimeout(() => {
emit('update:userName', 'Tom')
}, 2000);
}
})
在使用上,和 调用 emits 是一样的。
ref / emits
在学习 响应式 API 之 ref 的时候,我们了解到 ref
是可以用在 DOM 元素与子组件 上面。
父组件操作子组件
所以,父组件也可以直接通过对子组件绑定 ref
属性,然后通过 ref 变量去操作子组件的数据或者调用里面的方法。
比如导入了一个 Child.vue
作为子组件,需要在 template
处给子组件标签绑定 ref
:
<template>
<Child ref="child" />
</template>
然后在 script
部分定义好对应的变量名称(记得要 return
出来):
import { defineComponent, onMounted, ref } from 'vue'
import Child from '@cp/Child.vue'
export default defineComponent({
components: {
Child
},
setup () {
// 给子组件定义一个ref变量
const child = ref<HTMLElement>(null);
// 请保证视图渲染完毕后再执行操作
onMounted( () => {
// 执行子组件里面的ajax函数
child.value.getList();
// 打开子组件里面的弹窗
child.value.isShowDialog = true;
});
// 必须return出去才可以给到template使用
return {
child
}
}
})
子组件通知父组件
子组件如果想主动向父组件通讯,也需要使用 emit
,详细的配置方法可见:绑定 emits
爷孙组件通信
顾名思义,爷孙组件是比 父子组件通信 要更深层次的引用关系(也有称之为 “隔代组件”):
C组件引入到B组件里,B组件引入到A组件里渲染,此时A是C的爷爷级别(可能还有更多层级关系),如果你用 props
,只能一级一级传递下去,那就太繁琐了,因此我们需要更直接的通信方式。
他们之间的关系如下,Grandson.vue
并非直接挂载在 Grandfather.vue
下面,他们之间还隔着至少一个 Son.vue
(可能有多个):
Grandfather.vue
└─Son.vue
└─Grandson.vue
这一 Part 就是讲一讲 C 和 A 之间的数据传递,常用的方法有:
方案 | 爷组件向孙组件 | 孙组件向爷组件 |
---|---|---|
provide / inject | provide | inject |
EventBus | emit / on | emit / on |
Vuex | - | - |
为了方便阅读,下面的父组件统一叫 Grandfather.vue
,子组件统一叫 Grandson.vue
,但实际上他们之间可以隔无数代…
:::tip
因为上下级的关系的一致性,爷孙组件通信的方案也适用于 父子组件通信 ,只需要把爷孙关系换成父子关系即可。
:::
provide / inject
这个特性有两个部分:Grandfather.vue
有一个 provide
选项来提供数据,Grandson.vue
有一个 inject
选项来开始使用这些数据。
Grandfather.vue
通过provide
向Grandson.vue
传值(可包含定义好的函数)Grandson.vue
通过inject
向Grandfather.vue
触发爷爷组件的事件执行
无论组件层次结构有多深,发起 provide
的组件都可以作为其所有下级组件的依赖提供者。
tip
这一部分的内容变化都特别大,但使用起来其实也很简单,不用慌,也有相同的地方:
- 父组件不需要知道哪些子组件使用它 provide 的 property
- 子组件不需要知道 inject property 来自哪里
另外,要切记一点就是:provide 和 inject 绑定并不是可响应的。这是刻意为之的,但如果传入了一个可监听的对象,那么其对象的 property 还是可响应的。
发起 provide
我们先来回顾一下 2.x 的用法:
export default {
// 定义好数据
data () {
return {
tags: [ '中餐', '粤菜', '烧腊' ]
}
},
// provide出去
provide () {
return {
tags: this.tags
}
}
}
旧版的 provide
用法和 data
类似,都是配置为一个返回对象的函数。
3.x 的新版 provide
, 和 2.x 的用法区别比较大。
:::tip
在 3.x , provide
需要导入并在 setup
里启用,并且现在是一个全新的方法。
每次要 provide
一个数据的时候,就要单独调用一次。
:::
每次调用的时候,都需要传入 2 个参数:
参数 | 类型 | 说明 |
---|---|---|
key | string | 数据的名称 |
value | any | 数据的值 |
来看一下如何创建一个 provide
:
// 记得导入provide
import { defineComponent, provide } from 'vue'
export default defineComponent({
// ...
setup () {
// 定义好数据
const msg: string = 'Hello World!';
// provide出去
provide('msg', msg);
}
})
操作非常简单对吧哈哈哈,但需要注意的是,provide
不是响应式的,如果你要使其具备响应性,你需要传入响应式数据,详见:响应性数据的传递与接收
接收 inject
也是先来回顾一下 2.x 的用法:
export default {
inject: [
'tags'
],
mounted () {
console.log(this.tags);
}
}
旧版的 inject
用法和 props
类似,3.x 的新版 inject
, 和 2.x 的用法区别也是比较大。
:::tip
在 3.x, inject
和 provide
一样,也是需要先导入然后在 setup
里启用,也是一个全新的方法。
每次要 inject
一个数据的时候,就要单独调用一次。
:::
每次调用的时候,只需要传入 1 个参数:
参数 | 类型 | 说明 |
---|---|---|
key | string | 与 provide 相对应的数据名称 |
来看一下如何创建一个 inject
:
// 记得导入inject
import { defineComponent, inject } from 'vue'
export default defineComponent({
// ...
setup () {
const msg: string = inject('msg') || '';
}
})
也是很简单(写 TS 的话,由于 inject
到的值可能是 undefined
,所以要么加个 undefined
类型,要么给变量设置一个空的默认值)。
响应性数据的传递与接收
之所以要单独拿出来说, 是因为变化真的很大 - -
在前面我们已经知道,provide 和 inject 本身不可响应,但是并非完全不能够拿到响应的结果,只需要我们传入的数据具备响应性,它依然能够提供响应支持。
我们以 ref
和 reactive
为例,来看看应该怎么发起 provide
和接收 inject
。
先在 Grandfather.vue
里 provide
数据:
export default defineComponent({
// ...
setup () {
// provide一个ref
const msg = ref<string>('Hello World!');
provide('msg', msg);
// provide一个reactive
const userInfo: Member = reactive({
id: 1,
name: 'Petter'
});
provide('userInfo', userInfo);
// 2s 后更新数据
setTimeout(() => {
// 修改消息内容
msg.value = 'Hi World!';
// 修改用户名
userInfo.name = 'Tom';
}, 2000);
}
})
在 Grandsun.vue
里 inject
拿到数据:
export default defineComponent({
setup () {
// 获取数据
const msg = inject('msg');
const userInfo = inject('userInfo');
// 打印刚刚拿到的数据
console.log(msg);
console.log(userInfo);
// 因为 2s 后数据会变,我们 3s 后再看下,可以争取拿到新的数据
setTimeout(() => {
console.log(msg);
console.log(userInfo);
}, 3000);
// 响应式数据还可以直接给 template 使用,会实时更新
return {
msg,
userInfo
}
}
})
非常简单,非常方便!!!
:::tip
响应式的数据 provide
出去,在子孙组件拿到的也是响应式的,并且可以如同自身定义的响应式变量一样,直接 return
给 template
使用,一旦数据有变化,视图也会立即更新。
但上面这句话有效的前提是,不破坏数据的响应性,比如 ref 变量,你需要完整的传入,而不能只传入它的 value
,对于 reactive
也是同理,不能直接解构去破坏原本的响应性。
切记!切记!!!
:::
引用类型的传递与接收
这里是针对非响应性数据的处理
provide 和 inject 并不是可响应的,这是官方的故意设计,但是由于引用类型的特殊性,在子孙组件拿到了数据之后,他们的属性还是可以正常的响应变化。
先在 Grandfather.vue
里 provide
数据:
export default defineComponent({
// ...
setup () {
// provide 一个数组
const tags: string[] = [ '中餐', '粤菜', '烧腊' ];
provide('tags', tags);
// provide 一个对象
const userInfo: Member = {
id: 1,
name: 'Petter'
};
provide('userInfo', userInfo);
// 2s 后更新数据
setTimeout(() => {
// 增加tags的长度
tags.push('叉烧');
// 修改userInfo的属性值
userInfo.name = 'Tom';
}, 2000);
}
})
在 Grandsun.vue
里 inject
拿到数据:
export default defineComponent({
setup () {
// 获取数据
const tags: string[] = inject('tags') || [];
const userInfo: Member = inject('userInfo') || {
id: 0,
name: ''
};
// 打印刚刚拿到的数据
console.log(tags);
console.log(tags.length);
console.log(userInfo);
// 因为 2s 后数据会变,我们 3s 后再看下,能够看到已经是更新后的数据了
setTimeout(() => {
console.log(tags);
console.log(tags.length);
console.log(userInfo);
}, 3000);
}
})
引用类型的数据,拿到后可以直接用,属性的值更新后,子孙组件也会被更新。
:::warning
由于不具备真正的响应性,return
给模板使用依然不会更新视图,如果涉及到视图的数据,请依然使用 响应式 API 。
:::
基本类型的传递与接收
这里是针对非响应性数据的处理
基本数据类型被直接 provide
出去后,再怎么修改,都无法更新下去,子孙组件拿到的永远是第一次的那个值。
先在 Grandfather.vue
里 provide
数据:
export default defineComponent({
// ...
setup () {
// provide 一个数组的长度
const tags: string[] = [ '中餐', '粤菜', '烧腊' ];
provide('tagsCount', tags.length);
// provide 一个字符串
let name: string = 'Petter';
provide('name', name);
// 2s 后更新数据
setTimeout(() => {
// tagsCount 在 Grandson 那边依然是 3
tags.push('叉烧');
// name 在 Grandson 那边依然是 Petter
name = 'Tom';
}, 2000);
}
})
在 Grandsun.vue
里 inject
拿到数据:
export default defineComponent({
setup () {
// 获取数据
const name: string = inject('name') || '';
const tagsCount: number = inject('tagsCount') || 0;
// 打印刚刚拿到的数据
console.log(name);
console.log(tagsCount);
// 因为 2s 后数据会变,我们 3s 后再看下
setTimeout(() => {
// 依然是 Petter
console.log(name);
// 依然是 3
console.log(tagsCount);
}, 3000);
}
})
很失望,并没有变化。
:::tip
那么是否一定要定义成响应式数据或者引用类型数据呢?
当然不是,我们在 provide
的时候,也可以稍作修改,让它能够同步更新下去。
:::
我们再来一次,依然是先在 Grandfather.vue
里 provide
数据:
export default defineComponent({
// ...
setup () {
// provide 一个数组的长度
const tags: string[] = [ '中餐', '粤菜', '烧腊' ];
provide('tagsCount', (): number => {
return tags.length;
});
// provide 字符串
let name: string = 'Petter';
provide('name', (): string => {
return name;
});
// 2s 后更新数据
setTimeout(() => {
// tagsCount 现在可以正常拿到 4 了
tags.push('叉烧');
// name 现在可以正常拿到 Tom 了
name = 'Tom';
}, 2000);
}
})
再来 Grandsun.vue
里修改一下 inject
的方式,看看这次拿到的数据:
export default defineComponent({
setup () {
// 获取数据
const tagsCount: any = inject('tagsCount');
const name: any = inject('name');
// 打印刚刚拿到的数据
console.log(tagsCount());
console.log(name());
// 因为 2s 后数据会变,我们 3s 后再看下
setTimeout(() => {
// 现在可以正确得到 4
console.log(tagsCount());
// 现在可以正确得到 Tom
console.log(name());
}, 3000);
}
})
这次可以正确拿到数据了,看出这2次的写法有什么区别了吗?
:::tip
基本数据类型,需要 provide
一个函数,将其 return
出去给子孙组件用,这样子孙组件每次拿到的数据才会是新的。
但由于不具备响应性,所以子孙组件每次都需要重新通过执行 inject
得到的函数才能拿到最新的数据。
:::
按我个人习惯来说,使用起来挺别扭的,能不用就不用……
:::warning
由于不具备真正的响应性,return
给模板使用依然不会更新视图,如果涉及到视图的数据,请依然使用 响应式 API 。
:::
兄弟组件通信
兄弟组件是指两个组件都挂载在同一个 Father.vue
下,但两个组件之间并没有什么直接的关联,先看看他们的关系:
Father.vue
├─Brother.vue
└─LittleBrother.vue
既然没有什么直接关联, ╮(╯▽╰)╭ 所以也没有什么专属于他们的通信方式。
如果他们之间要交流,目前大概有这两类选择:
全局组件通信
全局组件通信是指,两个任意的组件,不管是否有关联(e.g. 父子、爷孙)的组件,都可以直接进行交流的通信方案。
举个例子,像下面这样,B2.vue
可以采用全局通信方案,直接向 D2.vue
发起交流,而无需经过他们的父组件。
A.vue
├─B1.vue
├───C1.vue
├─────D1.vue
├─────D2.vue
├───C2.vue
├─────D3.vue
└─B2.vue
常用的方法有:
方案 | 发起方 | 接收方 |
---|---|---|
EventBus | emit | on |
Vuex | - | - |
EventBus
EventBus
通常被称之为 “全局事件总线” ,它是用来在全局范围内通信的一个常用方案,它的特点就是: “简单” 、 “灵活” 、“轻量级”。
:::tip
在中小型项目,全局通信推荐优先采用该方案,事件总线在打包压缩后不到 200 个字节, API 也非常简单和灵活。
:::
回顾 2.x
在 2.x,使用 EventBus 无需导入第三方插件,直接在自己的 libs
文件夹下创建一个 bus.ts
文件,暴露一个新的 Vue 实例即可。
import Vue from 'vue';
export default new Vue;
然后就可以在组件里引入 bus ,通过 $emit
去发起交流,通过 $on
去监听接收交流。
旧版方案的完整案例代码可以查看官方的 2.x 语法 - 事件 API
了解 3.x
Vue 3.x 移除了 $on
、 $off
和 $once
这几个事件 API ,应用实例不再实现事件触发接口。
根据官方文档在 迁移策略 - 事件 API 的推荐,我们可以用 mitt 或者 tiny-emitter 等第三方插件来实现 EventBus
。
创建 3.x 的 EventBus
这里以 mitt
为例,示范如何创建一个 Vue 3.x 的 EventBus
。
首先,需要安装 mitt
:
npm install --save mitt
然后在 libs
文件夹下,创建一个 bus.ts
文件,内容和旧版写法其实是一样的,只不过是把 Vue 实例,换成了 mitt 实例。
import mitt from 'mitt';
export default mitt();
然后就可以定义发起和接收的相关事件了,常用的 API 和参数如下:
方法名称 | 作用 |
---|---|
on | 注册一个监听事件,用于接收数据 |
emit | 调用方法发起数据传递 |
off | 用来移除监听事件 |
on
的参数:
参数 | 类型 | 作用 |
---|---|---|
type | string | symbol | 方法名 |
handler | function | 接收到数据之后要做什么处理的回调函数 |
这里的 handler
建议使用具名函数,因为匿名函数无法销毁。
emit
的参数:
参数 | 类型 | 作用 |
---|---|---|
type | string | symbol | 与 on 对应的方法名 |
data | any | 与 on 对应的,允许接收的数据 |
off
的参数:
参数 | 类型 | 作用 |
---|---|---|
type | string | symbol | 与 on 对应的方法名 |
handler | function | 要删除的,与 on 对应的 handler 函数名 |
更多的 API 可以查阅 插件的官方文档 ,在了解了最基本的用法之后,我们来开始配置一对交流。
:::tip
如果你需要把 bus
配置为全局 API ,不想在每个组件里分别 import 的话,可以参考之前的章节内容: 全局 API 挂载 。
:::
创建和移除监听事件
在需要暴露交流事件的组件里,通过 on
配置好接收方法,同时为了避免路由切换过程中造成事件多次被绑定,多次触发,需要在适当的时机 off
掉:
import { defineComponent, onBeforeUnmount } from 'vue'
import bus from '@libs/bus'
export default defineComponent({
setup () {
// 定义一个打招呼的方法
const sayHi = (msg: string = 'Hello World!'): void => {
console.log(msg);
}
// 启用监听
bus.on('sayHi', sayHi);
// 在组件卸载之前移除监听
onBeforeUnmount( () => {
bus.off('sayHi', sayHi);
})
}
})
btw: 关于销毁的时机,可以参考 组件的生命周期 。
调用监听事件
在需要调用交流事件的组件里,通过 emit
进行调用:
import { defineComponent } from 'vue'
import bus from '@libs/bus'
export default defineComponent({
setup () {
// 调用打招呼事件,传入消息内容
bus.emit('sayHi', '哈哈哈哈哈哈哈哈哈哈哈哈哈哈');
}
})
旧项目升级 EventBus
在 Vue 3.x 的 EventBus,我们可以看到它的 API 和旧版是非常接近的,只是去掉了 $
符号。
如果你要对旧的项目进行升级改造,因为原来都是使用了 $on
、 $emit
等旧的 API ,一个一个组件去修改成新的 API 肯定不现实。
我们可以在创建 bus.ts
的时候,通过自定义一个 bus
对象,来挂载 mitt
的 API 。
在 bus.ts
里,改成以下代码:
import mitt from 'mitt';
// 初始化一个 mitt 实例
const emitter = mitt();
// 定义一个空对象用来承载我们的自定义方法
const bus: any = {};
// 把你要用到的方法添加到 bus 对象上
bus.$on = emitter.on;
bus.$emit = emitter.emit;
// 最终是暴露自己定义的 bus
export default bus;
这样我们在组件里就可以继续使用 bus.$on
、bus.$emit
等以前的老 API 了,不影响我们旧项目的升级使用。
Vuex
Vuex 是 Vue 生态里面非常重要的一个成员,运用于状态管理模式。
它也是一个全局的通信方案,对比 EventBus,Vuex 的功能更多,更灵活,但对应的,学习成本和体积也相对较大,通常大型项目才会用上 Vuex。
摘取一段官网的介绍,官方也只建议在大型项目里才用它:
什么情况下我应该使用 Vuex?
Vuex 可以帮助我们管理共享状态,并附带了更多的概念和框架。这需要对短期和长期效益进行权衡。
如果您不打算开发大型单页应用,使用 Vuex 可能是繁琐冗余的。
:::tip
2022-04-07 注:如果是全新的项目,建议直接上手 Pinia ,无需再用 Vuex 。
:::
在了解之前
在对 Vue 3.x 里是否需要使用 Vuex 的问题上,带有一定的争议,大部分开发者在社区发表的评论都认为通过 EventBus 和 provide / inject ,甚至 export 一个 reactive 对象也足以满足大部分业务需求。
见仁见智,请根据自己的实际需要去看是否需要启用它。
好在新版 Vuex 和旧版几乎没什么区别,大家可以了解一下大概的变化之后,按照之前的官网文档去配置,使用其他应该没有太大的问题。
Vuex 的目录结构
如果你在创建 Vue 项目的时候选择了带上 Vuex ,那么 src
文件夹下会自动生成 Vuex 的相关文件,如果创建时没有选择,你也可以自己按照下面解构去创建对应的目录与文件。
src
├─store
├───index.ts
└─main.ts
一般情况下一个 index.ts
足矣,它是 Vuex 的入口文件,如果你的项目比较庞大,你可以在 store
下创建一个 modules
文件夹,用 Vuex Modules 的方式导入到 index.ts
里去注册。
回顾 2.x
在 2.x ,你需要先分别导入 Vue
和 Vuex
,use
后通过 new Vuex.Store(...)
的方式去初始化
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})
了解 3.x
而 3.x 简化了很多,只需要从 vuex
里导入 createStore
,直接通过 createStore
去创建即可。
import { createStore } from 'vuex'
export default createStore({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})
Vuex 的配置
除了初始化方式有一定的改变,Vuex 的其他的配置和原来是一样的,具体可以查看 使用指南 - Vuex
在组件里使用 Vuex
和 2.x 不同的是,3.x 在组件里使用 Vuex,更像新路由那样,需要通过 useStore
去启用。
import { defineComponent } from 'vue'
import { useStore } from 'vuex';
export default defineComponent({
setup () {
// 需要创建一个 store 变量
const store = useStore();
// 再使用 store 去操作 Vuex 的 API
// ...
}
})
其他的用法,都是跟原来一样的。
Pinia
Pinia 和 Vuex 一样,也是 Vue 生态里面非常重要的一个成员,也都是运用于全局的状态管理。
但面向 Componsition API 而生的 Pinia ,更受 Vue 3 喜爱,已被钦定为官方推荐的新状态管理工具。
为了阅读上的方便,对 Pinia 单独开了一章,请跳转至 全局状态的管理 阅读。
本章结语
组件的通信在中大型项目里非常实用,它可以让你的组件避免写的又长又臭,可以按模块去拆分成不同的组件,然后通过组件之间的通信方式来关联起来。
全局状态的管理
本来这部分打算放在 组件之间的通信 里,里面也简单介绍了一下 Vuex ,但 Pinia 作为被官方推荐在 Vue 3 项目里作为全局状态管理的新工具,写着写着我觉得还是单独开一章来写会更方便阅读和理解。
官方推出的全局状态管理工具目前有 Vuex 和 Pinia ,两者的作用和用法都比较相似,但 Pinia 的设计更贴近 Vue 3 组合式 API 的用法。
:::tip
本章内的大部分内容都会和 Vuex 作对比,方便从 Vuex 项目向 Pinia 的迁移。
:::
关于 Pinia
由于 Vuex 4.x 版本只是个过渡版,Vuex 4 对 TypeScript 和 Composition API 都不是很友好,虽然官方团队在 GitHub 已有讨论 Vuex 5 的开发提案,但从 2022-02-07 在 Vue 3 被设置为默认版本开始, Pinia 已正式被官方推荐作为全局状态管理的工具。
Pinia 支持 Vue 3 和 Vue 2 ,对 TypeScript 也有很完好的支持,延续本指南的宗旨,我们在这里只介绍基于 Vue 3 和 TypeScript 的用法。
点击访问:Pinia 官网
安装和启用
Pinia 目前还没有被广泛的默认集成在各种脚手架里,所以如果你原来创建的项目没有 Pinia ,则需要手动安装它。
# 需要 cd 到你的项目目录下
npm install pinia
查看你的 package.json ,看看里面的 dependencies
是否成功加入了 Pinia 和它的版本号(下方是示例代码,以实际安装的最新版本号为准):
{
"dependencies": {
"pinia": "^2.0.11",
},
}
然后打开 src/main.ts
文件,添加下面那两行有注释的新代码:
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 导入 Pinia
createApp(App)
.use(createPinia()) // 启用 Pinia
.mount('#app')
到这里, Pinia 就集成到你的项目里了。
:::tip
也可以通过 Create Preset 创建新项目(选择 vue
技术栈进入,选择 vue3-ts-vite 模板),可以得到一个集成常用配置的项目启动模板,该模板现在使用 Pinia 作为全局状态管理工具。
:::
状态树的结构
在开始写代码之前,我们先来看一个对比,直观的了解 Pinia 的状态树构成,才能在后面的环节更好的理解每个功能的用途。
鉴于可能有部分同学之前没有用过 Vuex ,所以我加入了 Vue 组件一起对比( Options API 写法)。
作用 | Vue Component | Vuex | Pinia |
---|---|---|---|
数据管理 | data | state | state |
数据计算 | computed | getters | getters |
行为方法 | methods | mutations / actions | actions |
可以看到 Pinia 的结构和用途都和 Vuex 与 Component 非常相似,并且 Pinia 相对于 Vuex ,在行为方法部分去掉了 mutations (同步操作)和 actions (异步操作)的区分,更接近组件的结构,入门成本会更低一些。
下面我们来创建一个简单的 Store ,开始用 Pinia 来进行状态管理。
创建 Store
和 Vuex 一样, Pinia 的核心也是称之为 Store 。
参照 Pinia 官网推荐的项目管理方案,我们也是先在 src
文件夹下创建一个 stores
文件夹,并在里面添加一个 index.ts
文件,然后我们就可以来添加一个最基础的 Store 。
Store 是通过 defineStore
方法来创建的,它有两种入参形式:
形式 1 :接收两个参数
接收两个参数,第一个参数是 Store 的唯一 ID ,第二个参数是 Store 的选项:
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
// Store 选项...
})
形式 2 :接收一个参数
接收一个参数,直接传入 Store 的选项,但是需要把唯一 ID 作为选项的一部分一起传入:
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore({
id: 'main',
// Store 选项...
})
:::tip
不论是哪种创建形式,都必须为 Store 指定一个唯一 ID 。
:::
另外可以看到我把导出的函数名命名为 useStore
,以 use
开头是 Vue 3 对可组合函数的一个命名规范。
并且使用的是 export const
而不是 export default
(详见:命名导出和默认导出),这样在使用的时候可以和其他的 Vue 组合函数保持一致,都是通过 import { xxx } from 'xxx'
来导入。
如果你有多个 Store ,可以分模块管理,并根据实际的功能用途进行命名( e.g. useMessageStore
、 useUserStore
、 useGameStore
… )。
管理 state
在上一小节的 状态树的结构 这里我们已经了解过, Pinia 是在 state
里面定义状态数据。
给 Store 添加 state
它是通过一个箭头函数的形式来返回数据,并且能够正确的帮你推导 TypeScript 类型:
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
// 我们先定义一个最基本的 message 数据
state: () => ({
message: 'Hello World',
}),
// ...
})
需要注意一点的是,如果不显式 return ,箭头函数的返回值需要用圆括号 ()
套起来,这个是箭头函数的要求(详见:返回对象字面量)。
所以相当于这样写:
// ...
export const useStore = defineStore('main', {
state: () => {
return {
message: 'Hello World',
}
},
// ...
})
我个人还是更喜欢加圆括号的简写方式。
:::tip
可能有同学会问: Vuex 可以用一个对象来定义 state 的数据, Pinia 可以吗?
答案是:不可以! state 的类型必须是 state?: (() => {}) | undefined
,要么不配置(就是 undefined ),要么只能是个箭头函数。
:::
手动指定数据类型
虽然 Pinia 会帮你推导 TypeScript 的数据类型,但有时候可能不太够用,比如下面这段代码,请留意代码注释的说明:
// ...
export const useStore = defineStore('main', {
state: () => {
return {
message: 'Hello World',
// 添加了一个随机消息数组
randomMessages: [],
}
},
// ...
})
你的预期应该是一个字符串数组 string[]
,但是这个时候 Pinia 会帮你推导成 never[]
,那么类型就对不上了。
这种情况下你就需要手动指定 randomMessages 的类型,可以通过 as
来指定:
// ...
export const useStore = defineStore('main', {
state: () => {
return {
message: 'Hello World',
// 通过 as 关键字指定 TS 类型
randomMessages: [] as string[],
}
},
// ...
})
或者使用尖括号 <>
来指定:
// ...
export const useStore = defineStore('main', {
state: () => {
return {
message: 'Hello World',
// 通过尖括号指定 TS 类型
randomMessages: <string[]>[],
}
},
// ...
})
这两种方式是等价的。
获取和更新 state
获取 state 有多种方法,略微有区别(详见下方各自的说明),但相同的是,他们都是响应性的。
:::warning
不能直接通过 ES6 解构的方式( e.g. const { message } = store
),那样会破坏数据的响应性。
:::
使用 store 实例
用法上和 Vuex 很相似,但有一点区别是,数据直接是挂在 store
上的,而不是 store.state
上面!
:::tip
e.g. Vuex 是 store.state.message
, Pinia 是 store.message
。
:::
所以,你可以直接通过 store.message
直接调用 state 里的数据。
import { defineComponent } from 'vue'
import { useStore } from '@/stores'
export default defineComponent({
setup() {
// 像 useRouter 那样定义一个变量拿到实例
const store = useStore()
// 直接通过实例来获取数据
console.log(store.message)
// 这种方式你需要把整个 store 给到 template 去渲染数据
return {
store,
}
},
})
但一些比较复杂的数据这样写会很长,所以有时候更推荐用下面介绍的 computed API 和 storeToRefs API 等方式来获取。
在数据更新方面,在 Pinia 可以直接通过 Store 实例更新 state (这一点与 Vuex 有明显的不同,更改 Vuex 的 store 中的状态的唯一方法是提交 mutation),所以如果你要更新 message
,只需要像下面这样,就可以更新 message
的值了!
store.message = 'New Message.'
使用 computed API
现在 state 里已经有我们定义好的数据了,下面这段代码是在 Vue 组件里导入我们的 Store ,并通过计算数据 computed
拿到里面的 message
数据传给 template 使用。
<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useStore } from '@/stores'
export default defineComponent({
setup() {
// 像 useRouter 那样定义一个变量拿到实例
const store = useStore()
// 通过计算拿到里面的数据
const message = computed(() => store.message)
console.log('message', message.value)
// 传给 template 使用
return {
message,
}
},
})
</script>
和 使用 store 实例 以及 使用 storeToRefs API 不同,这个方式默认情况下无法直接更新 state 的值。
:::tip
这里的定义的 message
变量是一个只有 getter ,没有 setter 的 ComputedRef 数据,所以它是只读的。
:::
如果你要更新数据怎么办?
- 可以通过提前定义好的 Store Actions 方法进行更新。
- 在定义 computed 变量的时候,配置好 setter 的行为:
// 其他代码和上一个例子一样,这里省略...
// 修改:定义 computed 变量的时候配置 getter 和 setter
const message = computed({
// getter 还是返回数据的值
get: () => store.message,
// 配置 setter 来定义赋值后的行为
set(newVal) {
store.message = newVal
},
})
// 此时不再抛出 Write operation failed: computed value is readonly 的警告
message.value = 'New Message.'
// store 上的数据已成功变成了 New Message.
console.log(store.message)
使用 storeToRefs API
Pinia 还提供了一个 storeToRefs
API 用于把 state 的数据转换为 ref
变量。
这是一个专门为 Pinia Stores 设计的 API ,类似于 toRefs ,区别在于,它会忽略掉 Store 上面的方法和非响应性的数据,只返回 state 上的响应性数据。
import { defineComponent } from 'vue'
import { useStore } from '@/stores'
// 记得导入这个 API
import { storeToRefs } from 'pinia'
export default defineComponent({
setup() {
const store = useStore()
// 通过 storeToRefs 来拿到响应性的 message
const { message } = storeToRefs(store)
console.log('message', message.value)
return {
message,
}
},
})
通过这个方式拿到的 message
变量是一个 Ref 类型的数据,所以你可以像普通的 ref 变量一样进行读取和赋值。
// 直接赋值即可
message.value = 'New Message.'
// store 上的数据已成功变成了 New Message.
console.log(store.message)
使用 toRefs API
如 使用 storeToRefs API 部分所说,该 API 本身的设计就是类似于 toRefs ,所以你也可以直接用 toRefs 把 state 上的数据转成 ref 变量。
// 注意 toRefs 是 vue 的 API ,不是 Pinia
import { defineComponent, toRefs } from 'vue'
import { useStore } from '@/stores'
export default defineComponent({
setup() {
const store = useStore()
// 跟 storeToRefs 操作都一样,只不过用 Vue 的这个 API 来处理
const { message } = toRefs(store)
console.log('message', message.value)
return {
message,
}
},
})
详见 使用 toRefs 一节的说明,可以像普通的 ref 变量一样进行读取和赋值。
另外,像上面这样,对 store 执行 toRefs 会把 store 上面的 getters 、 actions 也一起提取,如果你只需要提取 state 上的数据,可以这样做:
// 只传入 store.$state
const { message } = toRefs(store.$state)
使用 toRef API
toRef 是 toRefs 的兄弟 API ,一个是只转换一个字段,一个是转换所有字段,所以它也可以用来转换 state 数据变成 ref 变量。
// 注意 toRef 是 vue 的 API ,不是 Pinia
import { defineComponent, toRef } from 'vue'
import { useStore } from '@/stores'
export default defineComponent({
setup() {
const store = useStore()
// 遵循 toRef 的用法即可
const message = toRef(store, 'message')
console.log('message', message.value)
return {
message,
}
},
})
详见 使用 toRef 一节的说明,可以像普通的 ref 变量一样进行读取和赋值。
使用 actions 方法
在 Vuex ,如果想通过方法来操作 state 的更新,必须通过 mutation 来提交;而异步操作需要更多一个步骤,必须先通过 action 来触发 mutation ,非常繁琐!
Pinia 所有操作都集合为 action ,无需区分同步和异步,按照平时的函数定义即可更新 state ,具体操作详见 管理 actions 一节。
批量更新 state
在 获取和更新 state 部分说的都是如何修改单个 state 数据,那么有时候要同时修改很多个,会显得比较繁琐。
如果你写过 React 或者微信小程序,应该非常熟悉这些用法:
// 下面不是 Vue 的代码,不要在你的项目里使用
// React
this.setState({
foo: 'New Foo Value',
bar: 'New bar Value',
})
// 微信小程序
this.setData({
foo: 'New Foo Value',
bar: 'New bar Value',
})
Pinia 也提供了一个 $patch
API 用于同时修改多个数据,它接收一个参数:
参数 | 类型 | 语法 |
---|---|---|
partialState | 对象 / 函数 | store.$patch(partialState) |
传入一个对象
当参数类型为对象时,key
是要修改的 state 数据名称, value
是新的值(支持嵌套传值),用法如下:
// 继续用我们前面的数据,这里会打印出修改前的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"Hello World","randomMessages":[]}
/**
* 注意这里,传入了一个对象
*/
store.$patch({
message: 'New Message',
randomMessages: ['msg1', 'msg2', 'msg3'],
})
// 这里会打印出修改后的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"New Message","randomMessages":["msg1","msg2","msg3"]}
对于简单的数据,直接修改成新值是非常好用的。
但有时候并不单单只是修改,而是要对数据进行拼接、补充、合并等操作,相对而言开销就会很大,这种情况下,更适合 传入一个函数 来处理。
:::tip
使用这个方式时, key
只允许是实例上已有的数据,不可以提交未定义的数据进去。
强制提交的话,在 TypeScript 会抛出错误, JavaScript 虽然不会报错,但实际上, Store 实例上面依然不会有这个新增的非法数据。
:::
传入一个函数
当参数类型为函数时,该函数会有一个入参 state
,是当前实例的 state ,等价于 store.$state ,用法如下:
// 这里会打印出修改前的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"Hello World","randomMessages":[]}
/**
* 注意这里,这次是传入了一个函数
*/
store.$patch((state) => {
state.message = 'New Message'
// 数组改成用追加的方式,而不是重新赋值
for (let i = 0; i < 3; i++) {
state.randomMessages.push(`msg${i + 1}`)
}
})
// 这里会打印出修改后的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"New Message","randomMessages":["msg1","msg2","msg3"]}
和 传入一个对象 比,不一定说就是哪种方式更好,通常要结合业务场景合理使用。
:::tip
使用这个方式时,和 传入一个对象 一样只能修改已定义的数据,并且另外需要注意,传进去的函数只能是同步函数,不可以是异步函数!
如果还不清楚什么是同步和异步,可以阅读 同步和异步 JavaScript - MDN 一文。
:::
全量更新 state
在 批量更新 state 我们了解到可以用 store.$patch
方法对数据进行批量更新操作,不过如其命名,这种方式本质上是一种 “补丁更新” 。
虽然你可以对所有数据都执行一次 “补丁更新” 来达到 “全量更新” 的目的,但 Pinia 也提供了一个更好的办法。
从前面多次提到 state 数据可以通过 store.$state
来拿到,而这个属性本身是可以直接赋值的。
还是继续用上面的例子, state 上现在有 message
和 randomMessages
这两个数据,那么要全量更新为新的值,就这么操作:
store.$state = {
message: 'New Message',
randomMessages: ['msg1', 'msg2', 'msg3'],
}
同样的,必须遵循 state 原有的数据和对应的类型。
:::tip
该操作不会使 state 失去响应性。
:::
重置 state
Pinia 提供了一个 $reset
API 挂在每个实例上面,用于重置整颗 state 树为初始数据:
// 这个 store 是我们上面定义好的实例
store.$reset()
具体例子:
// 修改数据
store.message = 'New Message'
console.log(store.message) // 输出 New Message
// 3s 后重置状态
setTimeout(() => {
store.$reset()
console.log(store.message) // 输出最开始的 Hello World
}, 3000)
订阅 state
和 Vuex 一样, Pinia 也提供了一个用于订阅 state 的 $subscribe
API 。
订阅 API 的 TS 类型
在了解这个 API 的使用之前,先看一下它的 TS 类型定义:
// $subscribe 部分的 TS 类型
// ...
$subscribe(
callback: SubscriptionCallback<S>,
options?: { detached?: boolean } & WatchOptions
): () => void
// ...
可以看到,它可以接受两个参数:
- 第一个入参是 callback 函数,必传
- 第二个入参是一些选项,可选
它还会返回一个函数,执行它可以用于移除当前订阅(源码有注释,这里我先省略,放在下面讲),下面来看看具体用法。
添加订阅
$subscribe
API 的功能类似于 watch ,但它只会在 state 被更新的时候才触发一次,并且在组件被卸载时删除(参考:组件的生命周期)。
从 订阅 API 的 TS 类型 可以看到,它可以接受两个参数,第一个参数是必传的 callback 函数,一般情况下默认用这个方式即可,使用例子:
// 你可以在 state 出现变化时,更新本地持久化存储的数据
store.$subscribe((mutation, state) => {
localStorage.setItem('store', JSON.stringify(state))
})
这个 callback 里面有 2 个入参:
入参 | 作用 |
---|---|
mutation | 本次事件的一些信息 |
state | 当前实例的 state |
其中 mutation 包含了以下数据:
字段 | 值 |
---|---|
storeId | 发布本次订阅通知的 Pinia 实例的唯一 ID(由 创建 Store 时指定) |
type | 有 3 个值:返回 direct 代表 直接更改 数据;返回 patch object 代表是通过 传入一个对象 更改;返回 patch function 则代表是通过 传入一个函数 更改 |
events | 触发本次订阅通知的事件列表 |
payload | 通过 传入一个函数 更改时,传递进来的荷载信息,只有 type 为 patch object 时才有 |
如果你不希望组件被卸载时删除订阅,可以传递第二个参数 options 用以保留订阅状态,传入一个对象。
可以简单指定为 { detached: true }
:
store.$subscribe((mutation, state) => {
// ...
}, { detached: true })
也可以搭配 watch API 的选项一起用。
移除订阅
在 添加订阅 部分已了解过,默认情况下,组件被卸载时订阅也会被一并移除,但如果你之前启用了 detached
选项,就需要手动取消了。
前面在 订阅 API 的 TS 类型 里提到,在启用 $subscribe
API 之后,会有一个函数作为返回值,这个函数可以用来取消该订阅。
用法非常简单,做一下简单了解即可:
// 定义一个退订变量,它是一个函数
const unsubscribe = store.$subscribe((mutation, state) => {
// ...
}, { detached: true })
// 在合适的时期调用它,可以取消这个订阅
unsubscribe()
跟 watch API 的机制非常相似, 它也是返回 一个取消监听的函数 用于移除指定的 watch 。
管理 getters{new}
在 状态树的结构 了解过, Pinia 的 getters
是用来计算数据的。
给 Store 添加 getter
:::tip
如果对 Vue 的计算数据不是很熟悉或者没接触过的话,可以先阅读 数据的计算 这一节,以便有个初步印象,不会云里雾里。
:::
添加普通的 getter
我们继续用刚才的 message
,来定义一个 Getter ,用于返回一句拼接好的句子。
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
state: () => ({
message: 'Hello World',
}),
// 定义一个 fullMessage 的计算数据
getters: {
fullMessage: (state) => `The message is "${state.message}".`,
},
// ...
})
和 Options API 的 Computed 写法一样,也是通过函数来返回计算后的值,但在 Pinia ,只能使用箭头函数,通过入参的 state
来拿到当前实例的数据。
添加引用 getter 的 getter
有时候你可能要引用另外一个 getter 的值来返回数据,这个时候不能用箭头函数了,需要定义成普通函数而不是箭头函数,并在函数内部通过 this
来调用当前 Store 上的数据和方法。
我们继续在上面的例子里,添加多一个 emojiMessage
的 getter ,在返回 fullMessage
的结果的同时,拼接多一串 emoji 。
export const useStore = defineStore('main', {
state: () => ({
message: 'Hello World',
}),
getters: {
fullMessage: (state) => `The message is "${state.message}".`,
// 这个 getter 返回了另外一个 getter 的结果
emojiMessage(): string {
return `🎉🎉🎉 ${this.fullMessage}`
},
},
})
如果你只写 JavaScript ,可能对这一条所说的限制觉得很奇怪,事实上用 JS 写箭头函数来引用确实不会报错,但如果你用的是 TypeScript ,不按照这个写法,在 VSCode 提示和执行 TSC 检查的时候都会给你抛出一条错误:
src/stores/index.ts:9:42 - error TS2339:
Property 'fullMessage' does not exist on type '{ message: string; } & {}'.
9 emojiMessage: (state) => `🎉 ${state.fullMessage}`,
~~~~~~~~~~~
Found 1 error in src/stores/index.ts:9
另外关于普通函数的 TS 返回类型,官方建议显示的进行标注,就像这个例子里的 emojiMessage(): string
里的 : string
。
给 getter 传递参数
getter 本身是不支持参数的,但和 Vuex 一样,支持返回一个具备入参的函数,用来满足需求。
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
state: () => ({
message: 'Hello World',
}),
getters: {
// 定义一个接收入参的函数作为返回值
signedMessage: (state) => {
return (name: string) => `${name} say: "The message is ${state.message}".`
},
},
})
调用的时候是这样:
const signedMessage = store.signedMessage('Petter')
console.log('signedMessage', signedMessage)
// Petter say: "The message is Hello World".
这种情况下,这个 getter 只是调用的函数的作用,不再有缓存,如果你通过变量定义了这个数据,那么这个变量也只是普通变量,不具备响应性。
// 通过变量定义一个值
const signedMessage = store.signedMessage('Petter')
console.log('signedMessage', signedMessage)
// Petter say: "The message is Hello World".
// 2s 后改变 message
setTimeout(() => {
store.message = 'New Message'
// signedMessage 不会变
console.log('signedMessage', signedMessage)
// Petter say: "The message is Hello World".
// 必须这样再次执行才能拿到更新后的值
console.log('signedMessage', store.signedMessage('Petter'))
// Petter say: "The message is New Message".
}, 2000)
获取和更新 getter
getter 和 state 都属于数据管理,读取和赋值的方法是一样的,请参考上方 获取和更新 state 一节的内容。
管理 actions{new}
在 状态树的结构 提到了, Pinia 只需要用 actions
就可以解决各种数据操作,无需像 Vuex 一样区分为 mutations / actions
两大类。
给 Store 添加 action
你可以为当前 Store 封装一些可以开箱即用的方法,支持同步和异步。
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
state: () => ({
message: 'Hello World',
}),
actions: {
// 异步更新 message
async updateMessage(newMessage: string): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
// 这里的 this 是当前的 Store 实例
this.message = newMessage
resolve('Async done.')
}, 3000)
})
},
// 同步更新 message
updateMessageSync(newMessage: string): string {
// 这里的 this 是当前的 Store 实例
this.message = newMessage
return 'Sync done.'
},
},
})
可以看到,在 action 里,如果要访问当前实例的 state 或者 getter ,只需要通过 this
即可操作,方法的入参完全不再受 Vuex 那样有固定形式的困扰。
:::tip
在 action 里, this
是当前的 Store 实例,所以如果你的 action 方法里有其他函数也要调用实例,请记得写成 箭头函数 来提升 this 。
:::
调用 action
像普通的函数一样使用即可,不需要和 Vuex 一样执行 commit 或者 dispatch,在 Pinia ,不需要,不需要。
export default defineComponent({
setup() {
const store = useStore()
const { message } = storeToRefs(store)
// 立即执行
console.log(store.updateMessageSync('New message by sync.'))
// 3s 后执行
store.updateMessage('New message by async.').then((res) => console.log(res))
return {
message,
}
},
})
添加多个 Store{new}
到这里,对单个 Store 的配置和调用相信都已经清楚了,实际项目中会涉及到很多数据操作,还可以用多个 Store 来维护不同需求模块的数据状态。
这一点和 Vuex 的 Module 比较相似,目的都是为了避免状态树过于臃肿,但用起来会更为简单。
目录结构建议
建议统一存放在 src/stores
下面管理,根据业务需要进行命名,比如 user
就用来管理登录用户相关的状态数据。
src
└─stores
│ # 入口文件
├─index.ts
│ # 多个 store
├─user.ts
├─game.ts
└─news.ts
里面暴露的方法就统一以 use
开头加上文件名,并以 Store
结尾,作为小驼峰写法,比如 user
这个 Store 文件里面导出的函数名就是:
// src/stores/user.ts
export const useUserStore = defineStore('user', {
// ...
})
然后以 index.ts
里作为统一的入口文件, index.ts
里的代码写为:
export * from './user'
export * from './game'
export * from './news'
这样在使用的时候,只需要从 @/stores
里导入即可,无需写完整的路径,例如,只需要这样:
import { useUserStore } from '@/stores'
而无需这样:
import { useUserStore } from '@/stores/user'
在 Vue 组件 / TS 文件里使用
这里我以一个比较简单的业务场景举例,希望能够方便的理解如何同时使用多个 Store 。
假设目前有一个 userStore
是管理当前登录用户信息, gameStore
是管理游戏的信息,而 “个人中心” 这个页面需要展示 “用户信息” ,以及 “该用户绑定的游戏信息”,那么就可以这样:
import { defineComponent, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
// 这里导入你要用到的 Store
import { useUserStore, useGameStore } from '@/stores'
import type { GameItem } from '@/types'
export default defineComponent({
setup() {
// 先从 userStore 获取用户信息(已经登录过,所以可以直接拿到)
const userStore = useUserStore()
const { userId, userName } = storeToRefs(userStore)
// 使用 gameStore 里的方法,传入用户 ID 去查询用户的游戏列表
const gameStore = useGameStore()
const gameList = ref<GameItem[]>([])
onMounted(async () => {
gameList.value = await gameStore.queryGameList(userId.value)
})
return {
userId,
userName,
gameList,
}
},
})
再次提醒,切记每个 Store 的 ID 必须不同,如果 ID 重复,在同一个 Vue 组件 / TS 文件里定义 Store 实例变量的时候,会以先定义的为有效值,后续定义的会和前面一样。
如果先定义了 userStore :
// 假设两个 Store 的 ID 一样
const userStore = useUserStore() // 是想要的 Store
const gameStore = useGameStore() // 得到的依然是 userStore 的那个 Store
如果先定义了 gameStore :
// 假设两个 Store 的 ID 一样
const gameStore = useGameStore() // 是想要的 Store
const userStore = useUserStore() // 得到的依然是 gameStore 的那个 Store
Store 之间互相引用
如果在定义一个 Store 的时候,要引用另外一个 Store 的数据,也是很简单,我们回到那个 message 的例子,我们添加一个 getter ,它会返回一句问候语欢迎用户:
// src/stores/message.ts
import { defineStore } from 'pinia'
// 导入用户信息的 Store 并启用它
import { useUserStore } from './user'
const userStore = useUserStore()
export const useMessageStore = defineStore('message', {
state: () => ({
message: 'Hello World',
}),
getters: {
// 这里我们就可以直接引用 userStore 上面的数据了
greeting: () => `Welcome, ${userStore.userName}!`,
},
})
假设现在 userName
是 Petter ,那么你会得到一句对 Petter 的问候:
const messageStore = useMessageStore()
console.log(messageStore.greeting) // Welcome, Petter!
专属插件的使用
Pinia 拥有非常灵活的可扩展性,有专属插件可以开箱即用满足更多的需求场景。
如何查找插件
插件有统一的命名格式 pinia-plugin-* ,所以你可以在 npmjs 上搜索这个关键词来查询目前有哪些插件已发布。
点击查询: pinia-plugin - npmjs
如何使用插件
这里以 pinia-plugin-persistedstate 为例,这是一个让数据持久化存储的 Pinia 插件。
TIP
数据持久化存储,指页面关闭后再打开,浏览器依然可以记录之前保存的本地数据,例如:浏览器原生的 localStorage 和 IndexedDB,或者是一些兼容多种原生方案并统一用法的第三方方案,例如: localForage 。
插件也是独立的 npm 包,需要先安装,再激活,然后才能使用。
激活方法会涉及到 Pinia 的初始化过程调整,这里不局限于某一个插件,通用的插件用法如下(请留意代码注释):
// src/main.ts
import { createApp } from 'vue'
import App from '@/App.vue'
import { createPinia } from 'pinia' // 导入 Pinia
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 导入 Pinia 插件
const pinia = createPinia() // 初始化 Pinia
pinia.use(piniaPluginPersistedstate) // 激活 Pinia 插件
createApp(App)
.use(pinia) // 启用 Pinia ,这一次是包含了插件的 Pinia 实例
.mount('#app')
使用前
Pinia 默认在页面刷新时会丢失当前变更的数据,没有在本地做持久化记录:
// 其他代码省略
const store = useMessageStore()
// 假设初始值是 Hello World
setTimeout(() => {
// 2s 后变成 Hello World!
store.message = store.message + '!'
}, 2000)
// 页面刷新后又变回了 Hello World
使用后
按照 persistedstate 插件的文档说明,我们在其中一个 Store 启用它,只需要添加一个 persist: true 的选项即可开启:
// src/stores/message.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
const userStore = useUserStore()
export const useMessageStore = defineStore('message', {
state: () => ({
message: 'Hello World',
}),
getters: {
greeting: () => `Welcome, ${userStore.userName}`,
},
// 这是按照插件的文档,在实例上启用了该插件,这个选项是插件特有的
persist: true,
})
回到我们的页面,现在这个 Store 具备了持久化记忆的功能了,它会从 localStorage 读取原来的数据作为初始值,每一次变化后也会将其写入 localStorage 进行记忆存储。
// 其他代码省略
const store = useMessageStore()
// 假设初始值是 Hello World
setTimeout(() => {
// 2s 后变成 Hello World!
store.message = store.message + '!'
}, 2000)
// 页面刷新后变成了 Hello World!!
// 再次刷新后变成了 Hello World!!!
// 再次刷新后变成了 Hello World!!!!
你可以在浏览器查看到 localStorage 的存储变化,以 Chrome 浏览器为例,按 F12 ,打开 Application 面板,选择 Local Storage ,可以看到以当前 Store ID 为 Key 的存储数据。
这是其中一个插件使用的例子,更多的用法请根据自己选择的插件的 README 说明操作。
本章结语
看完 Pinia 这一章,我感觉应该都回不去 Vuex 了,真的方便了太多!!!新项目建议直接用 Pinia ,老项目如果有计划迁移,可以和 Vuex 同时使用一段时间,然后再逐步替换。
高效开发
可能很多同学(包括我)刚上手 Vue 3.0 之后,都会觉得开发过程似乎变得更繁琐了,Vue 官方团队当然不会无视群众的呼声,如果你基于脚手架和 .vue 文件开发,那么可以享受到更高效率的开发体验。
在阅读这篇文章之前,需要对 Vue 3.0 的单组件有一定的了解,如果还处于完全没有接触过的阶段,请先抽点时间阅读 单组件的编写 一章。
:::tip
要体验以下新特性,请确保项目下 package.json 里的 vue 和 @vue/compiler-sfc 都在 v3.1.4 版本以上,最好同步 NPM 上当前最新的 @latest 版本,否则在编译过程中可能出现一些奇怪的问题(这两个依赖必须保持同样的版本号)。
:::
script-setup{new}
这是一个比较有争议的新特性,作为 setup 函数的语法糖,褒贬不一,不过经历了几次迭代之后,目前在体验上来说,感受还是非常棒的。
:::tip
截止至 2021-07-16 ,<script setup>
方案已在 Vue 3.2.0-beta.1
版本中脱离实验状态,正式进入 Vue 3.0 的队伍,在新的版本中已经可以作为一个官方标准的开发方案使用(但初期仍需注意与开源社区的项目兼容性问题,特别是 UI 框架)。
另外,Vue 的 3.1.2
版本是针对 script-setup 的一个分水岭版本,自 3.1.4
开始 script-setup 进入定稿状态,部分旧的 API 已被舍弃,本章节内容将以最新的 API 为准进行整理说明,如果您需要查阅旧版 API 的使用,请参阅 这里 。
:::
新特性的产生背景
在了解它怎么用之前,可以先了解一下它被推出的一些背景,可以帮助你对比开发体验上的异同点,以及了解为什么会有这一章节里面的新东西。
在 Vue 3.0 的 .vue 组件里,遵循 SFC 规范要求(注:SFC,即 Single-File Component,.vue 单组件),标准的 setup 用法是,在 setup 里面定义的数据如果需要在 template 使用,都需要 return 出来。
如果你使用的是 TypeScript ,还需要借助 defineComponent 来帮助你对类型的自动推导。
<!-- 标准组件格式 -->
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
// ...
return {
// ...
}
}
})
</script>
关于标准 setup 和 defineComponent 的说明和用法,可以查阅 全新的 setup 函数 一节。
script-setup 的推出是为了让熟悉 3.0 的用户可以更高效率的开发组件,减少一些心智负担,只需要给 script 标签添加一个 setup 属性,那么整个 script 就直接会变成 setup 函数,所有顶级变量、函数,均会自动暴露给模板使用(无需再一个个 return 了)。
Vue 会通过单组件编译器,在编译的时候将其处理回标准组件,所以目前这个方案只适合用 .vue 文件写的工程化项目。
<!-- 使用 script-setup 格式 -->
<script setup lang="ts">
// ...
</script>
对,就是这样,代码量瞬间大幅度减少……
:::tip
因为 script-setup 的大部分功能在书写上和标准版是一致的,这里只提及一些差异化的表现。
:::
全局编译器宏
在 script-setup 模式下,新增了 4 个全局编译器宏,他们无需 import 就可以直接使用。
但是默认的情况下直接使用,项目的 eslint 会提示你没有导入,但你导入后,控制台的 Vue 编译助手又会提示你不需要导入,就很尴尬…
哈哈哈哈不过不用着急,可以配置一下 lint ,把这几个编译助手写进全局规则里,就可以了,不需要导入也不会报错了。
// 项目根目录下的 .eslintrc.js
module.exports = {
// 原来的lint规则,补充下面的globals...
globals: {
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly',
},
}
关于几个宏的说明都在下面的文档部分有说明,你也可以从这里导航过去直接查看。
宏 | 说明 |
---|---|
defineProps | 点击查看 |
defineEmits | 点击查看 |
defineExpose | 点击查看 |
withDefaults | 点击查看 |
下面我们继续了解 script-setup 的变化。
template 操作简化
如果使用 JSX / TSX 写法,这一点没有太大影响,但对于习惯使用 <template />
的开发者来说,这是一个非常爽的体验。
主要体现在这两点:
变量无需进行 return
标准组件模式下,setup 里定义的变量,需要 return 后,在 template 部分才可以正确拿到:
<!-- 标准组件格式 -->
<template>
<p>{{ msg }}</p>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
const msg: string = 'Hello World!';
// 要给 template 用的数据需要 return 出来才可以
return {
msg
}
}
})
</script>
在 script-setup 模式下,你定义了就可以直接使用。
<!-- 使用 script-setup 格式 -->
<template>
<p>{{ msg }}</p>
</template>
<script setup lang="ts">
const msg: string = 'Hello World!';
</script>
子组件无需手动注册
子组件的挂载,在标准组件里的写法是需要 import 后再放到 components 里才能够启用:
<!-- 标准组件格式 -->
<template>
<Child />
</template>
<script lang="ts">
import { defineComponent } from 'vue'
// 导入子组件
import Child from '@cp/Child.vue'
export default defineComponent({
// 需要启用子组件作为模板
components: {
Child
},
// 组件里的业务代码
setup () {
// ...
}
})
</script>
在 script-setup 模式下,只需要导入组件即可,编译器会自动识别并启用。
<!-- 使用 script-setup 格式 -->
<template>
<Child />
</template>
<script setup lang="ts">
import Child from '@cp/Child.vue'
</script>
props 的接收方式变化
由于整个 script 都变成了一个大的 setup function ,没有了组件选项,也没有了 setup 入参,所以没办法和标准写法一样去接收 props 了。
这里需要使用一个全新的 API :defineProps
。
defineProps
是一个方法,内部返回一个对象,也就是挂载到这个组件上的所有 props ,它和普通的 props 用法一样,如果不指定为 prop, 则传下来的属性会被放到 attrs 那边去。
:::tip
前置知识点:接收 props - 组件之间的通信。
:::
defineProps 的基础用法
所以,如果只是单纯在 template 里使用,那么其实就这么简单定义就可以了:
defineProps([
'name',
'userInfo',
'tags'
])
使用 string[]
数组作为入参,把 prop 的名称作为数组的 item 传给 defineProps
就可以了。
如果 script 里的方法要拿到 props 的值,你也可以使用字面量定义:
const props = defineProps([
'name',
'userInfo',
'tags'
])
console.log(props.name);
但在作为一个 Vue 老玩家,都清楚不显性的指定 prop 类型的话,很容易在协作中引起程序报错,那么应该如何对每个 prop 进行类型检查呢?
有两种方式来处理类型定义。
通过构造函数检查 prop
这是第一种方式:使用 JavaScript 原生构造函数进行类型规定。
也就是跟我们平时定义 prop 类型时一样, Vue 会通过 instanceof
来进行 类型检查 。
使用这种方法,需要通过一个 “对象” 入参来传递给 defineProps
,比如:
defineProps({
name: String,
userInfo: Object,
tags: Array
});
所有原来 props 具备的校验机制,都可以适用,比如你除了要限制类型外,还想指定 name
是可选,并且带有一个默认值:
defineProps({
name: {
type: String,
required: false,
default: 'Petter'
},
userInfo: Object,
tags: Array
});
更多的 props 校验机制,可以点击 带有类型限制的 props 和 可选以及带有默认值的 props 了解更多。
使用类型注解检查 prop
这是第二种方式:使用 TypeScript 的类型注解。
和 ref 等 API 的用法一样,defineProps
也是可以使用尖括号 <> 来包裹类型定义,紧跟在 API 后面,另外,由于 defineProps
返回的是一个对象(因为 props 本身是一个对象),所以尖括号里面的类型还要用大括号包裹,通过 key: value
的键值对形式表示,如:
defineProps<{ name: string }>();
注意到了吗?这里使用的类型,和第一种方法提到的指定类型时是不一样的。
:::tip
在这里,不再使用构造函数校验,而是需要遵循使用 TypeScript 的类型。
比如字符串是 string
,而不是 String
。
:::
如果有多个 prop ,就跟写 interface 一样:
defineProps<{
name: string;
phoneNumber: number;
userInfo: object;
tags: string[];
}>();
其中,举例里的 userInfo
是一个对象,你可以简单的指定为 object,也可以先定义好它对应的类型,再进行指定:
interface UserInfo {
id: number;
age: number;
}
defineProps<{
name: string;
userInfo: UserInfo;
}>();
如果你想对某个数据设置为可选,也是遵循 TS 规范,通过英文问号 ?
来允许可选:
// name 是可选
defineProps<{
name?: string;
tags: string[];
}>();
如果你想设置可选参数的默认值,需要借助 withDefaults API。
:::warning 需要强调的一点是:在 构造函数 和 类型注解 这两种校验方式只能二选一,不能同时使用,否则会引起程序报错 :::
withDefaults 的基础用法
这个新的 withDefaults API 可以让你在使用 TS 类型系统时,也可以指定 props 的默认值。
它接收两个入参:
参数 | 类型 | 含义 |
---|---|---|
props | object | 通过 defineProps 传入的 props |
defaultValues | object | 根据 props 的 key 传入默认值 |
可能缺乏一些官方描述,还是看参考用法可能更直观:
withDefaults(defineProps<{
size?: number
labels?: string[]
}>(), {
size: 3,
labels: () => ['default label']
})
如果你要在 TS / JS 再对 props 进行获取,也可以通过字面量来拿到这些默认值:
// 如果不习惯上面的写法,你也可以跟平时一样先通过interface定义一个类型接口
interface Props {
msg?: string
}
// 再作为入参传入
const props = withDefaults(defineProps<Props>(), {
msg: 'hello'
})
// 这样就可以通过props变量拿到需要的prop值了
console.log(props.msg)
emits 的接收方式变化
和 props 一样,emits 的接收也是需要使用一个全新的 API 来操作,这个 API 就是 defineEmits
。
和 defineProps
一样, defineEmits
也是一个方法,它接受的入参格式和标准组件的要求是一致的。
:::tip
注意:从 3.1.3
版本开始,该 API 已被改名,加上了复数结尾,带有 s,在此版本之前是没有 s 结尾!
前置知识点:接收 emits - 组件之间的通信。
:::
defineEmits 的基础用法
由于 emit 并非提供给模板直接读取,所以需要通过字面量来定义 emits。
最基础的用法也是传递一个 string[]
数组进来,把每个 emit 的名称作为数组的 item 。
// 获取 emit
const emit = defineEmits(['chang-name']);
// 调用 emit
emit('chang-name', 'Tom');
由于 defineEmits
的用法和原来的 emits 选项差别不大,这里也不重复说明更多的诸如校验之类的用法了,可以查看 接收 emits 一节了解更多。
attrs 的接收方式变化
attrs
和 props
很相似,也是基于父子通信的数据,如果父组件绑定下来的数据没有被指定为 props
,那么就会被挂到 attrs
这边来。
在标准组件里, attrs
的数据是通过 setup
的第二个入参 context
里的 attrs
API 获取的。
// 标准组件的写法
export default defineComponent({
setup (props, { attrs }) {
// attrs 是个对象,每个 Attribute 都是它的 key
console.log(attrs.class);
// 如果传下来的 Attribute 带有短横线,需要通过这种方式获取
console.log(attrs['data-hash']);
}
})
但和 props
一样,由于没有了 context
参数,需要使用一个新的 API 来拿到 attrs
数据。
这个 API 就是 useAttrs
。
:::tip
请注意,useAttrs
API 需要 Vue 3.1.4
或更高版本才可以使用。
:::
useAttrs 的基础用法
顾名思义, useAttrs 可以是用来获取 attrs 数据的,它的用法非常简单:
// 导入 useAttrs 组件
import { useAttrs } from 'vue'
// 获取 attrs
const attrs = useAttrs()
// attrs是个对象,和 props 一样,需要通过 key 来得到对应的单个 attr
console.log(attrs.msg);
对 attrs
不太了解的话,可以查阅 获取非 Prop 的 Attribute
slots 的接收方式变化
slots
是 Vue 组件的插槽数据,也是在父子通信里的一个重要成员。
对于使用 template 的开发者来说,在 script-setup 里获取插槽数据并不困难,因为跟标准组件的写法是完全一样的,可以直接在 template 里使用 <slot />
标签渲染。
<template>
<div>
<!-- 插槽数据 -->
<slot />
<!-- 插槽数据 -->
</div>
</template>
但对使用 JSX / TSX 的开发者来说,就影响比较大了,在标准组件里,想在 script 里获取插槽数据,也是需要在 setup
的第二个入参里拿到 slots
API 。
// 标准组件的写法
export default defineComponent({
// 这里的 slots 就是插槽
setup (props, { slots }) {
// ...
}
})
新版本的 Vue 也提供了一个全新的 useSlots
API 来帮助 script-setup 用户获取插槽。
:::tip
请注意,useSlots
API 需要 Vue 3.1.4
或更高版本才可以使用。
:::
useSlots 的基础用法
先来看看父组件,父组件先为子组件传入插槽数据,支持 “默认插槽” 和 “命名插槽” :
<template>
<!-- 子组件 -->
<ChildTSX>
<!-- 默认插槽 -->
<p>I am a default slot from TSX.</p>
<!-- 默认插槽 -->
<!-- 命名插槽 -->
<template #msg>
<p>I am a msg slot from TSX.</p>
</template>
<!-- 命名插槽 -->
</ChildTSX>
<!-- 子组件 -->
</template>
<script setup lang="ts">
import ChildTSX from '@cp/context/Child.tsx'
</script>
在使用 JSX / TSX 编写的子组件里,就可以通过 useSlots
来获取父组件传进来的 slots
数据进行渲染:
// 注意:这是一个 .tsx 文件
import { defineComponent, useSlots } from 'vue'
const ChildTSX = defineComponent({
setup() {
// 获取插槽数据
const slots = useSlots()
// 渲染组件
return () => (
<div>
{/* 渲染默认插槽 */}
<p>{ slots.default ? slots.default() : '' }</p>
{/* 渲染命名插槽 */}
<p>{ slots.msg ? slots.msg() : '' }</p>
</div>
)
},
})
export default ChildTSX
ref 的通信方式变化
在标准组件写法里,子组件的数据都是默认隐式暴露给父组件的,也就是父组件可以通过 childComponent.value.foo
这样的方式直接操作子组件的数据(参见:DOM 元素与子组件 - 响应式 API 之 ref)。
但在 script-setup 模式下,所有数据只是默认隐式 return 给 template 使用,不会暴露到组件外,所以父组件是无法直接通过挂载 ref 变量获取子组件的数据。
在 script-setup 模式下,如果要调用子组件的数据,需要先在子组件显示的暴露出来,才能够正确的拿到,这个操作,就是由 defineExpose
来完成。
defineExpose 的基础用法
defineExpose
的用法非常简单,它本身是一个函数,可以接受一个对象参数。
在子组件里,像这样把需要暴露出去的数据通过 key: value
的形式作为入参(下面的例子是用到了 ES6 的 属性的简洁表示法):
<script setup lang="ts">
// 定义一个想提供给父组件拿到的数据
const msg: string = 'Hello World!';
// 显示暴露的数据,才可以在父组件拿到
defineExpose({
msg
});
</script>
然后你在父组件就可以通过挂载在子组件上的 ref 变量,去拿到暴露出来的数据了。
顶级 await 的支持
在 script-setup 模式下,不必再配合 async 就可以直接使用 await 了,这种情况下,组件的 setup 会自动变成 async setup 。
<script setup lang="ts">
const post = await fetch(`/api/post/1`).then((r) => r.json())
</script>
它转换成标准组件的写法就是:
<script lang="ts">
import { defineComponent, withAsyncContext } from 'vue'
export default defineComponent({
async setup() {
const post = await withAsyncContext(
fetch(`/api/post/1`).then((r) => r.json())
)
return {
post
}
}
})
</script>