这篇文章是讲JS异步原理和实现方式的第四篇文章,前面三篇是:

setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop

从发布订阅模式入手读懂Node.js的EventEmitter源码

手写一个Promise/A+,完美通过官方872个测试用例

本文主要会讲Generator的运用和实现原理,然后我们会去读一下co模块的源码,最后还会提一下async/await。

本文全部例子都在GitHub上:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/JavaScript/Generator

Generator

异步编程一直是JS的核心之一,业界也是一直在探索不同的解决方法,从“回调地狱”到发布订阅模式,再到Promise,都是在优化异步编程。尽管Promise已经很优秀了,也不会陷入“回调地狱”,但是嵌套层数多了也会有一连串的then,始终不能像同步代码那样直接往下写就行了。Generator是ES6引入的进一步改善异步编程的方案,下面我们先来看看基本用法。

基本用法

Generator的中文翻译是“生成器”,其实他要干的事情也是一个生成器,一个函数如果加了*,他就会变成一个生成器函数,他的运行结果会返回一个迭代器对象,比如下面的代码:

  1. // gen是一个生成器函数
  2. function* gen() {
  3. let a = yield 1;
  4. let b = yield a + 2;
  5. yield b + 3;
  6. }
  7. let itor = gen(); // 生成器函数运行后会返回一个迭代器对象,即itor。

next

ES6规范中规定迭代器必须有一个next方法,这个方法会返回一个对象,这个对象具有donevalue两个属性,done表示当前迭代器内容是否已经执行完,执行完为true,否则为falsevalue表示当前步骤返回的值。在generator具体运用中,每次遇到yield关键字都会暂停执行,当调用迭代器的next时,会将yield后面表达式的值作为返回对象的value,比如上面生成器的执行结果如下:

我们可以看到第一次调next返回的就是第一个yeild后面表达式的值,也就是1。需要注意的是,整个迭代器目前暂停在了第一个yield这里,给变量a赋值都没执行,要调用下一个next的时候才会给变量a赋值,然后一直执行到第二个yield。那应该给a赋什么值呢?从代码来看,a的值应该是yield语句的返回值,但是yield本身是没有返回值的,或者说返回值是undefined,如果要给a赋值需要下次调next的时候手动传进去,我们这里传一个4,4就会作为上次yield的返回值赋给a:

可以看到第二个yield后面的表达式a + 2的值是6,这是因为我们传进去的4被作为上一个yield的返回值了,然后计算a + 2自然就是6了。

我们继续next,把这个迭代器走完:

上图是接着前面运行的,图中第一个next返回的valueNaN是因为我们调next的时候没有传参数,也就是说bundefinedundefined + 3就为NaN了 。最后一个next其实是把函数体执行完了,这时候的value应该是这个函数return的值,但是因为我们没有写return,默认就是return undefined了,执行完后done会被置为true

throw

迭代器还有个方法是throw,这个方法可以在函数体外部抛出错误,然后在函数里面捕获,还是上面那个例子:

  1. function* gen() {
  2. let a = yield 1;
  3. let b = yield a + 2;
  4. yield b + 3;
  5. }
  6. let itor = gen();

我们这次不用next执行了,直接throw错误出来:

这个错误因为我们没有捕获,所以直接抛到最外层来了,我们可以在函数体里面捕获他,稍微改下:

  1. function* gen() {
  2. try {
  3. let a = yield 1;
  4. let b = yield a + 2;
  5. yield b + 3;
  6. } catch (e) {
  7. console.log(e);
  8. }
  9. }
  10. let itor = gen();

然后再来throw下:

这个图可以看出来,错误在函数里里面捕获了,走到了catch里面,这里面只有一个console同步代码,整个函数直接就运行结束了,所以done变成true了,当然catch里面可以继续写yield然后用next来执行。

return

迭代器还有个return方法,这个方法就很简单了,他会直接终止当前迭代器,将done置为true,这个方法的参数就是迭代器的value,还是上面的例子:

  1. function* gen() {
  2. let a = yield 1;
  3. let b = yield a + 2;
  4. yield b + 3;
  5. }
  6. let itor = gen();

这次我们直接调用return:

yield*

简单理解,yield*就是在生成器里面调用另一个生成器,但是他并不会占用一个next,而是直接进入被调用的生成器去运行。

  1. function* gen() {
  2. let a = yield 1;
  3. let b = yield a + 2;
  4. }
  5. function* gen2() {
  6. yield 10 + 5;
  7. yield* gen();
  8. }
  9. let itor = gen2();

上面代码我们第一次调用next,值自然是10 + 5,即15,然后第二次调用next,其实就走到了yield*了,这其实就相当于调用了gen,然后执行他的第一个yield,值就是1。

协程

其实Generator就是实现了协程,协程是一个比线程还小的概念。一个进程可以有多个线程,一个线程可以有多个协程,但是一个线程同时只能有一个协程在运行。这个意思就是说如果当前协程可以执行,比如同步代码,那就执行他,如果当前协程暂时不能继续执行,比如他是一个异步读文件的操作,那就将它挂起,然后去执行其他协程,等这个协程结果回来了,可以继续了再来执行他。yield其实就相当于将当前任务挂起了,下次调用再从这里开始。协程这个概念其实很多年前就已经被提出来了,其他很多语言也有自己的实现。Generator相当于JS实现的协程。

异步应用

前面讲了Generator的基本用法,我们用它来处理一个异步事件看看。我还是使用前面文章用到过的例子,三个网络请求,请求3依赖请求2的结果,请求2依赖请求1的结果,如果使用回调是这样的:

  1. const request = require("request");
  2. request('https://www.baidu.com', function (error, response) {
  3. if (!error && response.statusCode == 200) {
  4. console.log('get times 1');
  5. request('https://www.baidu.com', function(error, response) {
  6. if (!error && response.statusCode == 200) {
  7. console.log('get times 2');
  8. request('https://www.baidu.com', function(error, response) {
  9. if (!error && response.statusCode == 200) {
  10. console.log('get times 3');
  11. }
  12. })
  13. }
  14. })
  15. }
  16. });

我们这次使用Generator来解决“回调地狱”:

  1. const request = require("request");
  2. function* requestGen() {
  3. function sendRequest(url) {
  4. request(url, function (error, response) {
  5. if (!error && response.statusCode == 200) {
  6. console.log(response.body);
  7. // 注意这里,引用了外部的迭代器itor
  8. itor.next(response.body);
  9. }
  10. })
  11. }
  12. const url = 'https://www.baidu.com';
  13. // 使用yield发起三个请求,每个请求成功后再继续调next
  14. const r1 = yield sendRequest(url);
  15. console.log('r1', r1);
  16. const r2 = yield sendRequest(url);
  17. console.log('r2', r2);
  18. const r3 = yield sendRequest(url);
  19. console.log('r3', r3);
  20. }
  21. const itor = requestGen();
  22. // 手动调第一个next
  23. itor.next();

这个例子中我们在生成器里面写了一个请求方法,这个方法会去发起网络请求,每次网络请求成功后又继续调用next执行后面的yield,最后是在外层手动调一个next触发这个流程。这其实就类似一个尾调用,这样写可以达到效果,但是在requestGen里面引用了外面的迭代器itor,耦合很高,而且不好复用。

thunk函数

为了解决前面说的耦合高,不好复用的问题,就有了thunk函数。thunk函数理解起来有点绕,我先把代码写出来,然后再一步一步来分析它的执行顺序:

  1. function Thunk(fn) {
  2. return function(...args) {
  3. return function(callback) {
  4. return fn.call(this, ...args, callback)
  5. }
  6. }
  7. }
  8. function run(fn) {
  9. let gen = fn();
  10. function next(err, data) {
  11. let result = gen.next(data);
  12. if(result.done) return;
  13. result.value(next);
  14. }
  15. next();
  16. }
  17. // 使用thunk方法
  18. const request = require("request");
  19. const requestThunk = Thunk(request);
  20. function* requestGen() {
  21. const url = 'https://www.baidu.com';
  22. let r1 = yield requestThunk(url);
  23. console.log(r1.body);
  24. let r2 = yield requestThunk(url);
  25. console.log(r2.body);
  26. let r3 = yield requestThunk(url);
  27. console.log(r3.body);
  28. }
  29. // 启动运行
  30. run(requestGen);

这段代码里面的Thunk函数返回了好几层函数,我们从他的使用入手一层一层剥开看:

  1. requestThunk是Thunk运行的返回值,也就是第一层返回值,参数是request,也就是:

    1. function(...args) {
    2. return function(callback) {
    3. return request.call(this, ...args, callback); // 注意这里调用的是request
    4. }
    5. }
  2. run函数的参数是生成器,我们看看他到底干了啥:

    1. run里面先调用生成器,拿到迭代器gen,然后自定义了一个next方法,并调用这个next方法,为了便于区分,我这里称这个自定义的next为局部next

    2. 局部next会调用生成器的next,生成器的next其实就是yield requestThunk(url),参数是我们传进去的url,这就调到我们前面的那个方法,这个yield返回的value其实是:

      1. function(callback) {
      2. return request.call(this, url, callback);
      3. }
    3. 检测迭代器是否已经迭代完毕,如果没有,就继续调用第二步的这个函数,这个函数其实才真正的去request,这时候传进去的参数是局部next,局部next也作为了request的回调函数。

    4. 这个回调函数在执行时又会调gen.next,这样生成器就可以继续往下执行了,同时gen.next的参数是回调函数的data,这样,生成器里面的r1其实就拿到了请求的返回值。

Thunk函数就是这样一种可以自动执行Generator的函数,因为Thunk函数的包装,我们在Generator里面可以像同步代码那样直接拿到yield异步代码的返回值。

co模块

co模块是一个很受欢迎的模块,他也可以自动执行Generator,他的yield后面支持thunk和Promise,我们先来看看他的基本使用,然后再去分析下他的源码。

官方GitHub:https://github.com/tj/co

基本使用

支持thunk

前面我们讲了thunk函数,我们还是从thunk函数开始。代码还是用我们前面写的thunk函数,但是因为co支持的thunk是只接收回调函数的函数形式,我们使用时需要调整下:

  1. // 还是之前的thunk函数
  2. function Thunk(fn) {
  3. return function(...args) {
  4. return function(callback) {
  5. return fn.call(this, ...args, callback)
  6. }
  7. }
  8. }
  9. // 将我们需要的request转换成thunk
  10. const request = require('request');
  11. const requestThunk = Thunk(request);
  12. // 转换后的requestThunk其实可以直接用了
  13. // 用法就是 requestThunk(url)(callback)
  14. // 但是我们co接收的thunk是 fn(callback)形式
  15. // 我们转换一下
  16. // 这时候的baiduRequest也是一个函数,url已经传好了,他只需要一个回调函数做参数就行
  17. // 使用就是这样:baiduRequest(callback)
  18. const baiduRequest = requestThunk('https://www.baidu.com');
  19. // 引入co执行, co的参数是一个Generator
  20. // co的返回值是一个Promise,我们可以用then拿到他的结果
  21. const co = require('co');
  22. co(function* () {
  23. const r1 = yield baiduRequest;
  24. const r2 = yield baiduRequest;
  25. const r3 = yield baiduRequest;
  26. return {
  27. r1,
  28. r2,
  29. r3,
  30. }
  31. }).then((res) => {
  32. // then里面就可以直接拿到前面返回的{r1, r2, r3}
  33. console.log(res);
  34. });

支持Promise

其实co官方是建议yield后面跟Promise的,虽然支持thunk,但是未来可能会移除。使用Promise,我们代码写起来其实更简单,直接用fetch就行,不用包装Thunk。

  1. const fetch = require('node-fetch');
  2. const co = require('co');
  3. co(function* () {
  4. // 直接用fetch,简单多了,fetch返回的就是Promise
  5. const r1 = yield fetch('https://www.baidu.com');
  6. const r2 = yield fetch('https://www.baidu.com');
  7. const r3 = yield fetch('https://www.baidu.com');
  8. return {
  9. r1,
  10. r2,
  11. r3,
  12. }
  13. }).then((res) => {
  14. // 这里同样可以拿到{r1, r2, r3}
  15. console.log(res);
  16. });

源码分析

本文的源码分析基于co模块4.6.0版本,源码:https://github.com/tj/co/blob/master/index.js

仔细看源码会发现他代码并不多,总共两百多行,一半都是在进行yield后面的参数检测和处理,检测他是不是Promise,如果不是就转换为Promise,所以即使你yield后面传的thunk,他还是会转换成Promise处理。转换Promise的代码相对比较独立和简单,我这里不详细展开了,这里主要还是讲一讲核心方法co(gen)。下面是我复制的去掉了注释的简化代码:

  1. function co(gen) {
  2. var ctx = this;
  3. var args = slice.call(arguments, 1);
  4. return new Promise(function(resolve, reject) {
  5. if (typeof gen === 'function') gen = gen.apply(ctx, args);
  6. if (!gen || typeof gen.next !== 'function') return resolve(gen);
  7. onFulfilled();
  8. function onFulfilled(res) {
  9. var ret;
  10. try {
  11. ret = gen.next(res);
  12. } catch (e) {
  13. return reject(e);
  14. }
  15. next(ret);
  16. return null;
  17. }
  18. function onRejected(err) {
  19. var ret;
  20. try {
  21. ret = gen.throw(err);
  22. } catch (e) {
  23. return reject(e);
  24. }
  25. next(ret);
  26. }
  27. function next(ret) {
  28. if (ret.done) return resolve(ret.value);
  29. var value = toPromise.call(ctx, ret.value);
  30. if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
  31. return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
  32. + 'but the following object was passed: "' + String(ret.value) + '"'));
  33. }
  34. });
  35. }
  1. 从整体结构看,co的参数是一个Generator,返回值是一个Promise,几乎所有逻辑代码都在这个Promise里面,这也是我们使用时用then拿结果的原因。

  2. Promise里面先把Generator拿出来执行,得到一个迭代器gen

  3. 手动调用一次onFulfilled,开启迭代

    1. onFulfilled接收一个参数res,第一次调用是没有传这个参数,这个参数主要是用来接收后面的then返回的结果。
    2. 然后调用gen.next,注意这个的返回值ret的形式是{value, done},然后将这个ret传给局部的next
  4. 然后执行局部next,他接收的参数是yield返回值{value, done}

    1. 这里先检测迭代是否完成,如果完成了,就直接将整个promise resolve。
    2. 这里的value是yield后面表达式的值,可能是thunk,也可能是promise
    3. 将value转换成promise
    4. 将转换后的promise拿出来执行,成功的回调是前面的onFulfilled
  5. 我们再来看下onFulfilled,这是第二次执行onFulfilled了。这次执行的时候传入的参数res是上次异步promise的执行结果,对应我们的fetch就是拿回来的数据,这个数据传给第二个gen.next,效果就是我们代码里面的赋值给了第一个yield前面的变量r1。然后继续局部next,这个next其实就是执行第二个异步Promise了。这个promise的成功回调又继续调用gen.next,这样就不断的执行下去,直到done变成true为止。

  6. 最后看一眼onRejected方法,这个方法其实作为了异步promise的错误分支,这个函数里面直接调用了gen.throw,这样我们在Generator里面可以直接用try...catch...拿到错误。需要注意的是gen.throw后面还继续调用了next(ret),这是因为在Generator的catch分支里面还可能继续有yield,比如错误上报的网络请求,这时候的迭代器并不一定结束了。

async/await

最后提一下async/await,先来看一下用法:

  1. const fetch = require('node-fetch');
  2. async function sendRequest () {
  3. const r1 = await fetch('https://www.baidu.com');
  4. const r2 = await fetch('https://www.baidu.com');
  5. const r3 = await fetch('https://www.baidu.com');
  6. return {
  7. r1,
  8. r2,
  9. r3,
  10. }
  11. }
  12. // 注意async返回的也是一个promise
  13. sendRequest().then((res) => {
  14. console.log('res', res);
  15. });

咋一看这个跟前面promise版的co是不是很像,返回值都是一个promise,只是Generator换成了一个async函数,函数里面的yield换成了await,而且外层不需要co来包裹也可以自动执行了。其实async函数就是Generator加自动执行器的语法糖,可以理解为从语言层面支持了Generator的自动执行。上面这段代码跟co版的promise其实就是等价的。

总结

  1. Generator是一种更现代的异步解决方案,在JS语言层面支持了协程
  2. Generator的返回值是一个迭代器
  3. 这个迭代器需要手动调next才能一条一条执行yield
  4. next的返回值是{value, done},value是yield后面表达式的值
  5. yield语句本身并没有返回值,下次调next的参数会作为上一个yield语句的返回值
  6. Generator自己不能自动执行,要自动执行需要引入其他方案,前面讲thunk的时候提供了一种方案,co模块也是一个很受欢迎的自动执行方案
  7. 这两个方案的思路有点类似,都是先写一个局部的方法,这个方法会去调用gen.next,同时这个方法本身又会传到回调函数或者promise的成功分支里面,异步结束后又继续调用这个局部方法,这个局部方法又调用gen.next,这样一直迭代,直到迭代器执行完毕。
  8. async/await其实是Generator和自动执行器的语法糖,写法和实现原理都类似co模块的promise模式。

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

作者掘金文章汇总:https://juejin.im/post/5e3ffc85518825494e2772fd

从Generator入手读懂co模块源码的更多相关文章

  1. 【 js 模块加载 】深入学习模块化加载(node.js 模块源码)

    一.模块规范 说到模块化加载,就不得先说一说模块规范.模块规范是用来约束每个模块,让其必须按照一定的格式编写.AMD,CMD,CommonJS 是目前最常用的三种模块化书写规范.  1.AMD(Asy ...

  2. dubbo面试题,会这些说明你真正看懂了dubbo源码

    整理了一些dubbo可能会被面试的面试题,感觉非常不错.如果你基本能回答说明你看懂了dubbo源码,对dubbo了解的足够全面.你可以尝试看能不能回答下.我们一起看下有哪些问题吧? 1.dubbo中& ...

  3. 【 js 模块加载 】【源码学习】深入学习模块化加载(node.js 模块源码)

    文章提纲: 第一部分:介绍模块规范及之间区别 第二部分:以 node.js 实现模块化规范 源码,深入学习. 一.模块规范 说到模块化加载,就不得先说一说模块规范.模块规范是用来约束每个模块,让其必须 ...

  4. 【nodejs原理&源码赏析(4)】深度剖析cluster模块源码与node.js多进程(上)

    [摘要] 集群管理模块cluster浅析 示例代码托管在:http://www.github.com/dashnowords/blogs 一. 概述 cluster模块是node.js中用于实现和管理 ...

  5. 【nodejs原理&源码赏析(4)】深度剖析cluster模块源码与node.js多进程(上)

    目录 一. 概述 二. 线程与进程 三. cluster模块源码解析 3.1 起步 3.2 入口 3.3 主进程模块master.js 3.4 子进程模块child.js 四. 小结 示例代码托管在: ...

  6. 「从零单排canal 06」 instance模块源码解析

    基于1.1.5-alpha版本,具体源码笔记可以参考我的github:https://github.com/saigu/JavaKnowledgeGraph/tree/master/code_read ...

  7. XposedNoRebootModuleSample 不需要频繁重启调试的Xposed 模块源码例子

    XposedNoRebootModuleSample(不需要频繁重启调试的Xposed 模块源码例子) Xposed Module Sample No Need To Reboot When Debu ...

  8. nginx健康检查模块源码分析

    nginx健康检查模块 本文所说的nginx健康检查模块是指nginx_upstream_check_module模块.nginx_upstream_check_module模块是Taobao定制的用 ...

  9. Spark Scheduler模块源码分析之TaskScheduler和SchedulerBackend

    本文是Scheduler模块源码分析的第二篇,第一篇Spark Scheduler模块源码分析之DAGScheduler主要分析了DAGScheduler.本文接下来结合Spark-1.6.0的源码继 ...

随机推荐

  1. SSI服务器端包含注入

    服务器端嵌入:Server Side Include,是一种类似于ASP的基于服务器的网页制作技术.大多数(尤其是基于Unix平台)的WEB服务器如Netscape Enterprise Server ...

  2. 我们是怎么实现Grpc CodeFirst

    前言: Grpc默认是ProtoFirst的,即先写 proto文件,再生成代码,需要人工维护proto,生成的代码也不友好,所以出现了Grpc CodeFirst,下面来说说我们是怎么实现Grpc ...

  3. 采用vue编写的功能强大的swagger-ui页面

    think-swagger-ui-vuele swagger-ui有非常多的版本,觉得不太好用,用postman,每个接口都要自己进行录入.所以在基于think-vuele进行了swagger格式js ...

  4. 10行Python代码计算汽车数量

    当你还是个孩子坐车旅行的时候,你玩过数经过的汽车的数目的游戏吗? 在这篇文章中,我将教你如何使用10行Python代码构建自己的汽车计数程序. 以下是环境及相应的版本库: Python版本 3.6.9 ...

  5. UVA - 10200 Prime Time 关于 double类型 卡精度

    题意: 给定一个区间,a到b, n在区间内,有一个计算素数的公式,n*n+n+41,将n带进去可以得出一个数字.但是这个公式可能不准确,求出这个公式在这个区间内的准确率. 直接模拟就好了,不过要 注意 ...

  6. 有个原则就是实体类还是controller入参都应该是 包装类型

    问题说明 我在使用JPA作为项目的ORM框架的时候,在分页查询中,不管咋样使用查询不出来数据,然后发现Hibernate构建的查询SQL中,在where子句中带上了createTime=0这个条件.这 ...

  7. Springcloud zuul 实现API 网关

    1,https://github.com/Netflix/zuul zuul 网关文档 2,什么是网关 网关就是客户端进行访问的时候,先经过网关服务器,再由网关服务器进行转发到真实的服务器.类似于Ng ...

  8. NCEP CFSR数据下载

    一.简介 CFSR(Climate Forecast SystemReanalysis)再分析资料使用了 GEOS-5(Goddard EarthObserving System)大气模式与资料同化系 ...

  9. Python常用模块之模块的使用

      一 模块介绍 1.什么是模块? #常见的场景:一个模块就是一个包含了一组功能的python文件,比如spam.py,模块名为spam,可以通过import spam使用. #在python中,模块 ...

  10. rem布局和使用js rem动态改变字体大小,自适应

    解决rem文字动态改变字体大小: <script> console.log(window.devicePixelRatio); var iScale = 1; iScale = iScal ...