前端异步编程系列之Generator/async/await函数(4/4)

早些时间,我学习js异步编程,然后就想着把我学习异步编程时的一些体会记录下来,所以就有了现在和之前的几篇文章,之前因为一些事耽搁了,没精力去写这最后一篇了,因为我觉得这一篇写起来可能也不是那么轻松的,不过想了许久,最终还是打算花时间补上,不过时间相隔比较久了,可能感悟也没一开始钻研时那么印象深刻了,我会尽力就这之前我学习时的笔记和测试,编写一个完整的Generator/async/await异步编程介绍,也算是为自己的这个系列画上一个句号吧。

一:Generator函数

Generator函数是什么?Generator 函数是 ES6 提供的一种异步编程解决方案。Generator函数内部有多个状态或者说断点,而这种函数返回一个迭代器(不清楚Iterator对象的,请移步:http://es6.ruanyifeng.com/#docs/iterator)。函数中的yield 用来标识一个断点,Generator函数会执行到yield时停止。只有调用Generator返回的迭代器的next方法,才会开始继续执行,这里的next方法作用在于调试中的下一步。yield表达式本身没有返回值,或者说总是返回undefined。返回的迭代器的next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。ps:第一次的next的参数没用。可以在执行Generator函数时传一个参数。
 

function* gen(data) {
    console.log('gen函数执行')
    let a = yield 15;
    let b = yield 20;
    return a+b+data;
}
// gen函数会在第一次next时开始执行,而不是调用时就执行。
let iterator = gen(12);
iterator.next();  // { value: 15, done: false }
iterator.next();  // { value: 20, done: false }

调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象(Iterator Object)Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。
遍历器对象的next方法的运行逻辑如下。

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

二:Generator与异步

Generator有很多作用,比如可以利用 Generator 函数,可以在任意对象上部署 Iterator 接口,你可以将其作为数据结构,可以提供类似数组的接口等待。不过,最让人在意的,自然还是Generator提供的异步编程解决方案了。
为什么他可以实现异步编程,并且,还可以比较优雅呢???如何实现???

Generator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。

而一般情况下,在异步代码中,我需要等待异步返回真正的结果时,才执行后续代码。而yield的暂停和恢复执行机制可以做到,并且yield的代码书写方式几乎是以同步的方式书写代码,除此之外它还有两个特性,使它可以作为异步编程的完整解决方案:
一:函数体内外的数据交换和错误处理机制。
next返回值的 value 属性,是 Generator 函数向外输出数据;
next方法还可以接受参数,向 Generator 函数体内输入数据。
这是数据交换方式
二:Generator的异常处理可以在内部捕获在外部抛出的错误。这是他的异常处理机制。那么,如何使用Generator真正的封装异步操作?
我们以在node中读取一个两个文件的例子来看一下:

const readFile = path => {
    const promise = new Promise((resolve, reject) => {
        fs.readFile(path,(err,data) => {
            if(err) {
                reject(err);
                return;
            }
            resolve(data);
            return;
        })
    })
    return promise;
}

const readFileGenerator = function* (path) {
    const data1 = yield readFile(path);
    const data2 = yield readFile(data1.toString());
    console.log(data1.toString());
    console.log(data2.toString());
}

// 使用
const test1 = readFileGenerator('./test.txt');
let readPromise = test1.next(); //启动Generator
readPromise.value.then(data => {
    const readPromise = test1.next(data);
    readPromise.value.then(data2 => {
      test1.next(data2)
    });
})
// ./test.txt文件的内容为:./test2.txt
// ./test2.txt文件的内容为:我是test2文件
打印结果:
'./test2.txt'
'我是test2文件'

以上代码是为了读取test文件的内容,test文件中存放的是test2文件的地址,然后用这个地址他来加载test2文件,获取test2中的内容,并打印。

而readFileGenerator 函数首先他可以处理异步任务,读取文件是一个异步操作,而结果也如我们期望的一样,并且他的代码的书写方式除了新增了一个yield关键字之外和同步代码没有任何区别。看起来非常完美的符合了我们所期望的异步处理方式。但是,还是有一点美中不足的:

1.在使用readFileGenerator 函数时,我们还需要调用一次next函数。来开始启动异步任务
2.在其中一个异步任务执行完后,还需要继续调用next来启动Generator函数的下一步。

这就让人无法接受了,那么我们是否可以找一种方法来封装一下上面的两步,让Generator异步函数流程自动执行呢?
有,其中一种是Thunk函数,Thunk 函数是自动执行 Generator 函数的一种方法。那Thunk函数是啥?这里不解释啊,这里只简单的介绍一下如何将Generator函数自动执行的方法即可。想要深入理解Thunk函数的含义,请移步这里:https://segmentfault.com/a/1190000007525293
而另一种就是promise对象了。也是我们下面主要介绍的。

我们想一下,要想让Generator函数自动执行,不用我们操心,需要解决的无非就是:

自动开始Generator函数的执行,即自动进行第一次的next调用。
每次yield暂停时,当异步任务完成后,自动进行下一次next()调用。

那么按照这种设想,先假设yield后面都是跟着异步操作的,那么,异步操作中如何给每个异步任务增加一个自动执行next方法的任务,而又不破坏异步任务本身的代码逻辑呢?这时候,promise就派上用场了,为什么?因为promise的链式的then调用,可以很轻易将自动执行next的任务添加到promise的then上。
如果想让Generator函数进行自动流程管理,它本身肯定是做不到的,那么就需要将Generator函数进行转换了,我们使用一个工具函数来代替我们来自动的执行Generator函数,。
我们通过这个函数来转换Generator函数,让Generator自动执行。那么他的功能有那些?

1.自动开始Generator函数的执行
2.为yield返回的promise增加一个执行了next方法的then和catch方法,每当pormise成功或者失败后进入Generator函数的下一步。

以下是简化版的函数:

const generatorTool = (generator,...ags) => {
    const generatorTest = generator(...ags);
    // 开始启动Generator函数
    const nextData = generatorTest.next();
    next(nextData,generatorTest);

}
const next = (nextData,generatorTest) => {
    if(nextData.done) {
        return;
    }
    if(nextData.value instanceof Promise) {
        return nextData.value.then(data => {
            next(generatorTest.next(data),generatorTest);
        }).catch(err => {
            generatorTest.throw(err);
        })
    }
    else {
        next(generatorTest.next(nextData.value),generatorTest);
    }
}

// generatorTool通过自动执行Generator来简化Generator异步编程,并结合promise,自动迭代。
const test = generatorTool(readFileGenerator,'./test.txt');  // readFileGenerator看例子1
输出结果:
'./test2.txt'
'我是test2文件'

怎么样,这和之前的自己调用Generator函数,并且自己手动为返回的promise绑定then方法进行next迭代的代码是不是简单多了?
他的原理是什么?

首先generatorTool 接受一个Generator方法,能不能,需不需要接受其他参数随意,可用可不用。然后,generatorTool函数开始启动Generator函数。
之后进入到next方法中,首先他接收一个nextData,即每一次Generator的迭代器调用自身的next方法后返回的值,以及这个Generator函数的迭代器对象。
首先判断迭代器是否结束,如果已经结束,则直接返回。然后判断nextData的值即yield返回的值是否是一个promise:
1.如果是一个promise,则为这个promise添加一个then和catch方法,当这个promise完成时,会执行一次next方法,并且进行调用next方法进行绑定。如果这个promise失败时,则使用迭代器对象的throw方法抛出一个错误。
2.如果不是一个promise,那么直接可以进行下一次迭代。

以上我们就通过一个简单的generatorTool函数,实现了Generator函数流程的自动执行。其实如果你使用过Generator函数,那么你一定使用tj大神编写的co库,或者使用过其他类似的库,来转化Generator函数进行异步编程,而tj大神的co库就是利用think函数和promise来进行Generator函数的流程控制,感兴趣的同学可以研究一下其源码,或者移步这里了解更多关于Generator函数的知识:http://es6.ruanyifeng.com/#docs/generator-async


三:async/await与异步

ES2017 标准引入了 async 函数,用来解决异步编程,好像这是号称前端异步编程的终极解决方案,其实,到了这里,也许你只需要学会怎么用即可,他其实就是Generator的语法糖。
为什么这么说呢?因为Generator最大的毛病就在于,他需要我们自己管理异步流程,而async/await就是为了解决这一问题的。他内置了执行器,帮我们进行异步流程的管理。他的执行流程如下(自我总结,非官方):

1.为一个函数声明async关键字,告诉js引擎这个函数是一个async函数,也就是需要进行异步处理的一个函数。
2.async函数内部可以使用await关键字来暂停代码的执行(类似Generator的yield关键字)
3.await后面跟随的是一个promise对象,然后async会等待await后面的那个promise状态变为resolve时,代码会继续往下执行,并且其promsie的返回值就是await关键字的返回值。
4.每碰到一个await,就会暂停一次,直到async函数执行完返回。

注意以下几个点:

1.async返回的是一个promise对象,也就是说,你可以在await后面跟随另外一个async函数
2.async返回的promise对象p1,如果async函数内部,抛出了异常,并且没有捕获,那么异常不会被抛出到async函数之外,而是被p1这个promise对象给捕获,导致p1的状态变为reject。
3.async的返回值,会被他返回的p1这个promise对象使用,如果是返回原始类型,则用于p1的resolve方法的data,如果是抛出异常,则用于p1的reject方法的data。
4.await也可以跟随原始类型,那么此时就等同于同步操作。
5.只要一个await语句后面的 Promise 变为reject,那么整个async函数都会中断执行(我猜测类似于使用了Generator函数的迭代器的throw方法抛出了异常)。如果想继续执行,你可以使用try...catch来包裹代码,进行异常捕获。

6.如果await后面跟的promise已经是处于resolve或者reject,那么他会根据promise的状态进行直接返回或者抛出异常(应该是下一次nextTick或者是类似setTimeout(() => {}, 0)的执行)。

注意ps:如果async中的代码抛出了异常,浏览器不会报错,并且,async中的代码中断执行了,代码也不会如预期中的执行,因为错误被这个async函数返回的promise对象捕获了,所以,有时候你如果只调用了一个async函数,而代码又没有如你的预期那样执行,坑有可能在这,因为错误被async函数返回的promise对象给吃了,而你又没有为他绑定catch方法进行错误处理,所以,你有可能发生看不到任何错误信息,但代码就是不对的情况。还是上一段简单的使用代码吧,具体的使用代码可以移步这里:http://es6.ruanyifeng.com/#docs/async

const readFile = (path) => {
    const promise = new Promise((resolve, reject) => {
        fs.readFile(path, (err, data) => {
            if (err) {
                reject(err);
                return;
            }
            resolve(data);
        });
    });

    return promise;
};


async function asyncPrint() {
    const result1 = await readFile('./test.txt'); 
    const result2 = await readFile('./test2.txt'); 
    console.log(result1);
    console.log(result2);
}

asyncPrint();
输出:
我是test文件
我是test2文件

那么异步编程,如果仅仅是普通的单个的异步执行,肯定是不行的啊,那么如何进行异步协同呢?比如上面那个例子,其实第一个readFile('./test.txt')会阻塞第二个readFile('./test2.txt')的异步加载,因为await会等待readFile('./test.txt')返回后才执行下面的代码。
那如何解决呢?也很简单,因为await后面接受的是一个promise,所以,我们可以使用promise的all方法或者race来组合多个promise异步为一个promise,或者也可以这样:

async function read() {
    const promise1 = readFile('./test.txt'); 
    const promise2 = readFile('./test2.txt'); 

    const result1 = await promise1;
    const result2 = await promise2;
    console.log(result);
}

上述代码中的两个promise:promise1和promise2的异步执行,会立即开始,而不会阻塞,因为在await promise1之前,promise1和promise2其实已经开始执行异步了,所以不会阻塞。

还有一个要注意的,那就是比如forEach循环,比如我想要每隔arr中的秒数,打印一次,按理来说,总共需要9秒左右,即每隔3000毫秒打印一次。看如下代码:

const sleep = function (ms) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve();
    }, ms);
  })
}
async function test () {
  const arr = [3000, 3000, 3000];
  arr.forEach(function (value) {
    await sleep(value);
    console.log(value);
  })
}
test();

注意:上面代码运行会报错:await is only valid in async function
注意了,forEach中传入的回调函数也是一个函数,而回调函数中的await,他算是在那个回调函数中的await,外层的async虽然包裹了await,但没用,await只关心他所在的那个函数是不是async函数,没有向父级找的这个说法。那么改成这样呢?

async function test () {
  const arr = [3000, 3000, 3000];
  arr.forEach(async function (value) {
    await sleep(value);
    console.log(value);
  })
}
打印结果如下:
3000
3000
3000

看不出什么,但是他们是同时输出的,这并不是我想的,我想要的应该是:第一个3秒后,输出3000,然后第二个3秒后又输出3000,再过3秒后又输出3000。那为什么会同时输出呢?因为forEach函数接受的回调函数的遍历,可不会管你是不是异步的,也不会进行等待,所以,三个async的回调函数是同时执行的,要注意这一点(可能有时候正好需要这种)。
可以改成使用for循环:

async function test () {
  const arr = [3000, 3000, 3000];
  for (let i = 0; i < arr.length; i++) {
    await sleep(arr[i]);
    console.log(arr[i]);
  }
}

这样,三个3000就会每隔三秒输出一次了。

总结

异步编程到这里就告一段落了,从我们最开始使用的回调函数,再到我们理解事件的发布/订阅模块,再然后的promise,以及最后的Generator函数和async/await,他们都是在不同时期为了解决异步编程而提出的解决方案,而我们也在探索中,也逐渐完善js异步编程的体验,从最开始的回调地狱,到promise的链式处理,再到后来的Generator函数和async/await可以让我们以同步的方式书写异步代码,改变的不只是我们,还有越来越好的js这门语音本身。

最后,如果你还在纠结要不要学习这个号称js异步编程终极解决方案的async/await,如果你在怕他们的兼容性,我建议你还是学习并尝试使用为好,因为这是大势所趋,至于兼容性问题,我觉得你可以考虑使用Babel来进行语法编译,并且,大部分人都是使用他来进行语法编译,从而可以让我们拥抱新技术,新规范,而无需让我们为兼容性问题担忧,无论是async/await还是Babel他们都值得你去付出时间学习。因为他们会改变你的编程习惯,解放你的思维,不需要为一些无关紧要的问题担忧,让你编写出更好的程序。

更多请参考:阮一峰-ECMAScript 6 入门
http://es6.ruanyifeng.com/#docs/promise#Promise-race
http://es6.ruanyifeng.com/#docs/generator
http://es6.ruanyifeng.com/#docs/generator-async
http://es6.ruanyifeng.com/#docs/async
 

猜你喜欢

转载自blog.csdn.net/qq_33024515/article/details/85122141
今日推荐