JavaScript Set 遍历中修改:新加入的值为什么也会在本轮出现
用 Set 保存待处理编号时,一个处理函数顺手加入了新编号,结果这次循环比预想多执行了一轮。另一个函数删掉尚未轮到的编号,它又完全没有被处理。这里没有并行线程参与:即使同一段同步代码,遍历过程中改变集合,也会改变后续可见的成员。
Set 的遍历按插入顺序前进,并不是开始时自动复制一份成员清单。原集合上发生的删除与添加可能影响还没走完的迭代。要判断任务应该本轮立即处理,还是留到下一轮,必须先把这项业务规则写清楚。
用三个短实验确认访问顺序
把代码保存为 demo.mjs,用 node demo.mjs 运行。以下断言在 Node.js v24.19.0 验证。示例只用字符串成员和有限次数修改,不安排定时器,也不会制造无限循环,观察到的变化全部来自同一轮遍历里的同步操作。
AI概念示意图:用抽象图形说明本文主题,不代表真实界面或运行结果。
import assert from 'node:assert/strict';
const live = new Set(['a', 'b']);
const seen = [];
for (const value of live) {
seen.push(value);
if (value === 'a') { live.delete('b'); live.add('c'); }
}
assert.deepEqual(seen, ['a', 'c']);
assert.deepEqual([...live], ['a', 'c']);
console.log(`live=${seen.join(',')}`);
const original = new Set(['a', 'b']);
const snapshot = Array.from(original);
const fixedSeen = [];
for (const value of snapshot) {
fixedSeen.push(value);
if (value === 'a') { original.delete('b'); original.add('c'); }
}
assert.deepEqual(fixedSeen, ['a', 'b']);
assert.deepEqual([...original], ['a', 'c']);
console.log(`snapshot=${fixedSeen.join(',')}`);
const again = new Set(['a']);
const repeated = [];
let moved = false;
again.forEach(value => {
repeated.push(value);
if (!moved) { moved = true; again.delete(value); again.add(value); }
});
assert.deepEqual(repeated, ['a', 'a']);
assert.equal(again.size, 1);
console.log(`reinserted=${repeated.join(',')}`);第一轮为何从 a 跳到了 c
第一行输出 live=a,c。访问 a 时,尚未访问的 b 被删除,随后添加 c;迭代器继续前进时就跳过 b,并访问后来追加的 c。集合最终也是 a、c,但这个最终集合本身不能完整解释过程中发生过什么,因此示例单独保存 seen 作为访问记录。
调用 add 添加一个仍在集合里的已有值,不会把它重新排到末尾,也不会凭空增加重复成员。可一旦先删除再添加,情况就变了:旧位置被移除,新插入占据后来的位置,尚未结束的遍历可能再次遇到这个值。
先复制成员,决定本轮的处理名单
第二行输出 snapshot=a,b,虽然原集合依旧在处理 a 时删掉 b 并加入 c。这次循环遍历的是 Array.from 事先得到的数组,名单已经独立固定,所以 b 仍会处理,而 c 不在本轮名单中。选择快照就意味着接受这样的成员语义。
如果规则是“只处理开始时存在且现在仍有效的成员”,可以在遍历快照时另外检查原集合 has。那是额外的业务判定,不能认为复制操作会自动替你判断是否已取消。本例故意没有加这个条件,以保持两种行为的差别明确。
删除再加入,为什么同一个值访问两次
第三组用 forEach 演示同类动态行为。首次访问 a 时将它删除再加入,代码中的布尔开关保证该修改仅发生一次,所以最终输出 reinserted=a,a。这个受控实验说明 Set 中没有同时存在两个 a,仍然可能在同一次遍历中两次处理 a。
若每次访问都无条件删除再加入自己,遍历可能一直追着新的插入位置前进,无法结束。不要直接运行这样的无限例子来验证规则;使用明确次数限制或像本文一样只变更一次,就能观察问题而不占住事件循环。
快照只固定成员,不隔离对象内容
当 Set 里装的是对象时,Array.from 复制的是对象引用。后来修改对象字段,快照里的同一个对象也能看到变化;它不会自动形成数据版本或深拷贝。如果任务要求按开始时的内容处理,还要定义需要保存哪些字段以及这些字段是否能够独立复制。
订阅列表、待刷新项目和小型任务集合,都应为遍历期间的增删建立规则。验收时分别测试删除未访问项、加入新项、删除已访问项再插入,以及成员对象内容改变。这样才能确认处理次数与顺序符合业务预期,而不只是最终 Set 看起来正确。


