我不知道的 V8(05)— 为什么不建议使用 delete 删除属性
"delete 会破坏 V8 优化"——这个建议在前端社区流传很广,但通常没有解释背后的原因。知道"不要用"和知道"为什么不要用"是两回事。后者能帮你在真正需要用 delete 的场景里,做出更准确的判断。
“delete 会破坏 V8 优化”——这个建议在前端社区流传很广,但通常没有解释背后的原因。知道”不要用”和知道”为什么不要用”是两回事。后者能帮你在真正需要用 delete 的场景里,做出更准确的判断。
一、delete 做了什么
表面上,delete obj.name 就是从对象上移除 name 属性。但在 V8 内部,这个操作的后果远不止于此。
先看正常的对象创建:
const obj = { name: 'V8', version: '8.4' };
V8 的内部状态:
隐藏类 C0:
name → offset 0
version → offset 1
obj:
隐藏类 → C0
属性存储: [ "V8", "8.4" ]
快属性模式下,访问 obj.name 就是读数组的 offset 0,极快。
现在执行 delete obj.name:
V8 执行 delete 的步骤:
1. 检查 name 属性的描述符,确认 configurable 为 true(可删除)
2. 对象当前是快属性模式:
→ 将 offset 0 的槽位标记为"空洞"(hole)
→ 当前隐藏类 C0 不再准确(它描述的属性不存在了)
3. 决策:需要新建一个隐藏类,还是直接转为字典模式?
→ 如果这是一次偶发操作,可能新建隐藏类 C1(只含 version)
→ 如果已经有过多次属性变化,直接转为字典模式
转换后:
隐藏类 C1:
version → offset 0 ← 重新计算了 offset
obj:
隐藏类 → C1 ← 隐藏类切换了
属性存储: [ "8.4" ] ← 重新整理了存储
这不是简单地”从数组里删掉一个元素”,而是整个对象的元数据都发生了变化。
二、隐藏类分裂:为什么开销这么大
每次 delete 触发的隐藏类变化,会带来连锁反应。
假设代码里有 1000 个结构相同的对象,它们共享隐藏类 C0:
const users = Array.from({ length: 1000 }, (_, i) => ({
name: `User${i}`,
version: '8.4',
}));
// 所有 1000 个 user 对象共享隐藏类 C0
// 内联缓存 (IC) 非常高效:一次学习,1000 次复用
现在对其中一个执行 delete:
delete users[0].name;
// users[0] 的隐藏类从 C0 变成 C1
// 其他 999 个 user 还是 C0
// 访问 users[0] 的代码和访问 users[1] 的代码命中不同的隐藏类
内联缓存的影响: 原本 V8 只需要学习一次 C0 的结构,就能高效处理所有 1000 个 user。delete 之后,同一个访问点(比如 for...of 循环里的 user.name)碰到了两种隐藏类(C0 和 C1),内联缓存从单态(Monomorphic,最快)退化为多态(Polymorphic,略慢)。
继续删除更多对象的属性,碰到的隐藏类越来越多,内联缓存可能退化为超多态(Megamorphic),彻底失去优化效果。
三、转为字典模式:更严重的后果
如果 delete 操作频繁,V8 会做一个更激进的决定:直接把对象转为字典模式(Dictionary Mode)。
const obj = { a: 1, b: 2, c: 3 };
delete obj.a;
obj.d = 4;
delete obj.b;
obj.e = 5;
// 频繁的增删操作 → V8 判定:维持隐藏类得不偿失 → 转为字典模式
字典模式意味着:
- 属性访问从偏移量读取变为哈希查找
- 隐藏类优化完全失效
- 内联缓存对字典模式对象的优化能力大幅下降
// 验证
const obj = { name: 'V8' };
console.log(%HasFastProperties(obj)); // true
delete obj.name;
console.log(%HasFastProperties(obj)); // false(可能直接进字典模式)
四、替代方案
方案一:赋值 null 或 undefined
保留属性的存在,只改变它的值。隐藏类不变。
// 不推荐:破坏隐藏类
delete obj.name;
// 推荐:保持隐藏类稳定
obj.name = null;
// 或
obj.name = undefined;
语义上的差异:delete 后 "name" in obj 为 false;赋值 null 后 "name" in obj 仍为 true,只是值为 null。如果代码依赖 in 操作符检查属性存在性,两种方式有行为差异。
方案二:对象解构排除属性(创建新对象)
const { name, ...rest } = obj;
// rest 是不含 name 的新对象
// 原来的 obj 不受影响,隐藏类稳定
适合需要”逻辑上移除属性”但不在乎原对象是否修改的场景。
方案三:用 Map 替代频繁增删的对象
// 频繁增删键值对 → 换 Map
const map = new Map();
map.set('key', 'value');
map.delete('key'); // Map 天生支持高效删除,不影响其他操作的性能
五、什么时候 delete 是可以接受的
并非所有 delete 都会造成显著影响。以下情况可以接受:
- 冷路径代码:不在循环内、不在频繁执行的函数里
- 小型、低频访问的对象:如配置对象,创建一次,访问次数有限
- 逻辑正确性要求:代码必须依赖
in操作符区分”属性不存在”和”属性值为 null”
真正需要避免的场景:在热点代码(循环、渲染函数、事件处理器)里对结构稳定的对象频繁使用 delete。
六、总结
delete 的性能问题不是”它慢”,而是它改变了对象的隐藏类。隐藏类的改变会:
- 使当前对象与其他同类型对象的隐藏类不再一致,破坏内联缓存复用
- 频繁操作时让 V8 放弃快属性,转为字典模式,访问速度进一步下降
替代方案按优先级:
- 赋值
null/undefined(大多数情况下足够) - 解构新对象(需要干净的新对象时)
- 换
Map(需要频繁增删键值对时)
本系列其他文章:
- 上一篇:隐藏类与快慢属性:对象性能的核心
- 下一篇:prototype 和 proto 的本质区别
- 延伸阅读:字典模式下的非线性优化与限制