本文目录
桌游主持人读到“玩家进入森林”,把选择权交给玩家。玩家决定向左走,主持人不是从规则书第一页重读,而是从刚才停住的位置继续。
普通函数通常从入口跑到返回。生成器函数则可以交出一个值、暂停,并在以后恢复。暂停期间,局部变量和执行位置都被保留。
主持人要记住这一回合停在哪里
用手写 iterator 表达多步故事,需要自己保存阶段:
let step = 0
function nextScene() {
step += 1
if (step === 1) return '进入森林'
if (step === 2) return '看见木屋'
return '故事结束'
}分支越多,状态越难维护。生成器把“阶段”写回正常控制流:
function* story() {
yield '进入森林'
yield '看见木屋'
return '故事结束'
}星号说明这是 generator function,yield 表示暂停点。调用它得到 generator object,由这个对象负责之后的恢复。
先猜结果:调用函数时为什么没有日志
function* quest() {
console.log('打开规则书')
yield '进入森林'
console.log('继续下一幕')
return '故事结束'
}
const turn = quest()
console.log('已经拿到生成器')
console.log(turn.next())
console.log(turn.next())输出是:
已经拿到生成器
打开规则书
{ value: '进入森林', done: false }
继续下一幕
{ value: '故事结束', done: true }调用 quest() 时,函数体没有执行,所以“打开规则书”晚于“已经拿到生成器”。第一次 next() 才启动函数,运行到第一个 yield。第二次 next() 从那里恢复,直到 return。
function* 返回的是一台尚未启动的机器
普通函数调用会立刻进入函数体;generator function 调用会创建生成器对象,并让它处于尚未开始的状态。这个对象内部关联执行所需的上下文与恢复点,但这些是规范内部机制,不是能直接修改的公开字段。
可以观察到的主要状态有:
- 尚未开始:函数体一行也没运行。
- 正在执行:某次
next、return或throw正在推进它。 - 暂停在
yield:保存了恢复位置。 - 已完成:到达
return、函数末尾或未处理异常。
生成器正在执行时若被重入,例如在自身运行过程中又对同一个对象调用 next(),会抛出 TypeError。它不是允许随时同时进入的共享线程。
完成后再调用 next(),通常继续得到 { value: undefined, done: true },函数体不会自动重开。想从头执行,需要再次调用 generator function 创建新对象。
yield 把值交出去,也保存恢复位置
yield expression 同时做两件事:把右侧值作为本次迭代结果交给调用者,并暂停生成器求值。下一次恢复时,程序从这个表达式之后继续。
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 表达式结果:
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 的参数为什么没人接
下面的“开场白”不会成为任何变量:
function* demo() {
const answer = yield '问题'
console.log(answer)
}
const iterator = demo()
iterator.next('开场白')
iterator.next('真正答案')第一次 next('开场白') 是从函数入口启动。此时没有“上一个暂停中的 yield”,所以参数会被忽略。函数运行到 yield '问题' 后才第一次暂停。
这不是随意设计的坑,而是双向协议的自然结果:next(value) 的 value 是恢复输入,不是函数初始参数。若生成器需要初始数据,应像普通函数一样传给 generator function:
function* demo(question) {
const answer = yield question
}
const iterator = demo('问题')记住“参数送给上一个 yield”,就不需要单独死背第一次例外。
return、throw 与 finally 如何收尾
调用 generator.return(value) 相当于请求生成器从当前暂停点执行一次返回。若有 try/finally,清理代码仍会运行:
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 接住:
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:
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:
function* menu() {
yield '前菜'
yield* ['主食', '甜点']
}yield* 不只是一个手写 for...of 的文本缩写,它还会转发迭代协议中的 next、throw、return 交互,并产生委托迭代的完成值。简单序列里先理解为“逐项交出”即可,复杂双向通信时要查完整语义。
惰性计算让调用者决定做到哪一步
生成器很适合表达无限或巨大序列。下面的斐波那契数列不会预先创建一个无限数组:
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 会关闭生成器,生成器进入完成状态。
这种结构也能组合转换:
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 是表达式,但优先级较低。复杂运算最好写清括号和中间变量:
function* calculator() {
const base = 10
const extra = yield base
return base + extra
}第一次 next() 交出 10,第二次 next(5) 让 yield base 的结果成为 5,最终返回 15。
yield 只能直接出现在包含它的生成器函数体中。把它藏进普通箭头函数并不会自动连接到外层生成器:
function* valid() {
for (const value of [1, 2]) {
yield value
}
}不能把循环换成包含 yield 的 forEach 回调,因为回调是另一个函数,拥有自己的执行边界。这类限制提醒我们:生成器保存的是它自身的可恢复执行,不会穿透任意嵌套回调。
多个生成器对象不会共享局部进度
每次调用 generator function 都创建新对象:
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* 表达式却能取得它:
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
先预测每次调用的结果:
function* interview() {
const name = yield '姓名?'
const years = yield `${name},工作几年了?`
const city = yield `${years} 年,现居哪里?`
return `${name} / ${years} / ${city}`
}
const chat = interview()依次调用:
chat.next('不会被接收')
chat.next('小林')
chat.next(3)
chat.next('杭州')观察每个输入究竟落到哪个 yield。再在函数体外对已经完成的 chat 调一次 next(),确认它不会从头开始。
最后用 for...of 消费另一个生成器,并在第一项 break;在生成器里加 try/finally,验证迭代器关闭如何触发清理。
下一步:下一个值要过两秒才能拿到怎么办
目前 next() 都同步返回结果。可如果号码来自网络、文件或传感器,调用下一项时可能只能得到“稍后完成”的承诺。
把迭代器的拉取协议与 Promise 结合,就得到异步迭代器;再把生成器的写法与异步函数结合,就得到异步生成器。下一章让快递分三批到达,看看 for await...of 实际在等待什么。