先记住这个答案
Set 按成员第一次成功加入的顺序遍历,对已有的值再 add 不会改变它的位置。遍历过程中修改 Set,规则是这样的:新加入的成员排在末尾,本轮遍历会访问到;还没访问到就被删掉的成员会被跳过;已经访问过的值,删除后重新加入,会作为末尾的新成员再被访问一次。所以 Set 的「不重复」只是说任一时刻集合里没有重复值,不代表一轮遍历中每个值只处理一次。只想处理遍历开始时的成员,就先 [...set] 复制一份快照再遍历。
Set按成员第一次加入的顺序遍历- 遍历中新加的成员,本轮会被访问到
- 删掉再加回来的值会排到末尾,再访问一次
- 只想处理当前成员,先
[...set]复制一份
删掉再加回来,同一个值被访问两次
先看重复 add 和删除重插的区别。
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') // A 变成末尾的新成员
}
})
console.log(JSON.stringify(seen))
console.log(JSON.stringify([...values]))查看输出与解释
["A","B"]
["A","B","A"]
["B","A"]第一行说明重复 add('A') 没有把 A 挪到后面。遍历时,第一次访问 A 就把它删掉再加回来,A 排到了 B 后面,所以遍历访问完 B 之后又访问了一次 A。最后集合里只有 B 和 A 两个成员,没有重复,但 seen 里 A 出现了两次。
示例用 requeued 标志只重插一次。如果去掉它,每次访问 A 都会把 A 重新排到末尾,遍历就停不下来了,虽然集合始终只有两个成员。
为什么 Set 的遍历是「活」的
可以把 Set 的遍历想象成一个人沿着一条排队的队伍往后走,挨个打招呼。走的过程中,队尾新来的人他会走到;前面还没走到就离队的人,他就碰不到了;已经打过招呼的人离队后又去队尾重新排,他走到队尾时还会再打一次招呼。
- 和数组
forEach的对比 - 数组的
forEach在开始时记下了长度,末尾追加的元素本轮看不到。Set没有这个限制,遍历器每次都去找当前位置之后的下一个成员,只要遍历还没结束,新加的就能被找到。另外,Set删除当前成员不会导致后面的成员被跳过,这点比数组splice安全。for...of遍历Set和forEach遵循同样的规则。
容易答错的地方
- 以为
Set没有重复值,遍历就不会重复处理 - 唯一性说的是集合当前的状态。删除再加入会产生一个新位置,已经处理过的值可以再被访问。需要保证每个值只处理一次,要另外用一个集合记录「已处理」。
- 以为
size很小,遍历就一定能结束 - 示例里
size始终是 2,但只要回调每次都删了再加,遍历就会无限进行下去。判断会不会结束,要看遍历过程中是不是一直在产生新位置,而不是看成员数量。
面试官还会怎么问?
遍历时想删除符合条件的成员,安全吗?
安全。在 forEach 或 for...of 里直接 delete 当前成员,不会影响后面成员的访问,这是 Set 和 Map 的遍历器专门保证的。真正要小心的是遍历中新增成员。
怎样只处理遍历开始时的那批成员?
先复制一份快照:for (const item of [...set])。之后怎么增删原集合都不影响这次遍历。但要想清楚,快照里的成员如果在遍历中被删了,这次还要不要处理。
用 Set 做任务队列有什么问题?
回调里会不断加入新任务时,Set 的遍历确实能处理到它们,看起来像一个队列。但失败重试、去重、总数上限这些都没法表达,也很难看出什么时候结束。真要做任务队列,用数组加游标,或者专门的队列结构,把规则写清楚。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。