Node.js 官网入门教程(三) 依赖与开发依赖、包运行器 npx、事件循环(消息队列、工作队列、process.nextTick()、setImmediate())、定时器、回调、Promise、Async
文章目录
- Node.js 官网入门教程(三) 依赖与开发依赖、包运行器 npx、事件循环(消息队列、工作队列、process.nextTick()、setImmediate())、定时器、回调、Promise、Async
总结:
通用
- Node.js安装包:http://nodejs.cn/download/。
- API检索网址:API 文档 | Node.js 中文网 (nodejs.cn)
- 第三方模板引擎:art-template官方文档
- Express: 提供了创建 Web 服务器的最简单但功能最强大的方法之一。 它的极简主义方法,专注于服务器的核心功能,是其成功的关键。
- koa: 由 Express 背后的同一个团队构建,旨在变得更简单更轻巧。 新项目的诞生是为了满足创建不兼容的更改而又不破坏现有社区。
- readyState
- 存有 XMLHttpRequest 的状态。从 0 到 4 发生变化。
- 0: 请求未初始化
- 1: 服务器连接已建立
- 2: 请求已接收
- 3: 请求处理中
- 4: 请求已完成,且响应已就绪
- status
- 200, OK,访问正常
- 301, Moved Permanently,永久移动
- 302, Moved temporarily,暂时移动
- 304, Not Modified,未修改
- 307, Temporary Redirect,暂时重定向
- 401, Unauthorized,未授权
- 403, Forbidden,禁止访问
- 404, Not Found,未发现指定网址
- 500, Internal Server Error,服务器发生错误
CLI
npm
依赖与开发依赖
dependencies
设置了作为依赖安装的npm
软件包的列表。
devDependencies
设置了作为开发依赖安装的npm
软件包的列表。它们不同于
dependencies
,因为它们只需安装在开发机器上,而无需在生产环境中运行代码npx
npx是一个工具,npm v5.2.0引入的一条命令(npx),一个npm包执行器,指在提高从npm注册表使用软件包时的体验 ,npm使得它非常容易地安装和管理托管在注册表上的依赖项,npx使得使用CLI工具和其他托管在注册表。它大大简化了一些事情。
就像npm极大地提升了我们安装和管理包依赖的体验,在npm的基础之上,npx让npm包中的命令行工具和其他可执行文件在使用上变得更加简单。它极大地简化了我们之前使用纯粹的npm时所需要的大量步骤。
主要特点
临时安装可执行依赖包,不用全局安装,不用担心长期的污染
重要的特性是,无需先安装命令即可运行命令。
可以执行依赖包中的命令,安装完成自动运行。
自动加载node_modules中依赖包,不用指定$PATH。
运行
npx commandname
会自动地在项目的node_modules
文件夹中找到命令的正确引用,而无需知道确切的路径,也不需要在全局和用户路径中安装软件包。可以指定node版本、命令的版本,解决了不同项目使用不同版本的命令的问题。
事件循环
简介
- JavaScript 中几乎所有的 I/O 基元都是非阻塞的。 网络请求、文件系统操作等。 被阻塞是个异常,这就是 JavaScript 如此之多基于回调(最近越来越多基于 promise 和 async/await)的原因。
- 调用堆栈是一个 LIFO 队列(后进先出)
setTimeout(() => {}, 0)
的用例是调用一个函数,但是是在代码中的每个其他函数已被执行之后。消息队列、工作队列
消息队列将你排在队列的后面(在所有其他人的后面),你不得不等待你的回合。setTimeout(bar, 0)
而工作队列则是快速通道票,这样你就可以在完成上一次乘车后立即乘坐另一趟车。
Promise((resolve, reject) => resolve('应该在 baz 之后、bar 之前') ).then(resolve => console.log(resolve))
process.nextTick()
- 每当事件循环进行一次完整的行程时,我们都将其称为一个滴答。
- 当将一个函数传给
process.nextTick()
时,则指示引擎在当前操作结束(在下一个事件循环滴答开始之前)时调用此函数- 调用
setTimeout(() => {}, 0)
会在下一个滴答结束时执行该函数,比使用nextTick()
(其会优先执行该调用并在下一个滴答开始之前执行该函数)晚得多。- 当要确保在下一个事件循环迭代中代码已被执行,则使用
nextTick()
。
setImmediate()
与setTimeout(() => {}, 0)
(传入 0 毫秒的超时)、process.nextTick()
有何不同?
- 传给
process.nextTick()
的函数会在事件循环的当前迭代中(当前操作结束之后)被执行。 这意味着它会始终在setTimeout
和setImmediate
之前执行。- 延迟 0 毫秒的
setTimeout()
回调与setImmediate()
非常相似。 执行顺序取决于各种因素,但是它们都会在事件循环的下一个迭代中运行。setTimeout
也许一个较长时间的执行会与下一次执行重叠
为了避免这种情况,可以在回调函数完成时安排要被调用的递归的 setTimeout
回调
- 回调是一个简单的函数,会作为值被传给另一个函数,并且仅在事件发生时才被执行。
- 任何回调函数中的第一个参数为错误对象(即错误优先的回调)。
Promise 对象
- 是 JavaScript 的异步操作解决方案,为异步操作提供统一接口。它起到代理作用(proxy),充当异步操作与回调函数之间的中介,使得异步操作具备同步操作的接口。Promise 可以让异步操作写起来,就像在写同步操作的流程,而不必一层层地嵌套回调函数。
- 首先,Promise 是一个对象,也是一个构造函数。
- Promise 的设计思想是,所有异步任务都返回一个 Promise 实例。Promise 实例有一个then方法,用来指定下一步的回调函数。
- Promise 的最终结果只有两种。
- 异步操作成功,Promise 实例传回一个值(value),状态变为fulfilled。
- 异步操作失败,Promise 实例抛出一个错误(error),状态变为rejected。
async
在任何函数之前加上
async
关键字意味着该函数会返回 promise。设计出来就是为了代码看起来非常简单(相对于使用普通的 promise、链式和回调函数的代码)。
调试 promise 很难,因为调试器不会跳过异步的代码。
Async/await 使这非常容易,因为对于编译器而言,它就像同步代码一样。
1. npm 全局或本地的软件包
本地和全局的软件包之间的主要区别是:
- 本地的软件包 安装在运行
npm install <package-name>
的目录中,并且放置在此目录下的node_modules
文件夹中。 - 全局的软件包 放在系统中的单独位置(确切的位置取决于设置),无论在何处运行
npm install -g <package-name>
。
在代码中,应该只引入本地的软件包:
require('package-name')
所以何时应该以一种或另一种方式安装?
通常,所有的软件包都应本地安装。
这样可以确保计算机中可以有数十个应用程序,并且如果需要,每个应用程序都可以运行不同的版本。
更新全局软件包会使所有的项目都使用新的版本,这可能会导致维护方面的噩梦,因为某些软件包可能会破坏与其他依赖项的兼容性等。
所有的项目都有自己的软件包本地版本,即使这看起来有点浪费资源,但与可能产生的负面影响相比也很小。
当程序包提供了可从 shell(CLI命令行界面)运行的可执行命令、且可在项目间复用时,则该程序包应被全局安装。
也可以在本地安装可执行命令并使用 npx 运行,但是某些软件包最好在全局安装。
一些流行的全局软件包的示例有:
npm
create-react-app
vue-cli
grunt-cli
mocha
react-native-cli
gatsby-cli
forever
nodemon
可能已经在系统上安装了一些全局软件包。 可以通过在命令行上运行以下命令查看:
npm list -g --depth 0
2. npm 依赖与开发依赖
当使用 npm install <package-name>
安装 npm 软件包时,是将其安装为依赖项。
该软件包会被自动地列出在 package.json 文件中的 dependencies
列表下(在 npm 5 之前:必须手动指定 --save
)。
当添加了 -D
或 --save-dev
标志时,则会将其安装为开发依赖项(会被添加到 devDependencies
列表)。
开发依赖是仅用于开发的程序包,在生产环境中并不需要。 例如测试的软件包、webpack 或 Babel。
当投入生产环境时,如果输入 npm install
且该文件夹包含 package.json
文件时,则会安装它们,因为 npm 会假定这是开发部署。
需要设置 --production
标志(npm install --production
),以避免安装这些开发依赖项。
3. Node.js 包运行器 npx
npx
是一个非常强大的命令,从 npm 的 5.2 版本(发布于 2017 年 7 月)开始可用。
如果不想安装 npm,则可以安装 npx 为独立的软件包。
npx
可以运行使用 Node.js 构建并通过 npm 仓库发布的代码。
轻松地运行本地命令
Node.js 开发者过去通常将大多数可执行命令发布为全局的软件包,以使它们处于路径中且可被立即地执行。
这很痛苦,因为无法真正地安装同一命令的不同版本。
运行 npx commandname
会自动地在项目的 node_modules
文件夹中找到命令的正确引用,而无需知道确切的路径,也不需要在全局和用户路径中安装软件包。
无需安装的命令执行
npx
的另一个重要的特性是,无需先安装命令即可运行命令。
这非常有用,主要是因为:
- 不需要安装任何东西。
- 可以使用 @version 语法运行同一命令的不同版本。
使用 npx
的一个典型演示是使用 cowsay
命令。 cowsay
会打印一头母牛,并在命令中说出你写的内容。 例如:
cowsay "你好"
会打印:
_______
< 你好 >
-------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
只有之前已从 npm 全局安装了 cowsay
命令,才可以这样做,否则,当尝试运行该命令时会获得错误。
npx
可以运行该 npm 命令,而无需在本地安装:
npx cowsay "你好"
会执行这个工作。
这是一个有趣但无用的命令。 其他场景有:
- 运行
vue
CLI 工具以创建新的应用程序并运行它们:npx @vue/cli create my-vue-app
。 - 使用
create-react-app
创建新的React
应用:npx create-react-app my-react-app
。
还有更多其他的场景。
当被下载完,则下载的代码会被擦除。
使用不同的 Node.js 版本运行代码
使用 @
指定版本,并将其与 node
npm 软件包 结合使用:
npx node@10 -v #v10.18.1
npx node@12 -v #v12.14.1
这有助于避免使用 nvm
之类的工具或其他 Node.js 版本管理工具。
直接从 URL 运行任意代码片段
npx
并不限制使用 npm 仓库上发布的软件包。
可以运行位于 GitHub gist 中的代码,例如:
npx https://gist.github.com/zkat/4bc19503fe9e9309e2bfaa2c58074d32
当然,当运行不受控制的代码时,需要格外小心,因为强大的功能带来了巨大的责任。
4. Node.js 事件循环
介绍
事件循环是了解 Node.js 最重要的方面之一。
为什么这么重要? 因为它阐明了 Node.js 如何做到异步且具有非阻塞的 I/O,所以它基本上阐明了 Node.js 的“杀手级应用”,正是这一点使它成功了。
Node.js JavaScript 代码运行在单个线程上。 每次只处理一件事。
这个限制实际上非常有用,因为它大大简化了编程方式,而不必担心并发问题。
只需要注意如何编写代码,并避免任何可能阻塞线程的事情,例如同步的网络调用或无限的循环。
通常,在大多数浏览器中,每个浏览器选项卡都有一个事件循环,以使每个进程都隔离开,并避免使用无限的循环或繁重的处理来阻止整个浏览器的网页。
该环境管理多个并发的事件循环,例如处理 API 调用。 Web 工作进程也运行在自己的事件循环中。
主要需要关心代码会在单个事件循环上运行,并且在编写代码时牢记这一点,以避免阻塞它。
阻塞事件循环
任何花费太长时间才能将控制权返回给事件循环的 JavaScript 代码,都会阻塞页面中任何 JavaScript 代码的执行,甚至阻塞 UI 线程,并且用户无法单击浏览、滚动页面等。
JavaScript 中几乎所有的 I/O 基元都是非阻塞的。 网络请求、文件系统操作等。 被阻塞是个异常,这就是 JavaScript 如此之多基于回调(最近越来越多基于 promise 和 async/await)的原因。
调用堆栈
调用堆栈是一个 LIFO 队列(后进先出)。
事件循环不断地检查调用堆栈,以查看是否需要运行任何函数。
当执行时,它会将找到的所有函数调用添加到调用堆栈中,并按顺序执行每个函数。
你知道在调试器或浏览器控制台中可能熟悉的错误堆栈跟踪吗? 浏览器在调用堆栈中查找函数名称,以告知你是哪个函数发起了当前的调用:
一个简单的事件循环的阐释
举个例子:
const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
console.log('foo')
bar()
baz()
}
foo()
此代码会如预期地打印:
foo
bar
baz
当运行此代码时,会首先调用 foo()
。 在 foo()
内部,会首先调用 bar()
,然后调用 baz()
。
此时,调用堆栈如下所示:
每次迭代中的事件循环都会查看调用堆栈中是否有东西并执行它直到调用堆栈为空:
入队函数执行
上面的示例看起来很正常,没有什么特别的:JavaScript 查找要执行的东西,并按顺序运行它们。
让我们看看如何将函数推迟直到堆栈被清空。
setTimeout(() => {}, 0)
的用例是调用一个函数,但是是在代码中的每个其他函数已被执行之后。
举个例子:
const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
console.log('foo')
setTimeout(bar, 0)
baz()
}
foo()
该代码会打印:
foo
baz
bar
当运行此代码时,会首先调用 foo()。 在 foo() 内部,会首先调用 setTimeout,将 bar
作为参数传入,并传入 0 作为定时器指示它尽快运行。 然后调用 baz()。
此时,调用堆栈如下所示:
上面第9步到第10步很重要,是等foo()退出之后再执行bar()
这是程序中所有函数的执行顺序:
为什么会这样呢?
消息队列
当调用 setTimeout() 时,浏览器或 Node.js 会启动定时器。 当定时器到期时(在此示例中会立即到期,因为将超时值设为 0),则回调函数会被放入“消息队列”中。
在消息队列中,用户触发的事件(如单击或键盘事件、或获取响应)也会在此排队,然后代码才有机会对其作出反应。 类似 onLoad
这样的 DOM 事件也如此。
事件循环会赋予调用堆栈优先级,它首先处理在调用堆栈中找到的所有东西,一旦其中没有任何东西,便开始处理消息队列中的东西。
我们不必等待诸如 setTimeout
、fetch、或其他的函数来完成它们自身的工作,因为它们是由浏览器提供的,并且位于它们自身的线程中。 例如,如果将 setTimeout
的超时设置为 2 秒,但不必等待 2 秒,等待发生在其他地方。
ES6 作业队列
ECMAScript 2015 引入了作业队列的概念,Promise 使用了该队列(也在 ES6/ES2015 中引入)。 这种方式会尽快地执行异步函数的结果,而不是放在调用堆栈的末尾。
在当前函数结束之前 resolve 的 Promise 会在当前函数之后被立即执行。
有个游乐园中过山车的比喻很好:消息队列将你排在队列的后面(在所有其他人的后面),你不得不等待你的回合,而工作队列则是快速通道票,这样你就可以在完成上一次乘车后立即乘坐另一趟车。
示例:
const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
console.log('foo')
setTimeout(bar, 0)
new Promise((resolve, reject) =>
resolve('应该在 baz 之后、bar 之前')
).then(resolve => console.log(resolve))
baz()
}
foo()
这会打印:
foo
baz
应该在 baz 之后、bar 之前
bar
这是 Promise(以及基于 promise 构建的 async/await)与通过 setTimeout()
或其他平台 API 的普通的旧异步函数之间的巨大区别。
5. 了解 process.nextTick()
当尝试了解 Node.js 事件循环时,其中一个重要的部分就是 process.nextTick()
。
每当事件循环进行一次完整的行程时,我们都将其称为一个滴答。
当将一个函数传给 process.nextTick()
时,则指示引擎在当前操作结束(在下一个事件循环滴答开始之前)时调用此函数:
process.nextTick(() => {
//做些事情
})
事件循环正在忙于处理当前的函数代码。
当该操作结束时,JS 引擎会运行在该操作期间传给 nextTick
调用的所有函数。
这是可以告诉 JS 引擎异步地(在当前函数之后)处理函数的方式,但是尽快执行而不是将其排入队列。
调用 setTimeout(() => {}, 0)
会在下一个滴答结束时执行该函数,比使用 nextTick()
(其会优先执行该调用并在下一个滴答开始之前执行该函数)晚得多。
当要确保在下一个事件循环迭代中代码已被执行,则使用 nextTick()
。
6. 了解 setImmediate()
当要异步地(但要尽可能快)执行某些代码时,其中一个选择是使用 Node.js 提供的 setImmediate()
函数:
setImmediate(() => {
//运行一些东西
})
作为 setImmediate() 参数传入的任何函数都是在事件循环的下一个迭代中执行的回调。
setImmediate()
与 setTimeout(() => {}, 0)
(传入 0 毫秒的超时)、process.nextTick()
有何不同?
传给 process.nextTick()
的函数会在事件循环的当前迭代中(当前操作结束之后)被执行。 这意味着它会始终在 setTimeout
和 setImmediate
之前执行。
延迟 0 毫秒的 setTimeout()
回调与 setImmediate()
非常相似。 执行顺序取决于各种因素,但是它们都会在事件循环的下一个迭代中运行。
7. 探索 JavaScript 定时器
setTimeout()
当编写 JavaScript 代码时,可能希望延迟函数的执行。
这就是 setTimeout 的工作。 指定一个回调函数以供稍后执行,并指定希望它稍后运行的时间(以毫秒为单位)的值:
setTimeout(() => {
// 2 秒之后运行
}, 2000)
setTimeout(() => {
// 50 毫秒之后运行
}, 50)
该语法定义了一个新的函数。 可以在其中调用所需的任何其他函数,也可以传入现有的函数名称和一组参数:
const myFunction = (firstParam, secondParam) => {
// 做些事情
}
// 2 秒之后运行
setTimeout(myFunction, 2000, firstParam, secondParam)
setTimeout
会返回定时器的 id。 通常不使用它,但是可以保存此 id,并在要删除此安排的函数执行时清除它:
const id = setTimeout(() => {
// 应该在 2 秒之后运行
}, 2000)
// 改变主意了
clearTimeout(id)
零延迟
如果将超时延迟指定为 0
,则回调函数会被尽快执行(但是是在当前函数执行之后):
setTimeout(() => {
console.log('后者 ')
}, 0)
console.log(' 前者 ')
会打印 前者 后者
。
通过在调度程序中排队函数,可以避免在执行繁重的任务时阻塞 CPU,并在执行繁重的计算时执行其他函数。
某些浏览器(IE 和 Edge)实现的
setImmediate()
方法具有相同的确切功能,但是不是标准的,并且在其他浏览器上不可用。但是在 Node.js 中它是标准的函数。
setInterval()
setInterval
是一个类似于 setTimeout
的函数,不同之处在于:它会在指定的特定时间间隔(以毫秒为单位)一直地运行回调函数,而不是只运行一次:
setInterval(() => {
// 每 2 秒运行一次
}, 2000)
上面的函数每隔 2 秒运行一次,除非使用 clearInterval
告诉它停止(传入 setInterval
返回的间隔定时器 id):
const id = setInterval(() => {
// 每 2 秒运行一次
}, 2000)
clearInterval(id)
通常在 setInterval
回调函数中调用 clearInterval
,以使其自行判断是否应该再次运行或停止。 例如,此代码会运行某些事情,除非 App.somethingIWait
具有值 arrived
:
const interval = setInterval(() => {
if (App.somethingIWait === 'arrived') {
clearInterval(interval)
return
}
// 否则做些事情
}, 100)
递归的 setTimeout
setInterval
每 n 毫秒启动一个函数,而无需考虑函数何时完成执行。
如果一个函数总是花费相同的时间,那就没问题了:
函数可能需要不同的执行时间,这具体取决于网络条件,例如:
也许一个较长时间的执行会与下一次执行重叠:
为了避免这种情况,可以在回调函数完成时安排要被调用的递归的 setTimeout:
const myFunction = () => {
// 做些事情
setTimeout(myFunction, 1000)
}
setTimeout(myFunction, 1000)
实现此方案:
setTimeout
和 setInterval
可通过定时器模块在 Node.js 中使用。
Node.js 还提供 setImmediate()
(相当于使用 setTimeout(() => {}, 0)
),通常用于与 Node.js 事件循环配合使用。
8. JavaScript 异步编程与回调
编程语言中的异步性
计算机在设计上是异步的。
异步意味着事情可以独立于主程序流而发生。
在当前的用户计算机中,每个程序都运行于特定的时间段,然后停止执行,以让另一个程序继续执行。 这件事运行得如此之快,以至于无法察觉。 我们以为计算机可以同时运行许多程序,但这是一种错觉(在多处理器计算机上除外)。
程序在内部会使用中断,一种被发送到处理器以获取系统关注的信号。
这里不会深入探讨这个问题,只要记住,程序是异步的且会暂停执行直到需要关注,这使得计算机可以同时执行其他操作。 当程序正在等待来自网络的响应时,则它无法在请求完成之前停止处理器。
通常,编程语言是同步的,有些会在语言或库中提供管理异步性的方法。 默认情况下,C、Java、C#、PHP、Go、Ruby、Swift 和 Python 都是同步的。 其中一些语言通过使用线程(衍生新的进程)来处理异步操作。
JavaScript
JavaScript 默认情况下是同步的,并且是单线程的。 这意味着代码无法创建新的线程并且不能并行运行。
代码行是依次执行的,例如:
const a = 1
const b = 2
const c = a * b
console.log(c)
doSomething()
但是 JavaScript 诞生于浏览器内部,一开始的主要工作是响应用户的操作,例如 onClick
、onMouseOver
、onChange
、onSubmit
等。 使用同步的编程模型该如何做到这一点?
答案就在于它的环境。 浏览器通过提供一组可以处理这种功能的 API 来提供了一种实现方式。
更近点,Node.js 引入了非阻塞的 I/O 环境,以将该概念扩展到文件访问、网络调用等。
回调
你不知道用户何时单击按钮。 因此,为点击事件定义了一个事件处理程序。 该事件处理程序会接受一个函数,该函数会在该事件被触发时被调用:
document.getElementById('button').addEventListener('click', () => {
//被点击
})
这就是所谓的回调。
回调是一个简单的函数,会作为值被传给另一个函数,并且仅在事件发生时才被执行。 之所以这样做,是因为 JavaScript 具有顶级的函数,这些函数可以被分配给变量并传给其他函数(称为高阶函数)。
通常会将所有的客户端代码封装在 window
对象的 load
事件监听器中,其仅在页面准备就绪时才会运行回调函数:
window.addEventListener('load', () => {
//window 已被加载。
//做需要做的。
})
回调无处不在,不仅在 DOM 事件中。
一个常见的示例是使用定时器:
setTimeout(() => {
// 2 秒之后运行。
}, 2000)
XHR 请求也接受回调,在此示例中,会将一个函数分配给一个属性,该属性会在发生特定事件(在该示例中,是请求状态的改变)时被调用:
const xhr = new XMLHttpRequest()
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
xhr.status === 200 ? console.log(xhr.responseText) : console.error('出错')
}
}
xhr.open('GET', 'http://nodejs.cn')
xhr.send()
处理回调中的错误
如何处理回调的错误? 一种非常常见的策略是使用 Node.js 所采用的方式:任何回调函数中的第一个参数为错误对象(即错误优先的回调)。
如果没有错误,则该对象为 null
。 如果有错误,则它会包含对该错误的描述以及其他信息。
fs.readFile('/文件.json', (err, data) => {
if (err !== null) {
//处理错误
console.log(err)
return
}
//没有错误,则处理数据。
console.log(data)
})
回调的问题
回调适用于简单的场景!
但是,每个回调都可以添加嵌套的层级,并且当有很多回调时,代码就会很快变得非常复杂:
window.addEventListener('load', () => {
document.getElementById('button').addEventListener('click', () => {
setTimeout(() => {
items.forEach(item => {
//你的代码在这里。
})
}, 2000)
})
})
这只是一个简单的 4 个层级的代码,但还有更多层级的嵌套,这很不好。
该如何解决?
回调的替代方法
从 ES6 开始,JavaScript 引入了一些特性,可以帮助处理异步代码而不涉及使用回调:Promise(ES6)和 Async/Await(ES2017)。
9. 了解 JavaScript Promise
Promise 简介
Promise 通常被定义为最终会变为可用值的代理。
Promise 是一种处理异步代码(而不会陷入回调地狱)的方式。
多年来,promise 已成为语言的一部分(在 ES2015 中进行了标准化和引入),并且最近变得更加集成,在 ES2017 中具有了 async 和 await。
异步函数 在底层使用了 promise,因此了解 promise 的工作方式是了解 async
和 await
的基础。
Promise 如何运作
当 promise 被调用后,它会以处理中状态开始。 这意味着调用的函数会继续执行,而 promise 仍处于处理中直到解决为止,从而为调用的函数提供所请求的任何数据。
被创建的 promise 最终会以被解决状态或被拒绝状态结束,并在完成时调用相应的回调函数(传给 then
和 catch
)。
哪些 JS API 使用了 promise?
除了自己的代码和库代码,标准的现代 Web API 也使用了 promise,例如:
- Battery API
- Fetch API
- Service Worker
在现代 JavaScript 中,不太可能没有使用 promise,因此让我们开始深入研究它们。
创建 promise
Promise API 公开了一个 Promise 构造函数,可以使用 new Promise()
对其进行初始化:
let done = true
const isItDoneYet = new Promise((resolve, reject) => {
if (done) {
const workDone = '这是创建的东西'
resolve(workDone)
} else {
const why = '仍然在处理其他事情'
reject(why)
}
})
如你所见,promise 检查了 done
全局常量,如果为真,则 promise 进入被解决状态(因为调用了 resolve
回调);否则,则执行 reject
回调(将 promise 置于被拒绝状态)。 如果在执行路径中从未调用过这些函数之一,则 promise 会保持处理中状态。
使用 resolve
和 reject
,可以向调用者传达最终的 promise 状态以及该如何处理。 在上述示例中,只返回了一个字符串,但是它可以是一个对象,也可以为 null
。 由于已经在上述的代码片段中创建了 promise,因此它已经开始执行。 这对了解下面的消费 promise 章节很重要。
一个更常见的示例是一种被称为 Promisifying 的技术。 这项技术能够使用经典的 JavaScript 函数来接受回调并使其返回 promise:
const fs = require('fs')
const getFile = (fileName) => {
return new Promise((resolve, reject) => {
fs.readFile(fileName, (err, data) => {
if (err) {
reject(err) // 调用 `reject` 会导致 promise 失败,无论是否传入错误作为参数,
return // 且不再进行下去。
}
resolve(data)
})
})
}
getFile('/etc/passwd')
.then(data => console.log(data))
.catch(err => console.error(err))
在最新版本的 Node.js 中,无需为大多数 API 进行手动地转换。如果需要 promisifying 的函数具有正确的签名,则 util 模块中有一个 promisifying 函数可以完成此操作。
消费 promise
在上一个章节中,介绍了如何创建 promise。
现在,看看如何消费或使用 promise。
const isItDoneYet = new Promise(/* ... 如上所述 ... */)
//...
const checkIfItsDone = () => {
isItDoneYet
.then(ok => {
console.log(ok)
})
.catch(err => {
console.error(err)
})
}
运行 checkIfItsDone()
会指定当 isItDoneYet
promise 被解决(在 then
调用中)或被拒绝(在 catch
调用中)时执行的函数。
链式 promise
Promise 可以返回到另一个 promise,从而创建一个 promise 链。
链式 promise 的一个很好的示例是 Fetch API,可以用于获取资源,且当资源被获取时将 promise 链式排队进行执行。
Fetch API 是基于 promise 的机制,调用 fetch()
相当于使用 new Promise()
来定义 promsie。
链式 promise 的示例
const status = response => {
if (response.status >= 200 && response.status < 300) {
return Promise.resolve(response)
}
return Promise.reject(new Error(response.statusText))
}
const json = response => response.json()
fetch('/todos.json')
.then(status) // 注意,`status` 函数实际上在这里被调用,并且同样返回 promise,
.then(json) // 这里唯一的区别是的 `json` 函数会返回解决时传入 `data` 的 promise,
.then(data => { // 这是 `data` 会在此处作为匿名函数的第一个参数的原因。
console.log('请求成功获得 JSON 响应', data)
})
.catch(error => {
console.log('请求失败', error)
})
在此示例中,调用 fetch()
从域根目录中的 todos.json
文件中获取 TODO 项目的列表,并创建一个 promise 链。
运行 fetch()
会返回一个响应,该响应具有许多属性,在属性中引用了:
status
,表示 HTTP 状态码的数值。statusText
,状态消息,如果请求成功,则为OK
。
response
还有一个 json()
方法,该方法会返回一个 promise,该 promise 解决时会传入已处理并转换为 JSON 的响应体的内容。
因此,考虑到这些前提,发生的过程是:链中的第一个 promise 是我们定义的函数,即 status()
,它会检查响应的状态,如果不是成功响应(介于 200 和 299 之间),则它会拒绝 promise。
此操作(如果失败)会导致 promise 链跳过列出的所有被链的 promise,且会直接跳到底部的 catch()
语句(记录请求失败
的文本和错误消息)。
如果成功,则会调用定义的 json()
函数。 由于上一个 promise 成功后返回了 response
对象,因此将其作为第二个 promise 的输入。
在此示例中,返回处理后的 JSON 数据,因此第三个 promise 直接接收 JSON:
.then((data) => {
console.log('请求成功获得 JSON 响应', data)
})
只需将其记录到控制台即可。
处理错误
在上一章节的示例中,有个 catch
被附加到了 promise 链上。
当 promise 链中的任何内容失败并引发错误或拒绝 promise 时,则控制权会转到链中最近的 catch()
语句。
new Promise((resolve, reject) => {
throw new Error('错误')
}).catch(err => {
console.error(err)
})
// 或
new Promise((resolve, reject) => {
reject('错误')
}).catch(err => {
console.error(err)
})
级联错误
如果在 catch()
内部引发错误,则可以附加第二个 catch()
来处理,依此类推。
new Promise((resolve, reject) => {
throw new Error('错误')
})
.catch(err => {
throw new Error('错误')
})
.catch(err => {
console.error(err)
})
编排 promise
Promise.all()
如果需要同步不同的 promise,则 Promise.all()
可以帮助定义 promise 列表,并在所有 promise 都被解决后执行一些操作。
示例:
const f1 = fetch('/something.json')
const f2 = fetch('/something2.json')
Promise.all([f1, f2])
.then(res => {
console.log('结果的数组', res)
})
.catch(err => {
console.error(err)
})
ES2015 解构赋值语法也可以执行:
Promise.all([f1, f2]).then(([res1, res2]) => {
console.log('结果', res1, res2)
})
当然,不限于使用 fetch
,任何 promise 都可以以这种方式使用。
Promise.race()
当传给其的首个 promise 被解决时,则 Promise.race()
开始运行,并且只运行一次附加的回调(传入第一个被解决的 promise 的结果)。
示例:
const first = new Promise((resolve, reject) => {
setTimeout(resolve, 500, '第一个')
})
const second = new Promise((resolve, reject) => {
setTimeout(resolve, 100, '第二个')
})
Promise.race([first, second]).then(result => {
console.log(result) // 第二个
})
常见的错误
Uncaught TypeError: undefined is not a promise
如果在控制台中收到 Uncaught TypeError: undefined is not a promise
错误,则请确保使用 new Promise()
而不是 Promise()
。
UnhandledPromiseRejectionWarning
这意味着调用的 promise 被拒绝,但是没有用于处理错误的 catch
。 在 then
之后添加 catch
则可以正确地处理。
10. 具有 Async 和 Await 的现代异步 JavaScript
介绍
JavaScript 在很短的时间内从回调发展到了 promise(ES2015),且自 ES2017 以来,异步的 JavaScript 使用 async/await 语法甚至更加简单。
异步函数是 promise 和生成器的组合,基本上,它们是 promise 的更高级别的抽象。 而 async/await 建立在 promise 之上。
为什么引入 async/await
它们减少了 promises 的样板,且减少了 promise 链的“不破坏链条”的限制。
当 ES2015 中引入 Promise 时,它们旨在解决异步代码的问题,并且确实做到了,但是在 ES2015 和 ES2017 断开的两年中,很明显,promise 不可能成为最终的解决方案。
Promise 被引入了用于解决著名的回调地狱问题,但是它们自身引入了复杂性以及语法复杂性。
它们是很好的原语,可以向开发人员公开更好的语法,因此,当时机合适时,我们得到了异步函数。
它们使代码看起来像是同步的,但它是异步的并且在后台无阻塞。
工作原理
异步函数会返回 promise,例如以下示例:
const doSomethingAsync = () => {
return new Promise(resolve => {
setTimeout(() => resolve('做些事情'), 3000)
})
}
当要调用此函数时,则在前面加上 await
,然后调用的代码就会停止直到 promise 被解决或被拒绝。 注意:客户端函数必须被定义为 async
。 这是一个示例:
const doSomething = async () => {
console.log(await doSomethingAsync())
}
一个简单的示例
这是一个 async/await 的简单示例,用于异步地运行函数:
const doSomethingAsync = () => {
return new Promise(resolve => {
setTimeout(() => resolve('做些事情'), 3000)
})
}
const doSomething = async () => {
console.log(await doSomethingAsync())
}
console.log('之前')
doSomething()
console.log('之后')
上面的代码会打印以下的内容到浏览器的控制台:
之前
之后
做些事情 // 3 秒之后
Promise 所有事情
在任何函数之前加上 async
关键字意味着该函数会返回 promise。
即使没有显式地这样做,它也会在内部使它返回 promise。
这就是为什么此代码有效的原因:
const aFunction = async () => {
return '测试'
}
aFunction().then(alert) // 这会 alert '测试'
这与以下代码一样:
const aFunction = () => {
return Promise.resolve('测试')
}
aFunction().then(alert) // 这会 alert '测试'
代码更容易阅读
如在上面的示例中所见,代码看起来非常简单(相对于使用普通的 promise、链式和回调函数的代码)。
这是一个非常简单的示例,主要的好处要当代码更复杂得多时才会看到。
例如,这是使用 promise 获取并解析 JSON 资源的方法:
const getFirstUserData = () => {
return fetch('/users.json') // 获取用户列表
.then(response => response.json()) // 解析 JSON
.then(users => users[0]) // 选择第一个用户
.then(user => fetch(`/users/${user.name}`)) // 获取用户数据
.then(userResponse => userResponse.json()) // 解析 JSON
}
getFirstUserData()
这是使用 await/async 提供的相同功能:
const getFirstUserData = async () => {
const response = await fetch('/users.json') // 获取用户列表
const users = await response.json() // 解析 JSON
const user = users[0] // 选择第一个用户
const userResponse = await fetch(`/users/${user.name}`) // 获取用户数据
const userData = await userResponse.json() // 解析 JSON
return userData
}
getFirstUserData()
多个异步函数串联
异步函数可以很容易地链接起来,并且语法比普通的 promise 更具可读性:
const promiseToDoSomething = () => {
return new Promise(resolve => {
setTimeout(() => resolve('做些事情'), 10000)
})
}
const watchOverSomeoneDoingSomething = async () => {
const something = await promiseToDoSomething()
return something + ' 查看'
}
const watchOverSomeoneWatchingSomeoneDoingSomething = async () => {
const something = await watchOverSomeoneDoingSomething()
return something + ' 再次查看'
}
watchOverSomeoneWatchingSomeoneDoingSomething().then(res => {
console.log(res)
})
这会打印:
做些事情 查看 再次查看
更容易调试
调试 promise 很难,因为调试器不会跳过异步的代码。
Async/await 使这非常容易,因为对于编译器而言,它就像同步代码一样。