先记住这个答案
适合。WeakSet 的用途就是给对象打标记:add(obj) 表示标记过,has(obj) 查询有没有标记。它对成员是弱引用,对象在别处没有引用后,可以被正常回收,标记也跟着消失,不会造成内存泄漏。代价是它不能遍历,没有 size,只能存对象和非注册 Symbol。Set 正好相反:能存任意值,能遍历和计数,但会一直引用着对象,对象用完了还得手动 delete。所以只需要「这个对象有没有被处理过」时用 WeakSet,需要列出或统计所有成员时用 Set。
- 按对象引用打标记,字段相同的新对象不算
- 不会阻止对象被回收,标记随对象一起消失
- 没有
size也不能遍历,只能has已知对象 - 深拷贝和递归遍历里常用来防止循环引用
标记对象,避免重复处理
写一个只处理一次的函数,同一个对象第二次传进来就跳过。
const handled = new WeakSet()
let count = 0
function handleOnce(record) {
if (handled.has(record)) return false // 已经处理过
handled.add(record)
count++
return true
}
const first = { id: 7 }
console.log(handleOnce(first))
console.log(handleOnce(first)) // 同一个对象,跳过
console.log(handleOnce({ id: 7 })) // 字段一样但是新对象,照常处理
console.log(count)查看输出与解释
true
false
true
2第二次传入 first 被跳过了。第三次传入的对象 id 也是 7,但它是一个新对象,引用不同,所以照常处理。WeakSet 认的是对象本身,不看字段内容。如果要按业务 id 去重,就要用 Set 存 id。
这个例子里处理是同步的,不会失败,所以一开始就 add 没问题。真实场景里如果处理可能失败,应该在成功之后再 add,否则失败的对象以后就没机会重试了。
递归遍历时防止循环引用
写深拷贝、深度比较或者遍历一个对象树时,最怕遇到循环引用,比如 a.self = a,递归会一直进行下去直到栈溢出。常见做法是进入一个对象前先 has 检查,没见过就 add 再递归。
- 「全程访问过」和「当前路径上」不一样
- 用一个从不删除的
WeakSet记录所有访问过的对象,能保证每个对象只处理一次。但它不能用来判断「有环」:两个父节点共享同一个子节点时,第二次遇到这个子节点也会命中,而这不是环。 真正检测环,要记录当前递归路径:进入对象时add,处理完它的所有子节点后delete。只有在当前路径上再次遇到同一个对象,才说明有环。深拷贝要把已经拷贝过的对象和副本对应起来,这时用WeakMap更合适,键是原对象,值是副本。
容易答错的地方
- 把
WeakSet当成可以遍历的弱数组 WeakSet只有add、has、delete三个方法,没有size,不能for...of,也不能展开。需要展示或统计所有被标记的对象,要用Set或数组,并自己负责清理。- 以为用
Set标记对象一定会内存泄漏 - 不一定。如果
Set本身是个局部变量,函数结束后整个Set都会被回收,里面的对象也能释放。泄漏发生在Set长期存活、对象用完了又没删掉的时候,比如挂在模块顶层的缓存。
面试官还会怎么问?
WeakSet 里能存数字 id 吗?
不能,add(1) 会抛 TypeError。数字、字符串这些原始值不会被垃圾回收,放进 WeakSet 没有意义。按 id 标记用 Set。
标记后处理失败,怎样允许重试?
两种做法:要么处理成功后再 add;要么开始时 add,失败时在 catch 里 delete。如果还要区分「处理中」和「已完成」,一个布尔标记不够用,改用 WeakMap 存状态,比如 'pending' 和 'done'。
类里怎么用 WeakSet 做品牌检查?
在构造函数里把 this 加进一个模块内部的 WeakSet,方法里先 has(this) 检查,就能确认调用者是这个类真正创建的实例,而不是伪造的对象。现在也可以用私有字段加 #field in obj 语法做同样的检查。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。