Skip to content

现代能力补充:惰性迭代、分组与资源释放 ​

学过集合、迭代和错误处理之后,新的语言能力就更容易理解:它们往往为已有模式提供更直接的表达。本篇按问题组织这些能力;每个新 API 都需要核对目标环境,不能用“现代浏览器”四个字包办兼容性。

本篇运行约定 ​

以下新 API 与 using 语法例子使用 Node.js 26.0.0,每个代码块单独保存为 .mjs。先用 node --version 确认,再运行 node 文件名.mjs。传统对照仍可在 Node 22 运行,但不能将“不支持”的分支当成新 API 已学会或已验证。

截至 2026-09-15,迭代器辅助方法和 Promise.try 已进入已发布标准;显式资源管理已达 Stage 4,完成列表预期归入 2027。下面实际运行它,是因为指定引擎已实现;不据此推断所有浏览器或当前年度出版物包含它。

从数组链到惰性迭代链 ​

数组的 map、filter 产生中间数组。处理有限的小集合时,这通常很清楚;如果数据按需产生,迭代器可以把“每次拿一个”的语义延续下去。

js
function* sequence() {
  let value = 1
  while (true) yield value++
}
if (typeof Iterator !== 'undefined' && typeof Iterator.from === 'function') {
  const result = Iterator.from(sequence())
    .map(value => value * 3)
    .filter(value => value % 2 === 0)
    .take(3)
    .toArray()
  console.log(result) // [6, 12, 18]
} else {
  console.log('本运行环境未提供 Iterator helpers')
}

链的输入是无限序列,但 take(3) 限制了结果数量,所以终结操作 toArray() 可以完成。若去掉限制并把无限流收集成数组,程序就没有自然结束条件。

map、filter、take、drop、flatMap 返回继续按需消费的迭代器;reduce、toArray、forEach、some、every、find 消费它。其中 some 等可短路。消费过的同一个迭代器不会自动倒带。

js
if (typeof Iterator !== 'undefined') {
  function* values() { yield 2; yield 4; yield 6 }
  const cursor = Iterator.from(values())
  console.log(cursor.take(1).toArray()) // [2]
  console.log(cursor.toArray())        // []:take 提前结束时关闭底层生成器
}

提前结束会尝试通过底层 return() 关闭迭代。本例的生成器会因此结束;若底层迭代器没有 return,不能假定它也被永久关闭。协议允许调用清理动作,不等于所有迭代器都拥有同一种清理实现。

相同的结果,不代表相同的求值顺序 ​

把每个回调都记入日志,就能看到数组与迭代链的不同。下面先运行数组版本:

js
const log = []
const values = [1, 2, 3]
const result = values
  .map(n => { log.push(`转换${n}`); return n * 2 })
  .filter(n => { log.push(`筛选${n}`); return n > 2 })
console.log(result)        // [4, 6]
console.log(log.join(' ')) // 转换1 转换2 转换3 筛选2 筛选4 筛选6

map 先返回完整数组,之后 filter 才能开始。换成迭代器辅助方法,链的建立与链的消费成为两步:

js
if (typeof Iterator !== 'undefined' && typeof Iterator.from === 'function') {
  const log = []
  const chain = Iterator.from([1, 2, 3])
    .map(n => { log.push(`转换${n}`); return n * 2 })
    .filter(n => { log.push(`筛选${n}`); return n > 2 })
  console.log(log.length) // 0:此时还未消费
  console.log(chain.toArray()) // [4, 6]
  console.log(log.join(' '))   // 转换1 筛选2 转换2 筛选4 转换3 筛选6
}

消费端每要一个值,请求就向上游传递;筛选不通过时继续请求下一个,直到找到满足条件的值。这样可以减少中间容器,也会改变副作用的发生顺序。若回调要读取或修改共享变量,把数组链机械改成迭代链,不能仅凭最终数组看起来一样就认定行为等价。

take 的位置也属于含义的一部分:先取三项再筛选,结果可能不足三项;先筛选再取三项,则可能需要检查更多输入。面对无限源,后者只有在最终能找到足够匹配项,或者消费端主动结束时,才会完成。限制输出数量并不保证寻找每个输出的过程都有限。

分组的键可以是字符串,也可以是对象身份 ​

js
const files = [
  { name: 'a.txt', kind: 'text' },
  { name: 'b.png', kind: 'image' },
  { name: 'c.txt', kind: 'text' },
]
if (typeof Object.groupBy === 'function') {
  const groups = Object.groupBy(files, file => file.kind)
  console.log(groups.text.map(file => file.name)) // ['a.txt', 'c.txt']
  console.log(Object.getPrototypeOf(groups))      // null
}

结果是没有原型的对象。不要依赖它拥有 hasOwnProperty 方法,可使用 Object.hasOwn。分组内的元素仍是原对象的引用,并没有自动深复制。

Object.groupBy 的键经属性键转换后是字符串或 Symbol;Map.groupBy 能以任意值为键。如果两个对象代表两种分类实体,后者能保留对象身份。选择依据是键的语义,不是“哪个名字比较新”。

Set 的 union、intersection、difference、symmetricDifference 分别表达并集、交集、差集和对称差;isSubsetOf、isSupersetOf、isDisjointFrom 用于关系判断。它们让集合操作更贴近数学语言,元素相等性仍遵循 Set 的规则,不是深度比较对象字段。

分组保留的是引用,键的模型必须先确定 ​

假设两个团队对象具有相同名称,但在程序中代表不同实体:

js
const first = { name: '编辑组' }
const second = { name: '编辑组' }
const tasks = [
  { team: first, title: '校对' },
  { team: second, title: '排版' }
]
if (typeof Map.groupBy === 'function') {
  const grouped = Map.groupBy(tasks, task => task.team)
  console.log(grouped.size)           // 2
  console.log(grouped.get(first)[0].title) // 校对
  console.log(grouped.get({ name: '编辑组' })) // undefined
  grouped.get(first)[0].title = '复核'
  console.log(tasks[0].title)         // 复核
}

对象字段相同不代表对象身份相同。若业务确实按名称分类,应明确返回 task.team.name;若按实体身份分类,则保留对象键。Object.groupBy 会把普通对象键转换成属性键,常见对象会得到同样的字符串,不能用来替代身份分组。

分组创建了新的结果容器和组内数组,但没有克隆任务对象。改变组内对象字段会被原数组观察到;把组内某个位置换成另一个对象,则只是在改组内数组的位置。这与第三章的浅复制边界是一条规则。

集合新方法也遵循键与值的身份规则。new Set([{ id: 1 }]) 与另一份独立创建的同字段对象集合,不会因为字段一样就有交集;若需要按 id 求交,应先确定业务键再映射,而不是期待新方法自动进行深比较。

从“已选与可用”推导集合操作 ​

一个筛选面板已有选项 a、b、c,用户选择了 b、c、d。哪些仍然有效,哪些已经不在选项中?先用第三章的成员判断写清规则:有效项同时属于两边,失效项属于选择但不属于可用项。

js
const available = new Set(['a', 'b', 'c'])
const selected = new Set(['b', 'c', 'd'])
const valid = new Set([...selected].filter(id => available.has(id)))
const invalid = new Set([...selected].filter(id => !available.has(id)))
console.log([...valid])   // ['b', 'c']
console.log([...invalid]) // ['d']
if (typeof selected.intersection === 'function') {
  console.log([...selected.intersection(available)]) // ['b', 'c']
  console.log([...selected.difference(available)])   // ['d']
  console.log([...available.union(selected)])       // ['a', 'b', 'c', 'd']
  console.log([...available.symmetricDifference(selected)]) // ['a', 'd']
  console.log(valid.isSubsetOf(available))          // true
  console.log(available.isSupersetOf(valid))        // true
  console.log(invalid.isDisjointFrom(available))    // true
}
console.log([...selected]) // ['b', 'c', 'd']:源集合没有被改写

交集保留两边都有的元素,差集有方向:selected 减 available 得到失效选择,反过来得到还未选中的可用项 a。并集汇集两边;对称差保留只在一边出现的项,不能用来替代单向失效检测。

交集、差集、并集与对称差方法会创建新的 Set;后三种关系判断返回布尔值。若要从页面状态中实际删除失效项,应明确更新状态,不能只调用 difference 后忽略返回值。元素仍按身份判断;API 不会替你把两个同 id 对象当成一项。

集合成员和展示顺序还要分开考虑。intersection 可以按较小一方的顺序产生结果,不承诺始终保持左侧顺序。如果 UI 要求严格按 selected 的顺序展示,可使用前面的过滤写法;若要按名称展示,则再对结果排序。这里的传统写法只面向普通 Set,不作为现代方法所有协议细节的完整替代实现。

把异步来源收集成数组 ​

Array.fromAsync 可以消费异步可迭代对象,也能处理相应同步输入,并等待逐项映射的结果。它解决的是收集问题,不是自动开启不限量并发。

js
async function* readNumbers() {
  yield 2
  yield 5
}
if (typeof Array.fromAsync === 'function') {
  const result = await Array.fromAsync(readNumbers(), async value => value * 10)
  console.log(result) // [20, 50]
} else {
  const result = []
  for await (const value of readNumbers()) result.push(value * 10)
  console.log(result) // [20, 50]
}

运行于支持顶层 await 的模块。若输入永不结束,收集也不会结束;大流应逐段处理。与 Promise.all 一次接收一组已经创建的 Promise 相比,这里的数据获取节奏不同。

Promise.withResolvers() 将新 Promise 与其 resolve、reject 函数一起返回,适合桥接事件;它不免除清理监听器和只处理一次结果的责任。Promise.try(fn) 将函数的同步返回或抛错纳入 Promise 结果;回调会立即被调用,与 Promise.resolve().then(fn) 的调用时机不同。这些 API 也需要环境检测。

等待 next 的结果,不等于递归等待其中所有字段 ​

异步迭代器返回的结果有两层:一次 next() 的完成,以及完成后拿到的 { value, done }。若它确实实现了异步迭代协议,结果对象里的 value 可以仍然是一个 Promise。

js
function makeSource() {
  let sent = false
  return {
    [Symbol.asyncIterator]() { return this },
    async next() {
      if (sent) return { done: true }
      sent = true
      return { done: false, value: Promise.resolve(9) }
    }
  }
}
if (typeof Array.fromAsync === 'function') {
  const raw = await Array.fromAsync(makeSource())
  console.log(raw[0] instanceof Promise) // true
  console.log(await raw[0])              // 9
  const mapped = await Array.fromAsync(makeSource(), value => value)
  console.log(mapped[0])                 // 9
}

第一种调用等待 next(),把其 value 原样放入数组。第二种调用额外提供映射函数,映射结果会被等待,所以最终得到数字。同步可迭代输入的适配路径又会等待逐项值,不能把两个协议分支概括成“所有嵌套 Promise 都会自动拆开”。

通常用 async function* 不容易看到这个区别,因为异步生成器自身会等待 yield 的 Promise。上面的手写协议例子把这层行为单独暴露出来,便于理解等待发生在哪里。

还要区分获取下一项的节奏和任务本身何时启动:预先创建的 Promise 可能已经在执行;稍后顺序等待它们,不会把之前启动的任务倒退成串行任务。若需要控制启动时机,应让迭代过程逐次调用任务函数,而不是提前建立所有 Promise。

Promise 的桥接接口仍有清理责任 ​

Promise.withResolvers 只是把 Promise 和控制它的两个函数放在一起。一次性监听应在成功、失败或取消后解除;多次调用 resolve 不会多次改变状态,但触发回调的外部副作用仍可能重复发生。

先用手动完成的闸门观察这组接口,不引入定时器或外部事件。ready 消费结果,open 提交结果,它们仍连接到同一个 Promise:

js
if (typeof Promise.withResolvers === 'function') {
  function createGate() {
    const { promise, resolve, reject } = Promise.withResolvers()
    return { ready: promise, open: resolve, fail: reject }
  }
  const gate = createGate()
  const log = []
  const observed = gate.ready.then(value => log.push(`通过:${value}`))
  gate.open('第一批')
  gate.open('第二批')
  log.push('当前代码完成')
  await observed
  console.log(log) // ['当前代码完成', '通过:第一批']
}

open 让状态作出决定,但 then 处理器仍在后续作业中执行;第二次 open 不会让第一批变成第二批。若将 open 注册为 DOM 监听器,Promise 的这一规则只约束结果,监听器是否仍注册要另外处理。可回到第九章使用同一函数引用或 AbortSignal 解除注册。

这组返回值等价于把 new Promise 执行器收到的两个函数保存下来,便利之处在于创建时直接取得三者。不要将 ready 暴露给消费者的同时,也不加约束地公开 open/fail;消费者需要等待结果,不一定应该拥有决定结果的权限。

Promise.try 与 Promise.resolve().then 还存在一个能影响调用顺序的区别:

js
if (typeof Promise.try === 'function') {
  const log = []
  const first = Promise.try(() => log.push('try回调'))
  const second = Promise.resolve().then(() => log.push('then回调'))
  log.push('同步末尾')
  await Promise.all([first, second])
  console.log(log.join(' → ')) // try回调 → 同步末尾 → then回调
}

两者都能把结果或异常纳入 Promise 链,但 Promise.try 立即调用传入函数。选择接口时要同时考虑错误归属与调用时机。

资源释放为什么不能等垃圾回收 ​

垃圾回收决定对象内存何时可被回收;流锁、文件句柄、订阅等资源通常需要明确释放。第九章已讨论可达性,本节讨论另一条生命周期。

下面的例子只用传统语法,运行后即使中途抛错也会释放资源:

js
function acquire(log) {
  log.push('打开')
  return { close() { log.push('关闭') } }
}
const log = []
try {
  const resource = acquire(log)
  try {
    log.push('读取')
    throw new Error('格式错误')
  } finally {
    resource.close()
  }
} catch (error) {
  log.push(error.message)
}
console.log(log) // ['打开', '读取', '关闭', '格式错误']

显式资源管理使用 using、await using 与 Symbol.dispose、Symbol.asyncDispose 表达作用域退出时的释放。以下是需要支持该语法的环境才能单独运行的示例,不能直接放进旧引擎的文件:

js
const log = []
{
  using resource = {
    [Symbol.dispose]() { log.push('释放') }
  }
  log.push('使用')
}
console.log(log) // ['使用', '释放']

退出作用域时按逆声明顺序释放资源,包括异常退出。await using 可以等待异步释放;多个释放动作与原异常同时失败时,需要理解 SuppressedError 的错误关系:新的释放错误放在 error,被压住的先前错误放在 suppressed,连续释放失败还可能形成嵌套。DisposableStack/AsyncDisposableStack 用于组合多项清理责任。

只检测 Symbol.dispose 并不能让旧解析器理解 using。 新语法需要构建或独立探测环境;已支持语法也不意味着所有宿主资源自动实现释放协议。没有协议时,try/finally 仍是明确可靠的工具。

多个资源要按依赖的逆序释放 ​

如果游标依赖连接,先关连接再关游标就可能让清理失败。因此“后取得、先释放”有依赖上的理由。先用传统语法观察过程:

js
const log = []
function open(name) {
  log.push(`打开${name}`)
  return { close() { log.push(`关闭${name}`) } }
}
try {
  const connection = open('连接')
  try {
    const cursor = open('游标')
    try {
      throw new Error('读取失败')
    } finally {
      cursor.close()
    }
  } finally {
    connection.close()
  }
} catch (error) {
  log.push(error.message)
}
console.log(log.join(' → '))
// 打开连接 → 打开游标 → 关闭游标 → 关闭连接 → 读取失败

每项资源在取得之后立刻进入受保护的清理范围。如果取得游标时就失败,内层 try 尚未进入,但外层仍会关闭已经取得的连接。不要等所有初始化工作都成功后才登记清理,否则中间失败会留下无人负责的资源。

DisposableStack 将多层清理汇集起来:use 登记实现释放协议的资源,adopt 为普通值附上释放回调,defer 登记无参清理动作;move 把已登记责任转交给新栈。转交不是执行释放,也不是复制出两份释放责任。它适合“初始化期间先由临时栈兜底,成功后交给返回对象负责”的模式。

传统 finally 还有一个容易忽视的限制:如果关闭游标抛错,随后关闭连接也抛错,最后的异常可能遮住前面的异常。显式资源管理使用 SuppressedError 保留错误关系,并继续尝试剩余释放;这不等于自动重试,也不会撤销已完成的业务修改。资源关闭与业务回滚是两项责任。

异步清理必须被等待。await using 可以把作用域完成推迟到异步释放结束,但将资源对象返回到作用域外,并不会自动把本该在当前作用域结束时执行的释放责任转交出去。谁取得、谁持有、谁最终关闭,应在接口设计时明确。

先转交清理责任,再由新持有者释放 ​

下面用一个普通 close 方法模拟资源,便于观察登记、转交和释放的区别。它不使用 using 新语法,但需要环境提供 DisposableStack:

js
if (typeof DisposableStack !== 'undefined') {
  const log = []
  function initialize() {
    const pending = new DisposableStack()
    try {
      pending.adopt({ close() { log.push('关闭资源') } }, item => item.close())
      pending.defer(() => log.push('解除订阅'))
      log.push('初始化完成')
      return pending.move()
    } finally {
      pending.dispose()
    }
  }
  const owner = initialize()
  try {
    console.log(log) // ['初始化完成']:尚未释放
    log.push('使用资源')
  } finally {
    owner.dispose()
  }
  owner.dispose() // 再次释放无新动作
  console.log(log)
  // ['初始化完成', '使用资源', '解除订阅', '关闭资源']
} else {
  console.log('本环境未提供 DisposableStack;使用前面的 try/finally 版本')
}

adopt 将普通资源与它的关闭动作一起登记;defer 登记一个不接收参数的动作。后登记的订阅先清理,再关闭先登记的资源。若资源自己实现了 Symbol.dispose,就可用 use 登记,不必再提供关闭适配函数。

move 创建负责同一组清理动作的新栈,并将原栈标为已释放,却不执行这些动作。因此 initialize 的 finally 不会过早关闭已转交的资源。若在 move 之前抛错,原栈仍持有责任,finally 会按逆序清理。转交应紧接返回,调用方取得后立即放进自己的保护范围,避免中途抛错让两边都没有收尾入口。

如果移除 move、直接返回 pending,函数自己的 finally 就会先关闭资源;调用方拿到的是已经完成清理的栈。另一个错误是完全移除 finally:初始化失败时可能泄漏。转交、释放和复制是不同的动作,必须结合失败路径阅读。

练习 ​

  1. 给无限整数迭代器安排一条只取前四个平方数的消费链,说明哪一步保证结束。
  2. Object.groupBy 返回的分组里修改一个元素,原数组会不会看到变化?如果分类键是对象,应选什么?
  3. “我检测到 Symbol.dispose,所以 using 一定能解析”为什么推断不成立?
  4. 先 filter 再 take(3) 处理无限源是否保证结束?手写异步迭代器的 value 是 Promise 时,Array.fromAsync 是否总会解开这一层?
  5. 写 reconcile(available, selected),返回 valid、invalid 两个新 Set,且 valid 按 selected 的顺序排列,不修改输入。验证 available 为 [b,a]、selected 为 [a,b,c] 时得到 [a,b] 和 [c];再检查空集合与 NaN。为什么不能仅凭第一组演示输出,认定 intersection 永远保持左侧顺序?
  6. 在 initialize 的 move 之前注入一个初始化错误,预测并验证解除订阅、关闭资源和外层捕获的顺序。然后恢复成功路径,证明 initialize 返回时尚未关闭、owner.dispose 调用两次也只关闭一次。若环境缺少 DisposableStack,用前面的嵌套 try/finally 先验证失败时的逆序释放,不把它当作该 API 已运行。

查看本篇答案

用心记录每一步成长