《新时期的Node.js入门》学习日记-书写异步代码

0xGeekCat · 2020-8-31 · 次阅读


👇封装readFile的read方法

var fs = require('fs')

function read(path) {
    fs.readFile(path, function (err, data) {
        console.log(data.toString())
    })
}

read('foo.txt')
read('bar.txt')
read('baz.txt')

screenshot

调用read方法读取多个文件每次输出结果都不同

但有时需要依赖上一个异步操作的结果,假设foo.txt是一个配置文件,里面有用来解密的key,需要拿到key才能解密bar.txt里面的文本,那么这个时候就不能这样使用read方法

暂且不考虑readFileSync的情况下一般会使用嵌套的回调函数

var fs = require('fs')

function read(path1, path2, path3) {
    fs.readFile(path1, function (err, data) {
        console.log(data.toString())
        fs.readFile(path2, function (err, data) {
            console.log(data.toString())
            fs.readFile(path3, function (err, data) {
                console.log(data.toString())
            })
        })
    })
}

read('foo.txt', 'bar.txt', 'baz.txt')

screenshot 1

将下一个异步操作放到上一个异步操作的回调方法中,这样虽然能保证执行是串行的,但当代码嵌套的层数增加,代码的层次结构就会变得不清晰并且难以维护

回调地狱callback hell就被用来描述这种写法。它本身没有任何问题,只是因为不利于开发者阅读和维护才会遭到摒弃

异步操作的返回值

❓假设一个方法封装了一个异步操作,那如何能拿到返回值

美好的愿望是直接拿到异步方法的返回值

var fs = require('fs')

function read(path) {
    fs.readFile(path, function (err, data) {
        return data
    })
}

var data = read('foo.txt')
console.log(data) // undefined

然而直接调用read方法不会得到任何返回值,data打印出来也是undefined

🔔原因是read方法会先于内部的回调函数返回,即回调函数内部的return关键字不会将值返回到外部

组织回调方法

回调与CPS

当开发者刚开始接触回调时,通常都会写成👇的样式

function foo(args, function(err, data) {
    // todo
}) 

如果对于多个功能相同的异步操作,它们的回调函数都是相同的,这样的写法会产生很多功能重复的代码

另一种做法是将回调函数作为参数传递,这通常被称为延续传递方式Continuation Passing StyleCPS的本质仍然是一个高阶函数

CPS风格的回调

var fs = require('fs')

var callback = function (err, data) {
    if (err) {
        console.log(err)
        return
    }
    console.log(data.toString())
}

fs.readFile('foo.txt', callback)

如果需要调用readFile方法多次,并且它们的回调方法都相同的情况下,CPS可以省去一些重复代码

CPS可以在一定程度上解决回调嵌套的问题

👇使用CPS来处理多个回调

var fs = require('fs')

function callback1 (err, data) {
    if (err) {
        console.log(err)
    } else {
        console.log(data)
        fs.readFile('bar.txt', callback2)
    }
}

function callback2 (err, data) {
    if (err) {
        console.log(err)
    } else {
        console.log(data)
        fs.readFile('baz.txt', callback3)
    }
}

function callback3 (err, data) {
    if (err) {
        console.log(err)
    } else {
        console.log(data)
    }
}

fs.readFile('foo.txt', callback1)

这是之前嵌套回调的另一种写法,其本质上仍然是在回调中调用下一个异步方法,只是避免了多个回调函数在形式上嵌套在一起

虽然比嵌套调用看起来美观了一些,但仍然显得冗长,而且业务逻辑分散在不同的callback中,初次接触代码的开发者也不容易理清它们之间的关系

使用async模块简化回调

async是一个著名的第三方模块,它的初衷也是为了解决多个异步调用嵌套的问题

async.series

使用series方法处理多个回调

var fs = require('fs')
var async = require('async')

function read_foo(callback) {
    fs.readFile('foo.txt', 'utf-8', callback)
}

function read_bar(callback) {
    fs.readFile('bar.txt', 'utf-8', callback)
}

function read_baz(callback) {
    fs.readFile('baz.txt', 'utf-8', callback)
}

async.series([read_foo, read_bar, read_baz], function (err, data) {
    console.log(data) // [ 'foo', 'bar', 'baz' ]
})

series方法接收一个数组和一个回调函数,回调函数的第二个参数是一个数组,包含了全部异步操作的返回结果,结果集中的顺序和series参数数组的顺序是对应的

🔔该方法实际上是嵌套回调的语法糖,所有的异步调用都是顺序执行的,即执行完一个操作再进行下一个操作

async.parallel

调用方式和参数都与series相同,也会顺序返回所有的调用结果,区别在于所有的方法是并行执行,执行时间由耗时最长的调用决定

parallel方法在数组中的某个异步调用结束之后并没有立刻返回,而是将结果暂存起来,等所有的异步操作完成之后,再根据调用顺序将结果组装成顺序的结果集返回

async.waterfall

同样是顺序执行异步操作,和前两个方法的区别是每一个异步操作都会把结果传递给下一个调用

var fs = require('fs')
var async = require('async')

function read_foo(callback) {
    fs.readFile('foo.txt', 'utf-8', callback)
}

function read_bar(value, callback) {
    console.log('上一个操作传入的值', value)
    fs.readFile('bar.txt', 'utf-8', callback)
}

function read_baz(value, callback) {
    console.log('上一个操作传入的值', value)
    fs.readFile('baz.txt', 'utf-8', callback)
}

async.waterfall([read_foo, read_bar, read_baz], function (err, data) {
    console.log(data)
})

screenshot 2

async.map

map和上面的几个方法稍有不同,map接收一个数组作为参数,数组的元素不是方法名而是方法的参数,数组里的值会依次传递给定义的异步方法;map的第二个参数就是异步的方法,不需要再做额外封装

var fs = require('fs')
var async = require('async')

var arr = ['foo.txt', 'bar.txt', 'baz.txt']

async.map(arr, fs.readFile, function (err, results) {
    console.log(results.toString()) // foo,bar,baz
})

然而map方法有一个缺点,只能接受三个参数

  • 数组
  • 对应的异步方法
  • 回调函数

readFile为例,会发现没有多余的参数来定义编码格式,这种情况下还是需要对readFile做一层封装

var fs = require('fs')
var async = require('async')

function myReadFile(path, callback) {
    fs.readFile(path, 'utf-8', callback)
}

var arr = ['foo.txt', 'bar.txt', 'baz.txt']

async.map(arr, myReadFile, function (err, results) {
    console.log(results.toString())
})

async模块一度是管理异步调用的首选,然而它并不适用所有的场合

async通常使用一个数组来包含所有的异步方法或者调用的参数,然而有时无法在调用前就决定哪些异步方法会被调用

使用Promise

在Node中率先得到广泛应用的是async这样的第三方模块,async模块中没有应用什么新概念,只是形式上简化的语法糖。社区自然不会满足止步于此,开发者们把目光投向了别处,希望有一种新的方式来解决问题,在这种环境下Promise进入了视野

Promise的历史

Promise概念最早可以追溯到1976年,future、promise、delay、deferred这几个词经常放在一块讨论,它们都用来指代一个开始时状态未知的对象

jQuery在1.5及之后的版本中增加了deferred方法,该方法是Promise的一种实现,并且随着jQuery本身流行起来

👇普通的Ajax操作

$.ajax({
   url: "test.html",
   success: function () {
       alert('success')
   },
   error: function () {
       alert('fail')
   }
})

successfail方法都是作为参数的一部分传递给$.ajax()方法,它们是Ajax执行完成后的回调函数

👇使用deferred改写Ajax

$.ajax("test.html")
    .done(function (data) {
        alert('success')
    })
    .fail(function () {
        alert('fail')
    })

👆两段代码结构上最大的区别是使用deferred改写的Ajax方法,将successerror两个回调函数从$.ajax()方法中剥离,而且链式调用也表明$.ajax("test.html")异步方法产生了返回值,这是一个很大的进步,离最开始的理想更近一步

后面Promise概念得到推广并出了一些规范,以Promise/A+最为出名,社区也出现了一些支持Promise的第三方库,后来Promise/A+标准被社区接受,ES2015中的Promise就是按照它来实现的

Promise是什么

Promise表示一个异步操作的最终结果。这不太容易理解,可以将Promise理解为一个状态机,它存在下面三种不同的状态,并在某一时刻只能有一种状态

  • Pending:表示还在执行
  • Fulfilled或者Resolved:执行成功
  • Rejected:执行失败

一个Promise是对一个操作,通常是一个异步操作的封装,异步操作有等待完成、成功、失败三种可能结果,对应了Promise的三种状态

Promise的状态只能由Pending转换为Resolved或者由Pending转换为Rejected,一旦状态转换完成就无法再改变

假设用Promise封装一个异步操作,那么当它被创建的时候就处于Pending状态,当异步操作成功完成时,将状态转换为Fulfilled;如果执行中出现错误,将状态转换为Rejected

ES2015中的Promise

PromiseClass堪称ES2015的两个最重要的特性,在如何组织异步代码这个问题上,ES2015中的Generator或者ES2017的async方法,都是以Promise作为基础的

将异步方法封装成Promise

可以用Promise的构造函数来封装一个现有的异步操作

👇Promise的构造函数

var promise = new Promise(function (resolve, reject) {
    // some code

    if (/*异步操作成功*/) {
        resolve(value)
    } else {
        reject(error)
    }
})

使用Promise封装的readFile

var fs = require('fs')

function readFile_promise(path) {
    return new Promise(function (resolve, reject) {
        fs.readFile(path, 'utf-8', function (error, data) {
            if (data) {
                resolve(data)
            } else {
                reject(error)
            }
        })
    })
}

将一个异步方法封装成Promise其实很简单,只要在回调函数中针对不同的返回结果调用resolve或者reject方法即可

  • resolve函数会在异步操作成功完成时被调用,并将异步操作的返回值作为参数传递到外部
  • reject则是在异步操作出现异常时被调用,会将错误信息作为参数传递出去

刚刚接触Promise概念的开发者可能会对这两个方法感到困惑,简单地说,一个封装了异步操作的Promise对象实际上并没有做任何事情,它仅仅针对回调函数的不同结果定义了不同的状态

resolve方法和reject方法没有做多余的操作,仅仅是把异步的结果传递出去,对于异步结果的处理,是交给then方法来完成的

使用then方法获取结果

在封装好Promise对象后,就可以调用then方法来获取异步操作的值

process.then(function (value) {
    // success
}, function (error) {
    // failure
})

then方法接收两个匿名函数作为参数,它们代表onResolvedonRejected函数

valueerror参数代表回调的结果,以readFile为例,value就是执行成功时文本内容,error则是执行出错时的错误信息,两者中必有一个不为空

通常如果onRejected的回调方法被调用就表示异步过程中出现错误,是使用catch方法而不是回调函数来处理异常

promise
    .then(function (data) {
        // success
    })
    .catch(function (err) {
        // error
    })

then方法的返回值

then方法总是返回一个新的Promise对象,这也就表示对于一个Promise可以多次调用它的then方法,但由于默认返回的Promise是一个空的对象,除非做一些额外的操作,否则这一操作通常得不到有意义的值

var promise = readFile_promise('foo.txt')

promise.then(function (value) {
    console.log(value) // foo
}).then(function (value) { 👈 此时相当于{}.then() 
    console.log(value) // undefined
})

开发者也可以在回调函数定义一个新的Promise,然后使用return来返回

var promise = readFile_promise('foo.txt')

promise.then(function (value) {
    console.log(value) // foo
    return readFile_promise('bar.txt')
}).then(function (value) {
    console.log(value) // bar
})

👆第一个then方法中,再次调用readFile_promise,其返回的新的Promise覆盖了默认返回的Promise空对象,我们因此可以在下一个then方法中获取另一个异步操作的执行结果

Promise的执行

❓虽然是通过then方法来获取Promise的结果,但Promise是不是当then方法调用之后才会执行

var promise = new Promise((resolve, reject) => {
    console.log('begin')
    resolve()
})

setTimeout(() => {
    promise.then(() => {
        console.log('end')
    })
}, 1000)

实际运行就会发现,程序立刻打印出begin,然后等待1秒,随后再打印出end

Promise从被创建的那一刻起就开始执行,then方法只是提供了访问Promise状态的接口,与Promise的执行无关

Promise的常用API

Promise.resolve

Promise提供resolve方法用来将非Promise对象转化为Promise对象

在通常情况下,主动调用resolve方法的场景并不多,因为该方法能转换的通常只有thenable对象和一些原始类型的对象

thenable对象是指有then方法的对象,一个常见的例子就是jQuery中的deferred对象,也可以是自定义的对象

其被转换成Promise后,转换后的Promise会自动执行其then方法

var obj = {
    then: () => {
        console.log('I am a then method')
    }
}

Promise.resolve(obj) // I am a then method

如果转换的对象是一个常量或者不具备状态的语句,转换后的对象自动处于resolve状态,转换的对象作为resolved的结果原封不动地保留

var p = Promise.resolve('0xGeekCat')

p.then(function (result) {
    console.log(result) // 0xGeekCat
})

resolve并不能用来转换一个异步方法

screenshot 3

要转换异步方法,要么手动封装一个Promise,要么就使用一些现成的方法或者模块来操作

promise.reject

promise.reject同样返回一个Promise对象,不同之处在于这个Promise的状态为rejectreject方法的参数会作为错误信息传递给then方法

screenshot 4

看到控制台的warning信息,表明reject状态的Promise有可能等同于error而使进程退出

在使用Promise时最好加上catch来捕获这个异常

var promise = readFile_promise('foo.txt')

promise.then(function (value) {
    console.log(value)
}).catch(function (err) {
    console.log('error occurred', err)
})

promise.all

如果有多个Promise需要执行,可以使用promise.all方法统一声明,该方法将多个Promise对象包装成一个Promise

该方法接收一个数组作为参数,数据的元素如果不是Promise对象,则会先调用resolve方法转换

只有数组中的Promise的状态全部变成resolved之后,all方法生成Promise的状态才会变成resolved;如果中间有一个Promise状态为reject,那么转换后的Promise也会变成reject,并且将错误信息传给catch方法

var promises = ['foo.txt', 'bar.txt', 'baz.txt'].map(function (path) {
    return readFile_promise(path)
})

Promise.all(promises).then(function (results) {
    console.log(results) // [ 'foo', 'bar', 'baz' ]
}).catch(function (err) {
    console.log('error occurred', err)
})

使用promise.all方法封装的Promise,如果将结果集打印出来,会发现它们是按照顺序排列的

❓既然promise.all会按照顺序返回封装Promise的结果,那么是不是代表内部的Promise是顺序执行的

答案是否定的,前面已经提到一个Promise的执行是从被创建的那一刻开始的,也就是说当调用promise.all时,所有的Promise都已经开始执行了,**all方法只是等到全部的Promise完成后,对所有的执行结果做包装再返回**

promise.race

race方法接收Promise数组作为参数并返回一个新的Promise,数组中的Promise会同时开始执行,race返回的Promise的状态由数组中率先执行完毕的Promise的状态决定

function timeout(ms) {
    return new Promise((resolve, reject) => {
        setTimeout(resolve, ms, 'timeout first')
    })
}

let promise = Promise.race([timeout(1), readFile_promise('foo.txt')])

promise.then(function (value) {
    console.log(value)
})

👆两个Promise,如果readFile_promise先完成,then方法打印出的就是文件内容。由于timeout只设置了1ms,通常小于读取文件需要的时间,因此调用then方法总是会打印出timeout first

看起来race方法似乎没特别的用处,但在处理Web服务器中的超时逻辑时十分方便,例如为一个Promise,可能是一个数据库操作定义了100ms的执行时限,如果耗时超过这个时间就返回一个超时错误,在这种情况下就可以考虑使用race方法

promise.catch

Promise在执行中如果出了错误,可以使用throw关键字抛出错误,并且可以使用catch方法进行捕获;如果不设置任何回调函数捕捉错误,Promise内部抛出的错误就无法传递到外部

var promise = new Promise(function (resolve, reject) {
    throw new Error('get error')
})

promise.catch(function (err) {
    console.log(err)
})

除了使用throw主动抛出错误之外,也可以直接使用reject方法

var promise = new Promise(function (resolve, reject) {
    reject(Error('get error'))
})

promise.catch(function (err) {
    console.log(err)
})

screenshot 5

如果Promise的状态已经变成resolved,那么此时再抛出错误是无效的,因为这相当于改变一个状态确定的Promise的状态,👆提到过Promise一旦状态转换完成就无法再改变

var promise = new Promise(function (resolve, reject) {
    reject('0xGeekCat')
    throw new Error('get error')
})

promise.catch(function (err) {
    console.log(err) // 0xGeekCat
})

使用Promise组织异步代码

🔔使用promise的初衷是为了解决多个异步操作顺序执行的问题,因此可以使用then的链式调用来实现这一目标

使用Promise的链式调用改写三个文本文件顺序读取

readFile_promise('foo.txt').then(function (value) {
    console.log(value)
    return readFile_promise('bar.txt')
}).then(function (value) {
    console.log(value)
    return readFile_promise('baz.txt')
}).then(function (value) {
    console.log(value)
})

👆一堆链式调用让人头晕,这也是Promise的不足之处;为了再次简化,可以考虑利用CPS将then方法中的回调函数抽出来

var list = ['foo.txt', 'bar.txt', 'baz.txt']
var count = 0
readFile_promise('foo.txt').then(readCB).then(readCB).then(readCB)

function readCB(data) {
    console.log(data)

    if (++ count > 2)
        return
    return readFile_promise(list[count]);
}

第三方模块的Promise

在Node完全支持Promise之前,开发者们通常是使用一些第三方库提供的功能来使用Promise

bluebird是一个功能完善的Promise第三方库,项目开始于2013年,它提供了完整的Promise逻辑以及对非Promise方法转换为Promise的支持,而且在执行效率上高于原生的Promise

新建Promise

使用bluebird封装Ajax方法

var Promise = require('bluebird')

function ajaxGetAsync(url) {
    return new Promise(function (resolve, reject) {
        var xhr = new XMLHttpRequest
        xhr.addEventListener('error', reject)
        xhr.addEventListener('load', resolve)
        xhr.open('GET', url)
        xhr.send(null)
    })
}

和原生的Promise构造函数没有太大区别

使用Promisify来转换异步方法

要使用Promise,要么新建一个Promise,要么把现有的方法转换为Promise

bluebird提供promisify方法,用来直接将一个异步方法转换为Promise,这估计是bluebird最常用的API,时至今日,该方法的意义已经没那么大了,但姑且在这里标记一下

使用Promisify将一个方法转换为Promise

var {promisify} = require('bluebird')
var readFile_promise = promisify(require('fs').readFile)

readFile_promise('foo.txt', 'utf8').then(function (result) {
    console.log(result)
}).catch(function (err) {
    console.log('error:', err)
})

bluebird还提供promisifyAll方法来转换一个对象的全部方法,这个方法的便利性超乎想象。比如可以将fs模块的全部方法批量转换成Promise形式,省去一个个地进行转换

var {promisifyAll} = require('bluebird')
var fs = promisifyAll(require('fs'))

fs.readFileAsync('read.js', 'utf8').then(function (result) {
    console.log(result)
}).catch(function (err) {
    console.log(err.stack)
})

screenshot 6

promisifyAll通常用于转换一个对象的全部方法,比如👆会转换整个fs模块内部的异步方法,bluebird会在原方法名之后加上Async后缀,例如readFile的Promise化后的方法名为readFileAsync

Generator,一种过渡方案

无论是概念还是形式上,ES2015中的Generator几乎就是Python中Generator的翻版

🔔Generator本质上是一个函数,它最大的特点就是可以被中断,然后恢复执行

通常来说,当开发者调用一个函数之后,这个函数的执行就脱离了开发者的控制,只有函数执行完毕之后,控制权才能重新回到调用者手中,因此程序员在编写方法代码时,唯一能够影响方法执行的只有预先定义的return关键字

Promise也是如此,新建一个Promise后,其状态自动转换为pending同时开始执行,直到状态改变后才能进行下一步操作

而Generator函数不同,Generator函数可以由用户执行中断或者恢复执行的操作,Generator中断后可以转去执行别的操作,然后再回过头从中断的地方恢复执行,这其实是一种协程的概念

Generator的使用

Generator函数和普通函数在外表上两个最大的区别

  • 在function关键字和方法名中间有个星号
  • 方法体中使用yield关键字
function* Generator() {
    yield '0xGeekCat'
    return 'end'
}

和普通方法一样,Generator可以定义成多种形式

// 普通方法
function* generator() {}

// 函数表达式
var gen = function* generator() {}

// 对象属性方法
var obj = {
    * generator() {}
}

Generator函数的状态

yield关键字用来定义函数执行的状态,如果Generator中定义了xyield关键字,那么就有x + 1种状态,因为最后的return语句

Generator函数的执行

跟普通函数相比,Generator函数更像是一个类或者一种数据类型

直接执行Generator会得到Generator对象,而不是执行方法体中的内容

function* Generator() {
    yield '0xGeekCat'
    return 'end'
}

var gen = Generator()
console.log(gen) // Object [Generator] {}

按照通常的思路,gen应该是Generator()函数的返回值,👆也提到Generator函数可能有多种状态,Promise也可能有三种状态。不同的是Promise只能有一个确定的状态,而Generator对象会逐个经历所有的状态,直到Generator函数执行完毕

当调用Generator函数之后,该函数并没有立刻执行,函数的返回结果也不是字符串,而是一个对象,可以将该对象理解为一个指针,指向Generator函数当前的状态

当Generator被调用后,指针指向方法体的开始行,当next方法调用后,该指针向下移动,方法也跟着向下执行,最后会停在第一个遇到的yield关键字前面,当再次调用next方法时,指针会继续移动到下一个yield关键字,直到运行到方法的最后一行

var gen = Generator()
console.log(gen.next())
console.log(gen.next())
console.log(gen.next())

screenshot 7

上面的代码一共调用了三次next方法,每次都返回一个包含执行信息的对象

  • 一个表达式的值
  • 一个标记执行状态的flag

分析3次next触发的程序运行

  1. 遇到一个yield语句后停止,返回对象的value的值就是yield语句的值,done属性用来标志Generator方法是否执行完毕
  2. 程序执行到return语句的位置,返回对象的value值即为return语句的值,如果没有return语句,则会一直执行到函数结束,value值为undefined,done属性值为true
  3. Generator已经执行完毕,因此value的值为undefined

yield关键字

yield本意为生产,在Python、Java以及C#中都有yield关键字,但只有Python中yield的语义和Node相似

当next方法被调用时,Generator函数开始向下执行,遇到yield关键字时,会暂停当前操作,并且对yield后的表达式进行求值,无论yield后面表达式返回的是何种类型的值,yield操作最后返回的都是一个对象,该对象有value和done两个属性

value很好理解,如果后面是一个基本类型,那么value的值就是对应的值,更为常见的是yield后面跟的是Promise对象

done属性表示当前Generator对象的状态,刚开始执行时done属性的值为false,当Generator执行到return语句时,done的值会变成true,表示Generator执行结束

❗️yield关键字本身不产生返回值

function* foo(x) {
    var y = yield (x + 1)
    return y
}

var gen = foo(5)
console.log(gen.next())
console.log(gen.next())

screenshot 8

❓为什么第二个next方法执行后,y的值却是undefined

next方法的返回值是yield关键字后面表达式的值,而yield关键字本身可以视为一个不产生返回值的函数,因此y并没有被赋值

function* foo(x) {
    yield y =  (x + 1)
    return 'end'
}

var gen = foo(5)
console.log(gen.next())
console.log(gen.next())

👆这样就解决了计算y值的问题

screenshot 9

next方法还可以接受一个数值作为参数,代表上一个yield求值的结果

function* foo(x) {
    var y = yield (x + 1)
    return y
}

var gen = foo(5)
console.log(gen.next())
console.log(gen.next(10))

screenshot 10

👆代码等价于

function* foo(x) {
    var y = yield (x + 1)
    y = 10 👈
    return y
}

var gen = foo(5)
console.log(gen.next())
console.log(gen.next())

next可以接收参数代表可以从外部传一个值到Generator函数内部,实际上正是这个特性使得Generator可以用来组织异步方法

next方法与Iterator接口

Iterator同样使用next方法来遍历元素。由于Generator函数会返回的对象实现了一个Iterator接口,因此所有能够遍历Iterator接口的方法都可以用来执行Generator,可以使用for / of循环的方式来执行Generator函数内的步骤

❗️循环会在done属性为true时停止,之前例子中的end并不会被打印,如果希望被打印,需要将最后的return改为yield

function * Generator() {
    yield 'hello, world'
    yield '0xGeekCat'
    return 'end'
}

var gen = Generator()

for (let i of gen) {
    console.log(i)
}

console.log(Array.from(Generator())) 👈 将Generator()转换成数组,不过似乎其不是array-like Object

screenshot 11

直接打印Generator函数的示例没有结果,但既然Generator函数返回了一个遍历器,那就自然具有Symbol.iterator属性

console.log(gen[Symbol.iterator]) // [Function: [Symbol.iterator]]

Generator中的错误处理

Generator函数的原型中定义了throw方法,用于抛出异常

function * generator() {
    try {
        yield console.log('0xGeekCat')
    } catch (e) {
        console.log(e)
    }

    yield console.log('Node')
    return 'end'
}

var gen = generator()
gen.next()
gen.throw('error')
console.log(gen.next())

screenshot 12

执行完第一个yield操作后,Generator对象抛出了异常,然后被函数体中try / catch捕获

❗️当异常被捕获后,Generator函数会继续向下执行,直到遇到下一个yield操作并输出yield表达式的值

function* generator() {
    try {
        yield console.log('hello, world')
    } catch (e) {
        console.log(e)
    }

    console.log('test')
    yield console.log('0xGeekCat')
    return 'end'
}

var gen = generator()
gen.next()
gen.throw('throw error')

screenshot 13

如果Generator函数在执行的过程中出错,也可以在外部进行捕获

function* generator() {
    yield console.log(undefined.undefined)
}

var gen = generator()

try {
    gen.next()
} catch (e) {
    console.log(e)
}

Generator的原型对象还定义了return()方法,用来结束Generator函数的执行,这和函数内部的return关键字不是一个概念

function* generator() {
    yield console.log('hello, world')
    yield console.log('0xGeekCat')
    return 'end'
}

var gen = generator()
gen.next()
gen.return() 👈 之后的next不会被执行
gen.next()

screenshot 14

用Generator组织异步方法

使用Generator函数处理异步任务的原因

  • Generator函数可以中断和恢复执行,这个特性由yield关键字来实现
  • Generator函数内外可以交换数据,这个特性由next函数来实现

Generator函数处理异步操作的核心思想

先将函数暂停在某处,然后拿到异步操作的结果,然后再把这个结果传到方法体内

yield关键字后面除了通常的函数表达式外,比较常见的是后面跟的是一个Promise,由于yield关键字会对其后的表达式进行求值并返回,那么调用next方法时就会返回一个Promise对象,可以接着调用then方法,并在回调中使用next方法将结果传回Generator

使用Generator处理异步

var fs = require('fs')

function readFile_promise(path) {
    return new Promise(function (resolve, reject) {
        fs.readFile(path, 'utf-8', function (error, data) {
            if (data) {
                resolve(data)
            } else {
                reject(error)
            }
        })
    })
}

function* generator() {
    var result = yield readFile_promise('foo.txt')
    console.log(result)
}

var gen = generator()
var result = gen.next() 👈 result对象中含有Promise对象
console.log(result)
result.value.then(function (data) { 
    gen.next(data)
})

screenshot 15

👆Generator函数封装readFile_promise方法,该方法返回一个Promise,Generator函数对readFile_promise的调用方式和同步操作基本相同,除了yield关键字之外

使用Generator进行多个异步流程控制

var fs = require('fs')

function readFile_promise(path) {
    return new Promise(function (resolve, reject) {
        fs.readFile(path, 'utf-8', function (error, data) {
            if (data) {
                resolve(data)
            } else {
                reject(error)
            }
        })
    })
}

function* generator() {
    var result = yield readFile_promise('foo.txt')
    console.log(result)

    var result2 = yield readFile_promise('bar.txt')
    console.log(result2)
}

var gen = generator()
var result = gen.next()
console.log(result)
result.value.then(function (data) {
    gen.next(data).value.then(function (data) {
        gen.next(data)
    })
})

screenshot 16

Generator的缺点

虽然在调用时保持了同步形式,但需要手动执行Generator函数,于是在执行时又回到了嵌套调用

Generator的自动执行

开发者肯定不希望调用函数还要一步步地写代码。对Generator函数来说,如果要顺序地读取多个文件,也要写很多用来执行的代码

无论是Promise还是Generator,就算在编写异步代码时能获得便利,但执行阶段却要写更多的代码

  • Promise需要手动调用then方法
  • Generator需要手动调用next方法

当需要顺序执行异步操作的个数比较少的情况下,开发者还可以接受手动执行,但如果面对多个异步操作就有些难办,避免了回调地狱,却又陷到了执行地狱

自动执行器的实现

既然Generator函数是依靠next方法来执行的,那么只要实现函数自动执行next方法就可以

function auto(Generator) {
    var gen = Generator()

    while (gen.next().value !== undefined) {
        gen.next()
    }
}

👆思路虽然没错,但这种写法并不正确,首先这种方法只能用在像👇这种最简单的Generator函数上

function* Generator() {
    yield '0xGeekCat'
    return 'end'
}

另一方面,由于Generator没有hasNext方法,gen.next().value !== undefined在while循环中作为条件,在第一次条件判断时就开始执行了,这表示无法拿到第一次执行的结果。因此这种写法行不通,于是换个思路

function* Generator() {
    yield '0xGeekCat'
    yield 'hello, world'
    return 'end'
}


var gen = Generator()
for (let i of gen) {
    console.log(i)
}

for / of循环看起来没什么问题,但同样也只能拿来执行最简单的Generator函数

基于Promise的执行器

在实际应用中,yield后面跟的大都是Promise,这时候for / of实现的执行器就不起作用

观察发现Generator的嵌套执行是一种递归调用,每一次的嵌套的返回结果都是一个Promise对象

升级后的执行函数

function auto_exec(gen) {
    function next(data) {
        var result = gen.next(data)

        if (result.done)
            return result.value

        result.value.then(function (data) {
            next(data)
        })
    }

    next()
}

这个执行器因为调用then方法,因此只适用于yield后面跟一个Promise的方法

使用co模块来自动执行

为了解决generator执行的问题,2013年6月发布了著名co模块,这是一个用来自动执行Generator函数的小工具,和Generator配合可以实现接近同步的调用方式,co方法仍然会返回一个Promise

使用co模块执行Generator

var fs = require('fs')
var co = require('co')

function readFile_promise(path) {
    return new Promise(function (resolve, reject) {
        fs.readFile(path, 'utf-8', function (error, data) {
            if (data) {
                resolve(data)
            } else {
                reject(error)
            }
        })
    })
}

function* gen() {
    var result = yield readFile_promise('foo.txt')
    console.log(result)

    var result2 = yield readFile_promise('bar.txt')
    console.log(result2)
}

co(gen)

只要将Generator函数作为参数传给co方法就能将内部的异步任务顺序执行

❗️要使用co模块,yield后面的语句只能是promsie对象

到此为止,对异步的处理有了一个比较妥当的方式,利用generator + co,基本可以用同步的方式来书写异步操作

但co模块仍有不足之处,由于它仍然返回Promise,这代表如果想要获得异步方法的返回值,还要👇这种形式

co(gen).then(function (value) {
    console.log(value)
})

另外当面对多个异步操作时,除非将所有的异步操作都放在一个Generator函数中,否则如果需要对co的返回值进行进一步操作,仍然要将代码写到Promise的回调中去

回调的终点 async / await

async函数的概念

ES2017标准引入async函数,终结了回调处理的问题,async函数可以看作是自带执行器的Generator函数

用async函数改写Generator方法

function* gen() {
    var result = yield readFile_promise('foo.txt')
    console.log(result)

    var result2 = yield readFile_promise('bar.txt')
    console.log(result2)
}
👇 等效
var asyncReadFile = async function() {
    var result = readFile_promise('foo.txt')
    console.log(result)

    var result2 = readFile_promise('bar.txt')
    console.log(result2)
}

形式看起来没有什么大的变化,yield关键字换成了await,方法名前的*号变成了async关键字

Generator和async的区别

  • await后面往往是Promise,如果不是就隐式调用promise.resolve转换成Promise,await会等待后面的Promise执行完成后再进行下一步操作
  • 调用async方法是直接通过方法名调用

在这个过程中,完全没有了回调的影子也没有引入任何第三方模块,async拥有Node的原生支持,困扰Node社区多年的回调问题在这里终结

声明async方法

// 普通函数声明
async function foo() {}

// 函数表达式
const foo = async function() {}

// 箭头函数
const foo = async () => {}

async的返回值

async函数总是会返回一个Promise对象,如果return关键字后面不是一个Promise,那么默认调用promise.resolve方法进行转换

async function asyncFunc() {
    return '0xGeekCat'
}

asyncFunc().then(function (data) {
    console.log(data) // 0xGeekCat
})

console.log(asyncFunc()) // Promise { '0xGeekCat' }

asyncFunc()方法虽然看似返回了一个字符串,却使用then方法来获得值,这是内部将字符串转换成了Promise的缘故

async函数的执行过程

  • 在async函数开始执行的时候,会自动生成一个Promise对象
  • 当方法体开始执行后,如果遇到return关键字或者throw关键字,执行会立刻退出,如果遇到await关键字则会暂停执行,await后面的异步操作结束后会恢复执行
  • 执行完毕,返回一个Promise

通过👇例子了解async函数工作流程

async function asyncFunc() {
    console.log('begin')
    return '0xGeekCat'
}

asyncFunc().then(x => console.log(x))

setTimeout(function () {
    console.log('end')
}, 2000)

screenshot 18

async function asyncFunc() {
    console.log('begin')
    return '0xGeekCat'
}

asyncFunc().then(x => console.log(x))

console.log('end')

screenshot 19

async函数返回的Promise,既可以是resolved状态,也可以是reject状态,不过通常使用throw Error的方式来代替reject

async function asyncFunc() {
    return Promise.reject(new Error('0xGeekCat'))
}

asyncFunc().catch(err => console.log(err))

await关键字

对于async来说,await关键字不是必需的,由于async本质上是对Promise的封装,可以使用执行Promise的方法来执行async方法

🔔await关键字是这一情况的语法糖,它可以自动执行一个Promise

其实是等待后面的Promise完成后再进行下一步动作,当async函数内有多个Promise需要串行执行的时候,这种特性带来的好处是十分明显的

await操作符的结果是由其后面Promise对象的操作结果来决定的

  • 如果后面Promise对象变为resolved,await操作符返回的值就是resolve的值
  • 如果Promise对象的状态变成rejected,那么await也会抛出reject的值

异步读取一个文件

async function readFile() {
    var result = await readFile_promise('foo.txt')
    console.log(result)
}
readFile()
👇 等效
readFile_promise('foo.txt').then(function (data) {
    console.log(data)
})

由于await可以看作是Promise的执行器,所以也可以写成👇形式

async function readFile() {
    var result = await readFile_promise('foo.txt').then(function (result) {
        return result
    })
    console.log(result)
}

readFile()

在使用了await关键字之后,无论是代码还是执行,都变得和同步操作没什么两样

await与并行

await会等待后面的Promise完成后再采取下一步动作,这意味着当有多个await操作时,程序会变成完全的串行操作

为了发挥Node的异步优势,当异步操作之间不存在结果的依赖关系时,可以使用promise.all来实现并行

async function readFile() {
    const [result1, result2] = await Promise.all([
        readFile_promise('foo.txt'),
        readFile_promise('bar.txt')
    ])

    console.log(result1, result2)
}
👇 等效
function readFile() {
    return Promise.all([
        readFile_promise('foo.txt'),
        readFile_promise('bar.txt')
    ]).then((result) => {
        console.log(result)
    })
}

错误处理

当async函数中有多个await关键字时,如果有一个await的状态变成了rejected,那么后面的操作就不会继续执行

var asyncReadFile = async function() {
    var result1 = await readFile_promise('not exist')
    var result2 = await readFile_promise('foo.txt')

    console.log(result1.toString())
    console.log(result2.toString())
}

asyncReadFile()

screenshot 17

👆表明代码中有没有被处理的处于rejected状态的Promise。因此使用await时为了避免潜在的错误,最好用try / catch将所有的await包裹起来

在循环中使用async方法

for / while循环

var array = ['foo.txt', 'bar.txt', 'baz.txt']
async function readFile() {
    for (let i = 0; i < 3; i ++) {
        var result = await readFile_promise(array[i])
        console.log(result)
    }
}

readFile()

forEach循环

async function readFile(list) {
    list.forEach(async function (item) {
        var result = await readFile_promise(item)
        console.log(result)
    })
}

readFile(['foo.txt', 'bar.txt', 'baz.txt'])

❗️即使是在匿名函数中使用await关键字,也要在匿名函数前加上async关键字

for \ of循环

async function readFile(list) {
    for (const item of list) {
        var result = await readFile_promise(item)
        console.log(result)
    }
}

readFile(['foo.txt', 'bar.txt', 'baz.txt'])

如果异步方法的执行全都变成串行的话,就不能发挥出Node非阻塞IO的优势了,如果想要使用并行来提高执行效率,那么需要使用promise.all

async function readFile(list) {
    await Promise.all(list.map(async function (item) {
        var result = await readFile_promise(item)
        console.log(result)
    }))
}

readFile(['foo.txt', 'bar.txt', 'baz.txt'])

async和await小结

async函数是用async / await关键字来标识的,async函数返回一个Promise对象,当在方法体中遇到异步操作时,会立刻返回,随后不断轮询直到异步操作完成,随后再继续执行方法体内剩下的代码

async function timeout(ms) {
    await new Promise(resolve => {
        setTimeout(resolve, ms)
    })
}

async function asyncPrint(ms) {
    for (let i = 0; i < 5; i ++) {
        await timeout(ms)
        console.log(i)
    }
}

asyncPrint(1000)

❗️即使将timeout写成了async / await形式,但在asyncPrint方法中依然需要使用await关键字来调用,同时也药让asyncPrint函数带上async关键字

通常在只要函数体中调用async操作,该函数就不得不带上async关键字。这有可能导致所有的函数都变成async函数,就像采用同步事件处理的语言一样

对于await关键字使用的一些关键点

  • await关键字必须位于async函数内部
  • await关键字后面需要是一个Promise对象,不是的话就调用resolve转换
  • await关键字的返回结果就是其后面Promise执行的结果,可能是resolved或者rejected的值
  • 不能在普通箭头函数中使用await关键字,需要在箭头函数前面增加async关键字
  • await用来串行地执行异步操作,想实现并行可以考虑promise.all

async函数的缺点

async函数和Generator函数比起来,有着不少的优点,例如可以实现自动执行,无须借助第三方模块等,也免去了Generator函数中一些复杂的概念,async函数的声明和执行与普通同步函数几乎一模一样,除了async和await关键字,但仍然有一些不足,如果有很多层的方法调用,最底层的异步操作被封装成了async方法,那么该函数的所有上层方法可能都要变成async方法

假设有一个get方法,用来从数据库中找出一条id最大值的记录,然后调用set方法将这个值增加1后存入数据库,然后再返回修改后的值

async function update() {
    var value = await get()
    ++ value

    await set()
    // 应该等待set结束再返回
    return value
}

假设update是由一个对象触发update事件时执行的回调函数,通常情况下上一级的调用会是👇形式

obj.on('update', function () {
    var value = update()
})

对于async函数update来说,这种调用得不到正确的value值,🔔因为async方法返回的永远是Promise,即使开发者返回的是常量,也会被自动调用的promise.resolve方法转换为Promise,因此上层的调用方法也要是async函数

async function xxx() {
    var value = await update()
    return value
}

如果还存在更高层次的方法调用,那么从最底层的异步操作开始,到最顶层一个不需要返回值的函数为止,全部的方法都变成async方法

reference

《新时期的Node.js入门》 李锴