JavaScript是一种单线程的编程语言,需要通过异步的方式才能获得较高的性能。然而异步编程对于刚开始进行学习的时候往往会使大家产生困扰。

何为同步/异步
举个例子:如果现在你需要完成两件事情,烧水和吃饭。那么你会如何进行时间分配呢?

方法一:首先烧水,然后等待水烧开后再吃饭。

方法二:给烧水壶接通电源,无需等待水烧开,直接去吃饭。水烧开后水壶的蜂鸣器会发出声音,可以提醒你进行下一步操作。

上述方法一就属于同步编程,前一条程序没有执行完,后续程序就必须等待前方执行完成后才能继续执行。如果某一条程序是一个非常耗时的操作(比如读取硬盘上的文件、发起网络请求等),这时程序就会发生阻塞,表现在单线程的编程语言上就是性能急剧下降。

方法二属于异步编程,遇到耗时高的IO操作时runtime并不会等待此操作,而是直接返回结果并继续向下执行,程序并不会阻塞。但是IO操作的结果如何获取呢?在执行IO操作的时候向内传入了一个回调函数,IO操作完成后会调起回调函数并传入数据,回调函数会对数据进行后续操作(例如存储至数据库、通过网络传送数据等)。

回调函数(Callback)
简单说,就是把一个函数作为参数传进另一个函数。

/**
* 参数运算器
* @param {Number} a 参数a
* @param {Number} b 参数b
* @param {Function} callback 回调函数
* @returns 详细描述
*/
function deal(a, b, callback){
const result = callback(a, b)
const description = `参数a:${a}和参数b:${b},经过回调函数处理的结果是:${result}`
return description
}

// 加法处理函数
const add = function(a, b){
return a b
}

const value = deal(3, 5, add)

console.log(value)
console:

> 参数a:3和参数b:5,经过回调函数处理的结果是:8
代码解释:

有这样一个需求:写一个可以对两个数据进行处理的函数(不限于加减乘除等),但是处理的方式不要写死,要求根据需要动态的向函数内输入合理的处理方式。

由于处理方式不能写死,我们不能在函数内部编写数据处理的逻辑。正确的做法是把这个处理逻辑作为参数直接传进函数内部。值得一提的是,在Javascript中,函数的形参是不限制数据类型的,因此函数也可以作为参数被传递进去。所以说,可以直接把这个处理逻辑写成函数,传递进去,在合适的地方调用。这个被传进去的函数就称为回调函数。

这样说可能还是有一些抽象,举个最直观的例子:

某人需要装修(function)一个房子,各种装修材料(形参)都有了,但是自己不会装修(没有处理形参的逻辑),于是他请了一位会使用材料装修的工人(回调function:含有处理形参的逻辑),在合适的时候把装修材料交给工人(给回调函数传参),工人进行装修(回调函数执行)。当然,屋子的主人可以根据喜好请不同的工人来进行装修(回调函数可以根据需求传入)。

总结:上文使用了较多篇幅来帮助理解回调函数,如果觉得难以理解,无非就是这些原因:

不知道一个函数可以作为另一个函数的参数传入。

平时大多数时候是向函数形参中传入的是字符串、数值一类的数据,向其中传入函数不习惯、不适应。

回调函数与异步
现在你可能已经了解了回调函数的概念,那么回调函数与异步操作有什么关系呢?

这里再假设一个场景:

小明今天的事情很多,时间很紧迫(程序要保持高性能运行)。他来到店铺要买一杯奶茶,却被告之要等待两个小时(耗时IO操作)。小明时间来不及,于是把他的朋友叫来在奶茶店等待(传入回调函数),自己则离开继续做其他事情(非阻塞)。如果奶茶做好了就交给朋友(给回调函数传参),朋友拿到奶茶后再进行后续操作,例如找时间交给小明,或是把奶茶放小明家里...(回调函数内部的处理逻辑)。在这里,小明朋友的作用与回调函数是一样的。

下面通过代码来进一步解释:

先来一个错误的示范:

let a
setTimeout(() => {
a = 100
}, 1000)
console.log(a) // undefined
首先定义了一个变量a,再创建一个延时函数(setTimeout是异步的),其中的箭头函数会在指定时间后执行(当前设置为1秒)。箭头函数中,为变量a赋值为100。

程序运行后,下方console中输出却是undefined,并不是预期中的100。

错误的原因,setTimeout是异步函数,会立即返回,里面的逻辑仍接着执行。所以runtime不会等待其延时操作执行完成后再继续执行下方代码,因此当输出a的时候,a还未被赋值。

就像上面的例子那样,做奶茶要耗费时间的,小明下完单立即就喝奶茶,肯定是喝不到的。

修改为正确代码:

let a
setTimeout(() => {
a = 100
console.log(a) // 100
}, 1000)
只需要把console.log(a)移入箭头函数中,经过1秒后,控制台输出了a的值。

这里console.log()充当了回调函数的角色,尽管只有在控制台输出数据这样一个简单的功能。

一个实用的例子:

虽然使用setTimeout作为异步操作的演示能说明一定问题,但实际上这种用法是比较少见的,而且使用凭空捏造的数据进行演示容易把人搞晕。

下面通过文件读取的例子介绍异步与回调,以下代码需要运行在Node.js环境中:

const fs = require('fs')
fs.readFile('./d.txt', (err, data) => {
if (err) console.log(err)
console.log(data.toString());
})
第一行引入文件操作模块fs,第二行fs的readFile函数(异步)接收两个参数:文件路径和回调函数。readFile函数会根据传入的路径读取文件,读取结束后调用传入的回调函数,并向其中传入两个参数:(错误信息, 读取到的二进制数据)。回调函数内部的逻辑可以根据传进来的参数进行相应操作,比如说错误处理或数据加工。另外多说一句,Node.js中的回调函数参数一般都是错误优先的,也就是err在前,data在后。

新手易错总结:

直接使用变量接收异步函数返回的数据。错误原因:异步函数会直接返回,但返回的并不是IO操作获取的数据。用变量根本接不到异步函数返回的数据,且IO操作也不会瞬间完成。

试图在异步函数内为外部变量赋值。错误原因:要强调的依然是:异步函数启动IO操作后会直接返回,下面的程序接着执行不会等待,远快于IO操作的速度,没等IO操作拿到结果后给外部变量赋值,下面的程序已经跑完了,用到那个外部变量的时候还没赋值呢,肯定就是undefined。即便你想方设法让下面的程序变慢,等待IO操作,这依然不稳妥,而且也背离了异步操作的意义,对吗?

为什么提倡使用异步操作?

异步操作可以更高效利用资源,对于网络应用带来的优势更大,后端更容易实现高并发,前端加载资源使用异步,可缩短时间,提升页面加载速度。

同时,由于JavaScript单线程的特性,使用Node.js作为后端如果不使用异步处理IO,带来的性能大幅下降是不可接受的。

总结:

想要获取异步操作的数据,一定是通过回调函数的方式!

(当然,后面会介绍其他的方法)

Promise
假设有这样一个场景:程序发送一个异步网络请求,拿到返回的数据后则需要根据这个数据再发一次请求,拿到第二次请求返回的数据后则需要根据这个数据再发第三次请求...这样逐级套娃,会造成回调函数不断嵌套,代码走势不断向右,非常不利于查看和维护。我们称这种代码为回调地狱。

网图侵删
在这样的背景下,Promise被创建并进入了人们的视野。

Promise解决了最关键的问题:规范了回调函数的写法,使其变成可读性更强的链式调用写法。

Promise基本概念:

promise实例对象有三种状态:pending(初始状态), fulfilled(接受状态), rejected(拒绝状态)

promise对象的状态可以是不确定,但一旦发生变化,此状态便不可逆。

可以在promise内部调用resolve()、reject()方法改变promise的状态

Promise基本用法:

Promise是一个构造函数,需要使用new关键字进行创建实例。

const promiseDemo = new Promise((resolve, reject) => {
let success = true
if (success === true) resolve('success')
else reject('err')
})
promiseDemo.then((result) => {
console.log(result)
}).catch((err) => {
console.log(err);
})
代码解释:

首先使用new Promise创建实例对象并使用变量promiseDemo进行接收。创建实例的时候需要向构造函数中传入一个函数,传入的函数中存在两个名为resolve和reject的形参,类型为函数,在作用域内可被调用,调用后可改变promise对象的状态。

当一个promise对象状态变为fulfilled时(接受),会执行then()方法中的回调函数,并向回调函数中传递内部输出的数据。

当一个promise对象状态变为rejected时(拒绝),会被catch()方法捕获到,并向回调函数中传递内部输出的错误信息。

Promise链式调用:

promise对象的then()方法会返回一个新的promise对象,因此可以在then()方法后面直接再使用then()方法。

关于then()方法可返回的promise类型,可参考MDN上的描述:

返回了一个值,那么 then 返回的 Promise 将会成为接受状态,并且将返回的值作为接受状态的回调函数的参数值。

没有返回任何值,那么 then 返回的 Promise 将会成为接受状态,并且该接受状态的回调函数的参数值为 undefined。

抛出一个错误,那么 then 返回的 Promise 将会成为拒绝状态,并且将抛出的错误作为拒绝状态的回调函数的参数值。

返回一个已经是接受状态的 Promise,那么 then 返回的 Promise 也会成为接受状态,并且将那个 Promise 的接受状态的回调函数的参数值作为该被返回的Promise的接受状态回调函数的参数值。

返回一个已经是拒绝状态的 Promise,那么 then 返回的 Promise 也会成为拒绝状态,并且将那个 Promise 的拒绝状态的回调函数的参数值作为该被返回的Promise的拒绝状态回调函数的参数值。

返回一个未定状态(pending)的 Promise,那么 then 返回 Promise 的状态也是未定的,并且它的终态与那个 Promise 的终态相同;同时,它变为终态时调用的回调函数参数与那个 Promise 变为终态时的回调函数的参数是相同的。

下面我将通过代码来演示上面的情况:

1. 返回一个值

const promiseDemo = new Promise((resolve, reject) => {
resolve('success')
})
.then((result) => {
console.log(result);
return '666'
})

setTimeout(()=>{
console.log(promiseDemo);
},0)
console:

success
Promise { '666' }
then方法中返回字符串'666',实际上会被封装成一个状态为fulfilled、回调参数值为'666'的promise对象。

题外话:前文提到过异步操作不可以在外部获取值,为什么这里在consol.log(Promise)外面包一层延时为0的setTimeout就能取到值了呢?这里涉及到了Node事件循环中微任务与宏任务的知识。简单分析一下:Node中宏任务会优先执行,执行完发现有微任务,会把微任务队列中的任务全部执行完。Promise构造函数中传入的函数属于宏任务,执行完resolve()方法后将then()方法中的回调函数推入微任务队列。程序接着向下运行,遇到setTimeout(),由于延时为0,直接把传入的函数推入微任务队列,至此宏任务执行完毕。发现微任务队列中有两个任务,按照队列顺序先执行then()方法中的回调,回调返回'666',被包装成相应的Promise对象,此时变量promiseDemo为此promise对象。接着执行第二个微任务,在控制台输出promiseDemo变量指向的对象(隐式调用toString()),至此程序运行结束。

上面的代码使用这种写法是为了更直观的观察promise对象的状态。

 

网图侵删
2. 没有返回任何值

const promiseDemo = new Promise((resolve, reject) => {
resolve('success')
})
.then((result) => {
console.log(result);
})

setTimeout(()=>{
console.log(promiseDemo);
},0)
console:

success
Promise { undefined }
3. 抛出错误

const promiseDemo = new Promise((resolve, reject) => {
resolve('success')
})
.then((result) => {
console.log(result);
throw 'error'
})

setTimeout(()=>{
console.log(promiseDemo);
},0)
console:

success
node:internal/process/promises:246
triggerUncaughtException(err, true /* fromPromise */);
^

[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "error".] {
code: 'ERR_UNHANDLED_REJECTION'
}
注意:当promise状态为rejected时,需要使用catch(方法)捕获异常,否则程序会中途退出。

 

4. 返回一个已经是接受状态的 Promise

const promiseDemo = new Promise((resolve, reject) => {
resolve('success')
})
.then((result) => {
console.log(result);
return new Promise((resolve, reject) => {
resolve('hello')
})
})

setTimeout(()=>{
console.log(promiseDemo);
},0)
console:

success
Promise { 'hello' }

5. 返回一个已经是拒绝状态的 Promise

const promiseDemo = new Promise((resolve, reject) => {
resolve('success')
})
.then((result) => {
console.log(result);
return new Promise((resolve, reject) => {
reject('failed')
})
})

setTimeout(()=>{
console.log(promiseDemo);
},0)
console:

success
node:internal/process/promises:246
triggerUncaughtException(err, true /* fromPromise */);
^

[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "failed".] {
code: 'ERR_UNHANDLED_REJECTION'
}

5. 返回一个未定状态(pending)的 Promise

const promiseDemo = new Promise((resolve, reject) => {
resolve('success')
})
.then((result) => {
console.log(result);
return new Promise((resolve, reject) => {
// resolve('hello')
})
})

setTimeout(()=>{
console.log(promiseDemo);
},0)
console:

success
Promise { }
Promise.all和Promise.race

相同点:同时执行多个Promise

不同点:传入数组中的Promise状态全部变为resolved后,all方法生成的Promise才会变成resolved;而race方法返回的Promise的状态则是由率先执行完成的Promise确定的。

Promise.all()代码演示:

const a = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('a执行完毕');
resolve()
}, 1000)
})
const b = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('b执行完毕');
resolve()
}, 800)
})
const c = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('c执行完毕');
resolve()
}, 1200)
})
const promiseArray = [a, b, c] // 创建存储promise对象的数组
Promise.all(promiseArray).then(()=>{
console.log('Promise.all执行完毕');
})
console:

b执行完毕
a执行完毕
c执行完毕
Promise.all执行完毕
当数组中的所有promise对象状态都变为resolved时,Promise.all()返回的promise对象状态才会变为resolved。

数组中的promise一旦有一个状态变为rejected,那么Promise.all()返回的对象状态就会变为rejected。(代码演示略)

Promise.race()代码演示:

const a = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('a执行完毕');
resolve()
}, 1000)
})
const b = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('b执行完毕');
resolve()
}, 800)
})
const c = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('c执行完毕');
resolve()
}, 1200)
})
const promiseArray = [a, b, c] // 创建存储promise对象的数组
Promise.race(promiseArray).then(()=>{
console.log('Promise.race执行完毕');
})
console:

b执行完毕
Promise.race执行完毕
a执行完毕
c执行完毕
将Promise.all()替换为Promise.race()后发现数组内最先执行完成的promise就会触发Promise.all()返回的promise对象状态变为resolved。

如果状态最先发生改变,但是状态变为了rejected,那么Promise.all()返回的promise对象状态变为rejected。(代码演示略)

Generator函数——异步管理
使用generator来进行异步管理只是一种过渡方案,了解即可。

一个普通的函数,一旦开始执行,就脱离了调用者的控制,中间的流程无法干预,直到运行结束,控制权才能回到调用者处。

而generator函数最大的特点就是可以被暂停、恢复执行(有点像debug时用的单步调试方法)。另外一个特点就是可以将外部数据传进generator函数内部,供里面的逻辑使用。

generator的这两个个特点,可以被利用来管理异步操作。总体的思想是,遇到异步操作,将generator函数暂停,等待异步操作拿到结果后将数据传入,再恢复generator函数的执行。用同步化的方式来管理异步。

简单认识generator函数:

function* gen(){
yield 'hello world'
yield 'hello generator'
return 'end'
}

const generator = gen()

console.log(generator.next())
console.log(generator.next())
console.log(generator.next())
console:

{ value: 'hello world', done: false }
{ value: 'hello generator', done: false }
{ value: 'end', done: true }
generator函数与普通函数定义有一些相似之处,都是使用function进行定义,具有返回值。

不同之处在于,generator函数在定义时function后要加*号,同时多了yield关键字,generator函数执行时遇到yield会暂停并执行其后的表达式。调用next()方法时会向后执行,同时返回当前运算结果,格式为对象{ value: xxx, done: true/false }。value表示当前运算结果,done表示generator是否运行完成。

next()方法的参数

概念一:yield关键字本身不会产生返回值

function* gen(){
const x = yield 'hello world'
return x
}
const generator = gen()
console.log(generator.next())
console.log(generator.next())
console:

{ value: 'hello world', done: false }
{ value: undefined, done: true }
第二次调用next(),发现x并未收到yield返回的值

 

概念二:next()方法中传入的参数作为上一个yield运算的结果

function* gen(){
const x = yield 'hello world'
return x
}
const generator = gen()
console.log(generator.next())
console.log(generator.next(100))
console:

{ value: 'hello world', done: false }
{ value: 100, done: true }
这次,手动在第二次调用next()时向内传入了100这个值,就相当于为上一次的yield运算人为的指定了一个返回值,被变量x接收到了。然后再执行下一句代码,x就被输出了。

Generator自动执行:

使用co模块,这里不做过多介绍

async/await——异步终极解决方案
MDN上关于async函数的描述还是比较清楚的:

async函数是使用async关键字声明的函数。 async函数是AsyncFunction构造函数的实例, 并且其中允许使用await关键字。async和await关键字让我们可以用一种更简洁的方式写出基于Promise的异步行为,而无需刻意地链式调用promise。

使用async声明一个异步函数

// 三种声明异步函数的形式
async function go() {
// 函数体
}

const go = async function() {
// 函数体
}

const go = async () => {
// 函数体
}
async函数返回值

async函数总会返回一个Promise对象

如果你在关键字return后返回的不是一个Promise对象,那么将默认调用Promise.resolve()方法将其转换为一个Promise对象

async function go() {
return 'hello world'
}
console.log(go())
console:

Promise { 'hello world' }
如果你在关键字return后返回的是一个Promise对象,那么将原样返回这个Promise对象。

async function go() {
return new Promise((resolve, reject) => {
// 内部逻辑
})
}
console.log(go())
console:

Promise { }
await关键字

await正如其字面意思:等待。

当函数体执行过程中遇到await关键字,就会暂停执行,等待await后面的异步操作完成后再继续执行。

await可以“启动”一个promise并等待其执行完成,await返回值的结果由后面的promise对象的状态确定。

 

如果后面的promise状态变为resolved,那么await的结果就是传入resolve()的值。

const promiseA = new Promise((resolve, reject) => {
resolve('hello')
})

async function test() {
let a = await promiseA
return a
}
test().then((v) => {
console.log(v)
})
如果后面的promise状态变为rejected,那么await也会抛出传入reject()的值。

const promiseA = new Promise((resolve, reject) => {
reject('error')
})

async function test() {
try {
let a = await promiseA
return a
} catch(e) {
console.log(e);
}
}
test().then((v) => {
console.log(v)
})
值得注意的时,async/await组合没有像Promise那样有用于捕获错误的catch()方法,因此,在进行错误处理的时候要使用try/catch语句块。

async中“并发”执行异步操作

使用await关键字来执行promise是一种方便的方法,但如果都多个promise需要执行,使用await来执行就会造成类似于同步的执行方式,也就是说当前promise会等待上一个promise执行完毕后再执行,有些场景下没必要这样执行。

async function test() {
await promiseA
await promiseB
await promiseC
await promiseD
}
promiseA执行完毕后才会执行promiseB,以此类推。

“并发”执行promise

使用Promise.all()的方式可以“同时”执行多个promise,关于Promise.all()用法在上文有述。

async function test() {
const promiseArray = [promiseA, promiseB, promiseC, promiseD]
Promise.all(promiseArray)
}
async/await总结

1. async函数返回值总是一个Promise对象

2. async函数遇await暂停执行,等待异步操作完成

3. await关键字必须位于async函数内部

4. await后面要接promise对象

5. async/await错误处理使用try/catch语句块

 

结束语:

至此,关于JavaScript异步编程的主要思想和实现方式已经讲述完了,文中若有描述不恰当之处,请大家在评论区指出,我会及时纠正。有其他问题也可在评论区多多交流,共同进步。

参考文献:

[1] 李锴. 新时期的Node.js入门[M]. 北京: 清华大学出版社, 2018

[2] 阮一峰. ES6标准入门[M]. 北京: 电子工业出版社, 2015

引用资源:

Mozilla Developer Network:developer.mozilla.org 作者:咸鱼在奋斗 https://www.bilibili.com/read/cv15779890/ 出处:bilibili

一文看懂JS异步编程,回调、Promise、Generator、async/await用法详解
标签: