【Javascript】- 异步编程

170 阅读21分钟

我们所写的 Javascript 代码只有两种,一种是同步代码,一种是异步代码。同步可以满足绝大多数的编码需求,而处理复杂任务、网络请求、耗时操作这些高级需求就得靠异步编程了。 本文介绍 Javascript 中的几种异步编程方案及其用法,带你入门“异步世界”

现在有一个这样的场景:我们需要通过 node.js 来读取本地文件并输出到控制台上显示,下面是文件内容:

  • 1.txt 内容:111
  • 2.txt 内容:222
  • 3.txt 内容:333

创建一个 callback.js 文件,其中的代码如下

const fs = require('fs');// 导入node.js文件系统模块 fs
const path = require('path'); // 导入node.js处理路径的模块 path
// 参数 fpath 代表文件路径
function getFileByPath(fpath) {
    fs.readFile(fpath, 'utf-8', (err, data) => {
        if (err) {
            throw err
        }else{
            return data
        }
    })
}
const result = getFileByPath(path.join(__dirname, './1.txt'));
console.log(result); // undeined

使用命令:node callback.js 运行,你会发现控制台打印出的是 undefined

为什么呢?我们来分析一下:

  • 我们调用了getFileByPath函数,然后传入了文件路径fpath,如果读入失败,就会抛出错误到控制台,但是控制台并没有抛出错误呀,这可以说明文件读取是成功的
  • 既然读取成功了,那么就应该返回读到的数据data,可是,result 的值却是 undefined,所以问题就出在data上面——data 没有被正确的返回

原因就在于readFile 是一个异步执行的函数,异步任务并不是按照代码中书写的顺序来执行的readFile函数并不会被放到主线程上立即执行,而是被放入“任务队列”,这就造成getFileByPath函数瞬间执行完毕而不是等待着readFile函数执行完毕返回data,所以getFileByPath函数返回的就是undefined。也就是说:当 readFile 函数(异步任务)执行完毕时,getFileByPath函数(同步函数)早就执行完了,所以它的返回值就是 undefined

回调函数

在异步任务中使用回调

为了获取到readFile函数读取文件的返回值,可以对getFileByPath函数传入一个回调函数,当readFile执行完毕就调用回调函数并把读取到的data数据传入,这样我们就一定能获取到异步操作的结果:

// fpath:文件路径,successCallback:读文件成功执行的回调函数,errCallback:读文件失败执行的回调函数
function getFileByPath(fpath, successCallback, errCallback) {
    fs.readFile(fpath, 'utf-8', function(err, data){
        if (err) {
            errCallback(err);
        } else {
            successCallback(data);
        }
    })
}
getFileByPath(path.join(__dirname, './1.txt'), function(data){
    console.log(data)
}, function(err){
    console.log(err.message)
});

可以看到,我们传入了两个函数,分别用于处理发生错误的情况和读取成功的情况。一旦成功,就会调用 successCallback() 并且把读到的数据 data 传入回调函数;如果失败就会调用 errCallback() ,并且也会把错误信息传入回调函数,这样我们就能成功的读到 data 的数据了。

回调地狱

现在,我们需要按顺序依次读出 1.txt、2.txt、3.txt;由于 readFile 是异步函数,所以无法保证执行顺序;不过,嵌套回调函数就行啦

getFileByPath(path.join(__dirname, './1.txt'), function(data){
    
    // 如果 1.txt 读取成功,就去读取 2.txt
    getFileByPath(path.join(__dirname, './2.txt'), function(data){
        
        // 如果 2.txt 读取成功,就去读取 3.txt
        getFileByPath(path.join(__dirname, './3.txt'), function(data){
            console.log(data)
        }, function(err){
            console.log('1.txt 读取失败',err.message)
        })
    }, function(err){
        console.log('2.txt 读取失败',err.message)
    })
}, function(err){
    console.log('3.txt 读取失败',err.message)
})

通过嵌套回调函数,我们成功的实现了按顺序执行的异步任务 。现在我们只是读取3个文件,就嵌套了3层回调函数,即使是这样,看起来也非常臃肿;假如我们要读取1000个文件,那么我们就要写上1000个嵌套函数!这就是回调地狱,写起来就像这个样子: image.png 上图描述的就是回调函数的缺点,即:

  • 无法解决嵌套过多的问题
  • 不便于书写和维护
  • 重复代码过多,可读性很低

Promise

回调函数是 Javascript 中处理异步任务一种手段或者方式,我们不能因为它容易变臃肿就不可能不用这种手段,只能慢慢的改进它,所以,社区里的编程大神们想出了 Promise 这个好东西。

Promise 能大大简化回调函数的书写,它本质上还是使用的回调函数,它只是换了一种形式,让我们能更方便的使用回调函数。我们创建一个 promise.js 文件,在其中写入代码,使用Promise读取文件就像下面这样:

const promise = new Promise(function () {
    fs.readFile('./1.txt', 'utf-8', (err, data) => {
        if(err){
            throw err
        }else{
            console.log(data); // 111
        }
    })
})

Promise立即执行

直接运行上面的代码,你会发现,当你 new 一个 Promise之后,控制台立马就会打印出结果,这与普通的对象有很大不同;Promise 的定义就是:只要实例化,就会立即执行。

上面的 Promise 实例会立即执行,但是我们此时不需要它立即执行,希望它能被我们手动执行,所以,可以将 promise 实例放进一个函数里,只有当我们去调用函数,promise 才会被执行

function getFileByPath(fpath) {
    const promise = new Promise(function () {
        fs.readFile(fpath, 'utf-8', (err, data) => {
            if (err) throw err;
            console.log(data); // 111
        })
    })
}

getFileByPath('./2.txt'); // 222

Promise的状态

只是实例化一个 Promise 对象,并不能解决我们的问题,因为要处理失败和成功两种异步操作的结果,所以要给 Promise 实例传入处理失败和成功的回调函数,从而为失败和成功这两种状态指定下一步的操作。

Promise 有三种状态:Pending(进行中),Fulfilled(已成功),Rejected(已失败),Promise 提供了下面两个回调函数来分别处理失败的操作和成功的操作:

  • resolved:将 Promise 的状态从 Pending 变为 Fulfilled,如果异步操作成功,就会调用这个函数
  • rejected:将 Promise 的状态从 Pending 变为 Rejected,如果异步操作失败,则会调用这个函数

无论是 Promise 的状态从 Pending 变为 Fulfilled 还是从 Pending 变为 Rejected,只要发生了变化,之后就再也不会改变,就像整个状态凝固了,也称为 resolved(已定型)。

通常,如果状态从从 Pending 变为 Fulfilled ,就默认称状态为 Resolved

为Promise指定回调函数

function getFileByPath(fpath) {
    const promise =  new Promise(function (resolve, reject) {
        fs.readFile(fpath, 'utf-8', (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        })
    })
    
    // 为了在外部获取到 Promise 实例,所以要将其return
    return promise;
}

现在,已经为 Promise 构造函数指定了回调函数,那就是形参 resolve reject;可问题是,我们并没有去定义resolvereject这两个函数的内部细节,那要怎样去定义这两个函数呢?

Promise.then()

通常, Promise 的回调函数,由调用者去指定,谁调用谁就负责指定; Promise 规定,使用 Promise.then() 方法来指定回调:

function getFileByPath(fpath) {
    return new Promise(function (resolve, reject) {
        fs.readFile(fpath, 'utf-8', (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        })
    })
}

// 定义一个常量 p 用来保存返回的promise实例
const p = getFileByPath('./1.txt');

//使用 then 方法定义回调函数
//then 的形参顺序与 promise构造函数的形参顺序保持一致
//第一个函数代表 resolve(已成功)回调,第二个代表reject(已失败)回调
p.then(function(data){
    // promise 内部调用了 resolve 传入获取到的数据 data
    console.log(data);
},function(err){
    // promise 内部调用了 reject 传入发生的错误信息 err
    console.log(err);
})

我们非常有必要搞清楚这段代码的执行顺序:

  1. 调用 getFileByPath ,继而函数内部创建了一个 Promise 实例,并且立即执行
  2. 变量 p 获得 getFileByPath 返回一个 Promise 实例,立即执行 then 方法的定义
  3. 此时,主线程执行完了所有同步代码,开始执行异步代码(readFile函数)
  4. readFile函数执行完毕,调用then方法指定的回调函数
  5. 异步任务的操作结果被输出到控制台

通过分析我们可以发现,异步任务总是等着同步任务都执行完了才会被 Javascript 主线程执行,不过不用担心,我们已经通过 Promise.then() 为异步任务指定了它执行完毕以后的操作。

仔细观察上面的代码,就会发现,我们创建了许多中间变量,比如 promise p,其实它们都是不必要的,为了写出更加精炼的代码,我们可以使用 Promise 的链式调用写法:

function getFileByPath(fpath) {
    // 使用 return 直接返回 Promise 实例
    return new Promise(function (resolve, reject) {
        fs.readFile(fpath, 'utf-8', (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        })
    })
}
// getFileByPath函数的返回值 就是Promise实例,所以在后面直接使用then方法
getFileByPath('./1.txt').then(function(data){
    console.log(data);
},function(err){
    console.log(err.message);
})

Promise.catch()

Promise的第二个参数

现在,我们使用 Promise 来按顺序读取文件,给每个then方法都定义了作为第二个参数的回调函数,第一个参数处理成功的读取,第二个参数处理错误的读取。这种使用多个 then()来指定回调的方式,也叫做链式调用

getFileByPath('./1.txt')
.then(function (data) {
    console.log(data);
    return getFileByPath('./2.txt')
}, function(err){
    console.log(err.message);
    return getFileByPath('./2.txt')
})
.then(function (data) {
    console.log(data);
    return getFileByPath('./3.txt')
}, function(err){
    console.log(err.message);
    return getFileByPath('./3.txt')
})
.then(function (data) {
    console.log(data);
}, function(err){
    console.log(err.message);
})

运行上面的代码,你会发现,如果其中一个文件读取出错,代码不会立即停止执行,而是抛出错误,继续执行后面的操作;即,前面的错误不会阻塞会后面的执行;其实这样并不好,绝大多数情况下,在一系列操作中,如果前面某个步骤发生错误,应该抛出错误停止执行,因为再去执行后面的是没有意义的,我们想要得到的是一个严格正确的结果。那么怎样才能让程序只返回完全正确的结果呢?

使用 catch()

Promise 实例抛出的错误,具有冒泡的性质,也就是说错误会沿着作用域链一直向后传递,前面的 then 方法抛出的错误,总是会被传递到写在最后的catch方法中,所以我们要使用 catch方法来捕获错误,不必使用then 方法中的第二个参数,我们可以这样写:

getFileByPath('./0.txt').then(function (data) {
    console.log(data)
    // 如果当前的文件读取成功,那么就返回一个新的 Promise 实例,用于继续读取后面的文件
    return getFileByPath('./2.txt');
}).then(function (data) {
    console.log(data)
    return getFileByPath('./3.txt');
}).then(function (data) {
    console.log(data)
}).catch(function (err) {
    console.log(err.message)
})

运行上面的代码,如果某个文件读取出错,那么 Promise 就会抛出错误到 catch ,并且立即停止执行。

注意: Promise内部发生的错误不会传递到外部,也就是说如果不用 catch 来捕获错误,我们根本看不到Promise 报出的错误,整个程序还是会继续执行。

高下立判

现在我们来比较一下两种方式的差异,同样的,在读取文件这个异步操作中:

  • 使用回调函数
getFileByPath(path.join(__dirname, './1.txt'), function(data){
    console.log(data);
    getFileByPath(path.join(__dirname, './2.txt'), function(data){
        console.log(data);
        getFileByPath(path.join(__dirname, './3.txt'), function(data){
            console.log(data);
        }, function(err){
            console.log(err.message);
        });
    }, function(err){
        console.log(err.message);
    });
}, function(err){
    console.log(err.message);
});
  • 使用 Promise
// 前面的Promise中使用了很多匿名函数,所以可以用箭头函数将其替代,
getFileByPath('./1.txt').then(data => {
    console.log(data);
    return getFileByPath('./2.txt')
}).then(data => {
    console.log(data);
    return getFileByPath('./3.txt')
}).then(data => {
    console.log(data);
}).catch(err => console.log(err.message));

现在你应该懂了,为什么要使用 Promise 来代替回调函数了吧,Promise 能让我们的异步任务流程更加的清晰,它的链式调用方式非常易用,代码体验比回调函数的嵌套可是好了太多了!

Promise.all()

all() 用来一次性处理多个 Promise 实例。它可以将多个 Promise 实例包装成一个新的 Promise 实例,接受一个数组(或者不是数组,但是一定要可迭代),它的用法是:const P = Promise.all([p1, p2, p3])。根据下面两种情况返回各自的结果:

  • 只有当p1p2p3 的状态都是 Fulfilled 时,P的状态才转变为 Fulfilled ;此时p1p2p3的返回值组成一个数组,传递给P的回调函数
  • 只要p1p2p3其中有一个的状态变成 Rejectedp的状态就变成 Rejected ,此时第一个状态为 Rejected 的实例的返回值,会传递给P的回调函数

还是通过前面读取 TXT 文件的例子,我们实例化 3 个 Promise ,分别来读取TXT文件:

const p1 = new Promise((resolve, reject) => {
    fs.readFile('./1.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p2 = new Promise((resolve, reject) => {
    fs.readFile('./2.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p3 = new Promise((resolve, reject) => {
    fs.readFile('./3.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

然后,我们来使用 Promise.all() 将这三个实例合并起来管理:

const P = Promise.all([p1, p2, p3])
P.then(values => {
    console.log('This is printed by Promise.all', values) 
    // 'This is printed by Promise.all' [111, 222 ,333]
}).catch(err => {
    console.log(err.message)
})

查看控制台输出发现,Promise.all() 的状态变为了 Resolved,因为三个实例都成功的读到了 TXT 文件,并且三个实例都将自己的读取结果传入了 P.then() 指定的回调函数中。

上面代码演示了第一种情况,下面我们接着来看第二种情况,我们让其中一个 Promise 实例发生错误:

const p2 = new Promise((resolve, reject) => {
    // 给出一个不存在的文件路径
    fs.readFile('./hello.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p3 = new Promise((resolve, reject) => {
    // 给出一个不存在的文件路径
    fs.readFile('./good.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

然后再来查看控制台输出:

P.then(values => {
    console.log('This is printed by Promise.all', values) 
}).catch(err => {
    console.log(err.message) // ENOENT: no such file or directory hello.txt
})

此时,因为发生了错误,导致p2p3 的状态都是 Rejected,所以 P 的状态变成了 Rejected,catch() 也捕获到了错误信息,注意,p3 也发生了错误,但是只有第一个 Rejected 的实例(本例中的 p2)的返回值会被传递给 P 的 catch 方法。

注意 :如果其中一个 Promise 实例,自己定义了 catch() 来捕获错误,那么错误不会冒泡到 P.catch() 中,而只会触发自己的 catch()

Promise.allSettled()

Promise.allSettled() 也用来管理多个 Promise 实例,它会等到所有 Promise 实例的状态都凝固了(无论是 Resolved 还是 Rejected),才返回实例执行的结果,因为如此,Promise.allSettled()最终的状态总是fulfilled。我们还是用读取TXT的例子来演示Promise.allSettled() 的用法

const p1 = new Promise((resolve, reject) => {
    fs.readFile('./1.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p2 = new Promise((resolve, reject) => {
    // 让 p2 发生错误
    fs.readFile('./hello.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p3 = new Promise((resolve, reject) => {
    fs.readFile('./3.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const P = Promise.allSettled([p1, p2, p3])
P.then(results => {
    console.log('This is printed by Promise.allSettled', results)
}).catch(err => {
    console.log(err.message)
})

查看控制台的打印结果,你能清楚的看到每一个 Promise 的状态、哪一个 Promise 实例发生了错误以及错误的详细信息:

[
    { status: 'fulfilled', value: '111' },
    {
    	status: 'rejected',
    	reason: [Error: ENOENT: no such file or directory, open hello.txt'] {
      				errno: -4058,
      				code: 'ENOENT',
      				syscall: 'open',
      				path: 'hello.txt'
    			}
  	},
   	{ status: 'fulfilled', value: '111' }
]

Promise.allSettled() 最大的用途就是能让你清楚的了解到每个 Promise 实例的确切执行情况。再者,有时候,我们不关心异步操作的结果,只关心这些操作有没有结束。这时,Promise.allSettled()方法就能派上用场。 ​

Promise.race()

Promise.race()方法同样是用来管理多个 Promise 实例,只要有一个实例率先改变状态,p的状态就跟着改变。那个最先改变状态的 Promise 实例的返回值,就传递给Promise.race()的回调函数。 ​

其实可以这样来理解, race 意为“竞赛、比赛”,多个 Promise 实例之间就好像在“竞赛”,谁最先完成任务,那么谁就决定了Promise.race()最终的状态:

const P = Promise.race([p1, p2, p3])
P.then(results => {
    console.log('This is printed by Promise.race', results)
    // This is printed by Promise.race 111
}).catch(err => {
    console.log(err.message)
})

可以看到,1.txt 最先读取完成,所以 p1 的状态最先变为 Fulfilled ,p1 执行的结果也就能传递给 P 了

Promise.resolve() & Promise.reject()

Promise.resolve() 返回一个状态为 resolve 的 Promise 实例,Promise.reject() 返回一个状态为 reject 的 Promise 实例。它们接受任意参数,反正最后返回的都是状态已经凝固了的 Promise 实例。

有时需要将现有对象转为 Promise 对象Promise.resolve()方法就可以完成这个功能;Promise.reject() 的功能在于:通过使用[Error](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error)的实例获取错误原因reason,对调试和选择性错误捕捉很有帮助。

Promise.resolve() 的用法,有几条规则需要了解:

  • 如果参数是 Promise 实例,那么Promise.resolve将不做任何修改,直接返回这个实例
  • 可以不传入参数调用,直接返回一个resolved状态的 Promise 对象
  • 参数不是对象,会返回一个 Promise 对象,状态为 resolved

Promise.finally()

finally()方法用于指定,不管 Promise 对象最后状态如何,都会执行的操作,类似于try catch语句的finally的功能:

promise.then(result => {···})
.catch(error => {···})
.finally(() => {···})

上面的代码中,不管 promise 的状态如何,finally的回调函数都会被执行,其实 finally 就是 then 的一个特例。

回调函数跟 Promise 都是 Javascript 中处理异步任务的方式,但是回调函数存在许多缺点,所以未来将会被 Promise 所取代。而且,在ES6标准中,Promise 的优先级要高于 CallBack 回调函数的优先级,具体内容可以阅读这篇文章 《Understanding Asynchronous JavaScript》

Generator

Generator 函数是 ES6 提供的一种另一种异步编程解决方案,它其实就是一个状态机,内部封装了一系列状态,然后返回一个遍历器对象,可以通过不断调用这个对象的 next() 方法,来切换到下一个状态。

语法

声明

Generator 函数通过在 function关键字后面跟一个 *来声明,函数体内部使用yield表达式来定义不同的内部状态(yield 在英语里的意思是“产出”)。

function* getNum() {
    yield 1;
    yield 2;
    yield 3;
    return 'result';
}

调用

Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象——遍历器对象(Iterator Object)。

const g = getNum();

next()

Generator函数的返回值提供了一个 next()方法,用于将指针移向下一个状态。

g.next(); // {value: 1, done: false}
g.next(); // {value: 2, done: false}
g.next(); // {value: 3, done: false}
g.next(); // {value: 'result', done: true}

调用next()以后,会返回一个对象,对象中的 value属性就是yield后面表达式的值,而 done表示的是所有的状态是否遍历完,如果所有状态都遍历执行了,那么 done的值就为true

遍历器对象的next方法的运行逻辑如下:

  1. 遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回对象的value属性值。
  2. 下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。
  3. 如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值;如果该函数没有return语句,则返回的对象的value属性值为undefined

next 方法的参数

yield表达式本身总是返回undefined,不过,next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。

function* getNum() {
    console.log(`1--${yield}`);
    console.log(`2--${yield}`);
}

const g = getNum();

g.next();
g.next('茄子'); // 1--茄子
g.next('土豆'); // 2--茄子
g.next();

通过next方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为

使用 for of 循环

for of循环可以自动遍历 Generator 函数运行后返回的遍历器对象,且此时不再需要调用next 方法:

function* foo() {
  yield 1;
  yield 2;
  yield 3;
  yield 4;
  yield 5;
  return 6;
}

for (let num of foo()) {
  console.log(num);
}

// 1 2 3 4 5

注意,一旦next方法的返回对象的done属性为truefor of循环就会中止,且不包含该返回对象,所以上面代码的return语句返回的6,不包括在for of循环之中。

throw()

throw()方法用于在 Generator 函数体外抛出错误,然后在 Generator 函数体内捕获错误

function* getError() {
  	// 使用 try catch 来捕获错误
    try {
        yield '目前正常';
    } catch(e) {
        console.log(e);
    }
};

const g = getError();

g.next(); // {value: '目前正常', done: false}

g.throw(new Error('出错了!')); // Error: 出错了!...

如果 Generator 函数内部没有try...catch,那么throw方法抛出的错误,将被外部de try catch捕获;如果 Generator 函数内部和外部,都没有try catch,那么程序将报错,直接中断执行。

return()

return()方法可以返回给定的值,并且终止遍历 Generator 函数

function* genNum() {
    yield 1;
    yield 2;
    yield 3;
}

var g = genNum();

g.next();        // { value: 1, done: false }
g.return('停止'); // { value: "停止", done: true }
g.next();        // { value: undefined, done: true }

如果 Generator 函数内部有try finally代码块,且正在执行try代码块,那么return()方法会导致立刻进入finally代码块,执行完以后,整个函数才会结束。

yield* 表达式

为了在一个 Generator 函数内部方便的调用另一个 Generator 函数,可以使用 yield*表达式:

function* genNumI() {
    yield 1;
    yield 2;
    yield 3;
    yield 4;
    yield 5;
    yield 6;
    yield 7;
    yield 8;
    yield 9;
}

上面的代码等同于:

function* genNumII() {
    yield 4;
    yield 5;
    yield 6;
}

function* genNumI() {
    yield 1;
    yield 2;
    yield 3;
    yield* genNumII();
    yield 7;
    yield 8;
    yield 9;
}

异步用法

整个 Generator 函数就是一个异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。现在,我们使用 Generator 函数来实现开头的按顺序读取 txt 文件的操作:

const fs = require('fs');
const path = require('path');

// 读取文件内容
function getFileByPath(fpath) {
    return new Promise((resolve, reject) => {
        fs.readFile(fpath, 'utf-8', (err, data) => {
            if(err) {
                console.log(err);
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

// Generator 封装异步操作
function* readTxt() {
    try {
        yield getFileByPath(path.join(__dirname, './src/1.txt'));
        yield getFileByPath(path.join(__dirname, './src/2.txt'));
        yield getFileByPath(path.join(__dirname, './src/3.txt'));
    } catch(error) {
        console.log(error);
    }
}

// 获取遍历器对象
const go = readTxt();

// 第一次 next 调用表示启动 Generator 内部代码
let result = go.next();

// result.value 就是读取函数返回的 Promise 实例
result.value.then(data => {
    console.log('这是读取的数据', data); // 111
})

可以看到,使用 Generator 来封装异步操作以后,在函数内部,所有异步流程看起来就像同步流程,只是每个异步流程前面都跟在yield表达式后面。

虽然 Generator 函数使得异步流程看上去更清晰、更符合直觉,但是流程管理却不方便,也就是说,何时执行第一阶段、何时执行第二阶段不能很好的实现。上面的代码最后的打印结果为 111,但这只是第一个 txt 文件的内容,也就是 Gernerator 函数内部只执行到了第一个 yield,剩下两个yield要怎么执行到呢?我们来重写一下代码:

let result = go.next();

result.value.then(data => {
    console.log('这是读取的数据', data); // 111
}).then(data => {
    result = go.next();
    result.value.then(data => {
        console.log('这是读取的数据', data); // 222
    });
}).then(data => {
    result = go.next();
    result.value.then(data => {
        console.log('这是读取的数据', data); // 333
    });
});

可以看到,代码非常的冗余,可读性也非常差,所以 Gernerator 函数不擅长于流程管理。

async / await

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。async 函数就是 Generator 函数的语法糖。我们使用 async 函数来封装读取 txt 文件内容的操作:

// 异步读取函数与上面相同
// ...

async function readTxt() {
    try {
        const txt1 = await getFileByPath(path.join(__dirname, './src/1.txt'));
        console.log(txt1);

        const txt2 = await getFileByPath(path.join(__dirname, './src/2.txt'));
        console.log(txt2); 

        const txt3 = await getFileByPath(path.join(__dirname, './src/3.txt'));
        console.log(txt3);
    } catch(error) {
        console.log(error);
    }
}

readTxt(); // 111  222  333

一比较就会发现,async 函数就是将 Generator 函数的星号*替换成async,将yield替换成await,仅此而已。

async 函数对 Generator 函数的改进如下:

  1. 自动执行:Generator 函数的执行必须不断调用 next()方法,而async函数自带执行器,只需要一行代码,像普通函数一样调用async函数,然后它就会自动执行,输出最后结果。
  2. 语义更好asyncawait,比起*yield,语义更清楚,async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
  3. 返回 Promise:async 函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了,可以用then方法指定下一步的操作。

错误处理: async 函数的返回值是 Promise 对象,所以可以通过 Promise.catch()来捕获错误,或者像上面代码中那样,将 await以及后面的异步任务放入 try catch中,这样就能捕获到可能的错误。

参考链接: