先记住这个答案
Set 按成员成功加入的顺序迭代,数组展开消费同样的默认顺序;对已存在值再次 add 不会移动位置。遍历结束前新增且在访问前未删除的成员可以被访问,未访问就被删除的成员会跳过,已访问成员若删除后重新加入则可能再次出现。因此集合唯一性不保证本轮回调每个业务值只执行一次,持续删除重插还可能让遍历无法结束。固定批次应先建立成员快照或把变更延迟到扫描后;动态工作队列则应明确重试、去重和总工作上限。
- 初始顺序来自成功插入而不是排序
- 删除重插会建立新的可访问位置
- 唯一成员集合不等于一次遍历只处理一次
先区分重复 add 与删除重插
示例先创建 A、B,再次 add A 后展开仍是 A、B,因为已有成员并未移动。随后在 forEach 第一次看到 A 时删除并重新加入,A 成为末尾新位置,本轮又被访问一次。
只在首次执行分支里重插,使演示有确定终止条件。若每次遇到 A 都重复同样操作,就可能不断创造后续待访问位置,即使任意时刻 Set.size 都很小。
const values = new Set(['A', 'B']);
values.add('A');
console.log(JSON.stringify([...values]));
const seen = [];
let requeued = false;
values.forEach(value => {
seen.push(value);
if (value === 'A' && !requeued) {
requeued = true;
values.delete('A');
values.add('A');
}
});
console.log(JSON.stringify(seen));
console.log(JSON.stringify([...values]));查看输出与解释
["A","B"]
["A","B","A"]
["B","A"]同一轮访问序列包含两次 A,但集合最终只有 B、A 两个成员。唯一性约束的是当前集合状态,遍历历史还受到删除和重新加入的时间顺序影响。
新成员和已删除成员按访问时机判断
如果在访问 A 时删除尚未访问的 B,B 不会因为最初在集合里就必然被调用;此时追加 C,则 C 可以在本轮结束前被访问。不能把 Set 当作开始时复制了固定条目数组。
同样,这些规则不应写成引擎随意决定是否访问。应明确新增发生在遍历完成前、成员是否仍存在及是否重新建立位置,才能从规范语义推导具体例子。
固定批次与动态队列需要不同组织方式
只想处理启动时的成员,可以先 [...set] 得到快照再遍历,并决定期间被取消的项目是否还应执行。快照固定了引用序列,但对象成员的内部状态仍可能变化,必要时还需要版本或取消检查。
如果确实允许回调追加后续工作,就应把它作为队列设计,限制重试次数和总任务量,并记录失败与取消。不要依赖一个 Set 同时隐式承担队列、正在处理和已完成三种状态。
容易答错的地方
- Set 没重复元素就认为不会重复调用
- 删除后重插建立新的遍历位置,已经处理过的值仍可能再次出现。需要一次处理语义时应保存明确完成状态或固定批次,不能把集合唯一性扩大成执行历史去重保证。
- 用 size 有限证明循环一定结束
- 不断移除再加入可能让待访问位置持续出现,而当前成员数量几乎不变。终止证明要看工作是否减少、是否存在重试上限,不能只看集合在每个时刻有多少成员。
面试官还会怎么问?
把 Set 展开成数组后的顺序稳定吗?
在没有干扰读取的修改时,结果遵循成员成功插入顺序,重复 add 已有值不改变位置。若输入构建顺序来自并发完成时机,展开只是忠实保留它,不会自动变成业务排序。
删除当前成员会让后面的成员跳过吗?
Set 不像数组 splice 那样把后续索引前移,遍历会继续处理符合规则的剩余成员。但后续成员是否存在仍受其他删除操作影响,应按集合条目的生命周期分析。
快照遍历就绝对没有并发问题了吗?
快照只固定当时的成员引用列表,成员对象状态和外部业务条件仍可变化。需要执行前校验、取消检查或版本匹配时,应明确加入这些条件,不能把浅快照当成事务隔离。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。