28.JS高级-迭代器与生成器详解

1,022 阅读55分钟

该系列文章连载于公众号coderwhy和掘金XiaoYu2002中

  • 对该系列知识感兴趣和想要一起交流的可以添加wx:coderwhy666,拉你进群参与共学计划,一起成长进步
  • 课程对照进度:JavaScript高级系列163-173集(coderwhy)
  • 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力

脉络探索

  • 在本章节中,我们会探索迭代器与生成器在JS中都是怎么表达的,在代码中都是怎么体现的
    • 迭代器协议与可迭代协议又有什么区别?为什么使用for of等方式遍历,都要求遍历对象是一个可迭代对象?
    • 可迭代对象与迭代器之间有什么关系?迭代器与生成器又有什么联系?我们会通过几个案例代码,一次次的优化,来揭开JS的发展历程
    • 最后,我们会使用迭代器生成器结合上一章节的Promise进行异步代码的优化

一、什么是迭代器

  • 迭代器(iterator),使用户在容器对象(container,例如链表或数组)上遍访的对象,使用该接口无需关心对象的内部实现细节(细节指我们不需要关系他是数组还是链表或者说是哈希表、树结构这些,统统不需要关注)
    • 其行为像数据库中的光标,迭代器最早出现在1974年设计的CLU编程语言中
    • 因此迭代器并不是JS语言独有内容,在各种编程语言的实现中,迭代器的实现方式各不相同,但是基本都有迭代器,比如Java、Python等
    • 容器对象(container object)泛指用来存储一组数据元素的数据结构,例如数组、链表、栈与队列、哈希表、树结构、图等
  • 从迭代器的定义我们可以看出来,迭代器是帮助我们对某个数据结构进行遍历的对象(迭代器对象用于遍历数据结构)
  • 在JavaScript中,迭代器也是一个具体的对象,如果我们想要将一个对象变为迭代器,这个对象需要符合迭代器协议(iterator protocol):
    • 迭代器协议定义了产生一系列值(无论是有限还是无限个)的标准方式
    • 那么在js中这个标准就是一个特定的next方法,单从这句话来说,较为抽象
    • 换个说法则是:next方法在js中决定了产生值的方式,用于按顺序生成序列中的下一个值
  • 从MDN文档对next方法的描述中,接受无参或者一个参数的函数,普遍情况下都为无参,带一个参数主要用于生成器函数返回的迭代器中,在后续我们会详细进行说明

MDN文档对thenable的描述

图28-1 迭代器协议标准

  • 每次调用 next() 方法时,它返回一个包含两个属性的对象:
    • value:当前产生的值
    • done:布尔值,表示是否所有的值都已经被迭代完毕
  • 让我们来完成一个基础迭代器案例,观察到底怎么样才能算作是迭代器
    • 具备next方法
    • next方法的返回值格式符合要求
//普通对象,不符合迭代器协议
const iterator = {

}
//对象内具备next方法,但其next返回值一样需要遵守协议所要求的返回值
const iterator = {
  next: function() {
    return { done: true, value: 'coderwhy' }
  }
}
  • 一个基础迭代器就完成了,但该形式的迭代器并不能发挥作用,我们使用数组作为案例进行迭代器遍历
    • 不使用内容进行存储,唯一变动的是索引
    • 调用一次next方法,索引+1,直到索引大于数组长度时跳undefined值,done状态转true
    • 在内存的表达形式中,每调用一次next方法,指针指向位置在names中就后移一位
  • 因此可以理解为迭代器内部维护了一个指向当前元素的指针,而不会存储遍历过的元素或任何其他数据
    • 这种迭代器模式的优点在于其空间效率和简单性。它直接利用了原数组,无需复制或额外存储,也正因如此,迭代器的状态紧密依赖于原数组;如果在迭代过程中修改数组,迭代器的行为可能会受到影响
// 数组
const names = ["coderwhy", "XiaoYu", "JS"]

// 创建一个迭代器对象来访问数组
let index = 0

const namesIterator = {
  next: function() {
    if (index < names.length) {
      return { done: false, value: names[index++] }
    } else {
      return { done: true, value: undefined }
    }
  }
}
//测试案例
console.log(namesIterator.next()) // { done: false, value: 'coderwhy' }
console.log(namesIterator.next()) // { done: false, value: 'XiaoYu' }
console.log(namesIterator.next()) // { done: false, value: 'JS' }
console.log(namesIterator.next()) // { done: true, value: undefined }
console.log(namesIterator.next()) // { done: true, value: undefined }
console.log(namesIterator.next()) // { done: true, value: undefined }

//对照方案
const iterator = names[Symbol.iterator]()

console.log(iterator.next());//{ value: 'coderwhy', done: false }
console.log(iterator.next());//{ value: 'XiaoYu', done: false }
console.log(iterator.next());//{ value: 'JS', done: false }
console.log(iterator.next());//{ value: undefined, done: true }
  • namesIterator迭代器不具备通用性,是单独为names数组迭代而设计
    • 想要具备通用性,我们应抽取共性模型,其内容不同之处通过函数参数传递进去
    • 其参数放置在next方法是否可行?并不行,next方法非常存粹,回顾API的单一职责规范
    • 因此我们不能够在这里做出改变,哪怕结果与我们需要的一致
    • 在这里能够注意到迭代器名称也保持不变,如果名称足够通用,则无法通过名称知晓该迭代器用于迭代什么内容。而一旦名称足够专一,例如namesIterator用于遍历names是没有问题,而一旦用于遍历数字组成的数组arr,就会出现所做事情与名称不符的情况
let index = 0
//错误示范
const namesIterator = {
  next: function (arr) {
    if (index < arr.length) {
      return { done: false, value: arr[index++] }
    } else {
      return { done: true, value: undefined }
    }
  }
}

const arr = [1,2,3,4,5]
//迭代器名称与迭代内容主旨不一致
console.log(namesIterator.next(arr));//{ done: false, value: 1 }
console.log(namesIterator.next(arr));//{ done: false, value: 2 }
console.log(namesIterator.next(arr));//{ done: false, value: 3 }
console.log(namesIterator.next(arr));//{ done: false, value: 4 }
console.log(namesIterator.next(arr));//{ done: false, value: 5 }
console.log(namesIterator.next(arr));//{ done: true, value: undefined 
  • 因此,我们需要直接生成新的迭代器,只需要传入内容,就能够生成对应新的迭代器,并为新迭代器起名
    • 这时候无法像对象传递参数进去,则可以在对象外面包裹一层函数,用于传递需要迭代的数据内容
    • 同时注意index索引指针需要放入函数内,因为该索引指针并非多个迭代器共用且遵循纯函数设计理念
    • 然后返回符合规范,包含传入数据的新迭代器
//正确方式
function Iterator(arr) {
  let index = 0
  return {
    next: function () {
      if (index < arr.length) {
        return { done: false, value: arr[index++] }
      } else {
        return { done: true, value: undefined }
      }
    }
  }
}

const arr = [1, 2, 3, 4, 5]
const arrIterator = Iterator(arr)
console.log(arrIterator.next(arr));//{ done: false, value: 1 }
console.log(arrIterator.next(arr));//{ done: false, value: 2 }
console.log(arrIterator.next(arr));//{ done: false, value: 3 }
console.log(arrIterator.next(arr));//{ done: false, value: 4 }
console.log(arrIterator.next(arr));//{ done: false, value: 5 }
console.log(arrIterator.next(arr));//{ done: true, value: undefined 
  • 以上案例的迭代器是一个有限值迭代器,而在一开始的迭代器协议理论中,除了有限值之外,还有无限值迭代器的情况,在一些特定场景例如生成动态连续数据进行模拟测试等情况非常有用
    • 需要明确的退出条件或合适的终止机制,以防止程序出现资源耗尽的问题
    • 在该理念上出发,无限值迭代器并非产生无限内容,而是具备该无限潜质,从而做到完美符合实际所需的任意数量数值
// 创建一个无限的迭代器,每次调用next方法,值加1
function createNumberIterator() {
  let index = 0
  return {
    next: function() {
      return { done: false, value: index++ }
    }
  }
}

const numberInterator = createNumberIterator()
console.log(numberInterator.next())
console.log(numberInterator.next())//next方法无限调用

1.1 可迭代对象

  • 但是上面的代码整体来说看起来是有点奇怪的:
    • 因为我们获取一个数组的时候,需要自己创建一个index变量,再创建一个所谓的迭代器对象,使用较为复杂。数组与迭代器对象之间的联系本应该很紧密,却需要由我们手动搭建,增加了我们所维护的难度
    • 事实上我们可以对上面的代码进行进一步的封装,让其变成一个可迭代对象
  • MDN文档中对于可迭代对象的说明为:该对象必须实现 [Symbol.iterator]() 方法,但该说明较为正式,在当前并不是很好理解,什么是可迭代对象?
    • 它和迭代器是不同的概念,因此迭代协议分为具体两个协议:可迭代协议迭代器协议
    • 当一个对象实现了iterable protocol(可迭代)协议时,它就是一个可迭代对象
    • 这个对象的要求是必须实现 @@iterator 方法,在代码中的表达形式为使用 Symbol.iterator 访问该属性
  • 用我们目前已知部分进行说,则names(需要遍历的目标数组)、index(索引指针)、namesIterator(目标数组对应迭代器),三者紧密结合在一起时(封装为一体),我们就称为可迭代对象


表29-1 迭代器对象和可迭代对象区别

特性迭代器对象(Iterator Object)可迭代对象(Iterable Object)
定义一个实现了迭代器协议的对象,其包含 next() 方法,用于产生一系列值一个实现了可迭代协议的对象,可以使用 for...of 循环遍历
协议迭代器协议:必须实现 next() 方法,每次调用返回一个 { done, value } 对象可迭代协议:必须实现 Symbol.iterator 方法,返回一个迭代器对象
目标用途逐一产生数据值,直到 done: true,适合处理惰性计算和序列生成提供统一的方式遍历其所有元素,适合处理所有可被遍历的数据结构
实现要求需要提供 next() 方法,该方法每次返回 { done: boolean, value: any } 对象需要实现 Symbol.iterator 方法,返回一个符合迭代器协议的对象
  • 迭代器对象在于执行next方法返回 { done, value } 对象,可迭代对象则是执行Symbol.iterator方法返回迭代器
    • 以下实现的可迭代对象具备我们所说的三元素,紧密相连,都为一个对象内的属性
    • Symbol.iterator既为属性也为函数,因此MDN文档中所描述的必须实现该方法以及具备该属性的键,都是在说明同一件事情的不同细分情况

MDN文档对thenable的描述

图28-2 Symbol.iterator描述

//可迭代对象实现
const iterableObj = {
  //需要遍历的目标数组
  names:  ["coderwhy", "XiaoYu", "JS"],
  [Symbol.iterator]: function() {
    //索引指针
    let index = 0
    //迭代器
    return {
      next: () => {
        if (index < this.names.length) {
          return { done: false, value: this.names[index++] }
        } else {
          return { done: true, value: undefined }
        }
      }
    }
  }
}
  • 通过以上实现,能够在调用可迭代对象时返回一个迭代器,该迭代器已经与目标迭代数组完成深层绑定,因此可以直接调用该迭代器的next方法做到对应遍历功能
    • 可迭代对象实现了 Symbol.iterator 方法,每次调用它会返回一个新的迭代器对象。这意味着我们可以通过同一个可迭代对象多次生成不同的迭代器,每个迭代器都独立管理自己的遍历状态,例如iterator与iterator2是两个不同的迭代器,对iterator迭代器对象的操作不会影响到iterator2迭代器对象
//可迭代对象的使用方式
//[Symbol.iterator]()是一个函数执行,返回内部迭代器
const iterator = iterableObj[Symbol.iterator]()

//直接访问Symbol.iterator属性
console.log(iterableObj[Symbol.iterator]);//[Function: [Symbol.iterator]]
//对迭代器进行执行next方法
console.log(iterator.next());// { done: false, value: 'coderwhy' }
console.log(iterator.next());// { done: false, value: 'XiaoYu' }
console.log(iterator.next());// { done: false, value: 'JS' }
console.log(iterator.next());// { done: true, value: undefined }

//生成第二个迭代器
const iterator2 = iterableObj[Symbol.iterator]()
  • 其实咋一看,可迭代对象与迭代器具备及其相似的特性,在我们以上实现中,逐层深入分为以下几点:
    1. 实现基础迭代器,但该基础迭代器与某一数据深度绑定,无法广泛使用
    2. 实现通用迭代器,但遍历数据与基础迭代器的联系不够紧密,需要手动传入处理
    3. 实现可迭代对象,在通用迭代器的基础上(能生成任意数量的初始化迭代器),完善加强数据与迭代器之间的联系,因此通过对整体的操作,不需要去关心数据与迭代器之间的过程
  • 迭代器对象更倾向于描述一个“过程”,它是逐次产出元素的工具。而可迭代对象描述的是一个“集合”,它具有多个元素,可以被整体遍历
    • 可以被整体遍历是关键,这是for of等方法能够遍历数据的关键,也是这些使用该方法的前置条件为目标需要为可迭代对象
    • 一旦我们遍历普通对象,就会产生类型报错提示:TypeError: obj is not iterable,该目标对象不是一个可迭代的
const obj = {
  name: "coderwhy",
  age: 18
}
const iterableObj = {
  //需要遍历的目标数组
  names:  ["coderwhy", "XiaoYu", "JS"],
  [Symbol.iterator]: function() {
    //索引指针
    let index = 0
    //迭代器
    return {
      next: () => {
        if (index < this.names.length) {
          return { done: false, value: this.names[index++] }
        } else {
          return { done: true, value: undefined }
        }
      }
    }
  }
}
//报错
for (const item of obj) {
  console.log(item)
}
for (const item of iterableObj) {
  console.log(item)//正常遍历输出
}
  • 但将普通对象替换为我们前面所实现的可迭代对象,则可以正常遍历出内容,这是怎么做到的?
    • 使用 for...of 时,JS引擎会自动调用可迭代对象的 [Symbol.iterator]() 方法,生成一个迭代器对象
    • 然后,引擎使用这个迭代器对象的 next() 方法不断获取 value,直到返回的对象的 done 属性为 true,表示遍历结束,因为for...of 循环能够自动处理迭代器对象的 done 状态,无需我们手动调用 next()
  • 通过迭代遍历的原理,就很容易逆推出各类遍历方法的源码实现,在这里我们可以简单实现一下
    • 接收一个可迭代对象
    • 控制台打印遍历后的每一个结果
// 遍历原理:
// 1.调用 iterableObj[Symbol.iterator](),返回一个迭代器对象
// 2.不断调用迭代器对象的 next() 方法,获取下一个值和 done 状态
// 3.当 done 为 false 时,执行循环体,输出当前值
// 4.当 done 为 true 时,停止循环

// 定义一个可迭代对象
const iterableObj = ['coderwhy', 'XiaoYu', 'JS'];

// 模拟 for...of 循环的手写实现
function manualForOf(iterable) {
  // 1. 获取迭代器对象
  const iterator = iterable[Symbol.iterator]();

  // 2. 不断调用 next() 方法
  let result = iterator.next();
  while (!result.done) {
    // 3. 输出当前值,在这里也可以选择使用一个回调,将遍历值传入回调,如何处理交给用户,我们这里从简
    console.log(result.value);

    // 4. 获取下一个值
    result = iterator.next();
  }
}

// 调用手写 for...of 实现遍历可迭代对象
manualForOf(iterableObj);
  • 通过以上的学习,我们就不会再搞混可迭代对象迭代器对象
    • 迭代器对象实现了遍历逻辑,而可迭代对象在迭代器的基础上提供了数据来源
    • 因此可迭代对象可以被看作一种抽象的数据集合,可以进行迭代,而迭代器对象则是实际的执行者,它提供了遍历的实现
  • 而ECMA对此的标准为:
    • 可迭代协议(Iterable Protocol): 一个对象要被 for...of 循环使用,必须实现可迭代协议,即具有 [Symbol.iterator] 方法
    • 迭代器协议(Iterator Protocol): 迭代器对象必须实现 next() 方法,返回 { value, done }

1.2 原生迭代器对象

  • 在以上实现一个可迭代对象是比较麻烦的,但在日常使用并没有出现该内容即可遍历
    • 主要原因在于JS中已经内置的一部分可迭代对象,这些原生对象已经实现了可迭代协议,会生成一个迭代器对象的
    • String、Array、TypeArray、Map、Set以及Intl.Segments都是内置的可迭代对象,它们的每个 prototype 对象都实现了 [Symbol.iterator]() 方法
  • 数组形式数据之所以能够直接迭代,在于我们使用的是数组的字面量形式,但其本质依旧是new Array,在该过程会调用可迭代对象返回迭代器对象
    • 内置的Symbol.iterator打印出来的结果为一个函数,因此可以调用
    • 调用返回迭代器,迭代器具备next方法,因此可以在该迭代器中不断调用next方法从而实现遍历效果
//效果一致的不同写法
const names = ["coderwhy", "XiaoYu", "JS"]
const names2 = new Array("coderwhy", "XiaoYu", "JS")
//数组内置Symbol.iterator
console.log(names[Symbol.iterator]);//[Function: values]

1.3 可迭代对象的应用

  • 那么这些东西可以被用在哪里呢?
    • JS语法、创建对象时、方法调用时都是主要的使用场景


表29-2 可迭代对象英语场景

分类应用场景描述
JS 语法for...of用于遍历可迭代对象的每个元素,支持数组、字符串、Set、Map 等
展开语法(...)将可迭代对象展开为单个元素,常用于数组字面量、函数参数等位置
yield*在生成器函数中使用,可委托另一个可迭代对象,生成器可以产生其所有值
解构赋值从可迭代对象中提取值,赋给多个变量,例如数组解构赋值
创建对象时new Map([iterable])使用可迭代对象来创建 Map,迭代对象的每个元素必须是 [key, value]
new WeakMap([iterable])使用可迭代对象来创建 WeakMap,元素需为键值对,键必须是对象
new Set([iterable])使用可迭代对象来创建 Set,元素需是唯一的
new WeakSet([iterable])使用可迭代对象来创建 WeakSet,元素必须是对象且是唯一的
方法调用时Promise.all(iterable)接收一个可迭代对象,等待所有 Promise 解决并返回结果数组
Promise.race(iterable)接收一个可迭代对象,返回第一个解决或拒绝的 Promise 结果
Array.from(iterable)将可迭代对象转换为数组,适用于字符串、Set、Map 等
  • 在这些使用场景中也蕴含了一些特殊的处理情况
    • 在对象中是支持展开语法遍历展开内容的
    • 需要展开一个元素,首先我们肯定是需要遍历该元素的,而对象不支持遍历
    • 展开语法在ES9问世,其中部分原因是解决对象不支持遍历的痛点
  • 我们清楚对象之所以不可遍历,在于对象不是可迭代对象,也没有迭代器。展开语法之所以能够遍历对象,在于展开语法使用的不是迭代器
    • 对象与数组有一点具备极大区别,也是对象没有内置可迭代对象的原因,对象是无序的,而数组是有序的
    • 是否可遍历迭代,关键取决于可迭代协议,但是否为一个数据结构内置可迭代对象,则考虑其本身特性,有序性就是其中的一个考量范围
    • 迭代协议 主要用于线性、可顺序遍历的数据结构,例如数组、字符串、SetMap 等。它们设计的目的是为了处理一系列有序的值,适合以迭代的方式逐个访问。**对象(Object)**的设计初衷是用作键值对的集合,更多用于 存储无序的属性和数据。对象的结构适合动态添加和删除属性,不太适合线性顺序访问数据
  • 那可迭代对象为什么以对象为后缀,但对象本身却不可迭代?
    1. JS中万物皆是对象,因此这可以区分为普通对象和可迭代对象
    2. 普通对象是前面所说的无序性,这些对象的属性没有顺序,遍历对象属性可能导致不可预测的结果,因此不应该默认可迭代,以避免潜在的性能和逻辑问题
const obj = { name: "coderwhy", age: 18 }
// for (const item of obj) {
// 不支持遍历
// }
// ES9(ES2018)中新增的一个特性: 用的不是迭代器
const newObj = { ...obj } //能够展开对象
console.log(newObj)
  • 这是为什么对象没有内置可迭代对象的原因,因此扩展语法想要迭代对象,则需要从该特性入手
    • 对象本身不可迭代,但是内部的属性与值都是接受遍历的,Object的keys、values、entries三个方法就是专门做这件事的
    • 因此想要遍历对象,不通过迭代器进行,可以迂回处理一下,不遍历对象本身
const obj1 = { name: "coderwhy", age: 18 };
const obj2 = { address: "中国", Hobbies: "编程" }

function mergeObjects(...objs) {
  const result = {};
  for (const obj of objs) {
    for (const [key, value] of Object.entries(obj)) {
      result[key] = value;
    }
  }
  return result;
}

const newObj = mergeObjects(obj1, obj2);
console.log(newObj); // { name: 'coderwhy', age: 18, address: '中国', Hobbies: '编程' }
  • keys、values、entries三个方法是通过返回一个包含对象键值对的数组,实现了对对象属性的间接遍历
    • 由于数组是可迭代的,我们可以使用各种迭代方法(如 for...ofArray.prototype.forEach 等)来遍历这个数组
    • 对键值对二维数组的遍历,进行处理,得到所需内容
const obj = { foo: "bar", baz: 42 };
console.log(Object.entries(obj)); // [ ['foo', 'bar'], ['baz', 42] ]

// 类数组对象
const obj = { 0: "a", 1: "b", 2: "c" };
console.log(Object.entries(obj)); // [ ['0', 'a'], ['1', 'b'], ['2', 'c'] ]

// 具有随机键排序的类数组对象
const anObj = { 100: "a", 2: "b", 7: "c" };
console.log(Object.entries(anObj)); // [ ['2', 'b'], ['7', 'c'], ['100', 'a'] ]
  • 并且由于对象本身是无序性,一旦转为数组,则会偏向于有序性
    • 该性质不仅作用于我们刚才手写的mergeObjects方法,也作用于扩展语法,更会作用于所有遍历到对象的应用场景中
    • 这同时会影响到一些事情,例如在浏览器输出无序对象,再打印该对象会转为有序,如下图
const anObj = { 100: "a", 2: "b", 7: "c" };

function mergeObjects(...objs) {
  const result = {};
  for (const obj of objs) {
    for (const [key, value] of Object.entries(obj)) {
      result[key] = value;
    }
  }
  return result;
}

//会转为有序性质
const newObj = mergeObjects(anObj);
console.log(newObj); // { '2': 'b', '7': 'c', '100': 'a' }
console.log({...anObj});//{ '2': 'b', '7': 'c', '100': 'a' }

MDN文档对thenable的描述

图28-3 对象遍历后,无序性向有序性的转变

  • 这是各数据结构设计初衷所产生的冲突问题,在涉及类似场景需要小心处理,该问题在stack overflow社区也有探讨过
    • javascript - Chrome 按键对对象进行排序 - Stack Overflow
    • 在浏览器中发生的行为问题,更多的应该去看浏览器所遵守的规范版本,Chrome Opera 的 JavaScript 解析引擎遵循的是新版 ECMA-262 第五版规范。因此,使用 for-in 语句遍历对象属性时遍历顺序并非属性构建顺序。而 IE6 IE7 IE8 Firefox Safari 的 JavaScript 解析引擎遵循的是较老的 ECMA-262 第三版规范,属性遍历顺序由属性构建的顺序决定

stack overflow社区关于浏览器中对象排序的讨论

图28-4 stack overflow社区关于浏览器中对象排序的讨论

1.4 自定义类对象可迭代性

  • 在前面我们看到Array、Set、String、Map等类创建出来的对象都是可迭代对象:
    • 在面向对象开发中,我们可以通过class定义一个自己的类,这个类可以创建很多的对象
    • 如果我们也希望自己的类创建出来的对象默认是可迭代的,那么在设计类的时候我们就可以添加上 @@iterator 方法
  • 我们以一个案例进行实践:创建一个classroom的类
    • 教室中有自己的位置、名称、当前教室的学生
    • 这个教室可以进来新学生(push)
    • 创建的教室对象是可迭代对象
//自定义类的迭代
class Classroom {
  constructor(address, name, students) {
    this.address = address
    this.name = name
    this.students = students
  }
  entry(newStudent) {
    this.students.push(newStudent)
  }
}
//其实创建的这对象不是可迭代对象(因为对象本身就是不可迭代的)
const classroom1 = new Classroom("3幢5楼205", "计算机教室", ["小余1", "小余2", "小余3", "小余4"])
const classroom2 = new Classroom("3幢5楼304", "土木教室", ["小余5", "小余6", "小余7", "小余8"])

classroom1.entry("小余666")//能够正常添加
console.log(classroom);
  • 如果说,我想要往教室里添加学生,是能够正常完成的
    • 但因类产生的classroom对象并不能够遍历,虽然我们能够通过直接拿到classroom对象中的students数组来进行遍历
    • 该取巧方式只能遍历students部分,而对象内是不止该部分的,单独遍历会产生一定的割裂效果,其对象所带来的关联性优势会进一步削减
  • 因此,我们想要实现通过Classroom类所实现的对象都属于可迭代对象,这应该如何实现?
    1. 我们可以在Person这个类中进行定义,然后创建对象的时候就继承上面类,使其可迭代
    2. 实现思路:classroom1跟classroom2里面必须有一个属性,而这个属性对应的是一个函数,而这个函数需要返回一个迭代器,想要做到其共通性,则需要在其共同的类中进行实现
class Classroom {
  constructor(address, name, students) {
    this.address = address
    this.name = name
    this.students = students
  }

  entry(newStudent) {
    this.students.push(newStudent)
  }
//实现迭代器
  [Symbol.iterator]() {
    let index = 0
    return {
      next: () => {
        if (index < this.students.length) {
          return { done: false, value: this.students[index++] }
        } else {
          return { done: true, value: undefined }
        }
      },
      //可以做出其他针对性处理,不过很少见
      return: () => {
        console.log("迭代器提前终止了~")
        return { done: true, value: undefined }
      }
    }
  }
}
  • 此时再使用for of遍历迭代这两个对象,就能正常得到所需内容,因为我们已经实现可迭代协议的标准
    • 有时候出现的面试题会要求使对象得以遍历,该做法并不符合对象本身的设计理念,其目的是想考察对可迭代对象的理解
const classroom1 = new Classroom("3幢5楼205", "计算机教室", ["小余1", "小余2", "小余3", "小余4"])
const classroom2 = new Classroom("3幢5楼304", "土木教室", ["小余5", "小余6", "小余7", "小余8"])

classroom1.entry("小余666")//能够正常添加
classroom2.entry("小余888")//能够正常添加

//正常迭代
for (const stu of classroom1) {
  console.log(stu)
}
//正常迭代
for (const stu of classroom2) {
  console.log(stu)
}

二、什么是生成器

  • 生成器(Generator)是 ES6(ECMAScript 2015)中引入的一种特殊函数类型,它可以让我们在函数执行过程中暂停和恢复执行,为 JavaScript 提供协程能力。需要注意的是,生成器(Generator)的概念并非源自 JavaScript,它在计算机科学中有着悠久的历史,早在 20 世纪 60 年代就已经出现,初衷是在程序中更灵活地控制流程和状态
    • 生成器的历史与协程能力密不可分,这是其诞生的主要原因,最早是在1967 年Simula 67 引入了协程的概念,允许函数在执行过程中暂停和恢复,这为后来的生成器概念奠定了基础
    • 1975 年,CLU 引入了迭代器的概念,允许函数在遍历数据结构时,每次返回一个元素
  • 单独看会较难理解,我们使用以下案例进行说明:
    • 目前有一个需求,我想要在执行value1赋值和打印后,先暂停,该foo函数后续内容先不继续执行。而在某些场景中,则希望恢复后续执行
    • 通过return虽然也能够做到不继续往后执行,但这不属于暂停而是返回,且后续内容就再也没有机会执行
    • 平时我们会编写很多的函数,这些函数终止的条件通常是返回值或者发生了异常
//普通函数执行流程
function foo() {
  const value1 = 100
  console.log(value1)
 //以下内容,希望正常情况下不执行,在某些场景中恢复执行
  const value2 = 200
  console.log(value2)

  const value3 = 300
  console.log(value3)
}

foo()

  • 生成器能够完成该想法,但什么是生成器?为什么叫做生成器?
    • 生成器可以看作是一个生成迭代器的工厂函数,返回一个迭代器对象,迭代器本身是用于逐步获取值的
    • 1977 年,Icon 首次使用了“生成器”一词,支持表达式生成多个结果,程序可以逐步获取这些结果
    • 因此这个名称源于它的功能,即逐步生成值(一个渐变的过程),而不是一次性返回所有结果
  • 在JS中,生成器也是一个函数,但是和普通的函数有一些区别:
    • 首先,生成器函数需要在function的后面加一个符号:*
    • 其次,生成器函数可以通过yield关键字来控制函数的执行流程
    • 最后,生成器函数的返回值是一个Generator(生成器)

2.1 生成器的基本概念

  • 生成器函数使用 function* 关键字定义,星号 * 可以紧贴着 function或者函数名,也可以在两者之间有空格,空格的数量并不影响效果
function* generatorFunction() {
  // 函数体
}
  • 在生成器函数内部,可以使用 yield 关键字暂停函数执行,并返回一个值
    • 此时就可以回顾前面的暂停案例,普通函数无法实现代码执行的暂停与恢复效果
    • 同样的案例,生成器除了函数定义方式外,在函数体中在想要暂停的位置添加上对应 yield 关键字即可
function* foo() {
  console.log("函数开始执行~")

  const value1 = 100
  console.log("第一段代码:", value1)
  yield//生成器暂停

  const value2 = 200
  console.log("第二段代码:", value2)
  yield

  const value3 = 300
  console.log("第三段代码:", value3)
  yield

  console.log("函数执行结束~")
}
  • 但生成器除了定义声明与函数体中决定暂停位置之外,还需要处理调用方式
    • 以上生成器函数代码如果像普通函数foo()一样执行调用,是一行代码都不会调用的
    • 但这不意味着该生成器函数无法使用,而是使用方式有所区别,因为使用生成器函数需要解决生成器内的yield关键字调用问题
  • 生成器的工作原理
    1. 初始化:调用生成器函数时,并不会立即执行函数体,而是返回一个生成器对象
    2. 执行与暂停:调用迭代器对象的 next() 方法时,生成器函数开始执行,直到遇到第一个 yield 表达式,函数暂停执行,并返回 yield 的值
    3. 继续执行:再次调用 next() 方法,生成器函数从上次暂停的地方继续执行,直到下一个 yield 或函数结束
// 调用生成器函数时(不需要加星号), 会给我们返回一个生成器对象
const generator = foo()

// 开始执行第一段代码
generator.next()

// 开始执行第二端代码
console.log("-------------")
generator.next()//每一次调用next都会执行到下一个yield或函数结束为止
generator.next()
console.log("----------")
generator.next()

2.2 生成器函数执行流程

  • 调用生成器函数所返回的是一个生成器对象,但却可以调用next方法
    • 而该next方法遵守的是迭代器对象的返回值和对应返回标准的,返回内容需要为一个对象,对象内包含value与done属性
    • 那么生成器函数所返回的生成器对象,可以认为是一个特殊的迭代器
function* foo() {
  console.log("函数开始执行~")

  const value1 = 100
  console.log("第一段代码:", value1)
  yield

  const value2 = 200
  console.log("第二段代码:", value2)
  yield

  const value3 = 300
  console.log("第三段代码:", value3)
  yield

  console.log("函数执行结束~")
  return "123"
}

// generator本质上是一个特殊的iterator
const generator = foo()
console.log("返回值1:", generator.next())
console.log("返回值2:", generator.next())
console.log("返回值3:", generator.next())
console.log("返回值3:", generator.next())
  • 返回内容如下:
    • 每次yield关键字进行暂停时,后续内容将不继续执行,不会影响到已经执行的代码
    • 但其中的value是undefined,原因为我们没有明确返回内容,在最后一段执行中,返回字符串123,此时value有明确内容并且done转为true(意味没有下一个值,到头了)
// 函数开始执行~
// 第一段代码: 100
// 返回值1: { value: undefined, done: false }
// 第二段代码: 200
// 返回值2: { value: undefined, done: false }
// 第三段代码: 300
// 返回值3: { value: undefined, done: false }
// 函数执行结束~
// 返回值3: { value: '123', done: true }
  • 如果我们希望在每一段yield中都有具体的返回值,而不是undefined,这需要怎么做?
    • 通过return虽然可以有返回值,但同时后续代码将无法继续执行,只能有一段具体返回值,不是所期望的效果
//不被期望的return返回方式
function* foo(){
console.log("执行内部代码:1");
console.log("执行内部代码:2");
yield "第一段yield"
console.log("执行内部代码:3");
console.log("执行内部代码:4");
return "第二段yield"			//注意这里的代码,变成了return。那再调用next的话,后面5、6的代码就不会执行了
console.log("执行内部代码:5");
console.log("执行内部代码:6");
}

//2.调用生成器函数,返回一个生成器对象
const generator = foo()

//调用生成器的next方法
console.log(generator.next());
console.log(generator.next());
console.log(generator.next());

我们需要做到每一段都有返回值,yield有实现该功能,其语法为:[rv] = yield [expression]

  • expression:定义通过迭代器协议从生成器函数返回的值。如果省略,则返回 undefined
  • rv:返回传递给生成器(特殊迭代器)的 next() 方法的可选值,以恢复其执行
function* foo() {
  console.log("函数开始执行~")

  const value1 = 100
  console.log("第一段代码:", value1)
  yield value1//返回具体的内容

  const value2 = 200
  console.log("第二段代码:", value2)
  yield value2

  const value3 = 300
  console.log("第三段代码:", value3)
  yield value3

  console.log("函数执行结束~")
  return "123"
}

// generator本质上是一个特殊的iterator
const generator = foo()
console.log("返回值1:", generator.next())
console.log("返回值2:", generator.next())
console.log("返回值3:", generator.next())
console.log("返回值3:", generator.next())
// 函数开始执行~
// 第一段代码: 100
// 返回值1: { value: 100, done: false }
// 第二段代码: 200
// 返回值2: { value: 200, done: false }
// 第三段代码: 300
// 返回值3: { value: 300, done: false }
// 函数执行结束~
// 返回值3: { value: '123', done: true }
  • 通过该方式,我们切实的拿到了对应的数值,该方式传递不仅可以传递具体数值,也可以传递表达式、函数等内容
    • 该流程即是生成器函数的执行流程,通过yield关键字实现了与普通函数截然不同的运行
    • 而通过yield关键字的语法,可以具备一定的联想
//yield的返回值使用方式 
yield value1
//类似以下代码:
const rv = value1
迭代器对象(rv)
  • 生成器函数在每个 yield 处暂停执行,可以将其理解为被分割成多个代码段,每个代码段之间的状态被保存下来,等待下一次调用 next() 时继续执行
    • 我们使用普通函数来模拟生成器函数,观察其运行原理
function fooSimulator() {
  let state = 0;
  let x, y;

  return {
    next: function (input) {
      switch (state) {
        case 0:
          state++;
          return { value: 1, done: false };

        case 1:
          x = input;
          console.log("接收到的 x:", x);
          state++;
          return { value: 2, done: false };

        case 2:
          y = input;
          console.log("接收到的 y:", y);
          state++;
          return { value: undefined, done: true };

        default:
          return { value: undefined, done: true };
      }
    }
  };
}

const simulator = fooSimulator();
console.log(simulator.next());          // { value: 1, done: false }
console.log(simulator.next("Hello"));   // 接收到的 x: Hello
// { value: 2, done: false }
console.log(simulator.next("World"));   // 接收到的 y: World
// { value: undefined, done: true }
  • 该过程中实现了一个特殊的迭代器,我们也可以理解为一个demo级别的生成器对象
    • 通过该特殊的迭代器实现了与生成器函数类似的效果
    • 不同在于生成器函数中的yield还决定了更多的暂停、恢复处理方式,因此我们可以理解为yield可以与该特殊迭代器(生成器对象)实现如下配合
    • 在上述封装demo中,我们所return的value是固定写死,但也可以做到传递yield所输入的值,只需要将return { value: 2, done: false }改为return { value: x, done: false }即可,我们以log打印作为演示
  • 将其拆开后,yield也不是那么难以理解的事情,其传递方式不会超出我们理解,只不过yield将其过程隐藏起来,正常函数调用形参能够传递什么,yield的传递就应该能够传递什么,其规则相似
    • 我们模拟案例的信息传递是通过调用时的next方法,但生成器函数是通过yield传递,但这并非没有共通之处
//yield的返回值使用方式 
yield value1
//类似以下代码:
const rv = value1
simulator.next(rv)
  • 在该生成器函数中,我们已知每一段yield关键字都可以接收对应返回值,传递给特殊迭代器对象,从而在特殊迭代器调用next方法时进行接收

    • 但如果我想要在生成器函数中,yield的下一段片段代码中拿到上一段片段代码的返回值rv,我要怎么样才能做到?
  • 直接使用变量进行接收即可,这意味着yield具备返回值,也说明着yield与next方法之间存在信息传递

    • generator.next(value) 的参数 value,将作为上一个 yield 表达式的返回值
    • 首次调用 next() 时,如果不传参数,或者传入的参数会被忽略,因为此时还没有遇到第一个 yield
    • 从第二次调用 next(value) 开始,传入的参数才会被传递给 yield 表达式,作为其返回值
function* foo() {
  console.log("函数开始执行~")

  const value1 = 100
  console.log("第一段代码:", value1)
  const rv = yield value1//rv是输入,yield value1是输出
  // ------分割线
  console.log("第二段代码:", rv)
}


const generator = foo()
//`yield` 表达式的值取决于下一次调用 `next(value)` 时传入的参数
console.log("返回值1:", generator.next())//next是接收到输出的内容
console.log("返回值2:", generator.next(25))//next()传入的内容是输入
  • 因此yield具备双向通信功能,yield 表达式返回一个值给外部,同时可以接收外部通过 next(value) 传入的值
    • 作为输出: yield 后面的值会被 next() 方法返回给外部
    • 作为输入: yield 表达式的值取决于下一次调用 next(value) 时传入的参数

2.3 yield错位双向传递

我们注意到这种 错位(staggered) 的现象,即输入的值似乎总是滞后于输出一个阶段。这种设计确实需要深入理解,才能掌握生成器的工作机制

  • 首先,需要理解的是 生成器函数和调用者是交替执行的。每次调用 next() 方法,生成器函数都会执行直到下一个 yield,然后暂停,等待下一次 next() 调用。这个过程可以视为 生成器和调用者之间的协程,它们轮流控制程序的执行权
    1. 生成器执行阶段:从上次暂停处开始,执行到下一个 yield,然后暂停,将控制权交回调用者
    2. 调用者执行阶段:调用者可以处理生成器返回的值,并通过下一次调用 next(value),将值传回生成器
  • 错位传递 产生的原因在于 执行流程的先后顺序
    • 当生成器函数执行到 yield 时,它 暂停执行,并将 yield 后面的值返回给调用者。此时,yield 表达式尚未完成,它需要等待下一次 next(value) 调用,才能获取其返回值
    • 当调用者再次调用 next(value) 时,生成器函数从暂停处继续执行。value 被作为上一个 yield 表达式的返回值
  • 这种设计导致 输出(yield 返回的值)和输入(next(value) 传入的值)发生在两个不同的阶段,从而产生了错位
  • 这种你来我往的机制,确保了每一方都在合适的时机接收和处理数据
    • 关键点: 生成器和调用者 轮流 执行,不能同时进行
    • 我们以关键点为线索,一旦不能同时执行,为什么是先yield返回内容而不是next传入内容?好像这两者没有什么更优更劣一说
    • 和前面所说与协程的通信模式有关,这种模式需要先有一个初始的消息发送,才能开始通信。反过来,如果没有初始的输出,通信就无法开始
    • 从使用角度理解,如果不在当前调用返回内容,还能从哪个地方返回?下一次执行吗?我如果不打算执行下一次怎么办?而输入内容,也就第一个next方法无法输入内容,但可以通过在一开始生成器函数调用时输入内容来弥补
    • 当然,我们最好还是以状态来理解,暂停状态说明当前函数已经执行完了,可以返回内容了。恢复状态说明函数开始执行了,可以传入参数了,就是位置需要注意一下
    • 生成器的设计遵循单向数据传递的原则,而next和yield可以理解为切换生成器"恢复"和"暂停"状态的开关,切换的时机也是介入的时刻

stack overflow社区关于浏览器中对象排序的讨论

图28-5 yield双向通信

错位传递的本质:生成器在 暂停 时,通过 yield 输出值给调用者;当 恢复 时,通过 next(value) 接收调用者传入的值。这种机制导致了输入和输出在时间上错开

stack overflow社区关于浏览器中对象排序的讨论

图28-6 恢复与暂停传输过程

  • 我们可以进一步思考,如果没有错位传递,会怎样?

    • 输入和输出在同一阶段,生成器函数需要在未暂停的情况下接收外部输入,这在单线程的 JavaScript 中是无法实现的
    • 暂停时输出值,恢复时接收输入,之所以错位正是因为生成器函数的暂停和恢复机制所决定的
  • 如果尝试在同一阶段同时进行输入(接收调用者传入的值)和输出(向调用者返回值),会出现以下问题:


表29-3 yield没有错位传递需要考虑的问题

问题类型描述
控制权的归属问题生成器函数在暂停时无法处理输入:当生成器函数遇到 yield 表达式并暂停执行时,控制权已经交给了调用者。在暂停状态下,生成器函数无法接收输入,因为它不再执行。
调用者无法在生成器暂停前提供输入:调用者只能在生成器暂停后,通过 next(value) 方法传入值。如果要求调用者在生成器暂停前提供输入,逻辑上无法实现。
执行顺序的混乱无法确定先后顺序:如果输入和输出在同一阶段,生成器函数无法确定先处理输入还是先返回输出。这会导致代码逻辑混乱,无法保证正确的执行流程。
技术实现的限制JavaScript 单线程模型:JavaScript 是单线程执行的,生成器和调用者不能同时执行代码。在同一时刻,只能有一方(生成器或调用者)在执行。
数据传递的矛盾yield 表达式的值问题:如果 yield 要同时返回值给调用者,又要接收调用者传入的值,yield 表达式的值将无法确定。这会导致变量赋值的矛盾,程序无法正常运行。
  • 以数字举例,观察其交替顺序
    • 事实证明,虽然第一次next调用无法传入内容,但通过正常的调用函数即可传入,错位并不影响使用
function* foo(rv) {
  console.log("生成器1:", rv)
  const rv1 = yield 'v1'
  //每一个rv都是上一阶段的内容
  console.log('生成器2:', rv1)
  const rv2 = yield 'v2'

  console.log("生成器3:", rv2)
  const rv3 = yield 'v3'

  console.log("生成器4:", rv3)
  const rv4 = yield 'v4'

  console.log("生成器5:", rv4)
  const rv5 = yield 'v5'
}


const generator = foo('输入1')
generator.next()//第一个不填
generator.next('输入2')//rv1
generator.next('输入3')//rv2
generator.next('输入4')//rv3
generator.next('输入5')//rv4

//每一个内容都刚好能同步对应
// 生成器1: 输入1
// 生成器2: 输入2
// 生成器3: 输入3
// 生成器4: 输入4
// 生成器5: 输入5

2.4 生成器的其他方法使用

2.4.1 生成器的终止执行

  • next双向传递使用场景非常多,需要好好掌握
    • 而我们说过该生成器对象是一个特殊的迭代器,是因为该迭代器除了next方法之外,还存在一些生成器对应的方法
    • 其中包括了return和throw两个实例方法,都需要在生成器的基础上进行调用

stack overflow社区关于浏览器中对象排序的讨论

图28-7 内置对象生成器的三个实例方法

  • Generator.prototype.return方法
    • return() 方法用于提前终止生成器函数的执行,同时可以指定一个返回值
    • 当调用 return(value) 时,生成器会立即停止执行,且后续的 next() 调用不会再执行生成器函数内部的代码
    • 在生成器函数内部使用 return 语句,会使生成器结束,donetrue,并返回指定的值。而调用 generator.return(value) 方法,则是从外部强制生成器结束,与在生成器函数内部使用没有区别
  • 当不再需要生成器生成值时,可以调用 return() 方法终止执行,释放资源
function* gen() {
  yield 1;
  yield 2;//相当于在这里不使用yield,直接return 42
  yield 3;
}

const g = gen();

console.log(g.next());        // { value: 1, done: false }
console.log(g.return(42));    // { value: 42, done: true }
console.log(g.next());        // { value: undefined, done: true }
  • 对应使用规则如下:
    1. 立即结束生成器:调用 return() 后,生成器的 done 属性变为 true,表示生成器已完成
    2. 可选的返回值:可以传入一个值作为返回值,供外部使用
    3. finally 块仍会执行:如果生成器函数内部有 try...finally 结构,finally 块的代码会在 return() 调用后执行
    4. 后续调用 next() 不会再执行生成器代码:生成器已结束,next() 调用只会返回 { value: undefined, done: true }

2.4.2 生成器的抛出异常

  • Generator.prototype.throw() 方法:
    • throw() 方法用于向生成器函数内部抛出一个错误,在生成器暂停的 yield 表达式处抛出异常
    • 可以在生成器外部触发生成器内部的 catch 语句或导致未捕获的异常
//参数:exception —— 要抛出的错误对象或值
//返回值:根据生成器函数内部的处理,返回下一个 yield 表达式的结果,或抛出异常
generatorObject.throw(exception)
  • 对应使用规则如下:

    异常处理

    • 如果生成器内部有对应的 try...catch 块捕获异常,会按照正常的异常处理流程执行
    • 如果未捕获异常,生成器函数会终止,异常向外传播

    生成器状态变化

    • 如果异常被捕获,生成器可以继续执行,done 可能为 false
    • 如果异常未被捕获,生成器会结束,donetrue
  • 首次调用的特殊情况:如果在生成器开始执行之前调用 throw(),即在第一次调用 next() 之前调用,会在生成器函数的开始位置抛出异常

  • 因此thorw分为两种情况,捕获以及未捕获,我们需要分类讨论

  • 当捕获成功时,调用 throw('错误信息'),在 yield 1 处抛出异常,异常被生成器内部的 catch 块捕获,打印 '内部捕获异常:错误信息',生成器继续执行,返回 { value: 3, done: false }

//捕获成功
function* gen() {
  try {
    yield 1;
    yield 2;
  } catch (e) {
    console.log('内部捕获异常:', e);
  }
  yield 3;
}

const g = gen();

console.log(g.next());            // { value: 1, done: false }
console.log(g.throw('错误信息')); // 内部捕获异常:错误信息
// { value: 3, done: false }
console.log(g.next());            // { value: undefined, done: true }
  • 调用 throw('未捕获的错误'),在 yield 1 处抛出异常,生成器内部没有捕获异常,生成器终止,不继续执行,异常被外部的 catch 捕获,打印 '外部捕获异常:未捕获的错误',生成器结束
//未捕获
function* gen() {
  yield 1;
  yield 2;
}

const g = gen();

console.log(g.next());           // { value: 1, done: false }
try {
  g.throw('未捕获的错误');        // 生成器函数内部没有捕获,异常向外传播
} catch (e) {
  console.log('外部捕获异常:', e);
}
//生成器终止,value为undefined,而非2
console.log(g.next());           // { value: undefined, done: true }

三、优化可迭代对象写法

  • 在一开始的时候,我们写过一个可迭代器对象,并返回迭代器
    • 不管是可迭代对象的Symbol.iterator还是迭代器的next方法以及返回值,我们都遵守了可迭代协议、迭代器协议
    • 但通过学习生成器之后,原有写法是可以简化的,因此生成器的逻辑与迭代器保持一致,并通过yield关键字做到了更加强大
//可迭代对象
const iterableObj = {
  //需要遍历的目标数组
  names: ["coderwhy", "XiaoYu", "JS"],
  //生成器替代迭代器
  *[Symbol.iterator]() {
    for(item of this.names) yield item
  }
}

for (const item of iterableObj) {
  console.log(item)//正常遍历输出
}
  • 只需要简单几行代码即可完成一开始的目标,首先使用生成器函数来替代[Symbol.iterator]() 方法,则内部不再需要手写迭代器对象,而是yield进行控制,确定"切割"为几个迭代器对象
//第一种写法
*[Symbol.iterator]() {
    for(item of this.names) yield item
  }

//第二种写法:相当于如下内容
*[Symbol.iterator]() {
  yield names[0]
  yield names[1]
  yield names[2]
}

//*[Symbol.iterator](){}
//function *[Symbol.iterator](){} 其中在对象内写方法时,function可以省略
  • 而到目前为止,这就是最简便的写法了吗?
    • 其实并不是,yield作为关键字,还有对应的语法糖yield*,这有什么区别?
    • 概况的说,yield暂停生成器函数并返回一个值,yield*迭代操作数,并产生它返回的每个值
  • 因此在使用语法糖yield*的时候,可以继续精简掉遍历循环的过程,但紧跟的使用目标需要是一个可迭代对象(才能遍历)
const iterableObj = {
  names: ["coderwhy", "XiaoYu", "JS"],
  //第三种写法
  *[Symbol.iterator]() { yield* this.names }
}

for (const item of iterableObj) {
  console.log(item)//正常遍历输出
}

3.1 迭代数字案例

  • 我们来实现一个案例,输入两个数字,要求迭代数字1到数字2之间的所有数字,使用迭代器与生成器各自完成
    • 可以看到,使用生成器只需要两行代码即可完成,核心的代码只需要一行就能够替代原本的九行代码
    • 这是因为生成器是迭代器的更高抽象层次封装
//迭代器写法
function createRangeIterator(start, end) {
  let index = start
  return {
    next: function() {
      if (index < end) {
        return { done: false, value: index++ }
      } else {
        return { done: true, value: undefined }
      }
    }
  }
}

const rangeIterator = createRangeIterator(10, 20)
console.log(rangeIterator.next()) //{ done: false, value: 10 }
console.log(rangeIterator.next()) //{ done: false, value: 11 }
console.log(rangeIterator.next()) //{ done: false, value: 12 }
console.log(rangeIterator.next()) //{ done: false, value: 13 }
console.log(rangeIterator.next()) //{ done: false, value: 14 }
//生成器写法
function* createRangeIterator(start, end) {
  let index = start
  while (index < end) yield index++
}

const rangeIterator = createRangeIterator(10, 20)
console.log(rangeIterator.next())//重复调用,省略

3.2 自定义类对象优化

  • 因此我们可以继续通过生成器来完善我们的自定义类对象可迭代性
class Classroom {
  constructor(address, name, students) {
    this.address = address
    this.name = name
    this.students = students
  }

  entry(newStudent) {
    this.students.push(newStudent)
  }
  //实现迭代器
  *[Symbol.iterator]() {
    yield* this.students
  }
}
const classroom1 = new Classroom("3幢5楼205", "计算机教室", ["小余1", "小余2", "小余3", "小余4"])
const classroom2 = new Classroom("3幢5楼304", "土木教室", ["小余5", "小余6", "小余7", "小余8"])

classroom1.entry("小余666")//能够正常添加
classroom2.entry("小余888")//能够正常添加

//正常迭代
for (const stu of classroom1) {
  console.log(stu)
}
//正常迭代
for (const stu of classroom2) {
  console.log(stu)
}
  • 到这里为止,我们就通过几个案例说明了迭代器到生成器的演变过程,并清楚了能够用迭代器的地方,就能够使用生成器来进一步精简使用

四、异步代码的处理方案

  • 现在需要回顾前面所学的Promise知识,我们是如何通过Promise实现异步数据的获取的
    • 在这里精简掉一些不必要的边界判断,接收了什么数据,我们2秒后直接返回,主要体现其异步性质
    • 我们通过Promise的两个实例方法:then、catch实现了对数据的接收和兜底
function requestData(url) {
  // 异步请求的代码会被放入到executor中
  return new Promise((resolve, reject) => {
    // 模拟网络请求
    setTimeout(() => {
      // 拿到请求的结果
      resolve(url)
    }, 2000);
  })
}
//使用方式
requestData('juejin.cn').then(res => {
  console.log(res)
}).catch(err => {
  console.log(err);
})
  • 之所以回顾这段代码,是因为我们要在这个基础上修改需求,与本章节内容进行结合了
    • 我希望在返回的异步数据上进一步处理或者多次处理,能够怎么做?这是有实际应用场景的,假设我们需要获取用户相关信息,后端不会一次性把数据都返回给我们,我们需要先请求用户id接口,再根据返回的用户id去请求用户的部门信息,再根据返回的部门信息去获取其他信息
    • 从原理上来说,我需要从服务器中拿到数据,进行第一次处理,然后返回给服务器,该流程循环多次形成多次处理
/*
  1.发送一次网络请求,等到这次网络请求的结果
  2.发送第二次网络请求,等待这次网络请求的结果
  3.发送第三次网络请求,等待这次网络请求的结果

  这发送这多次网络请求不是并行的,而是后者需要依赖前者的结果
  就是说第一次网络请求会携带一些信息回来,而第二次网络请求就需要这些信息,第三次网络请求也可能需要的不止第二次网络请求带回来的内容,还有第一次网络请求带回来的内容
*/

stack overflow社区关于浏览器中对象排序的讨论

图28-8 异步信息数据的多次处理

4.1 方式1:层层嵌套

  • 如果以目前我们Promise的then、catch做法,很容易形成回调地狱(嵌套太多层难以阅读维护称为xxx地狱,由回调产生的则称为回调地狱)
// 1.第一种方案: 多次回调
// 回调地狱
requestData("why").then(res => {
  requestData(res + "aaa").then(res => {
    requestData(res + "bbb").then(res => {
      console.log(res)
    })
  })
})

4.2 方式2:链式调用

  • 第二种方式,可以使用链式调用来解决,相对于回调地狱来说,不会存在嵌套的问题,也是正常情况下我们会使用的方式
    • then可以接收上一阶段所返回的内容进行调用(返回内容会被包裹一层Promise,本身为Promise则直接返回)
requestData("why").then(res => {
  return requestData(res + "aaa")
}).then(res => {
  return requestData(res + "bbb")
}).then(res => {
  console.log(res)
})

4.3 方案3:Generator方案

  • 第三种方案则是结合我们本章所学的Generator生成器
    • 我们的需求可以拆解为发送请求,接收请求,循环多次
    • 而生成器函数通过yield刚好可以做到输入输出内容,与服务器构成交互,从代码层面实现了同步代码的效果
function* getData() {
  //左侧接收请求,右侧发送请求
  const res1 = yield requestData("why")//拿到res1的结果执行res2结果
  const res2 = yield requestData(res1 + "aaa")//拿到res2结果执行res3结果
  const res3 = yield requestData(res2 + "bbb")//...依次执行
  const res4 = yield requestData(res3 + "ccc")
  console.log(res4)
}
  • 但调用的时候,一样需要面对嵌套问题,需要我们手动执行生成器函数
    • 这样的做法并不好,而在过往的案例中,next方法作为迭代器对象的特殊标注方法,会在遍历时自动调用
    • 但我们目前是处于异步的案例,不适合进行遍历,遍历是需要已知的结果且遍历内容相互独立
const generator = getData()
generator.next().value.then(res => {
  generator.next(res).value.then(res => {
    generator.next(res).value.then(res => {
      //无限嵌套下去
      generator.next(res)
    })
  })
})
  • 我们需要另一种模式:判断+自动化执行,这和之前所实现的调用+自动化执行是不同的,分析我们目前的写法有两个问题:
    • 第一,我们不能确定到底需要调用几层的Promise关系
    • 第二,如果还有其他需要这样执行的函数,我们应该如何操作呢?
  • 所以,我们可以封装一个工具函数execGenerator自动执行生成器函数
    • 调用是基于索引,当前内容执行后,索引+1,后续内容自动化执行
    • 判断则是在原有调用基础上多进行判断一层,只有当前内容得到返回结果后才能继续后续内容的自动化执行
    • 由于我们不清楚到底有几层需要调用,因此以迭代器返回值的done属性来动态判断是否已经遍历到底,然后递归执行
//封装使用方案
function execGenerator(genFn) {
  //获取对应函数的generator
  const generator = genFn()
  function exec(res) {
    const result = generator.next(res)
    if (result.done) return result.value//result里的done是false跟true,为true证明已经到最后了,就返回result的value
    result.value.then(res => {
      exec(res)//递归
    })
  }
  exec()
}

//使用
execGenerator(getData)//要自动执行就放进参数
  • 在该基础上,把已封装的代码部分忽略,单从使用角度来说,是怎么样的情况:
    • 分为四部分:1、异步请求模拟(真实场景为接口) 2、完成业务需求(多次发起网络请求) 3、封装使用方案 4、使用封装的方案
    • 因此实际的代码只有第二步完成业务需求的getData以及第四步使用封装函数execGenerator调用发起网络请求getData

stack overflow社区关于浏览器中对象排序的讨论

图29-9 自动执行生成器函数使用

  • 自动执行生成器函数的思路封装使用方案其实已经有npm库实现,库名是co,GitHub仓库:co仓库,npm库地址:co - npm (npmjs.com)
    • co 是由 TJ Holowaychuk 开发的一个 JavaScript 库,通过生成器函数(Generators)实现协程(Coroutines),从而简化异步代码的编写,核心功能是自动管理生成器函数的执行过程,尤其是处理其中的异步操作(通常是 Promise)。它通过递归调用生成器的 next() 方法,逐步执行生成器函数,直到所有异步操作完成
//安装co 这里使用的是pnpm,如果只有默认的npm的化,可以使用npm i pnpm -g全局安装一下
pnpm install co
  • co的基本原理如下,与我们实现的自动执行思路一致,主要处理迭代执行:
    1. 调用生成器函数co 接受一个生成器函数,并创建其迭代器对象
    2. 迭代执行:通过不断调用 next()co 逐步执行生成器函数,处理每个 yield 表达式
    3. 处理 Promise:当 yield 表达式返回一个 Promise 时,co 会等待其解决(resolve)或拒绝(reject),然后将结果传回生成器函数
    4. 错误处理:如果 Promise 被拒绝,co 会将错误抛回生成器函数中,允许在生成器内部进行捕获和处理
function requestData(url) {/*省略*/}
function* getData() {/*省略*/}
//使用方式
const co = require('co')
co(getData)//要自动执行就放进参数

后续预告

  • 在下一章节,我们会基于迭代器与生成器,进一步去学习异步函数async和他的await操作符
    • async和await的出现,又能够解决我们已存在的什么问题呢?
    • 事件循环又是什么?和事件队列有关系吗?队列细分的微任务队列和宏任务队列又有什么区别?
    • JS为什么是单线程执行的,和浏览器有什么关系吗?进程和线程之间的关系又是怎么样的?
    • 掌握async和await后,结合事件队列,我们将会通过几道面试题来深入Promise的执行顺序中
  • 同时和大家分享一个和迭代器相关的最新消息:**「Stage 4」迭代器助手(Iterator Helpers)**已经进入「Stage 4」阶段了,在介绍ECMAScript委员会时,我们介绍过一个特性纳入使用的流程,而4 阶段就是新特性准备纳入规范并发布!
    • 迭代器在表示大型或无限可枚举数据集时非常有用。然而,迭代器缺乏与数组或其他有限数据结构同样易用的辅助方法,导致一些问题不得不通过数组或外部库来解决。虽然许多库和编程语言已经提供了类似的接口,就像co库所提供的方法一样,但这都只能说是因为JS本身不具备才演变出来的需求
    • 迭代器助手这个提案引入了一系列新的迭代器原型方法,包括但不限于map、filter、reduce、drop、flatMap等多个学习过的方法,允许我们更方便地使用和消费迭代器
  • 该提案不出意外在ES16就有可能和大家见面了,对应的GitHub地址如下:tc39/proposal-iterator-helpers: Methods for working with iterators in ECMAScript (github.com)

stack overflow社区关于浏览器中对象排序的讨论

图29-1 「Stage 4」迭代器助手提案仓库