广告位 · 页头(后台-模块管理-新建模块后替换此区域)

JavaScript对象属性读取速度到底由什么决定?

在 JavaScript 中读取对象属性是最常见的操作之一,但它的性能并不总是恒定。V8、SpiderMonkey 等现代引擎通过隐藏类(Hidden Class,V8 中称为 Map)和内联缓存(Inline Cache,IC)来加速属性访问。理解这套机制,有助于避免在无意中把对象的读取路径从快速偏移量查找退化为字典查询。

JavaScript对象属性读取速度到底由什么决定?

一、引擎如何加速对象属性读取

当代码多次访问同一个对象的属性时,引擎不会每次都从头计算属性在内存中的位置。V8 会为具有相同属性结构的对象生成同一个隐藏类。例如通过同一个构造函数创建的对象,只要属性名称、顺序和类型没有变化,就会共享隐藏类。引擎在隐藏类中记录每个属性对应的偏移量,读取属性时只需要按照偏移量访问内存。

内联缓存进一步减少查找成本。某一行读取 obj.name 的代码在第一次执行时会记录对象的隐藏类和属性偏移量,后续执行如果对象形状相同,就直接使用缓存的偏移量。若遇到不同形状的对象,IC 会从单态变为多态,读取速度随之下降。因此,保持对象形状稳定是优化读取速度的核心。

function createUser(id, name) {
  const user = {};
  user.id = id;
  user.name = name;
  return user;
}
// 使用相同顺序初始化属性,可以共享隐藏类
const u1 = createUser(1, 'Alice');
const u2 = createUser(2, 'Bob');
console.log(u1.name, u2.name);

上面示例中,如果所有用户对象都通过 createUser 创建并且没有后续动态添加属性,读取 name 会走最快的单态内联缓存路径。相反,如果代码中频繁使用 delete user.name 或者在不同位置随意扩展字段,对象可能进入字典模式,属性读取会退化为哈希表查找,性能明显下降。

二、破坏读取速度的常见写法

动态添加属性是破坏对象形状的主要原因之一。并不是说完全不能动态添加属性,而是应该避免对同一批对象采用不一致的添加顺序。比如先给 A 对象添加 age 再添加 city,给 B 对象先添加 city 再添加 age,两者会生成不同的隐藏类。虽然单独访问可能仍然正确,但内联缓存会很快进入多态状态,每次都进行额外检查。

使用 delete 删除属性对读取速度的影响更大。在 V8 中,删除属性会让对象从快速模式切换到字典模式,因为隐藏类无法再准确描述剩余属性的偏移。即使之后重新添加同名属性,对象也可能继续留在字典模式。推荐做法是把不需要的属性值置为 null 或 undefined,而不是删除属性本身。

const config = {
  retries: 3,
  timeout: 5000,
  verbose: true
};
// 不推荐:delete 会改变对象内部结构
delete config.verbose;
// 推荐:保留属性,将值设为 null
config.verbose = null;

原型链也会影响读取速度。访问深层原型上的属性时,引擎需要沿着原型链向上查找。现代引擎对原型链路径同样会做缓存,但如果代码动态修改原型对象,例如使用 Object.setPrototypeOf 或者给内置原型添加属性,缓存会失效。对于需要高频率读取的对象,尽量把常用属性定义在对象自身,而不是挂载在原型链深层。

三、Map 与普通对象的读取对比

在需要大量键值对、键经常增删的场景,使用 Map 可能比普通对象更合适。普通对象在形状稳定时读取速度很快,但对于频繁新增和删除键的情况,容易退化为字典模式。Map 从一开始就为动态键集合设计,内部使用哈希结构,插入和删除不会带来隐藏类迁移的成本,读取性能在不同键数量下更稳定。

不过这并不意味着所有场景都应该用 Map。如果键是固定的几个字符串,并且对象形状在初始化后保持不变,普通对象的读取通常会更快,因为引擎可以使用隐藏类和内联缓存直接定位偏移。Map 的 get 方法内部也经过了高度优化,但函数调用和哈希计算仍有额外开销。可以根据数据是否动态变化来选择。

const fixedObj = {
  status: 'ok',
  code: 200,
  message: 'success'
};
// 读取固定字段,普通对象更快
console.log(fixedObj.status);
const dynamicMap = new Map();
dynamicMap.set('session_id', 'abc');
dynamicMap.set('retry_count', 2);
// 动态键集合,Map 更稳定
console.log(dynamicMap.get('retry_count'));

还有一点容易被忽略:如果对象属性数量很少且固定,使用数组加索引通常比对象更快,因为数组在连续内存区域中可以通过索引直接访问。比如坐标数据 [x, y] 的读取通常快于 {x: 1, y: 2}。不过这更多是数据局部性带来的优势,需要结合代码可读性权衡。

四、工程实践中的优化建议

第一,尽量在构造函数或工厂函数中一次性初始化全部属性,并保持属性类型不变。例如一个 Point 类始终包含 x 和 y 两个数值属性,不要某些实例额外挂载 z,另一些实例额外挂载 label。这能保证所有实例共享隐藏类,读取路径最短。

第二,避免使用 delete 和动态修改原型。对于可选字段,用 undefined 或 null 占位。对于确实需要不同形状的对象,可以考虑拆分成多个结构一致的对象类型,而不是在同一个对象上随机增加字段。

class Order {
  constructor(id, amount, remark) {
    this.id = id;
    this.amount = amount;
    this.remark = remark ?? null;
  }
}
// 所有实例都有 id、amount、remark,隐藏类一致
const o1 = new Order(1, 99.5, null);
const o2 = new Order(2, 88.0, 'urgent');
console.log(o1.remark, o2.amount);

第三,在循环或高频函数中,把频繁访问的对象属性缓存到局部变量。局部变量存储在栈上,读取速度比对象属性更快,因为它不涉及隐藏类查找和原型链遍历。尤其是嵌套访问如 user.profile.address.city,可以在循环外提前取出,避免每一轮都重复深度访问。

for (const user of users) {
  const city = user.profile.address.city;
  // 后续多次使用 city,而不是每轮都读 user.profile.address.city
  processCity(city);
}

最后,不要过早进行微优化。对象属性读取的性能差异通常只在百万级以上的热路径代码中才会产生明显影响。对于业务代码中的一次性对象创建和少量属性访问,可读性和可维护性优先。只有在性能分析工具确认属性读取成为瓶颈后,再按照上述建议调整对象形状和访问方式。

广告位 · 文章底部(后台-模块管理替换此区域)