一、对象:JavaScript 的万能数据结构
如果说函数是 JavaScript 的“动词”,对象就是它的“名词”。在 JavaScript 中,除了那七种基本类型,剩下的所有值都是对象。数组是对象,函数是对象,正则表达式是对象,就连基本类型的包装(new String()、new Number())也是对象。
但对象的真正力量,不在于你往里面塞了多少属性,而在于每个属性背后都有一套完整的行为描述机制。大多数人只用到了对象的 20%——创建属性、读取属性、修改属性。剩下的 80%——属性描述符、访问器属性、对象状态控制、原型操作——才是对象真正的核心能力。
这一篇,我们要把这 80% 彻底剖开。
二、属性的两种类型:数据属性与访问器属性
在 JavaScript 引擎内部,对象的每个属性都不是简单的“名字-值”对,而是一个带有属性描述符的结构体。属性描述符分为两种:
数据属性
就是我们最常见的、直接存值的属性。它包含四个特性:
| 特性 | 含义 | 默认值 |
|---|---|---|
value |
属性的值 | undefined |
writable |
属性值是否可以被修改 | true |
enumerable |
属性是否出现在 for...in 和 Object.keys() 中 |
true |
configurable |
属性是否可以被删除、是否可以修改描述符 | true |
访问器属性
不直接存值,而是通过 get 和 set 函数来读写。它包含四个特性:
| 特性 | 含义 | 默认值 |
|---|---|---|
get |
读取属性时调用的函数 | undefined |
set |
写入属性时调用的函数 | undefined |
enumerable |
属性是否出现在 for...in 和 Object.keys() 中 |
true |
configurable |
属性是否可以被删除、是否可以修改描述符 | true |
关键认知:value 和 writable 只能用于数据属性。get 和 set 只能用于访问器属性。一个属性不能同时拥有 value 和 get,尝试同时定义会报错。
三、Object.defineProperty():精确控制属性
当你用字面量方式 obj.name = '张三' 创建属性时,该属性的 writable、enumerable、configurable 都默认为 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...in、Object.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,遍历时隐藏
}
});
四、访问器属性:getter 和 setter
访问器属性让你在读写属性时执行自定义逻辑,而不改变属性的使用方式。外部代码仍然像读写普通属性一样操作它,但内部可以加入验证、计算、日志等逻辑。
用 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中检查值的合法性,拒绝非法值。 - 计算属性:一个属性值由其他属性动态计算得出(如
fullName由firstName和lastName拼接)。 - 数据劫持:在
getter和setter中记录日志、触发更新——这是 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会变成空对象,Map、Set会变成空对象。
完整的手写深拷贝:
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()、class、Object.assign()、Object.fromEntries()。 - 浅拷贝与深拷贝:
{ ...obj }和Object.assign是浅拷贝;JSON.parse(JSON.stringify())有大量局限;手写深拷贝需要处理循环引用、Date、RegExp、Map、Set 等特殊对象。
对象是 JavaScript 编程的核心载体。理解属性描述符和访问器属性,你就能读懂 Vue 响应式系统的源码;理解对象状态控制和遍历方式,你就能写出更安全的工具函数;理解深拷贝的原理,你就能在面试和实战中游刃有余。
下一篇预告
下一篇——《从规范看 JavaScript 执行上下文(上)》:这是整个 JS 系列中最硬核的一篇。我们将直接从 ECMAScript 规范出发,拆解执行上下文的创建过程、词法环境和变量环境的区别、let 和 var 的底层差异、以及“暂时性死区”到底是怎么形成的。准备好深入 JavaScript 引擎的内部。
前端,每周更新。













暂无评论内容