背景
阮一峰在《es6标准入门》一书中,对async和await的讲解中有这样一句话:async和await其实是generator的语法糖,所以想真正理解async和await,深入学习一下generator是有必要的,本篇文章会对generator的核心流程手写重现,并分析一下相关源码,了解实现流程。
generator简介
我们日常开发中,其实对于generator的应用应该是比较少的,所以先简单介绍一下generator。
什么是generator
generator翻译过来就是发生器、生成器的意思,而实际上generator就是一个比较特殊的函数。
与普通函数写法上的区别
先看一下一个最简单的generator代码
function* gen(){
yield 'result1'
yield 'result2'
yield 'result3'
return 'ending'
}
从代码中可以看出,与普通函数的写法上的区别主要有两点,一点为function关键字后有一个星号,另一点为内部使用yield关键字来声明了一系列的状态
与普通函数使用上的区别
依然先看例子
function* gen(){
yield 'result1'
yield 'result2'
yield 'result3'
return 'ending'
}
var demo = gen() // 返回值为迭代器对象
console.log(demo.next()) // {value:'result1',done:false}
console.log(demo.next()) // {value:'result2',done:false}
console.log(demo.next()) // {value:'result3',done:false}
console.log(demo.next()) // {value:'ending',done:true}
console.log(demo.next()) // {value:undefind,done:true}
var demo2 = gen() // 一个全新的对象
console.log(demo.next()) // {value:'result1',done:false}
从代码中看出,调用定义的generator,获取到的实际上是一个对象,而且是互相独立的对象,到这可以理解为什么会起这样一个名字,因为每一个generator(生成器)调用的时候,都可以理解为生成了一个迭代器对象,这个对象中用next方法,当我们调用next方法时,代码会分段执行,每次执行到遇到yield为止,返回值为一个包含value和done的对象,value为我们yield声明的状态。当全部yield执行完后,done会变成true,如果我们遇到了return语句,会把返回值作为最终的value,如果没有就直接返回undefined。
以上就是generator的主逻辑,通俗的讲,其实就是一个思想,将函数的执行权交给了使用者,分段执行。
这一点其实和async和await是有一定的相似之处的,async和await是把await后面的代码暂时挂起,等待await的代码块执行完毕,再执行后面,所以说async和await是generator的语法糖是说得通的。
手写generator
接下来,我们从一个最简单的例子开始实现代码的手写。
function* gen(){
yield 'result1'
yield 'result2'
yield 'result3'
return 'ending'
}
首先我们整理一下思路,我们每次调用,都会执行到下一个yield,并返回我们设定的状态,如果用最基本的逻辑,我们可以选择用一个变量来确认我们执行到了哪一步,然后返回不同的返回值,如果不考虑其他,单纯识别执行步骤和返回结果,这里用switch case来实现是最简单的。
function gen$(nextStep){
switch (nextStep){
case 0 :
return 'result1';
case 1 :
return 'result2';
case 2 :
return 'result3';
case 3 :
return 'ending'
}
}
我们按最基本的思路,实现了一个简单的识别执行步骤,再进行返回的逻辑。这个函数可以是我们手写generator的一部分,nextStep在调用的时候传入,那么这个nextStep就有了一些说法,我们是让这个变量变成闭包变量,还是变成全局变量,就需要进行选择。
我们在这里可以简单思考一下,回想一下之前写的demo,在yield语句是否全部执行完,返回的done状态是不一样的,而done是根据执行的步骤产生的变化,所以大概率跟nextStep是同级的变量,如果较多变量变成闭包变量,这肯定是不太合适的,所以这里我们选择用全局变量的方式。
var context = {
prev: 0,
next: 0
};
function gen$(context) {
switch (context.prev = context.next) {
case 0:
context.next = 1;
return 'result1';
case 1:
context.next = 2;
return 'result2';
case 2:
context.next = 3;
return 'result3';
case 3:
return 'ending';
}
}
目前为止,我们只需要上一步、下一步两个变量,但是后续功能逐渐完善之后,我们还可能会继续添加其他的方法、属性等,所以在这里声明一个上下文对象context,作为后续全局变量、方法的容器。我们用上一步、下一步值更替替代了原本的逻辑。
我们回过头再看最开始的generator例子,我们还有很多不一致的,首先,第一次调用gen函数的时候,返回值应该是一个对象,这个对象包含一个next方法。调用这个next方法,返回值是一个对象,对象包含我们对每一步定义的结果value,以及当前运行状态done。我们按照这个思路来升级代码
var context = {
prev: 0,
next: 0,
done: false,
stop: function() {
this.done = true
}
}
function gen$(context) {
switch (context.prev = context.next) {
case 0:
context.next = 1;
return 'result1';
case 1:
context.next = 2;
return 'result2';
case 2:
context.next = 3;
return 'result3';
case 3:
context.stop();
return 'ending';
}
}
function foo() {
return {
next: function() {
var value = gen$(context);
var done = context.done
return {
value,
done
}
}
}
}
对代码进行如下升级后,我们基本上已经达成了generator最核心的逻辑,调用foo返回一个对象,对象包含next方法,调用next方法返回了我们设定的结果值和执行状态,但是还有另外一个较为核心的问题没有解决,就是现在只有一个context上下文对象,如果我们用foo函数生成了多个对象,这些对象其实是共用了这一个context,我们要做的最后一件事,就是要确保foo返回的不同对象里跟随独立的context,这里我们参考开发模式之一的单例模式来处理。
class Context {
constructor() {
this.prev = 0
this.next = 0
this.done = false
}
stop() {
this.done = true
}
}
function foo() {
var context = new Context
return {
next: function() {
var value = gen$(context);
var done = context.done
return {
value,
done
}
}
}
}
我们把context变成一个类,每一次调用foo都实例化一个context来跟随对象,至此我们算是手写了generator最核心部分的原理。
generator源码简析
babel编译
我们虽然手写并实现了generator的核心原理,但是我们还需要确认我们的思路究竟是否正确,所以研究一下源码是有必要的,下面我们来看一下babel对一个最简单generator的编译结果
// 示例
function* helloWorldGenerator() {
yield 'hello';
yield 'world';
return 'ending';
}
var hw = helloWorldGenerator();
console.log(hw.next()); // {value: "hello", done: false}
console.log(hw.next()); // {value: "world", done: false}
console.log(hw.next()); // {value: "ending", done: true}
console.log(hw.next()); // {value: undefined, done: true}
// 编译结果
var _marked = /*#__PURE__*/ regeneratorRuntime.mark(helloWorldGenerator)
function helloWorldGenerator() {
return regeneratorRuntime.wrap(
function helloWorldGenerator$(_context) {
while (1) {
switch ((_context.prev = _context.next)) {
case 0:
_context.next = 2;
return "hello";
case 2:
_context.next = 4;
return "world";
case 4:
return _context.abrupt("return", "ending");
case 5:
case "end":
return _context.stop();
}
}
},
_marked);
}
以上就是generator的编译结果,乍一看代码并不多,看内部的逻辑也是用switch case实现的,大体和我们的思路相同,但是细看会发现,有几个东西不认识,regeneratorRuntime是个什么鬼,mark和wrap又是个啥?想要弄懂原理,我们有必要搞清楚这些东西都是什么。
先说一下regenerator,这个是facebook旗下的一个工具,用来编译es6的generator,如果想看到完整的generator代码,需要去这个工具里去看源码。
mark函数
我们先查看完整的mark函数源码
runtime.mark = function(genFun) {
genFun.__proto__ = GeneratorFunctionPrototype;
genFun.prototype = Object.create(Gp);
return genFun;
};
这部分代码比较少,虽然又牵扯到了我们两个不知道的东西,GeneratorFunctionPrototype和Gp,但是其实也无关紧要,从这部分代码中我们可以看出,mark函数其实就是对我们传入的genFun绑定了一系列的原型,继承了一些属性方法(想查看具体继承了什么可以查阅上面提到的regenerator)。
wrap函数
接下来我们再看看wrap函数究竟做了什么
function wrap(innerFn, outerFn, self) {
var generator = Object.create(outerFn.prototype);
var context = new Context([]);
generator._invoke = makeInvokeMethod(innerFn, self, context);
return generator;
}
从这段代码中可以看出,wrap做的东西比较简单,创建了一个generator,new了一个context对象,再给generator绑定了一个invoke方法,该方法是makeInvokeMethod,接收了三个参数,innerFn,self以及context,最后再把generator返回。
到这里我们先联系一下最开始我们用babel编译的结果,helloWorldGenerator被分成了两部分,一部分是外层的helloWorldGenerator函数,另一部分是用wrap包裹的helloWorldGenerator$函数,而wrap函数接受的是内层函数,所以在wrap定义中,第一个参数是innerFn,也就是内层函数的意思。
但是这部分代码里,我们还有一些东西不知道,Context类和makeInvokeMethod函数还需要继续阅读源码,我们先看context。
var ContinueSentinel = {};
var context = {
done: false,
method: "next",
next: 0,
prev: 0,
abrupt: function(type, arg) {
var record = {};
record.type = type;
record.arg = arg;
return this.complete(record);
},
complete: function(record, afterLoc) {
if (record.type === "return") {
this.rval = this.arg = record.arg;
this.method = "return";
this.next = "end";
}
return ContinueSentinel;
},
stop: function() {
this.done = true;
return this.rval;
}
};
以上就是generator中,对于context的定义,这部分可以联系我们之前手写那部分中的context,功能大体相同,存储了上下步next和prev,是否完成的done,还有一些方法。只是单纯看这部分代码,还是不是很好理解,我们接下来联系makeInvokeMethod方法的源码来一起理解。
var ContinueSentinel = {};
function makeInvokeMethod(innerFn, self, context) {
// 状态设置为start
var state = 'start';
return function invoke(method, arg) {
// 已完成
if (state === 'completed') {
return { value: undefined, done: true };
}
context.method = method;
context.arg = arg;
// 执行中
while (true) {
state = 'executing';
var record = {
type: 'normal',
arg: innerFn.call(self, context) // 执行下一步,并获取状态(其实就是switch里return的值)
};
if (record.type === "normal") {
// 判断是否已经执行完成
state = context.done
? 'completed'
: 'yield';
// ContinueSentinel其实是一个空对象,record.arg === {}则跳过return进入下一个循环,那什么什么record.arg会为空对象呢,答案是没有后续yield语句或已经return 的时候,也就是switch反悔了空值的情况
if (record.arg === ContinueSentinel) {
continue;
}
return {
value: record.arg,
done: context.done
};
}
}
};
}
我们把这两部分代码连着解读,当函数一开始执行的时候,我们把状态设置为start,该状态被内部返回的invoke方法占用,所以不会被销毁,invoke内部先判定state是否是completed状态,如果是直接返回最终状态,如果不是我们把invoke方法传入method和arg赋值给上下文对象context。状态不为结束时,会进入下面的循环,在这里状态被改成了executing,在循环中最终的结果就是返回了value和done,只是在循环中,增加了运行状态的一些判定。
现在整片源码,我们剩下的只有循环中的数据处理,以及联系context上下文方法解读这两个部分没有分析完毕,我们再继续看这两个部分。
先看循环内部,循环的一开始,我们声明了record对象,定义了type为normal,arg为innerFn的返回值,也就是switch case那部分函数的返回值,而innerFn的返回值就是我们设定的每一步的结果。
再联系helloWorldGenerator$,也就是innerFn的内部逻辑,只有在倒数第二步的时候才通过调用context中的abrupt修改了record的type,把type修改为了return,abrupt又调用了complete方法,complete方法把record里面的arg,也就是我们设定的状态赋值给了context内部的rval和自己的arg,然后返回了ContinueSentinel这个空对象。这里我们连起来看,就是在没有执行到倒数第二步的时候,循环内声明的record的type一直是normal,arg一直是我们已经写好了的结果,到了倒数第二步的时候会有一些不同,这里我们的type变成了return,arg变成了ContinueSentinel这个空对象。然后在循环内部,record.arg === ContinueSentinel这个判定生效,没有执行到return,直接continue进入下一轮的循环。
而最后一轮的循环就很清晰了,调用了stop方法,stop把done变成了true,把我们在上一轮存起来的arg返回,形成了最终的结果,在最后一轮循环中,state因为context.done的值发生了变化, context.done ? ‘completed’ : 'yield的三元运算符也将取到completed的值,这样保证了在最后一次执行结束后,再进行调用的时候再函数的上层就直接return了{ value: undefined, done: true }这个结果,而且在complete调用时,也修改了next为end,同时保证了在拿到最终结果多次调用的时候也会走invoke函数。
至此为止,对源码的分析已经结束了,但是我们回过头看,这个invoke的功能是不是觉得有几分熟悉?这不就是调用generator后,返回的对象的next方法吗?可是为什么变了个名字?我们再查阅源码可以看出,其实invoke就是next方法。
// Helper for defining the .next, .throw, and .return methods of the
// Iterator interface in terms of a single ._invoke method.
function defineIteratorMethods(prototype) {
["next", "throw", "return"].forEach(function(method) {
prototype[method] = function(arg) {
return this._invoke(method, arg);
};
});
}
defineIteratorMethods(Gp);
这是在facebook的runtime中的一段代码,为generator生成迭代器对象绑定了next,throw,return三个方法,而这三个方法的原型上都绑定了_invoke,所以实际上我们在使用的时候,调用的next就是invoke
总结
我们现在回到开始,重新考虑,其实generator的核心部分和我们手写的代码模式差不多,都分成了三部分,上下文对象,函数主体,还有逻辑处理这三个部分。
其实generator的核心就是在于上下文的保存,函数并没有真的被挂起,每一次yield,其实都执行了一遍传入的生成器函数,只是在这个过程中间用了一个context对象储存上下文,使得每次执行生成器函数的时候,都可以从上一个执行结果开始执行,看起来就像函数被挂起了一样。
那么再看,async和await其实是generator的语法糖这一句话也就可以理解了,我们把两者互相比对不难发现,async和await给人的感觉也是类似挂起的感觉,把后面代码挂起,等await的代码有了结果再向后执行,从挂起的思路来看,这两者是一致的。