K 的一隅

JavaScript JavaScript 核心机制

让函数走一步、停一下:生成器与 yield

用桌游回合的暂停与恢复,理解生成器对象、yield、next(value)、return、throw 和清理逻辑。

11 分钟阅读 更新于 2026-07-23
本文目录

桌游主持人读到“玩家进入森林”,把选择权交给玩家。玩家决定向左走,主持人不是从规则书第一页重读,而是从刚才停住的位置继续。

普通函数通常从入口跑到返回。生成器函数则可以交出一个值、暂停,并在以后恢复。暂停期间,局部变量和执行位置都被保留。

主持人要记住这一回合停在哪里

用手写 iterator 表达多步故事,需要自己保存阶段:

js
let step = 0

function nextScene() {
  step += 1
  if (step === 1) return '进入森林'
  if (step === 2) return '看见木屋'
  return '故事结束'
}

分支越多,状态越难维护。生成器把“阶段”写回正常控制流:

js
function* story() {
  yield '进入森林'
  yield '看见木屋'
  return '故事结束'
}

星号说明这是 generator function,yield 表示暂停点。调用它得到 generator object,由这个对象负责之后的恢复。

先猜结果:调用函数时为什么没有日志

js
function* quest() {
  console.log('打开规则书')
  yield '进入森林'
  console.log('继续下一幕')
  return '故事结束'
}

const turn = quest()
console.log('已经拿到生成器')

console.log(turn.next())
console.log(turn.next())

输出是:

text
已经拿到生成器
打开规则书
{ value: '进入森林', done: false }
继续下一幕
{ value: '故事结束', done: true }

调用 quest() 时,函数体没有执行,所以“打开规则书”晚于“已经拿到生成器”。第一次 next() 才启动函数,运行到第一个 yield。第二次 next() 从那里恢复,直到 return

function* 返回的是一台尚未启动的机器

普通函数调用会立刻进入函数体;generator function 调用会创建生成器对象,并让它处于尚未开始的状态。这个对象内部关联执行所需的上下文与恢复点,但这些是规范内部机制,不是能直接修改的公开字段。

可以观察到的主要状态有:

  • 尚未开始:函数体一行也没运行。
  • 正在执行:某次 nextreturnthrow 正在推进它。
  • 暂停在 yield:保存了恢复位置。
  • 已完成:到达 return、函数末尾或未处理异常。

生成器正在执行时若被重入,例如在自身运行过程中又对同一个对象调用 next(),会抛出 TypeError。它不是允许随时同时进入的共享线程。

完成后再调用 next(),通常继续得到 { value: undefined, done: true },函数体不会自动重开。想从头执行,需要再次调用 generator function 创建新对象。

yield 把值交出去,也保存恢复位置

yield expression 同时做两件事:把右侧值作为本次迭代结果交给调用者,并暂停生成器求值。下一次恢复时,程序从这个表达式之后继续。

js
function* ticketMachine() {
  let number = 101

  while (number <= 103) {
    yield number
    number += 1
  }
}

const tickets = ticketMachine()

console.log(tickets.next()) // { value: 101, done: false }
console.log(tickets.next()) // { value: 102, done: false }

局部变量 number 没有变成全局变量,也没有每次重置。生成器暂停状态保留了继续执行所需的信息。

当函数自然走到末尾,没有显式 return,最终结果是 { value: undefined, done: true }。显式 return value 会把 value 放进完成结果;但 for...of 遇到 done: true 就结束,不会把这个返回值当作普通循环项。

next(value) 把值送回上一个 yield

yield 不只是输出,也是一条双向通道。恢复生成器时,next(value) 的参数会成为上一次暂停的 yield 表达式结果:

js
function* order() {
  const drink = yield '想喝什么?'
  const size = yield `收到 ${drink},要多大杯?`
  return `${size}${drink}`
}

const waiter = order()

console.log(waiter.next())
// { value: '想喝什么?', done: false }

console.log(waiter.next('柠檬茶'))
// { value: '收到 柠檬茶,要多大杯?', done: false }

console.log(waiter.next('大'))
// { value: '大杯 柠檬茶', done: true }

第一次 yield 把问题送出去。第二次 next('柠檬茶') 恢复时,暂停中的 yield 求值为“柠檬茶”,所以赋给 drink。随后运行到第二个 yield。最后一个参数同理成为 size

这种对话能力曾被用于表达异步流程控制,但现代 Promise 和 async/await 已提供更直接的标准语义。生成器更常用于惰性序列、遍历器和需要显式控制推进的流程。

第一次 next 的参数为什么没人接

下面的“开场白”不会成为任何变量:

js
function* demo() {
  const answer = yield '问题'
  console.log(answer)
}

const iterator = demo()
iterator.next('开场白')
iterator.next('真正答案')

第一次 next('开场白') 是从函数入口启动。此时没有“上一个暂停中的 yield”,所以参数会被忽略。函数运行到 yield '问题' 后才第一次暂停。

这不是随意设计的坑,而是双向协议的自然结果:next(value)value 是恢复输入,不是函数初始参数。若生成器需要初始数据,应像普通函数一样传给 generator function:

js
function* demo(question) {
  const answer = yield question
}

const iterator = demo('问题')

记住“参数送给上一个 yield”,就不需要单独死背第一次例外。

return、throw 与 finally 如何收尾

调用 generator.return(value) 相当于请求生成器从当前暂停点执行一次返回。若有 try/finally,清理代码仍会运行:

js
function* openShop() {
  try {
    yield '营业'
    yield '继续营业'
  } finally {
    console.log('关灯并锁门')
  }
}

const shop = openShop()
console.log(shop.next())
console.log(shop.return('提前打烊'))

先打印清理日志,再得到通常为 { value: '提前打烊', done: true } 的结果。for...of 提前 break 时会尝试调用 iterator 的 return();生成器对象已经提供了这个方法,因此 finally 是放清理逻辑的合适位置。

generator.throw(error) 则像从暂停的 yield 位置抛出异常。生成器内部可以用 try/catch 接住:

js
function* task() {
  try {
    yield '等待输入'
  } catch (error) {
    yield `已处理:${error.message}`
  }
}

const job = task()
job.next()
console.log(job.throw(new Error('输入失效')))

若内部没有处理,异常会从 throw() 调用处传播给外部,生成器进入完成状态。

还要注意,finally 里可以出现 yield。这会让一次 return() 暂时得到 done: false,要再次推进才真正完成。虽然合法,但控制流会明显变复杂,除非确实需要可暂停的清理过程,否则不必刻意使用。

生成器既是 iterator,也是 iterable

生成器对象提供 next(),符合 iterator 接口;它也有 [Symbol.iterator]() 并返回自身,因此是 iterable iterator:

js
function* colors() {
  yield 'red'
  yield 'green'
}

const values = colors()

console.log(values[Symbol.iterator]() === values) // true
console.log([...values]) // ['red', 'green']
console.log([...values]) // []

这意味着可以直接交给 for...of、展开或解构,也意味着同一个生成器对象通常是一次性的。要重新遍历,重新调用 colors()

生成器还可以用 yield* 委托给另一个 iterable:

js
function* menu() {
  yield '前菜'
  yield* ['主食', '甜点']
}

yield* 不只是一个手写 for...of 的文本缩写,它还会转发迭代协议中的 nextthrowreturn 交互,并产生委托迭代的完成值。简单序列里先理解为“逐项交出”即可,复杂双向通信时要查完整语义。

惰性计算让调用者决定做到哪一步

生成器很适合表达无限或巨大序列。下面的斐波那契数列不会预先创建一个无限数组:

js
function* fibonacci() {
  let previous = 0
  let current = 1

  while (true) {
    yield current
    ;[previous, current] = [current, previous + current]
  }
}

for (const number of fibonacci()) {
  console.log(number)
  if (number >= 34) break
}

调用者读到 34 就停止,之后的值从未计算。循环的 break 会关闭生成器,生成器进入完成状态。

这种结构也能组合转换:

js
function* map(iterable, transform) {
  for (const value of iterable) {
    yield transform(value)
  }
}

const doubled = map([1, 2, 3], (value) => value * 2)
console.log([...doubled]) // [2, 4, 6]

map 自身不会立即遍历输入。直到展开 doubled,消费者才推进生成器,生成器又向上游 iterable 要值。对于大数据,这能减少中间数组;对于三项小数组,主要价值只是表达方式,不必为了显得高级强行改写。

yield 在表达式里也有语法边界

yield 是表达式,但优先级较低。复杂运算最好写清括号和中间变量:

js
function* calculator() {
  const base = 10
  const extra = yield base
  return base + extra
}

第一次 next() 交出 10,第二次 next(5)yield base 的结果成为 5,最终返回 15。

yield 只能直接出现在包含它的生成器函数体中。把它藏进普通箭头函数并不会自动连接到外层生成器:

js
function* valid() {
  for (const value of [1, 2]) {
    yield value
  }
}

不能把循环换成包含 yieldforEach 回调,因为回调是另一个函数,拥有自己的执行边界。这类限制提醒我们:生成器保存的是它自身的可恢复执行,不会穿透任意嵌套回调。

多个生成器对象不会共享局部进度

每次调用 generator function 都创建新对象:

js
function* counter() {
  let count = 0
  while (true) yield ++count
}

const left = counter()
const right = counter()

console.log(left.next().value)  // 1
console.log(left.next().value)  // 2
console.log(right.next().value) // 1

局部变量跟随各自的可恢复现场。如果把计数器移到函数外,两个生成器才会共享同一个绑定。生成器没有改变词法作用域规则,只改变执行可以暂停和恢复这一点。

调试生成器时沿着暂停点看

断点停在生成器内部时,调用栈显示的是这一次 next() 推进形成的同步调用。生成器暂停后,这次调用已经返回;下一次 next() 形成新的调用,再恢复保存的生成器上下文。

因此,暂停的生成器不是一个一直没出栈的普通函数。它与闭包相似之处是必要状态仍可达,不同之处是生成器还保存了由语言管理的执行位置。

实际排错时,可以给每个 yield 前后打日志,观察哪一次 next() 触发哪一段。双向输入错位通常不是生成器随机跳转,而是调用者忘了“参数送给上一个暂停的 yield”。

yield* 还能接住被委托序列的完成值

普通 for...of 看不到生成器 return 的完成值,yield* 表达式却能取得它:

js
function* chapter() {
  yield '第一节'
  return '本章完成'
}

function* book() {
  const result = yield* chapter()
  yield `收到结果:${result}`
}

console.log([...book()])
// ['第一节', '收到结果:本章完成']

外层先把内层产生的普通值转交给消费者;内层 done: true 时,完成值不作为循环项,而成为 yield* chapter() 的表达式结果。

如果消费者对外层调用 throw()return(),委托机制还会尝试把请求转给内层 iterator。内层缺少对应方法时会按协议规则处理,可能关闭并抛错。这就是为什么复杂委托不能完全等同于“套一个循环”。

实际业务若只拼接几个同步序列,yield* 很清楚;若同时使用双向输入、异常注入和可暂停清理,阅读成本会快速上升。能用普通函数边界表达的流程,不必为了展示生成器能力而塞进一条委托链。

生成器 API 最适合调用者本来就需要控制“再走一步”的场景。若调用者永远会立即读完全部结果,普通函数返回数组可能更直白。选择惰性不是追求形式,而是让计算时机、数据规模和清理边界与消费方式匹配。

先从消费方式出发,再选工具。

容易踩的坑:生成器不是后台线程

yield 不会自动把工作交给事件循环,也不会让 CPU 密集循环变成非阻塞。若两次 yield 之间计算五秒,调用那次 next() 的线程仍会被占五秒。

生成器也不会自己定时恢复。外部消费者决定何时调用 next()。把 Promise yield 出去,并不会让语言自动等待 Promise,除非你另写一个运行器来识别并调度它。

最后,暂停不等于复制现场。生成器是沿同一条执行路径向前推进,局部对象仍可能被外部共享和修改。它保存的是恢复所需状态,不是时间旅行快照。

还有一种误解是“生成器总比数组省内存”。若最终仍用展开语法把全部值收进数组,结果内存依然要占;生成器只避免生产阶段不必要的预计算和中间集合。是否更省取决于消费方式。

验证题:给双向对话再加一个 yield

先预测每次调用的结果:

js
function* interview() {
  const name = yield '姓名?'
  const years = yield `${name},工作几年了?`
  const city = yield `${years} 年,现居哪里?`
  return `${name} / ${years} / ${city}`
}

const chat = interview()

依次调用:

js
chat.next('不会被接收')
chat.next('小林')
chat.next(3)
chat.next('杭州')

观察每个输入究竟落到哪个 yield。再在函数体外对已经完成的 chat 调一次 next(),确认它不会从头开始。

最后用 for...of 消费另一个生成器,并在第一项 break;在生成器里加 try/finally,验证迭代器关闭如何触发清理。

下一步:下一个值要过两秒才能拿到怎么办

目前 next() 都同步返回结果。可如果号码来自网络、文件或传感器,调用下一项时可能只能得到“稍后完成”的承诺。

把迭代器的拉取协议与 Promise 结合,就得到异步迭代器;再把生成器的写法与异步函数结合,就得到异步生成器。下一章让快递分三批到达,看看 for await...of 实际在等待什么。