深入解析JavaScript Generator 生成器的概念及应用场景

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 本文讲解了JS生成器的概念和应用场景。生成器是一个可以暂停和恢复执行的函数。利用生成器我们可以很方便地实现自定义的可迭代对象、状态机、惰性计算等,并且还能用它来简化我们的异步操作代码。

生成器(Generator)是 ES6 中引入的语言特性,其本质是一个可以暂停和恢复执行的函数。利用生成器我们可以很方便地实现自定义的可迭代对象(Iterable)、状态机(state machine)、惰性计算(lazy evaluation)等,并且还能用它来简化我们的异步操作代码,这些在文章中都会有具体的例子介绍。

生成器是一类特殊的迭代器(Iterator)。所以要了解生成器,首先我们要学习迭代器的概念。

迭代器(Iterator)

简单地说,迭代器就是实现了next()方法的一类特殊的对象。这个next()方法的返回值是一个对象,包含了valuedone两个属性。例如:

someIterator.next() 
// { value: 'something', done: false }
someIterator.next()
// { value: 'anotherThing', done: false }
someIterator.next()
// { value: undefined, done: true }

代码中的someIterator就是一个实现了迭代器模式的对象,其中包含了somethinganotherThing两个值。我们通过调用next()方法,每次从其中取出一个值。

next()方法的返回值中:value的值是从对象中取出的值,done代表迭代是否结束,如果为true说明迭代器中没有更多的值可以取了。

那么迭代器的作用是什么呢?

设想我们写了一个简单的链表数据结构,如下面的代码所示:

class ListNode {
   
   
  constructor(val) {
   
   
    this.val = val;
    this.next = null;
  }
}

class LinkedList {
   
   
  constructor() {
   
   
    this.head = null;
    this.length = 0;  
  }

  append(val) {
   
   
    const newNode = new ListNode(val);
    if(!this.head) {
   
   
      this.head = newNode;
    } else {
   
   
      let current = this.head;  
      while(current.next) {
   
   
        current = current.next;   
      }
      current.next = newNode;
    }
    this.length++;  
  }
}

这个链表有一个缺点,就是遍历起来非常的麻烦,需要声明一个额外的node变量,还要用 while 循环进行判断。

// 创建一个链表,往其中添加了1,2,3三个元素
const linkedList = new LinkedList()
linkedList.append(1)
linkedList.append(2)
linkedList.append(3)

// 对链表进行遍历
let node = linkedList.head
while (node) {
   
   
  console.log(node.val)
  node = node.next
}
// 依次打印1, 2, 3

我们希望可以使用这个链表结构的人可以用一种更加自然的方式来进行遍历,比如遍历数组时用的for...of循环,减少开发时的心智负担。这个时候迭代器就可以派上用场了。

// 通过 Iterator 接口实现遍历 
[Symbol.iterator]() {
   
   
  let current = this.head;
  return {
   
   
    next: () => {
   
   
      if (current) {
   
   
        const value = current.val
        current = current.next;
        return {
   
    value, done: false };
      }
      return {
   
    done: true }; 
    }
  }  
}

我们可以给LinkedList类添加一个[Symbol.iterator]属性,它是一个函数,其返回值就是我们一开始所说的迭代器对象,其中包含了next()方法。next()方法会依次返回链表中的值,直到到达链表末尾返回{ done: true }

正确实现了[Symbol.iterator]方法的对象就可以通过for...of来进行遍历了。此时我们就可以像遍历数组一样遍历链表中的内容了。

for (const node of linkedList) {
   
   
  console.log(node)
}
// 依次打印1, 2, 3

不仅如此,我们还可以使用扩展运算符来一次性获取链表中的所有元素并转换为数组。

console.log([...linkedList])
// [1, 2, 3]

生成器基础

生成器函数和生成器对象

生成器是一个函数,通过在函数名称前面加一个星号*来标识生成器函数,例如:

function* genFunction() {
   
   
  yield "hello world!";
}

生成器函数返回了一个生成器对象,生成器对象实现了刚刚所说的迭代器接口,因此它具有next()方法。

let genObject = genFunction();
genObject.next();
// { value: "hello world!", done: false }
genObject.next();
// { value: undefined, done: true }

生成器函数中的yield关键字会暂停函数的执行并返回一个值,return关键字会结束生成器函数的执行。

function* loggerator() {
   
   
  console.log('开始执行');
  yield '暂停';
  console.log('继续执行');
  return '停止';
}

let logger = loggerator();
logger.next(); // 开始执行
// { value: '暂停', done: false }
logger.next(); // 继续执行
// { value: '停止', done: true }

第一次调用next()方法时,生成器函数会执行到第一个yield的位置然后暂停执行,并把对应的值放在value中返回,此时函数还没有return,所以done的值为false

第二次调用next()方法时,生成器函数从第三行开始执行,在return语句处结束执行。函数返回值同样被放在value属性中,但因为此时函数的运行已经结束了,所以done的值为true

因为生成器对象实现了迭代器协议,所以我们可以通过for...of循环和扩展运算符来对其进行操作。

function* abcs() {
   
   
  yield 'a';
  yield 'b';
  yield 'c';
}

for (let letter of abcs()) {
   
   
  console.log(letter.toUpperCase());
}
// 依次打印 A, B, C

[...abcs()] // [ "a", "b", "c" ]

接收输入

yield除了返回一个值之外,还能用来接收外界的输入。next()方法中传的第一个参数会被yield接收。下面是一个例子:

function* listener() {
   
   
  console.log("你说,我在听...");
  while (true) {
   
   
    let msg = yield;
    console.log('我听到你说:', msg);
  }
}

let l = listener();
l.next('在吗?'); // 你说,我在听...
l.next('你在吗?'); // 我听到你说: 你在吗?
l.next('芜湖!'); // 我听到你说: 芜湖!

第一个next()方法中传入的值会被忽略,这是因为此时函数才刚刚开始执行,还没有遇到任何yield可以接收输入值,函数会在let msg = yield处停止执行,因为遇到了yield关键字。

第二次调用next()时,传入的参数'你在吗?'会被yield语句返回并赋值给变量msg,然后被打印出来。

随后函数再度在let msg = yield处停止执行,这次yield接收到的值是'芜湖!',因此打印出来msg的值就是'芜湖!'

递归生成器

我们在生成器中想要实现递归调用时不能直接使用yield,因为执行生成器函数返回的值是生成器对象,但我们希望返回的是生成出来的值。

通过yield*可以实现生成器函数的递归,这里我们以一个二叉树结构的中序遍历函数为例:

class TreeNode {
   
   
  constructor(value) {
   
   
    this.value = value
    this.leftChild = null
    this.rightChild = null
  }

  // 中序遍历函数,通过yield*实现递归
  *[Symbol.iterator]() {
   
   
    yield this.value
    if (this.leftChild) yield* this.leftChild
    if (this.rightChild) yield* this.rightChild
  }
}

我们构造一个二叉树实例来验证一下我们的中序遍历方法。

const tree = new TreeNode('root')
tree.leftChild = new TreeNode("branch-left")
tree.rightChild = new TreeNode("branch-right")
tree.leftChild.leftChild = new TreeNode("leaf-L1")
tree.leftChild.rightChild = new TreeNode("leaf-L2")
tree.rightChild.leftChild = new TreeNode("leaf-R1")
//              root
//             /    \
//    branch-left  branch-right
//      /    \         /
// leaf-L1  leaf-L2  leaf-R1

console.log([...tree])
// ['root', 'branch left', 'leaf L1', 'leaf L2', 'branch right', 'leaf R1']

代码可以正确地运行,而且由于我们的二叉树实现了Symbol.iterator,所以我们可以用扩展运算符和for...of循环对其进行遍历。

异步生成器

生成器函数也可以是异步函数。当我们想创建一个异步生成一系列值的对象时就可以使用异步生成器。

async function* count() {
   
   
  let i = 0;
  // 每秒产生1个新的数字
  while (true) {
   
   
    // 等待1秒钟
    await new Promise(resolve => setTimeout(resolve, 1000));
    yield i;
    i++;
  }
}

(async () => {
   
   
  let countGenerator = count();
  console.log(await countGenerator.next().value);  // 1s 后打印 0
  console.log(await countGenerator.next().value); // 1s 后打印 1
  console.log(await countGenerator.next().value); // 1s 后打印 2
})();

在这个例子中,我们声明了一个count函数,其作用是每秒生成一个数字,从 0 开始,每次加 1。由于生成器函数是异步的,所以调用countGenerator.next()得到的会是一个Promise,需要通过await来获取最终生成的值。

除了普通的同步迭代器(也就是上面讲的[Symbol.iterator])之外,JavaScript中的对象还可以声明[Symbol.asyncIterator]属性。声明了asyncIterator的对象可以用for await...of循环进行遍历。

const range = {
   
   
  from: 1,
  to: 5,
  async *[Symbol.asyncIterator]() {
   
   
    // 生成从 from 到 to 的数值
    for(const value = this.from; value <= this.to; value++) {
   
   
      // 在 value 之间暂停一会儿,等待一些东西
      await new Promise(resolve => setTimeout(resolve, 1000));
      yield value;
    }
  }
};

(async () => {
   
   
  for await (let value of range) {
   
   
    console.log(value); // 打印 1,然后 2,然后 3,然后 4,然后 5。每个 log 之间会有1s延迟。
  }
})();

这里我们声明了一个 range 对象,设定了其开始数值为 1,结束数值为 5。对其进行迭代可以异步地获取从 1 到 5 的数值。

生成器应用

自定义序列生成

生成器可以帮我们更方便地进行序列的生成。例如我们想要开发一个扑克游戏,需要一个包含了所有扑克牌数值的序列,如果一一列举会非常麻烦,用生成器就可以简化我们的工作。

const cards = ({
   
   
  suits: ["♣️", "♦️", "♥️", "♠️"],
  court: ["J", "Q", "K", "A"],
  [Symbol.iterator]: function* () {
   
   
    for (let suit of this.suits) {
   
   
      for (let i = 2; i <= 10; i++) yield suit + i;
      for (let c of this.court) yield suit + c;
    }
  }
})

我们在cards[Symbol.iterator]中对四种花色进行遍历,对于每一种花色,我们首先从2数到10,并数字字符与花色的字符进行拼接,从而生成该花色对应的所有数字牌,共九张;随后再遍历四个特殊的字母字符"J", "Q", "K", "A",与花色的字符进行拼接,生成剩余的四张牌。

console.log([...cards])
// ['♣️2', '♣️3', '♣️4', '♣️5', '♣️6', '♣️7', '♣️8', '♣️9', '♣️10', '♣️J', '♣️Q', '♣️K', '♣️A', '♦️2', '♦️3', '♦️4', '♦️5', '♦️6', '♦️7', '♦️8', '♦️9', '♦️10', '♦️J', '♦️Q', '♦️K', '♦️A', '♥️2', '♥️3', '♥️4', '♥️5', '♥️6', '♥️7', '♥️8', '♥️9', '♥️10', '♥️J', '♥️Q', '♥️K', '♥️A', '♠️2', '♠️3', '♠️4', '♠️5', '♠️6', '♠️7', '♠️8', '♠️9', '♠️10', '♠️J', '♠️Q', '♠️K', '♠️A']

使用扩展运算符就可以一次性取出cards中的所有扑克牌数值并转化为数组了,这样的写法比直接一一列举方便了很多。

惰性计算

因为生成器函数执行时会在yield处停止,所以即便是while(true)这样的死循环也不会导致程序卡死,我们可以写一个不断生成随机数的generateRandomNumbers函数。

function* generateRandomNumbers(count) {
   
   
  for (let i = 0; i < count; i++) {
   
   
    yield Math.random()
  }
}

惰性计算的含义是在要用到的时候才进行求值。以上面的generateRandomNumbers函数为例,如果我们采用普通的函数写法,调用函数时JS引擎会一次性把所有随机数都计算出来,如果传入的count过大的话,容易造成应用卡顿。

如果采用生成器的写法,我们可以一次从生成器中取出一个随机数,这样每次只进行了一次随机数生成的操作,避免了过度的性能消耗。

状态机

利用生成器可以接受输入的特性,我们可以通过生成器函数来构建一个状态机。

function* bankAccount() {
   
   
  let balance = 0;
  while (balance >= 0) {
   
   
    balance += yield balance;
  }
  return '你破产了!';
}

let account = bankAccount();
account.next();    // { value: 0, done: false }
account.next(50);  // { value: 50, done: false }
account.next(-10); // { value: 40, done: false }
account.next(-60); // { value: "你破产了!", done: true }

这里我们构造了一个银行账户的生成器,一开始用户的余额是0,我们可以通过在account.next()传入账户变动的数值来改变余额。如果余额变成了负数,就结束函数执行并告知用户破产。

简化分页请求

目前,有很多在线服务都是发送的分页的数据(paginated data)。例如,当我们需要一个用户列表时,一个请求只返回一个预设数量的用户(例如 100 个用户)—— “一页”,并提供了指向下一页的 URL。

星球大战API为例,其中的planetsAPI中包含了星球大战中的各个行星的信息。这是一个分页的API,每次请求返回 60 个星球的数据,包含在 results 字段中,下一页的链接在next字段中。

image-20230607214000904.png

直接请求这个接口来迭代地获取每一页的行星数据是非常麻烦的。所以我们希望对这个接口进行封装,创建一个函数 fetchPlanets(),我们每次调用这个函数就可以获得下一页的 Planets 数据,并且可以使用 for await..of 来迭代获取所有行星数据。其代码如下所示:

async function* fetchPlanets() {
   
   
    let nextUrl = `https://swapi.dev/api/planets`;
    while (nextUrl) {
   
   
      const response = await fetch(nextUrl);
      const data = await response.json();
      nextUrl = data.next;
      yield data.results;
    }
}

调用这个函数,我们可以得到一个planetPages生成器对象,对这个对象进行for await..of 迭代可以得到每一页的行星数据。可以看到,通过异步生成器进行请求封装大大简化了我们的异步请求代码。

const planetPages = fetchPlanets();
(async () => {
   
   
  for await (const page of planetPages) {
   
   
    console.log(page);
  }
})();

代码运行的效果如图所示:

ezgif-5-eb1465aed6.gif

总结

本文中的例子和思路参考了 Anjana Vakil 的演讲以及现代 JavaScript 教程中的相关章节

本文讲解了JavaScript中生成器的概念和相关应用场景。

  • 生成器是一个函数,调用生成器函数可以得到一个生成器对象,生成器对象是一种迭代器(Iterator)。可以用扩展运算符...来一次性获取所有生成值,也可以用for...of循环来遍历生成器生成的值。
  • 生成器可以通过在next()函数调用时传递参数来接受输入。
  • 生成器可以通过yield*关键字进行递归调用。
  • 生成器可以是异步函数,异步生成器对象可以用for await...of循环进行遍历。
  • 利用生成器可以实现自定义序列的生成、惰性计算、状态机和异步分页请求的优雅封装。

本文作者 wzkMaster, 如果有帮助的话欢迎点赞收藏~

相关文章
|
26天前
|
JavaScript 前端开发 Go
CSS 与 JS 对 DOM 解析和渲染的影响
【10月更文挑战第16天】CSS 和 JS 会在一定程度上影响 DOM 解析和渲染,了解它们之间的相互作用以及采取适当的优化措施是非常重要的。通过合理的布局和加载策略,可以提高网页的性能和用户体验,确保页面能够快速、流畅地呈现给用户。在实际开发中,要根据具体情况进行权衡和调整,以达到最佳的效果。
|
21天前
|
机器学习/深度学习 算法 安全
随机性、熵与随机数生成器:解析伪随机数生成器(PRNG)和真随机数生成器(TRNG)
随机性在密码学、仿真和机器学习等领域中至关重要,本文探讨了随机性、熵的概念以及伪随机数生成器(PRNG)和真随机数生成器(TRNG)的原理和应用。PRNG通过算法生成看似随机的序列,适用于高效需求;TRNG利用物理过程生成真正随机数,适用于高安全需求。文章还讨论了两者的协同应用及其面临的挑战。
57 5
随机性、熵与随机数生成器:解析伪随机数生成器(PRNG)和真随机数生成器(TRNG)
|
22天前
|
存储 前端开发 JavaScript
JavaScript垃圾回收机制深度解析
【10月更文挑战第21】JavaScript垃圾回收机制深度解析
97 59
|
8天前
|
JavaScript 前端开发
尾调用在 JavaScript 中的应用场景
尾调用是函数式编程中的一个重要概念,在 JavaScript 中可以用于优化递归等场景,避免调用栈溢出,提高程序性能。通过将递归调用放在函数的末尾,可以实现尾调优化。
|
12天前
|
前端开发 JavaScript 开发者
除了 Generator 函数,还有哪些 JavaScript 异步编程解决方案?
【10月更文挑战第30天】开发者可以根据具体的项目情况选择合适的方式来处理异步操作,以实现高效、可读和易于维护的代码。
|
14天前
|
前端开发 JavaScript
JavaScript新纪元:ES6+特性深度解析与实战应用
【10月更文挑战第29天】本文深入解析ES6+的核心特性,包括箭头函数、模板字符串、解构赋值、Promise、模块化和类等,结合实战应用,展示如何利用这些新特性编写更加高效和优雅的代码。
32 0
|
30天前
|
JavaScript 前端开发 开发者
原型链深入解析:JavaScript中的核心机制
【10月更文挑战第13天】原型链深入解析:JavaScript中的核心机制
29 0
|
1月前
|
JavaScript API
深入解析JS中的visibilitychange事件:监听浏览器标签间切换的利器
深入解析JS中的visibilitychange事件:监听浏览器标签间切换的利器
91 0
|
1月前
|
JavaScript
深入解析:JS与Vue中事件委托(事件代理)的高效实现方法
深入解析:JS与Vue中事件委托(事件代理)的高效实现方法
39 0
|
JavaScript 前端开发 算法
ES6:【深扒】 深入理解 JavaScript 中的生成器
ES6:【深扒】 深入理解 JavaScript 中的生成器
181 0
ES6:【深扒】 深入理解 JavaScript 中的生成器

推荐镜像

更多