这篇文章是讲JS异步原理和实现方式的第四篇文章,前面三篇是:javascript
setTimeout和setImmediate到底谁先执行,本文让你完全理解Event Loop前端
从发布订阅模式入手读懂Node.js的EventEmitter源码java
手写一个Promise/A+,完美经过官方872个测试用例node
本文主要会讲Generator的运用和实现原理,而后咱们会去读一下co模块的源码,最后还会提一下async/await。git
本文所有例子都在GitHub上:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/JavaScript/Generatorgithub
Generator
异步编程一直是JS的核心之一,业界也是一直在探索不一样的解决方法,从“回调地狱”到发布订阅模式,再到Promise,都是在优化异步编程。尽管Promise已经很优秀了,也不会陷入“回调地狱”,可是嵌套层数多了也会有一连串的then
,始终不能像同步代码那样直接往下写就好了。Generator是ES6引入的进一步改善异步编程的方案,下面咱们先来看看基本用法。编程
基本用法
Generator的中文翻译是“生成器”,其实他要干的事情也是一个生成器,一个函数若是加了*
,他就会变成一个生成器函数,他的运行结果会返回一个迭代器对象,好比下面的代码:promise
// gen是一个生成器函数 function* gen() { let a = yield 1; let b = yield a + 2; yield b + 3; } let itor = gen(); // 生成器函数运行后会返回一个迭代器对象,即itor。
next
ES6规范中规定迭代器必须有一个next
方法,这个方法会返回一个对象,这个对象具备done
和value
两个属性,done
表示当前迭代器内容是否已经执行完,执行完为true
,不然为false
,value
表示当前步骤返回的值。在generator
具体运用中,每次遇到yield
关键字都会暂停执行,当调用迭代器的next
时,会将yield
后面表达式的值做为返回对象的value
,好比上面生成器的执行结果以下:网络
咱们能够看到第一次调next
返回的就是第一个yeild
后面表达式的值,也就是1。须要注意的是,整个迭代器目前暂停在了第一个yield
这里,给变量a
赋值都没执行,要调用下一个next
的时候才会给变量a
赋值,而后一直执行到第二个yield
。那应该给a
赋什么值呢?从代码来看,a
的值应该是yield
语句的返回值,可是yield
自己是没有返回值的,或者说返回值是undefined
,若是要给a
赋值须要下次调next
的时候手动传进去,咱们这里传一个4,4就会做为上次yield
的返回值赋给a
:app
能够看到第二个yield
后面的表达式a + 2
的值是6,这是由于咱们传进去的4被做为上一个yield
的返回值了,而后计算a + 2
天然就是6了。
咱们继续next
,把这个迭代器走完:
上图是接着前面运行的,图中第一个next
返回的value
是NaN
是由于咱们调next
的时候没有传参数,也就是说b
为undefined
,undefined + 3
就为NaN
了 。最后一个next
实际上是把函数体执行完了,这时候的value
应该是这个函数return
的值,可是由于咱们没有写return
,默认就是return undefined
了,执行完后done
会被置为true
。
throw
迭代器还有个方法是throw
,这个方法能够在函数体外部抛出错误,而后在函数里面捕获,仍是上面那个例子:
function* gen() { let a = yield 1; let b = yield a + 2; yield b + 3; } let itor = gen();
咱们此次不用next
执行了,直接throw
错误出来:
这个错误由于咱们没有捕获,因此直接抛到最外层来了,咱们能够在函数体里面捕获他,稍微改下:
function* gen() { try { let a = yield 1; let b = yield a + 2; yield b + 3; } catch (e) { console.log(e); } } let itor = gen();
而后再来throw
下:
这个图能够看出来,错误在函数里里面捕获了,走到了catch
里面,这里面只有一个console
同步代码,整个函数直接就运行结束了,因此done
变成true
了,固然catch
里面能够继续写yield
而后用next
来执行。
return
迭代器还有个return
方法,这个方法就很简单了,他会直接终止当前迭代器,将done
置为true
,这个方法的参数就是迭代器的value
,仍是上面的例子:
function* gen() { let a = yield 1; let b = yield a + 2; yield b + 3; } let itor = gen();
此次咱们直接调用return
:
yield*
简单理解,yield*
就是在生成器里面调用另外一个生成器,可是他并不会占用一个next
,而是直接进入被调用的生成器去运行。
function* gen() { let a = yield 1; let b = yield a + 2; } function* gen2() { yield 10 + 5; yield* gen(); } let itor = gen2();
上面代码咱们第一次调用next
,值天然是10 + 5
,即15,而后第二次调用next
,其实就走到了yield*
了,这其实就至关于调用了gen
,而后执行他的第一个yield
,值就是1。
协程
其实Generator就是实现了协程,协程是一个比线程还小的概念。一个进程能够有多个线程,一个线程能够有多个协程,可是一个线程同时只能有一个协程在运行。这个意思就是说若是当前协程能够执行,好比同步代码,那就执行他,若是当前协程暂时不能继续执行,好比他是一个异步读文件的操做,那就将它挂起,而后去执行其余协程,等这个协程结果回来了,能够继续了再来执行他。yield
其实就至关于将当前任务挂起了,下次调用再从这里开始。协程这个概念其实不少年前就已经被提出来了,其余不少语言也有本身的实现。Generator至关于JS实现的协程。
异步应用
前面讲了Generator的基本用法,咱们用它来处理一个异步事件看看。我仍是使用前面文章用到过的例子,三个网络请求,请求3依赖请求2的结果,请求2依赖请求1的结果,若是使用回调是这样的:
const request = require("request"); request('https://www.baidu.com', function (error, response) { if (!error && response.statusCode == 200) { console.log('get times 1'); request('https://www.baidu.com', function(error, response) { if (!error && response.statusCode == 200) { console.log('get times 2'); request('https://www.baidu.com', function(error, response) { if (!error && response.statusCode == 200) { console.log('get times 3'); } }) } }) } });
咱们此次使用Generator来解决“回调地狱”:
const request = require("request"); function* requestGen() { function sendRequest(url) { request(url, function (error, response) { if (!error && response.statusCode == 200) { console.log(response.body); // 注意这里,引用了外部的迭代器itor itor.next(response.body); } }) } const url = 'https://www.baidu.com'; // 使用yield发起三个请求,每一个请求成功后再继续调next const r1 = yield sendRequest(url); console.log('r1', r1); const r2 = yield sendRequest(url); console.log('r2', r2); const r3 = yield sendRequest(url); console.log('r3', r3); } const itor = requestGen(); // 手动调第一个next itor.next();
这个例子中咱们在生成器里面写了一个请求方法,这个方法会去发起网络请求,每次网络请求成功后又继续调用next执行后面的yield
,最后是在外层手动调一个next
触发这个流程。这其实就相似一个尾调用,这样写能够达到效果,可是在requestGen
里面引用了外面的迭代器itor
,耦合很高,并且很差复用。
thunk函数
为了解决前面说的耦合高,很差复用的问题,就有了thunk函数。thunk函数理解起来有点绕,我先把代码写出来,而后再一步一步来分析它的执行顺序:
function Thunk(fn) { return function(...args) { return function(callback) { return fn.call(this, ...args, callback) } } } function run(fn) { let gen = fn(); function next(err, data) { let result = gen.next(data); if(result.done) return; result.value(next); } next(); } // 使用thunk方法 const request = require("request"); const requestThunk = Thunk(request); function* requestGen() { const url = 'https://www.baidu.com'; let r1 = yield requestThunk(url); console.log(r1.body); let r2 = yield requestThunk(url); console.log(r2.body); let r3 = yield requestThunk(url); console.log(r3.body); } // 启动运行 run(requestGen);
这段代码里面的Thunk函数返回了好几层函数,咱们从他的使用入手一层一层剥开看:
-
requestThunk
是Thunk运行的返回值,也就是第一层返回值,参数是request
,也就是:function(...args) { return function(callback) { return request.call(this, ...args, callback); // 注意这里调用的是request } }
-
run
函数的参数是生成器,咱们看看他到底干了啥:-
run里面先调用生成器,拿到迭代器
gen
,而后自定义了一个next
方法,并调用这个next
方法,为了便于区分,我这里称这个自定义的next
为局部next
-
局部
next
会调用生成器的next
,生成器的next
其实就是yield requestThunk(url)
,参数是咱们传进去的url
,这就调到咱们前面的那个方法,这个yield
返回的value
实际上是:function(callback) { return request.call(this, url, callback); }
-
检测迭代器是否已经迭代完毕,若是没有,就继续调用第二步的这个函数,这个函数其实才真正的去
request
,这时候传进去的参数是局部next
,局部next
也做为了request
的回调函数。 -
这个回调函数在执行时又会调
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是只接收回调函数的函数形式,咱们使用时须要调整下:
// 仍是以前的thunk函数 function Thunk(fn) { return function(...args) { return function(callback) { return fn.call(this, ...args, callback) } } } // 将咱们须要的request转换成thunk const request = require('request'); const requestThunk = Thunk(request); // 转换后的requestThunk其实能够直接用了 // 用法就是 requestThunk(url)(callback) // 可是咱们co接收的thunk是 fn(callback)形式 // 咱们转换一下 // 这时候的baiduRequest也是一个函数,url已经传好了,他只须要一个回调函数作参数就行 // 使用就是这样:baiduRequest(callback) const baiduRequest = requestThunk('https://www.baidu.com'); // 引入co执行, co的参数是一个Generator // co的返回值是一个Promise,咱们能够用then拿到他的结果 const co = require('co'); co(function* () { const r1 = yield baiduRequest; const r2 = yield baiduRequest; const r3 = yield baiduRequest; return { r1, r2, r3, } }).then((res) => { // then里面就能够直接拿到前面返回的{r1, r2, r3} console.log(res); });
支持Promise
其实co官方是建议yield后面跟Promise的,虽然支持thunk,可是将来可能会移除。使用Promise,咱们代码写起来其实更简单,直接用fetch就行,不用包装Thunk。
const fetch = require('node-fetch'); const co = require('co'); co(function* () { // 直接用fetch,简单多了,fetch返回的就是Promise const r1 = yield fetch('https://www.baidu.com'); const r2 = yield fetch('https://www.baidu.com'); const r3 = yield fetch('https://www.baidu.com'); return { r1, r2, r3, } }).then((res) => { // 这里一样能够拿到{r1, r2, r3} console.log(res); });
源码分析
本文的源码分析基于co模块4.6.0版本,源码:https://github.com/tj/co/blob/master/index.js
仔细看源码会发现他代码并很少,总共两百多行,一半都是在进行yield后面的参数检测和处理,检测他是否是Promise,若是不是就转换为Promise,因此即便你yield后面传的thunk,他仍是会转换成Promise处理。转换Promise的代码相对比较独立和简单,我这里不详细展开了,这里主要仍是讲一讲核心方法co(gen)
。下面是我复制的去掉了注释的简化代码:
function co(gen) { var ctx = this; var args = slice.call(arguments, 1); return new Promise(function(resolve, reject) { if (typeof gen === 'function') gen = gen.apply(ctx, args); if (!gen || typeof gen.next !== 'function') return resolve(gen); onFulfilled(); function onFulfilled(res) { var ret; try { ret = gen.next(res); } catch (e) { return reject(e); } next(ret); return null; } function onRejected(err) { var ret; try { ret = gen.throw(err); } catch (e) { return reject(e); } next(ret); } function next(ret) { if (ret.done) return resolve(ret.value); var value = toPromise.call(ctx, ret.value); if (value && isPromise(value)) return value.then(onFulfilled, onRejected); return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, ' + 'but the following object was passed: "' + String(ret.value) + '"')); } }); }
-
从总体结构看,co的参数是一个Generator,返回值是一个Promise,几乎全部逻辑代码都在这个Promise里面,这也是咱们使用时用then拿结果的缘由。
-
Promise里面先把Generator拿出来执行,获得一个迭代器
gen
-
手动调用一次
onFulfilled
,开启迭代onFulfilled
接收一个参数res
,第一次调用是没有传这个参数,这个参数主要是用来接收后面的then返回的结果。- 而后调用
gen.next
,注意这个的返回值ret的形式是{value, done},而后将这个ret传给局部的next
-
而后执行局部next,他接收的参数是yield返回值{value, done}
- 这里先检测迭代是否完成,若是完成了,就直接将整个promise resolve。
- 这里的value是yield后面表达式的值,多是thunk,也多是promise
- 将value转换成promise
- 将转换后的promise拿出来执行,成功的回调是前面的
onFulfilled
-
咱们再来看下
onFulfilled
,这是第二次执行onFulfilled
了。此次执行的时候传入的参数res是上次异步promise的执行结果,对应咱们的fetch就是拿回来的数据,这个数据传给第二个gen.next
,效果就是咱们代码里面的赋值给了第一个yield
前面的变量r1
。而后继续局部next,这个next其实就是执行第二个异步Promise了。这个promise的成功回调又继续调用gen.next
,这样就不断的执行下去,直到done
变成true
为止。 -
最后看一眼
onRejected
方法,这个方法其实做为了异步promise的错误分支,这个函数里面直接调用了gen.throw
,这样咱们在Generator里面能够直接用try...catch...
拿到错误。须要注意的是gen.throw
后面还继续调用了next(ret)
,这是由于在Generator的catch
分支里面还可能继续有yield
,好比错误上报的网络请求,这时候的迭代器并不必定结束了。
async/await
最后提一下async/await
,先来看一下用法:
const fetch = require('node-fetch'); async function sendRequest () { const r1 = await fetch('https://www.baidu.com'); const r2 = await fetch('https://www.baidu.com'); const r3 = await fetch('https://www.baidu.com'); return { r1, r2, r3, } } // 注意async返回的也是一个promise sendRequest().then((res) => { console.log('res', res); });
咋一看这个跟前面promise版的co是否是很像,返回值都是一个promise,只是Generator换成了一个async
函数,函数里面的yield
换成了await
,并且外层不须要co来包裹也能够自动执行了。其实async函数就是Generator加自动执行器的语法糖,能够理解为从语言层面支持了Generator的自动执行。上面这段代码跟co版的promise其实就是等价的。
总结
- Generator是一种更现代的异步解决方案,在JS语言层面支持了协程
- Generator的返回值是一个迭代器
- 这个迭代器须要手动调
next
才能一条一条执行yield
next
的返回值是{value, done},value
是yield后面表达式的值yield
语句自己并无返回值,下次调next
的参数会做为上一个yield
语句的返回值- Generator本身不能自动执行,要自动执行须要引入其余方案,前面讲
thunk
的时候提供了一种方案,co
模块也是一个很受欢迎的自动执行方案 - 这两个方案的思路有点相似,都是先写一个局部的方法,这个方法会去调用
gen.next
,同时这个方法自己又会传到回调函数或者promise的成功分支里面,异步结束后又继续调用这个局部方法,这个局部方法又调用gen.next
,这样一直迭代,直到迭代器执行完毕。 async/await
实际上是Generator和自动执行器的语法糖,写法和实现原理都相似co模块的promise模式。
文章的最后,感谢你花费宝贵的时间阅读本文,若是本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是做者持续创做的动力。
“前端进阶知识”系列文章及示例源码: https://github.com/dennis-jiang/Front-End-Knowledges
欢迎关注个人公众号进击的大前端第一时间获取高质量原创~