九:JavaScript 对象剖析

一、对象:JavaScript 的万能数据结构

如果说函数是 JavaScript 的“动词”,对象就是它的“名词”。在 JavaScript 中,除了那七种基本类型,剩下的所有值都是对象。数组是对象,函数是对象,正则表达式是对象,就连基本类型的包装(new String()new Number())也是对象。

但对象的真正力量,不在于你往里面塞了多少属性,而在于每个属性背后都有一套完整的行为描述机制。大多数人只用到了对象的 20%——创建属性、读取属性、修改属性。剩下的 80%——属性描述符、访问器属性、对象状态控制、原型操作——才是对象真正的核心能力。

这一篇,我们要把这 80% 彻底剖开。

二、属性的两种类型:数据属性与访问器属性

在 JavaScript 引擎内部,对象的每个属性都不是简单的“名字-值”对,而是一个带有属性描述符的结构体。属性描述符分为两种:

数据属性

就是我们最常见的、直接存值的属性。它包含四个特性:

特性 含义 默认值
value 属性的值 undefined
writable 属性值是否可以被修改 true
enumerable 属性是否出现在 for...inObject.keys() true
configurable 属性是否可以被删除、是否可以修改描述符 true

访问器属性

不直接存值,而是通过 getset 函数来读写。它包含四个特性:

特性 含义 默认值
get 读取属性时调用的函数 undefined
set 写入属性时调用的函数 undefined
enumerable 属性是否出现在 for...inObject.keys() true
configurable 属性是否可以被删除、是否可以修改描述符 true

关键认知valuewritable 只能用于数据属性。getset 只能用于访问器属性。一个属性不能同时拥有 valueget,尝试同时定义会报错。

三、Object.defineProperty():精确控制属性

当你用字面量方式 obj.name = '张三' 创建属性时,该属性的 writableenumerableconfigurable 都默认为 true。要精确控制这些特性,你需要 Object.defineProperty()

基本语法

Object.defineProperty(obj, propertyName, descriptor);
  • obj:要定义属性的对象。
  • propertyName:属性名(字符串)。
  • descriptor:属性描述符对象。

创建只读属性

let user = {};

Object.defineProperty(user, 'id', {
  value: 'U001',
  writable: false,       // 不可修改
  enumerable: true,      // 可枚举
  configurable: false    // 不可删除、不可重新配置
});

console.log(user.id);    // 'U001'
user.id = 'U002';        // 静默失败(严格模式下会报错)
console.log(user.id);    // 'U001' —— 没变

delete user.id;          // 静默失败
console.log(user.id);    // 'U001' —— 仍在

创建不可枚举的属性

不可枚举的属性不会出现在 for...inObject.keys()JSON.stringify() 中:

let obj = {
  name: '张三',
  age: 28
};

Object.defineProperty(obj, 'password', {
  value: 'secret123',
  enumerable: false,    // 不可枚举
  writable: true,
  configurable: true
});

console.log(Object.keys(obj));  // ['name', 'age'] —— password 不在其中
console.log(obj.password);      // 'secret123' —— 仍然可以访问,只是遍历时看不到

这个特性非常实用:你可以给对象添加内部数据(比如缓存、元信息),它们不会干扰正常的遍历和序列化。

批量定义:Object.defineProperties()

let product = {};

Object.defineProperties(product, {
  name: {
    value: '笔记本电脑',
    writable: true,
    enumerable: true
  },
  price: {
    value: 5999,
    writable: false,      // 价格不能改
    enumerable: true
  },
  _id: {
    value: 'PROD-001',
    enumerable: false     // 内部 ID,遍历时隐藏
  }
});

四、访问器属性:gettersetter

访问器属性让你在读写属性时执行自定义逻辑,而不改变属性的使用方式。外部代码仍然像读写普通属性一样操作它,但内部可以加入验证、计算、日志等逻辑。

Object.defineProperty() 定义

let person = {
  firstName: '三',
  lastName: '张'
};

Object.defineProperty(person, 'fullName', {
  get: function() {
    // 读取 fullName 时调用
    return this.lastName + this.firstName;
  },
  set: function(value) {
    // 写入 fullName 时调用
    let parts = value.split(' ');
    this.lastName = parts[0];
    this.firstName = parts[1];
  },
  enumerable: true,
  configurable: true
});

console.log(person.fullName);     // '张三' —— 调用了 get
person.fullName = '李 四';        // 调用了 set
console.log(person.firstName);    // '四'
console.log(person.lastName);     // '李'

用字面量语法定义(ES6)

let account = {
  _balance: 1000,  // 下划线约定表示“私有”(实际并不私有,只是约定)

  get balance() {
    console.log('读取余额');
    return this._balance;
  },

  set balance(value) {
    if (value < 0) {
      throw new Error('余额不能为负数');
    }
    console.log('设置余额:' + value);
    this._balance = value;
  }
};

console.log(account.balance);  // 读取余额 → 1000
account.balance = 2000;        // 设置余额:2000
// account.balance = -500;     // 抛出错误:余额不能为负数

访问器属性的典型应用场景

  • 数据验证:在 setter 中检查值的合法性,拒绝非法值。
  • 计算属性:一个属性值由其他属性动态计算得出(如 fullNamefirstNamelastName 拼接)。
  • 数据劫持:在 gettersetter 中记录日志、触发更新——这是 Vue 2.x 响应式系统的核心原理。
  • 向后兼容:旧代码使用 obj.oldName,新代码用 obj.newName,通过 getter/setter 让两者同步。

五、读取属性描述符

如果你拿到一个对象,想知道它的某个属性的描述符信息:

Object.getOwnPropertyDescriptor()

获取单个属性的描述符:

let obj = { name: '张三' };
let desc = Object.getOwnPropertyDescriptor(obj, 'name');

console.log(desc);
// {
//   value: '张三',
//   writable: true,
//   enumerable: true,
//   configurable: true
// }

Object.getOwnPropertyDescriptors()(ES2017)

一次性获取所有自身属性的描述符:

let obj = {
  name: '张三',
  get age() { return 28; }
};

console.log(Object.getOwnPropertyDescriptors(obj));
// {
//   name: { value: '张三', writable: true, enumerable: true, configurable: true },
//   age:  { get: [Function: get age], set: undefined, enumerable: true, configurable: true }
// }

这个方法最实用的场景是浅克隆一个对象,同时保留所有属性的描述符

let clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(original));

六、控制对象的整体状态

以上是控制单个属性的行为。JavaScript 还提供了三个方法,可以从对象整体层面限制其可变性。它们的安全级别由弱到强:

Object.preventExtensions():禁止添加新属性

let obj = { name: '张三' };
Object.preventExtensions(obj);

obj.age = 28;             // 静默失败(严格模式报错)
console.log(obj.age);     // undefined
delete obj.name;          // 可以删除已有属性
console.log(obj.name);    // undefined

你可以通过 Object.isExtensible(obj) 检查对象是否可以添加新属性。

Object.seal():密封——不能添加、不能删除

let obj = { name: '张三', age: 28 };
Object.seal(obj);

// 不能添加
obj.city = '上海';       // 失败

// 不能删除
delete obj.age;          // 失败
console.log(obj.age);    // 28

// 可以修改已有属性的值
obj.name = '李四';
console.log(obj.name);   // '李四'

// 已有属性被自动设置为 configurable: false
let desc = Object.getOwnPropertyDescriptor(obj, 'name');
console.log(desc.configurable);  // false

你可以通过 Object.isSealed(obj) 检查对象是否被密封。

Object.freeze():冻结——完全不可变

let obj = { name: '张三', age: 28 };
Object.freeze(obj);

// 不能添加
obj.city = '上海';       // 失败

// 不能删除
delete obj.name;         // 失败

// 不能修改已有属性的值
obj.name = '李四';       // 失败
console.log(obj.name);   // '张三'

// 已有属性被自动设置为 writable: false, configurable: false
let desc = Object.getOwnPropertyDescriptor(obj, 'name');
console.log(desc.writable);      // false
console.log(desc.configurable);  // false

你可以通过 Object.isFrozen(obj) 检查对象是否被冻结。

一张对比表

操作 普通对象 preventExtensions seal freeze
添加新属性
删除属性
修改属性值
修改属性描述符

重要提醒:这三个方法都是操作——它们只影响对象本身的属性,不影响其属性值中引用的子对象:

let obj = { inner: { value: 42 } };
Object.freeze(obj);
obj.inner.value = 100;    // 可以修改!因为 freeze 是浅冻结
console.log(obj.inner.value); // 100

要彻底冻结(包括嵌套对象),需要递归遍历并冻结每一个子对象,这通常称为“深冻结”(deep freeze)。

七、属性遍历的四种方式及其区别

遍历对象的属性有多种方式,它们覆盖的范围各不相同。这是面试和实战中经常需要区分清楚的知识点。

let parent = { inherited: '来自原型' };

let child = Object.create(parent);
child.own = '自身属性';

Object.defineProperty(child, 'hidden', {
  value: '不可枚举',
  enumerable: false
});

Object.defineProperty(child, Symbol('sym'), {
  value: 'Symbol 属性',
  enumerable: true
});

四种遍历方式对比:

方法 遍历自身属性 遍历继承属性 包括不可枚举属性 包括 Symbol 键
for...in
Object.keys()
Object.getOwnPropertyNames()
Object.getOwnPropertySymbols() ✅(仅 Symbol)
Reflect.ownKeys()
// 验证
console.log('--- for...in ---');
for (let key in child) {
  console.log(key);  // 'own', 'inherited' —— 不包括 hidden 和 Symbol
}

console.log('--- Object.keys ---');
console.log(Object.keys(child));  // ['own']

console.log('--- Object.getOwnPropertyNames ---');
console.log(Object.getOwnPropertyNames(child));  // ['own', 'hidden']

console.log('--- Object.getOwnPropertySymbols ---');
console.log(Object.getOwnPropertySymbols(child));  // [Symbol(sym)]

console.log('--- Reflect.ownKeys ---');
console.log(Reflect.ownKeys(child));  // ['own', 'hidden', Symbol(sym)]

实战建议

  • 日常遍历用 Object.keys()for...in(如果确实需要遍历继承属性)。
  • 需要包含不可枚举属性时用 Object.getOwnPropertyNames()
  • 需要完整克隆一个对象(包括 Symbol 键和不可枚举属性)时用 Reflect.ownKeys() 配合 Object.getOwnPropertyDescriptors()

八、对象的创建与继承:六种方式

JavaScript 中创建对象的方式有很多,每种都反映了原型链机制的不同侧面。

方式一:对象字面量

let obj = { name: '张三', age: 28 };

等价于 let obj = Object.create(Object.prototype); 然后赋值属性。原型是 Object.prototype

方式二:new 构造函数

function Person(name) {
  this.name = name;
}
Person.prototype.sayHi = function() {
  console.log('你好');
};

let p = new Person('张三');
// p.__proto__ === Person.prototype
// Person.prototype.__proto__ === Object.prototype

方式三:Object.create()

这是最纯粹的原型继承方式——不经过构造函数,直接指定原型:

let animal = {
  breathe: function() { console.log('呼吸'); }
};

let dog = Object.create(animal, {
  name: { value: '旺财', writable: true, enumerable: true },
  bark: { value: function() { console.log('汪汪'); }, enumerable: true }
});

dog.breathe();  // '呼吸' —— 沿原型链找到
dog.bark();     // '汪汪'
console.log(dog.__proto__ === animal);  // true

Object.create() 的第二个参数和 Object.defineProperties() 格式一致,可以在创建对象的同时精确控制每个属性的描述符。

方式四:class 语法

class Animal {
  constructor(type) {
    this.type = type;
  }
  breathe() {
    console.log('呼吸');
  }
}

class Dog extends Animal {
  constructor(name) {
    super('犬科');
    this.name = name;
  }
  bark() {
    console.log('汪汪');
  }
}

底层仍然是原型链:Dog.prototype.__proto__ === Animal.prototype

方式五:Object.assign()——混入(Mixin)

把一个或多个对象的自身可枚举属性复制到目标对象:

let target = { a: 1 };
let source1 = { b: 2 };
let source2 = { c: 3, a: 100 };  // a 会覆盖 target 的 a

let result = Object.assign(target, source1, source2);
console.log(target);  // { a: 100, b: 2, c: 3 }
console.log(result === target);  // true —— 返回值就是目标对象本身

注意Object.assign() 是浅拷贝——如果源对象的属性值是引用类型,复制的是引用,不是真正的副本。而且它不会复制不可枚举属性和 Symbol 键。

方式六:Object.fromEntries()——从键值对数组创建对象

let entries = [['name', '张三'], ['age', 28], ['city', '上海']];
let obj = Object.fromEntries(entries);
console.log(obj);  // { name: '张三', age: 28, city: '上海' }

// 和 Map 配合使用
let map = new Map([['a', 1], ['b', 2]]);
let obj2 = Object.fromEntries(map);
console.log(obj2);  // { a: 1, b: 2 }

这是 Object.entries() 的逆操作。Object.entries(obj) 把对象转为键值对数组,Object.fromEntries(arr) 把键值对数组转回对象。

九、浅拷贝与深拷贝

在 JavaScript 中,拷贝对象是一个高频操作,也是高频踩坑点。核心问题在于:对象属性值可能是引用类型,拷贝时是复制引用还是复制值?

浅拷贝(Shallow Copy)

只复制对象的第一层属性。如果属性值是引用类型,新旧对象共享同一个引用。

let original = {
  name: '张三',
  address: { city: '上海', district: '浦东' }
};

// 方法一:展开运算符
let copy1 = { ...original };

// 方法二:Object.assign
let copy2 = Object.assign({}, original);

// 方法三:遍历赋值
let copy3 = {};
for (let key of Object.keys(original)) {
  copy3[key] = original[key];
}

// 验证浅拷贝的问题
copy1.name = '李四';              // 修改基本类型属性 → 不影响 original
copy1.address.city = '北京';      // 修改引用类型属性的内部值 → 影响 original!
console.log(original.address.city);  // '北京' —— original 也被改了

深拷贝(Deep Copy)

递归复制对象的所有层级,创建完全独立的副本。

简单但有限制的方案——JSON 序列化:

let original = {
  name: '张三',
  address: { city: '上海' },
  hobbies: ['读书', '编程']
};

let copy = JSON.parse(JSON.stringify(original));
copy.address.city = '北京';
console.log(original.address.city);  // '上海' —— 不受影响

JSON 方案的局限(每一项都是坑):

  • 无法处理函数:属性值是函数会被丢弃。
  • 无法处理 undefined:属性值是 undefined 会被丢弃。
  • 无法处理 Symbol:Symbol 键和值都会被忽略。
  • 无法处理循环引用:对象引用了自己时会直接报错。
  • 无法处理特殊对象Date 会变成字符串,RegExp 会变成空对象,MapSet 会变成空对象。

完整的手写深拷贝:

function deepClone(value, cache = new WeakMap()) {
  // 基本类型直接返回
  if (value === null || typeof value !== 'object') {
    return value;
  }

  // 处理循环引用:如果已经拷贝过,直接返回缓存的副本
  if (cache.has(value)) {
    return cache.get(value);
  }

  // 处理 Date
  if (value instanceof Date) {
    return new Date(value.getTime());
  }

  // 处理 RegExp
  if (value instanceof RegExp) {
    return new RegExp(value.source, value.flags);
  }

  // 处理 Map
  if (value instanceof Map) {
    let mapCopy = new Map();
    cache.set(value, mapCopy);
    for (let [key, val] of value) {
      mapCopy.set(deepClone(key, cache), deepClone(val, cache));
    }
    return mapCopy;
  }

  // 处理 Set
  if (value instanceof Set) {
    let setCopy = new Set();
    cache.set(value, setCopy);
    for (let item of value) {
      setCopy.add(deepClone(item, cache));
    }
    return setCopy;
  }

  // 处理数组和普通对象
  let clone = Array.isArray(value) ? [] : {};
  cache.set(value, clone);  // 先缓存,再递归(防止循环引用导致无限递归)

  // 包括 Symbol 键和不可枚举属性
  Reflect.ownKeys(value).forEach(key => {
    clone[key] = deepClone(value[key], cache);
  });

  return clone;
}

代码解析:

  • WeakMap 用于记录已经拷贝过的对象,遇到循环引用时直接返回缓存的副本,避免无限递归。WeakMap 的键是弱引用,不影响垃圾回收。
  • 先用 cache.set(value, clone) 缓存空壳,再递归填充属性——这个顺序至关重要。如果先递归再缓存,遇到循环引用时就会栈溢出。
  • Reflect.ownKeys() 包括了 Symbol 键和不可枚举属性,比 Object.keys() 更完整。

十、综合演示:一个带有验证和日志的数据模型

下面这段代码综合运用了访问器属性、属性描述符和冻结操作,模拟了一个简单的数据模型:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>对象剖析综合演示</title>
</head>
<body>
  <script>
    // 创建一个带有数据验证的用户模型
    function createUser(initialData) {
      let user = {};

      // 用闭包存储真实数据,防止外部直接访问
      let data = {
        name: initialData.name || '',
        age: initialData.age || 0,
        email: initialData.email || ''
      };

      // 定义访问器属性:name
      Object.defineProperty(user, 'name', {
        get() {
          console.log('[读取] name →', data.name);
          return data.name;
        },
        set(value) {
          if (typeof value !== 'string' || value.trim() === '') {
            throw new Error('name 必须是非空字符串');
          }
          console.log('[写入] name ←', value);
          data.name = value.trim();
        },
        enumerable: true,
        configurable: false
      });

      // 定义访问器属性:age
      Object.defineProperty(user, 'age', {
        get() {
          return data.age;
        },
        set(value) {
          if (!Number.isInteger(value) || value < 0 || value > 150) {
            throw new Error('age 必须是 0-150 之间的整数');
          }
          console.log('[写入] age ←', value);
          data.age = value;
        },
        enumerable: true,
        configurable: false
      });

      // 定义只读属性:id
      Object.defineProperty(user, 'id', {
        value: 'USER-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
        writable: false,
        enumerable: true,
        configurable: false
      });

      // 密封对象:不能添加或删除属性,但可以修改现有属性值
      Object.seal(user);

      return user;
    }

    // === 使用示例 ===
    let u = createUser({ name: '张三', age: 28, email: 'zhangsan@example.com' });

    console.log('ID:', u.id);       // ID: USER-1700000000000-abc123
    console.log('姓名:', u.name);   // [读取] name → 张三  → 姓名: 张三

    u.name = '李四';               // [写入] name ← 李四
    u.age = 30;                     // [写入] age ← 30

    // u.name = '';                 // Error: name 必须是非空字符串
    // u.age = 200;                 // Error: age 必须是 0-150 之间的整数
    // u.id = 'hack';              // 静默失败(严格模式报错)
    // u.city = '上海';             // 静默失败(对象被密封)

    console.log(u);
    // { name: [Getter/Setter], age: [Getter/Setter], id: 'USER-...' }

    // 遍历
    console.log(Object.keys(u));        // ['name', 'age', 'id']
    console.log(Object.getOwnPropertyDescriptors(u));
    // 可以看到每个属性的完整描述符信息
  </script>
</body>
</html>

设计要点:

  • 真实数据存储在闭包中的 data 对象里,外部只能通过 getter/setter 访问。这是一种“私有数据”的模拟方式。
  • setter 中加入了数据验证,非法值会立即抛出错误。
  • id 属性设为了 writable: false,用户创建后 ID 不可更改。
  • 整个对象被 Object.seal() 密封,防止添加或删除属性。
  • 每个属性的 configurable 都设为了 false,防止描述符被修改。

十一、本篇小结

这一篇我们把 JavaScript 对象从里到外剖了一遍:

  • 属性的两种类型:数据属性(value + writable + enumerable + configurable)和访问器属性(get + set + enumerable + configurable)。
  • Object.defineProperty():精确控制单个属性的描述符。只读、不可枚举、不可配置,都可以精细设定。
  • 访问器属性:在读写属性时执行自定义逻辑。典型用途:数据验证、计算属性、数据劫持。
  • 对象状态控制preventExtensions(禁止添加)→ seal(禁止添加和删除)→ freeze(完全不可变)。都是浅操作。
  • 属性遍历for...in(含继承)、Object.keys()(仅自身可枚举)、Object.getOwnPropertyNames()(含不可枚举)、Object.getOwnPropertySymbols()(Symbol 键)、Reflect.ownKeys()(全部自身属性)。
  • 对象的六种创建方式:字面量、new 构造函数、Object.create()classObject.assign()Object.fromEntries()
  • 浅拷贝与深拷贝{ ...obj }Object.assign 是浅拷贝;JSON.parse(JSON.stringify()) 有大量局限;手写深拷贝需要处理循环引用、Date、RegExp、Map、Set 等特殊对象。

对象是 JavaScript 编程的核心载体。理解属性描述符和访问器属性,你就能读懂 Vue 响应式系统的源码;理解对象状态控制和遍历方式,你就能写出更安全的工具函数;理解深拷贝的原理,你就能在面试和实战中游刃有余。

下一篇预告

下一篇——《从规范看 JavaScript 执行上下文(上)》:这是整个 JS 系列中最硬核的一篇。我们将直接从 ECMAScript 规范出发,拆解执行上下文的创建过程、词法环境和变量环境的区别、letvar 的底层差异、以及“暂时性死区”到底是怎么形成的。准备好深入 JavaScript 引擎的内部。

前端,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞14 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容