2020年10月

前言

虽然了解过生成器,也了解过生成器函数和promise的关系,可迭代协议和迭代器协议也都看过,可是面试的时候遇到了,还是没有顺利的解决,于是决定再学一次。

迭代协议

本章节依赖《MDN-迭代协议》一章,做简要总结。

是协议而不是语法

迭代协议是ECMAScript 2015的一组补充规范,所以可以有多种具体的实现。迭代协议包含了两个具体协议:可迭代协议和迭代器协议。

可迭代协议

可迭代协议其实就是一组约束JavaScript对象迭代行为的规则。而迭代行为包括且不限于for-of结构的循环等,针对可以被遍历的值进行约束。Array和Map是ECMAScript 2015体系中包含的两个常用的可迭代对象,也就是说他们具有默认的迭代行为,符合可迭代协议的约束。

在协议中要求,可迭代对象必须实现@@iterator方法,不管怎样,整个原型链系统中必须要有可访问的@@iterator属性。之所以这个属性由两个@开头,是由于这个属性必须由Symbol.iterator属性进行访问,也就是[Symbol.iterator]形式访问。(这里只需要知道Symbol是一个新的基本数据类型,Symbol.iterator是一个内建的Symbol值,是一个唯一标量。)@@iterator属性提供一个无参函数值,用于返回一个符合迭代器协议的对象。也就是说,在对一个可迭代对象进行跌到操作时,会先调用@@iterator提供的函数得到迭代器,然后通过迭代器来获取具体迭代的值。

所以可迭代协议的约束核心就是@@iterator属性提供的函数,可以是普通函数,也可以是生成器函数,由于调用的时候是作为可迭代对象的方法进行调用的,所以内部this的指向是可访问该可迭代对象的。

迭代器协议

迭代器协议是一组约束如何产生一系列值的规则。注意,产生一系列的值,可以是无限个。如果产生有限个值,那么额外要求再产生最后一个值后,要返回换一个默认的用于标记结束的值。

根据要求,一个对象拥有一个可访问的next方法,该方法无参数,返回如下结构对象:

{
    done: boolean,
    value?: T
}

其中done表示迭代器是否可以产生下一个值,字面上理解就是迭代器是否迭代完成。当done为true是,value可以不存在,否则应当指定value为默认返回值。value就是迭代器产生的一些列值的具体返回。next方法如果返回的是非对象值,会产生TypeError异常。

从迭代器协议的要求来看,无法准确的确认一个对象是否实现了迭代器协议,因为无法预知next方法的返回值结构。但是,同时实现可迭代协议和迭代器协议的对象,直观感受就是这样的:

var myIterator = {
    next: function() {
        // 返回结构正确
    },
    [Symbol.iterator]: function() { return this }
}

迭代器协议,拥有next方法,且假定返回结构正常;可迭代协议,拥有[Symbol.iterator]属性,返回自身,由于其自身满足迭代器协议,故满足了可迭代协议。

常见的迭代协议的例子

字符串是一个常用的实现了迭代协议的对象,可以通过typeof someString[Symbol.iterator]的方式来检查。通常迭代字符串中的元素,可以通过for循环进行输出,在支持Symbol的环境下,也可以通过可迭代协议要求的属性来获取对应的迭代器。

注意:[Symbol.iterator]属性是可读可写的,所以在环境支持的情况下是可以进行复写的。比如MDN提供的例子:

// 必须构造 String 对象以避免字符串字面量 auto-boxing
var someString = new String("hi");
someString[Symbol.iterator] = function() {
  return { // 只返回一次元素,字符串 "bye",的迭代器对象
    next: function() {
      if (this._first) {
        this._first = false;
        return { value: "bye", done: false };
      } else {
        return { done: true };
      }
    },
    _first: true
  };
};

// 结果如下
[...someString];                              // ["bye"]
someString + "";                              // "hi"

除了字符串,数组(包括TypedArray类数组)、Map和Set,原型对象都实现了@@iterator方法,它们都是可迭代对象,都可以使用for循环和展开语法等结构。前面提到可迭代协议的@@iterator方法可以是普通方法,也是可以是生成器函数,所以可以像这样定制自己的可迭代对象:

var myIterable = {};
myIterable[Symbol.iterator] = function* () {
    yield 1;
    yield 2;
    yield 3;
};
[...myIterable]; // [1, 2, 3]

其实迭代协议从本质上讲作为一种行为约束,可以看做是具有接口特性,因此在构造Map或者Set时,会发现可以依赖一个可迭代对象生成Map或Set,所以在文档中通常可以看到这样的函数签名:

new Map([iterable])

除了for循环结构和展开语法,yield*和解构赋值,都需要可迭代对象。更多的迭代器范例可以参加MDN文档,这里不做过多列举。

本文主要内容生成器和迭代协议有什么关系呢?生成器或者叫生成器对象,是生成器函数(Generator Function)调用返回的对象,符合可迭代协议和迭代器协议,因此简单说生成器是一个满足迭代协议的对象,即同时具备@@iterator属性和next方法的对象。

生成器

生成器函数-Generator Function

生成器函数调用并返回生成器对象,这很好理解。生成器函数叫Genterator Function,返回的生成器对象叫Genterator对象,简称生成器。

生成器函数的基本语法:

function* generator() {//...}

即function关键字后带一个*号。同样的,你还可以使用生成器函数构造函数,生成新的生成器函数对象,通过Object.getPrototypeOf(function*(){}).constructor来获取生成器函数构造函数

生成器函数在执行时可以暂停,并且还能从暂停的地方恢复执行。好了,最绕的地方来了。注意以下描述:

  • 调用生成器函数,不会立即执行函数体
  • 调用生成器函数,会得到一个迭代器对象(即实现了迭代协议的对象)
  • 迭代器对象的next()方法调用时,函数体从上次结束的地方开始,执行到下一个yield关键词(这里具体的位置以及next方法稍后介绍)
  • yield*表示移交执行权

通过官方例子进行说明:

function *gen(){
    yield 10;
    x=yield 'foo';
    yield x;
}

var gen_obj=gen();
console.log(gen_obj.next());// 执行 yield 10,返回 10
console.log(gen_obj.next());// 执行 yield 'foo',返回 'foo'
console.log(gen_obj.next(100));// 将 100 赋给上一条 yield 'foo' 的左值,即执行 x=100,返回 100
console.log(gen_obj.next());// 执行完毕,value 为 undefined,done 为 true
  • gen是一个生成器函数
  • gen_obj是这个生成函数的迭代器对象
  • 第一次执行。迭代器第一次调用next()方法,函数体从0开始,往下执行,直到遇到了包含yield的语句,yield 10暂停执行,并且将10返回。
  • 第二次执行。从yield 10恢复执行。x = yield 'foo'; 是一个赋值语句,先对等号右边进行求值,yield ‘foo’先暂停执行并返回‘foo’,而整体的求值要等到下一次恢复执行。
  • 第三次执行。从yield ‘foo’恢复执行,向next方法传入了参数100,即yield ‘foo’表达式本身求值为100。因此,恢复执行后,等号右边求值100,然后执行赋值x为100,遇到下一个yield x 暂停执行并将x的值100返回出去。
  • 第四次执行。从yield x恢复执行,函数体结束,将value为undefined,done为true的对象返回,标志着生成器不再生成值。

yield

从官方的文档中,对于yield语法的描述是这样的:

[rv] = yield [expression];

其中expression求值后用于生成器函数返回的迭代器调用next时返回。允许expression为空,即next()方法返回的结构中value为undefined。其中rv为yield表达式求值结果,也就是传递给next()方法的参数值。

yield关键字用于生成器函数体的执行暂停,不妨把他看成一个return结构。根据文档的专业描述,yield关键字返回IteratorResult对象(其实就是迭代器协议要求返回的特定结构)。从上面的简单流程分析,生成器的暂停与恢复由yield和next()的调用相关。每次next()的调用,生成器开始或者恢复执行,直到:1.yield关键词,生成器暂停。2.throw抛出异常,生成器终止。3.生成器函数体结尾,生成器终止,返回value为undefined,done为true的结果。4.return语句,生成器终止,返回value是return指定的值,done是true的结果。

根据上面例子的执行顺序,不难发现生成器函数体代码,是分段执行的。yield是其分段标志,next()方法调用是执行标志。如果将调用next()方法的代码片段称为生成器外部,那么next()就是将执行顺序矫正到生成器外部的标志;当执行顺序来到生成器内部,遇到了yield,执行顺序又被矫正到生成器外部。

yield是将执行顺序从生成器内部转到生成器外部,yield也类似,只不过将执行顺序从本生成器内部转到目标生成器内部。yield只能操作可迭代对象,下面是一个迭代器对象的例子,因为迭代器调用next()方法才能将执行顺序转到生成器内部,yield将执行顺序转交。甚至可以看做是yield将另一个生成器融合到自身中。

function* g1() {
  yield 2;
  yield 3;
  yield 4;
}

function* g2() {
  yield 1;
  yield* g1();
  yield 5;
}

var iterator = g2();

console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: 4, done: false }
console.log(iterator.next()); // { value: 5, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

假如把生成器g2改写成下面这样:

function* g2() {
  yield 1;
    { // 这里只用作强调区分,没有语句块的作用
      yield 2;
      yield 3;
      yield 4;
    }
  yield 5;
}

似乎也没有什么问题。除此之外,委托给数组等可迭代对象也是类似的:

function* g3() {
  yield* [1, 2];
  yield* "34";
}

// 合并起来似乎也没有什么问题
function* g3() {
    yield 1
    yield 2
    yield '3'
    yield '4'
}

注意!yield*和返回值的关系比较复杂:

function* g4() {
  yield* [1, 2, 3];
  return "foo";
}

// 不妨改写一下g4
function* g4() {
  yield 1;
    yield 2;
  yield 3;
  return "foo";
}

var result;

function* g5() {
  result = yield* g4();
}

var iterator = g5();

console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }, 
                              // 此时 g4() 返回了 { value: "foo", done: true }

console.log(result);          // "foo"

第一次执行iterator.next(),执行顺序来到yield g4(),转交到yield [1, 2, 3],再转交到yield 1(将数组的迭代过程用yield代替),暂停,执行顺序回到console.log,并且将1带了出来。依次类推,第四次执行iterator.next(),执行顺序来到yield g4(),转交到return "foo",这个时候要注意,并没有yield所以并没有暂停,执行顺序也没有回到console.log,而是正常结束g4的迭代,由于是return,所以返回为{ value: "foo", done: true }。也就是说yield表达式求值完毕,是{ value: "foo", done: true },将其赋值给result,此时g5生成器函数体完毕,执行顺序回到console.log,并返回{ value: undefined, done: true }表示不再产生下一个迭代值。所以yield*表达式也是有具体值的。

从上面的例子不难看出,不管怎么转交,只要看到yield,就要将执行顺序矫正到最外部,因为yield*相当于将生成器嵌入生成器,所以执行顺序直接去最外面准没错。

注意

yield只能在生成器函数体内,而生成器函数体内如果有其他闭包(在直接点就是其他独立的程序块),都不算做生成器函数体。比如:

function* gen() {
    setTimeout(()=>{
        yield 1
    })
    yield 2
}

yield 1是不被允许的,因为其存在于一个非生成器函数体内。

小结

大概了解了生成器函数,迭代器和yield后,对执行顺序的变化有了初步的概念。next()将执行顺序矫正到生成器函数体内部,yield又将执行顺序矫正到生成器函数体外部。然后还要知道生成器函数体内部的return语句不会改变执行顺序,基本上能够理清一个生成器的工作过程。

再仔细想一下,Promise的使用,提供一个任务,再注册一个回调,当任务完成时调用resolve了,注册的回调会被执行。这个过程居然和next()调用了执行生成器函数体出奇的相似,两者是否存在联系,下一篇文章将讨论相关问题。