写在前面
如果要用一句话概括 JavaScript,我会说:这是一门"万物皆对象,唯独对象最不讲道理"的语言。
数组是对象,函数是对象,日期是对象,正则是对象,连 Object 和 Function 这两个看起来像"类型"的东西,本质上也是对象,而且它们互为对方的构造产物,绕成了一个先有鸡还是先有蛋的循环。很多写了几年 JavaScript 的人,遇到 {} + []、a.__proto__.__proto__ 这类问题时依然会栽跟头,原因不在于记性差,而在于对对象系统的理解停留在语法层,没有下沉到运行时机制。
一、先搞清楚:对象到底是什么
引用语义,一切困惑的起点
JavaScript 的值分两类。七个原始类型------string、number、boolean、symbol、bigint、null、undefined------是按值存储的,变量里装的就是值本身。剩下的一切都是对象,变量里装的不是对象,而是一个指向堆内存的引用。
这个区别在日常代码里无处不在:
javascript
let a = 1;
let b = a;
b = 2;
console.log(a); // 1,原始类型互不影响
let o1 = { x: 1 };
let o2 = o1;
o2.x = 2;
console.log(o1.x); // 2,o1 和 o2 指向同一个对象
由此可以直接推出三条推论,每一条都对应着大量实际 bug:
第一,对象比较永远比的是引用 。{} === {} 是 false,两个内容完全相同的对象也互不相等。想比较内容,要么手写递归比较,要么用 lodash.isEqual 这类工具,语言层面没有内置方案(TC39 曾提案过 Object.equals,至今没有落地)。
第二,函数传参传的是引用的副本 。函数内部修改传入对象的属性,外面的对象会变;但函数内部把参数重新赋值为另一个对象,外面不受影响。这一点对 null 和 undefined 之外的所有对象类型都成立。
第三,浅拷贝解决不了嵌套问题 。展开运算符和 Object.assign 只复制第一层,深层的嵌套对象仍然是共享引用。React 里最常见的错误------直接改了 state 里的嵌套对象然后发现界面不更新------根源就在这。
键只能是字符串和 Symbol
对象属性的键,类型上只有两种可能:字符串和 Symbol。你写的数字键会被悄悄转成字符串:
javascript
const obj = {};
obj[1] = 'a';
obj['1'] = 'b';
console.log(obj[1]); // 'b',两次赋值写的是同一个键
想拿对象当键?不行,对象会被转成 "[object Object]" 字符串,所有对象键都会撞在一起。这个限制直接催生了 Map------后面会专门讲。
属性顺序:从"看运气"到"有规范"
ES2015 之前,对象键的遍历顺序是实现自定义的,不同引擎可能给出不同结果。ES2015 之后规范做了明确规定,现在所有引擎行为一致:
- 整数索引键(
"0"、"1"、"42"这种能被解析为非负整数的字符串)排在最前,按数值升序; - 其余字符串键按插入顺序;
- Symbol 键最后,也按插入顺序。
javascript
const obj = {};
obj.b = 1;
obj[2] = 2;
obj.a = 3;
obj[1] = 4;
Object.keys(obj); // ['1', '2', 'b', 'a'],整数键提前且升序
这意味着一个反直觉的事实:对象不是可靠的有序容器 ,如果你的数据顺序重要且键可能是数字(比如以时间戳、ID 为键),请用 Map 或数组。
二、创建对象的六种方式,以及各自的心智模型
字面量:默认选择
javascript
const person = {
name: '张三',
age: 30,
greet() {
console.log(`你好,我是${this.name}`);
}
};
字面量不只是写起来短。V8 等引擎对它有专门优化:每个对象字面量在引擎内部对应一个 Hidden Class(隐藏类,也叫 Shape),相同结构的字面量共享同一份形状描述,属性访问走内联缓存,速度远快于动态增删属性构造出来的对象。所以能用字面量就用字面量,既是风格问题也是性能问题。
ES6 给字面量加的几个增强语法值得单独记住:
javascript
const name = '李四';
const obj = {
name, // 属性简写,等价于 name: name
greet() { return 'hi'; }, // 方法简写
['user_' + name]: true, // 计算属性名
get info() { return this.name; } // 访问器
};
计算属性名在"把枚举/常量映射成处理函数表"这类场景里特别顺手:
javascript
const handlers = {
[STATUS.PENDING]: handlePending,
[STATUS.DONE]: handleDone
};
new Object():知道即可,不要用
功能上与字面量等价,写法上更啰嗦,没有任何使用它的理由。唯一有点意思的行为是:不加 new 直接调用 Object(value) 时,如果传入的已经是对象,会原样返回:
javascript
const original = { a: 1 };
Object(original) === original; // true
早年有人用它做"确保参数是对象"的防御,现在有更好的写法,了解即可。
Object.create():理解原型的钥匙
Object.create(proto) 创建一个新对象,并以 proto 作为它的原型。它是原型机制最直白的体现:
javascript
const animal = {
eat() { console.log('eating...'); }
};
const dog = Object.create(animal);
dog.eat(); // 方法来自原型,委托查找
它的特殊用法是传入 null:
javascript
const dict = Object.create(null);
console.log('toString' in dict); // false
console.log(dict.__proto__); // undefined,真的没有原型
Object.create(null) 造出来的对象是一条"干净"的键值容器,没有任何继承来的属性。两种场景强烈建议用它:一是键名来自用户输入,用它可以天然免疫 __proto__ 污染(因为没有原型可以污染);二是键名可能与 Object.prototype 上的属性撞车(比如某个键就叫 constructor 或 toString,普通对象上 'toString' in obj 会意外为 true)。代价是它连 hasOwnProperty 都没有,只能靠 Object.hasOwn()、Object.keys() 这类静态方法操作。
构造函数与 new:必须搞懂的四步
ES6 之前,"类"就是函数加约定:
javascript
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.greet = function () {
console.log(`你好,我是${this.name}`);
};
const p = new Person('张三', 30);
这段代码能不能真正理解,取决于你能不能把 new 拆开。new Person(...) 在引擎内部执行的是四步:
- 创建一个空对象
obj; - 令
obj.__proto__ === Person.prototype; - 以
obj为this执行Person函数体; - 如果构造函数显式返回了一个对象,
new表达式的结果就是那个对象;否则返回obj。
手写模拟一遍,理解会深刻很多:
javascript
function myNew(Ctor, ...args) {
const obj = Object.create(Ctor.prototype);
const ret = Ctor.apply(obj, args);
return (ret !== null && typeof ret === 'object') ? ret : obj;
}
第 4 步藏着一个面试常客兼实际陷阱------构造函数返回对象会覆盖 this:
javascript
function Weird() {
this.a = 1;
return { b: 2 };
}
new Weird(); // { b: 2 },a 丢了
返回原始值(数字、字符串、null、undefined)则会被忽略,this 照常返回。
还有一点常被忽视:构造函数忘了写 new 不会报错(非严格模式下),this 会指向全局对象,相当于往 window 上挂了一堆属性。ES6 的 class 从语法层面堵死了这个坑,这也是应该用 class 的理由之一。
class:语法糖,但不是"仅仅是"语法糖
javascript
class Person {
#id; // 私有字段
constructor(name, age) {
this.name = name;
this.age = age;
this.#id = Math.random();
}
greet() { console.log(`你好,我是${this.name}`); }
static create(name) { return new Person(name, 0); }
get info() { return `${this.name} (${this.age})`; }
}
class Student extends Person {
constructor(name, age, school) {
super(name, age); // 派生类构造函数里,this 在 super() 之前不存在
this.school = school;
}
greet() {
super.greet();
console.log(`我来自${this.school}`);
}
}
class 的底层依然是原型------Student.prototype.__proto__ === Person.prototype,方法共享、继承查找的机制与构造函数模式完全一致。但两者有五个实质性差异,都不是纯语法层面的:
- 类声明有暂时性死区 。函数声明整体提升,class 声明虽然也"提升"但在初始化前访问会抛
ReferenceError。 - 类体自动运行在严格模式下 。忘了写
new调用类会直接抛TypeError,而不是把属性挂到全局。 - 类的方法不可枚举 。
Object.keys(Person.prototype)是空数组;构造函数模式下手动赋值的原型方法默认可枚举,会被for...in扫出来,当年很多库为此手动改描述符。 - 支持真正的私有成员 。
#id是语言层面的私有,外部访问直接语法报错,不是_id那种"君子协定"。私有方法、私有静态成员、#field in obj存在性检查也都已标准化。 - 实例字段语法 。
count = 0写在类体里,每个实例独立拥有,在 constructor 执行前按声明顺序初始化。
继承部分还有一个细节容易忽略:extends 同时建立了两条原型链------实例链(Student.prototype.__proto__ → Person.prototype)和静态链(Student.__proto__ → Person),所以静态方法也是可继承的。
工厂函数:闭包换来了什么
javascript
function createPerson(name) {
let visitCount = 0; // 闭包私有状态
return {
name,
greet() {
visitCount++;
console.log(`你好,我是${name},第 ${visitCount} 次问候`);
},
getVisitCount: () => visitCount
};
}
工厂函数这几年在社区的口碑回升,原因很实际:它完全不依赖 this,把方法解构出来单独传递也不会丢失上下文(class 方法解构后 this 就没了,React 里为此发明了各种 bind 姿势);闭包提供了天然私有状态;还能配合 Object.freeze 返回不可变对象。代价是每个实例都持有一份独立的方法副本,创建几十万个实例时内存差距才明显------对绝大多数应用,这个代价约等于零。
选型上可以这么记:一次性数据结构用字面量;需要 instanceof、需要方法共享、写库对外暴露类型用 class;想要彻底的封装和函数式风格用工厂函数;需要控制原型或纯净字典用 Object.create。
三、属性描述符:每个属性背后的四个开关
数据属性与访问器属性
JavaScript 里不存在"裸的键值对"。每个属性背后都挂着一组元数据,叫属性描述符,分为两种互斥的形态。
数据属性有四个特性:
value:属性值;writable:能否改值;enumerable:能否被for...in、Object.keys扫到;configurable:能否删除该属性、能否再次修改描述符。
访问器属性用一对函数替代了前两个:
get:读取时调用;set:写入时调用;- 外加同样的
enumerable和configurable。
通过字面量或直接赋值创建的属性,四个开关默认全开(writable/enumerable/configurable 均为 true)。而通过 Object.defineProperty 定义时,不写的特性默认是 false------这个默认值的不对称坑过不少人:
javascript
const obj = {};
Object.defineProperty(obj, 'a', { value: 1 });
// 等价于 { value: 1, writable: false, enumerable: false, configurable: false }
obj.a = 2; // 静默失败(严格模式抛 TypeError)
Object.keys(obj); // [],不可枚举
delete obj.a; // 无效
configurable: false 是一扇单向门
一旦某个属性的 configurable 被设为 false,它就永远回不到 true,也不能再修改描述符------唯一的例外是允许把 writable 从 true 收紧为 false。这个"单向棘轮"设计正是 Object.freeze 的实现基础,也解释了为什么冻结是不可逆的。
javascript
const obj = {};
Object.defineProperty(obj, 'locked', {
value: 42,
enumerable: true,
configurable: false
});
Object.defineProperty(obj, 'locked', { value: 43 });
// TypeError: Cannot redefine property
读取与整体拷贝描述符
javascript
Object.getOwnPropertyDescriptor(obj, 'key'); // 单个
Object.getOwnPropertyDescriptors(obj); // 全部(ES2017)
getOwnPropertyDescriptors 最有价值的用法,是配合 Object.create 做保留访问器的完整拷贝 ------这是 Object.assign 和展开运算符都做不到的:
javascript
const source = {};
Object.defineProperty(source, 'x', {
get() { return 42; },
enumerable: true,
configurable: true
});
const copy1 = Object.assign({}, source);
// x 的 getter 被"求值"成了普通数据属性 42,访问器本质丢失
const copy2 = Object.create(
Object.getPrototypeOf(source),
Object.getOwnPropertyDescriptors(source)
);
// x 依然是 getter,原型也保留了
访问器属性的真实用途
getter/setter 不是玩具,它在工程里承担着四类正经职责。
校验与规范化。 把非法值挡在门外:
javascript
class Temperature {
#celsius = 0;
set celsius(v) {
if (typeof v !== 'number' || Number.isNaN(v)) {
throw new TypeError('温度必须是有效数字');
}
if (v < -273.15) {
throw new RangeError('不能低于绝对零度');
}
this.#celsius = v;
}
get celsius() { return this.#celsius; }
get fahrenheit() { return this.#celsius * 9 / 5 + 32; }
}
派生属性。 上面的 fahrenheit 就是例子:每次读取时实时计算,与源数据永远一致,不存在"忘了同步"的 bug。
惰性求值。 昂贵计算推迟到第一次访问:
javascript
const config = {
#cache: undefined,
get bigData() {
this.#cache ??= expensiveCompute();
return this.#cache;
}
};
响应式劫持。 Vue 2 的整个响应式系统就是遍历数据对象,用 Object.defineProperty 给每个属性装上 getter/setter:get 时收集依赖,set 时派发更新。这个方案的天花板也很明显------属性在初始化后新增或删除侦测不到(所以要发明 Vue.set),数组的索引赋值和 length 变化侦测不到(所以要重写七个数组方法)。Vue 3 换成 Proxy 之后这些问题全部消失,这是理解两代框架差异的最佳切口,第七节展开讲。
四、原型链:JavaScript 继承的全部真相
三个容易混淆的东西
原型部分的概念混乱,八成来自这三个词分不清:
obj.__proto__ (标准 API 是 Object.getPrototypeOf(obj))------对象的原型 ,即"我找不到的属性去哪儿找"的那个对象。除了 Object.create(null) 的产物,所有对象都有。
Fn.prototype ------只有函数才有的属性(箭头函数没有),它是 new Fn() 产出的所有实例的原型。它本身是个普通对象,出厂时带一个指回 Fn 的 constructor 属性。
obj.constructor ------注意这通常不是对象自己的属性 ,而是顺着原型链在 Fn.prototype 上找到的:
javascript
function Person(name) { this.name = name; }
const p = new Person('张三');
Object.getPrototypeOf(p) === Person.prototype; // true
Person.prototype.constructor === Person; // true
p.constructor === Person; // true,但 p 自身没有这个属性
Object.hasOwn(p, 'constructor'); // false
顺带说一句,__proto__ 这个写法虽然所有引擎都支持、也写进了 ES2015 规范的附录 B(作为 Web 兼容的遗留特性),但规范明确标注它"不推荐",正经代码应该用 Object.getPrototypeOf。至于 Object.setPrototypeOf,规范自己都警告它性能极差------需要指定原型时,在创建那一刻用 Object.create 完成,别事后再改。
查找是向上的,写入是原地的
属性读取走 [[Get]],流程很朴素:自身有就返回;没有就去原型上找;逐层向上直到原型为 null;都没有返回 undefined。
但写入不走原型链。给对象赋值时,如果自身没有这个属性,就在自身创建一个,绝不会写到原型上去:
javascript
const proto = { count: 0 };
const obj = Object.create(proto);
obj.count = 5;
console.log(obj.count); // 5,obj 自己的新属性
console.log(proto.count); // 0,原型纹丝不动
obj 上的 count **遮蔽(shadow)**了原型上的同名属性。此后读取永远命中自身那个,原型上的属性被"挡住"了但并没有消失------delete obj.count 之后它又会重新显形。
写入有一个重要的例外:如果原型上的同名属性是带 setter 的访问器属性,赋值会触发那个 setter,而不是创建自身属性:
javascript
const proto = {
_v: 0,
get v() { return this._v; },
set v(val) { this._v = val; }
};
const obj = Object.create(proto);
obj.v = 10;
Object.hasOwn(obj, 'v'); // false!没有创建 v
Object.hasOwn(obj, '_v'); // true,setter 里的 this 是 obj,_v 写到了 obj 身上
obj.v; // 10,通过原型上的 getter 读到
这条规则是原型模式下最隐蔽的行为之一,也是很多"我明明赋值了怎么没生效/怎么串了"问题的答案。
一条完整的继承链长什么样
javascript
class Animal {
speak() { return '...'; }
}
class Dog extends Animal {
speak() { return 'Woof!'; }
}
const d = new Dog();
d 背后的完整链条:
d
└→ Dog.prototype (d.speak 在这层命中,返回 'Woof!')
└→ Animal.prototype (Dog 没定义 speak 的话会命中这层)
└→ Object.prototype (toString、hasOwnProperty 都住在这)
└→ null (链的终点)
所谓"方法重写",本质就是子类原型上的属性遮蔽了父类原型上的同名属性;所谓 super.speak(),本质是引擎在 [[HomeObject]] 机制的帮助下,显式跳过当前层去上层原型查找。没有任何魔法,全是查找规则。
再往深处看,内置对象也殊途同归:
javascript
[] .__proto__ → Array.prototype → Object.prototype → null
(function(){}).__proto__ → Function.prototype → Object.prototype → null
而 Function.prototype 本身是个函数(调用返回 undefined),它的 __proto__ 又指向 Object.prototype;Function 构造函数自己的 __proto__ 指向 Function.prototype------Function 是自己的实例。这个自指结构是 JavaScript 对象模型里最烧脑的一环,画几遍图就通了。
对象如何变成原始值
当对象出现在字符串拼接、数值运算这类需要原始值的场合,引擎触发 [[ToPrimitive]],顺序是:
- 对象定义了
[Symbol.toPrimitive]方法?调它,传入 hint("number"/"string"/"default"); - 没有的话,hint 为
"string"时依次试toString()→valueOf();hint 为"number"或"default"时顺序反过来; - 都拿不到原始值,抛
TypeError。
这个机制可以完全接管:
javascript
const magic = {
[Symbol.toPrimitive](hint) {
if (hint === 'number') return 42;
if (hint === 'string') return 'magic!';
return 'default';
}
};
+magic; // 42
`${magic}`; // 'magic!'
magic + ''; // 'default'
理解了它,[] + {}、+[]、[] + [] 这些著名怪题就不再是玄学,而是查找规则加转换规则的机械执行。
另外,ES6 的知名符号允许自定义更多核心行为:Symbol.hasInstance 接管 instanceof 的判断逻辑,Symbol.toStringTag 接管 Object.prototype.toString.call() 输出的那个 [object X] 里的 X。写库给自定义类型"上户口"时会用到。
五、遍历与类型检测:把每个方法的边界背下来
属性操作基础
读写属性两种语法:点语法要求键是合法标识符,方括号语法接受任意表达式,动态键、含特殊字符的键只能走方括号。
删除用 delete:
javascript
delete obj.name; // true
关于 delete 有四件事要知道:它只能删自身属性;删不存在的属性也返回 true(严格模式下删除不可配置属性才抛错);删掉原型上的属性会影响所有继承者;以及------它有性能代价 。前面提过 V8 的 Hidden Class 机制,delete 会让对象退化成字典模式,属性访问失去内联缓存优化。热点路径上如果只是想"清空"一个值,赋 undefined 比 delete 对引擎友好得多(当然,两者的语义有差别:in 检测的结果不同)。
五种遍历方法,边界各不相同
这是日常开发最容易用错的一组 API,值得逐个过。
for...in :遍历自身加原型链 上所有可枚举的字符串键。两个先天问题------会扫出继承属性、历史上顺序无保证------决定了它在现代代码里基本只剩"配合 hasOwn 过滤"这一种正确姿势,而这种场景完全有更好的替代品。直说结论:别用 for...in,数组尤其别用(它遍历的是字符串化的索引键,还会把原型上挂的可枚举属性一起扫出来)。
Object.keys / Object.values / Object.entries:只返回自身、可枚举的字符串键,顺序符合规范,产出数组。这是遍历的默认选择:
javascript
for (const [key, value] of Object.entries(user)) {
console.log(`${key} = ${value}`);
}
Object.getOwnPropertyNames :自身所有字符串键,包括不可枚举的。想看清一个对象的完整家底时用它。
Object.getOwnPropertySymbols:自身所有 Symbol 键。
Reflect.ownKeys:以上两者的并集,自身属性一网打尽,含不可枚举、含 Symbol。这是最全的键枚举方法:
javascript
const obj = { a: 1, [Symbol('s')]: 2 };
Object.defineProperty(obj, 'hidden', { value: 3 }); // 不可枚举
Object.keys(obj); // ['a']
Reflect.ownKeys(obj); // ['a', 'hidden', Symbol(s)]
一张边界表:
| 方法 | 原型链 | 不可枚举 | Symbol |
|---|---|---|---|
for...in |
含 | 不含 | 不含 |
Object.keys/values/entries |
不含 | 不含 | 不含 |
getOwnPropertyNames |
不含 | 含 | 不含 |
getOwnPropertySymbols |
不含 | --- | 只含 |
Reflect.ownKeys |
不含 | 含 | 含 |
属性存在性的三种问法
'key' in obj 问的是"自身或原型链上有没有",所以 'toString' in {} 是 true。
Object.hasOwn(obj, key)(ES2022)问的是"自身有没有"。它是 hasOwnProperty 的全面替代品,替代的必要性体现在两个真实场景:
javascript
// 场景一:方法被恶意/意外重写
const evil = { hasOwnProperty: () => false, name: 'x' };
evil.hasOwnProperty('name'); // false,被骗
Object.hasOwn(evil, 'name'); // true,不受影响
// 场景二:纯净字典上根本没有这个方法
const bare = Object.create(null);
bare.x = 1;
// bare.hasOwnProperty('x'); // TypeError
Object.hasOwn(bare, 'x'); // true
ES2022 之前的安全写法是 Object.prototype.hasOwnProperty.call(obj, key),老代码里常见的这个别扭句式,就是在防上面两种情况。
类型检测:没有银弹,只有组合拳
typeof 对对象的分辨率约等于零:
javascript
typeof {}; // 'object'
typeof []; // 'object'
typeof null; // 'object' ← 第一版实现遗留的 bug,靠类型标签位判断,null 的标签恰好是 000
typeof (()=>{}); // 'function' ← 唯一被特殊对待的对象
typeof null === 'object' 这个 bug 在规范里挂了二十多年,因为修复会破坏无数存量代码而被判了"永不得释"。判空请显式写 x === null,或者用 x == null 一次性覆盖 null 和 undefined(== 的少数正当用法之一)。
instanceof 沿原型链找,同 realm 内可靠,跨 realm 立刻失效------每个 iframe、每个 Worker 都是独立的执行环境,各有一套内置构造函数:
javascript
const iframeArr = window.frames[0].Array;
const arr = new iframeArr();
arr instanceof Array; // false!它的原型是另一个 realm 的 Array.prototype
最可靠的通用方案是 Object.prototype.toString.call(),它读取 Symbol.toStringTag(没有则用内部标签):
javascript
const typeOf = v => Object.prototype.toString.call(v).slice(8, -1).toLowerCase();
typeOf([]); // 'array'
typeOf(null); // 'null' ← 正确区分了 null
typeOf(new Date()); // 'date'
typeOf(/x/); // 'regexp'
typeOf(new Map()); // 'map'
但工程上更推荐"专用工具优先":数组用 Array.isArray(它走内部标签,跨 realm 也正确),NaN 用 Number.isNaN,其余再考虑 toString 方案。
六、不可变性:三个层级,两个盲区
preventExtensions、seal、freeze
三个 API 是递进收紧的关系:
Object.preventExtensions:禁止新增属性。已有属性照常改、照常删。
Object.seal :在禁止新增的基础上,把所有属性设为 configurable: false。结果是不能增、不能删、不能改描述符,但值还能改。
Object.freeze :在 seal 基础上,把所有数据属性设为 writable: false。不能增、不能删、不能改值、不能改描述符。
javascript
const obj = Object.freeze({ a: 1 });
obj.a = 2; // 静默失败;严格模式抛 TypeError
obj.b = 3; // 同上
delete obj.a; // 同上
对应的检测函数是 Object.isExtensible / isSealed / isFrozen。注意冻结是不可逆 的------没有 unfreeze,这正是前面讲的 configurable 单向门在起作用。另一个细节:freeze 不影响访问器属性------只有 setter 的"属性"照样能通过赋值触发 setter 改内部状态,想真冻住就得把访问器也纳入考量。
盲区一:冻结是浅的
javascript
const config = Object.freeze({
server: { port: 8080 }
});
config.server.port = 9999; // 成功。只冻了第一层
深度冻结需要递归,写的时候必须带上防循环引用的检查:
javascript
function deepFreeze(obj) {
for (const v of Object.values(obj)) {
if (v && typeof v === 'object' && !Object.isFrozen(v)) {
deepFreeze(v);
}
}
return Object.freeze(obj);
}
盲区二:集合的内部结构管不到
javascript
const frozen = Object.freeze({ list: [1, 2, 3] });
frozen.list = []; // 失败,list 这个引用不可变
frozen.list.push(4); // 成功,数组内容不归 freeze 管
Map、Set、Date 同理。冻结的是"哪个属性指向哪个对象",不是"那个对象里面装了什么"。
为什么工程上如此在意不可变性
原生冻结能力有限,但不可变数据依然是现代前端的基石,原因很具体:React 的渲染优化(memo、PureComponent)靠引用比较判断"变没变",直接修改 state 里的对象,引用没变,更新直接丢失;Redux 的 reducer 契约要求返回新 state;时间旅行调试、变更检测、并发安全,全都以不可变为前提。
所以实际项目里,"不可变"更多是一种编码约定------永远返回新对象而不是修改旧对象:
javascript
// 错误:setState(user.age = 31) 或先改再 set
// 正确:
setUser(prev => ({ ...prev, age: prev.age + 1 }));
// 深层嵌套更新:
setUser(prev => ({
...prev,
address: { ...prev.address, city: '上海' }
}));
嵌套一深,展开运算符就难写了。这时候上 Immer :用可变的语法写更新,它基于 Proxy 在幕后生成不可变的新对象,produce(base, draft => { draft.address.city = '上海' }),可读性和正确性兼得。需要高性能持久化数据结构则看 Immutable.js。
七、Proxy 与 Reflect:拦截对象的一切
Proxy 是什么
ES6 的 Proxy 允许你包一层"代理",拦截目标对象的底层操作。可拦截的操作共 13 种,覆盖了对象的完整生命周期:get、set、has、deleteProperty、ownKeys、getOwnPropertyDescriptor、defineProperty、getPrototypeOf、setPrototypeOf、isExtensible、preventExtensions,以及函数专属的 apply(调用)和 construct(new)。
javascript
const proxy = new Proxy({ name: '张三', age: 30 }, {
get(obj, prop, receiver) {
console.log(`读取 ${String(prop)}`);
return Reflect.get(obj, prop, receiver);
},
set(obj, prop, value, receiver) {
if (prop === 'age' && (typeof value !== 'number' || value < 0)) {
throw new TypeError('age 必须是非负数字');
}
return Reflect.set(obj, prop, value, receiver);
}
});
proxy.name; // 日志:读取 name
proxy.age = -1; // 抛 TypeError
从 Vue 2 到 Vue 3 的技术演进是理解 Proxy 价值的最佳案例。Object.defineProperty 只能劫持已存在的、单个的 属性,所以 Vue 2 侦测不到属性的新增删除、侦测不到数组索引赋值,被迫发明了 $set 并重写了数组方法。Proxy 直接代理整个对象,任何操作都过一遍陷阱,新增、删除、数组索引全部天然覆盖;而且它是惰性的------嵌套对象在第一次被访问时才递归创建代理,初始化开销远低于 Vue 2 的一次性全量劫持。代价是 Proxy 无法 polyfill,Vue 3 因此放弃了 IE。
Reflect 存在的意义
Reflect 提供与 13 个陷阱一一对应的静态方法,每个方法就是该操作的"默认行为"。初看多余------Reflect.get(obj, prop) 和 obj[prop] 不是差不多吗?差在两个地方。
其一,receiver 的正确转发 。陷阱里的 get 拿到第三个参数 receiver,当被访问的属性是继承来的 getter 时,receiver 决定了 getter 里 this 的指向。手写 obj[prop] 会把这个信息丢掉,在代理参与继承的场景下产生隐蔽的 this 错乱:
javascript
const parent = {
_name: 'parent',
get name() { return this._name; }
};
const child = Object.create(parent);
child._name = 'child';
const p = new Proxy(parent, {
get(obj, prop, receiver) {
return Reflect.get(obj, prop, receiver); // 转发 receiver
// 若写 return obj[prop],getter 的 this 会错绑到 parent
}
});
Reflect.get(p, 'name', child); // 'child' ✅
其二,失败语义的函数式化 。Reflect.defineProperty 失败返回 false 而不是抛异常,Reflect.deleteProperty 同理,适合用返回值做流程控制。
实践守则一句话:写了陷阱,就用对应的 Reflect 方法做默认转发,几乎总是对的。
Proxy 能玩出什么
响应式系统(上面已经讲了原理),负索引数组,数据校验,访问日志,权限控制,惰性缓存,把远程调用伪装成本地对象的 RPC 客户端......凡是"想在属性操作前后插入横切逻辑",Proxy 都是语言层面最正统的答案。举一个负索引的例子:
javascript
function withNegativeIndex(arr) {
return new Proxy(arr, {
get(target, prop, receiver) {
const i = Number(prop);
if (Number.isInteger(i) && i < 0) prop = String(target.length + i);
return Reflect.get(target, prop, receiver);
}
});
}
const a = withNegativeIndex([1, 2, 3, 4, 5]);
a[-1]; // 5
a[-2]; // 4
Proxy 的边界
它也不是万能的,四个限制需要心里有数:
- 代理默认不可解除 。需要时用
Proxy.revocable(),调用revoke()之后再碰代理会抛 TypeError。 - this 穿透问题 。目标对象的方法内部用的
this指向原始对象而非代理,方法内部的属性读写会绕过所有陷阱。想全程拦截,得保证方法调用时 this 绑定到代理上。 - 内部槽失效 。Date、Map 这类对象的方法依赖内部槽(internal slot),隔着代理调用直接抛错------
new Proxy(new Map(), {}).get('k')会 TypeError,因为get里的 this 是代理而不是真 Map。要代理这类对象,得在陷阱里手动把 this 重绑到目标。 - 性能。每次被拦截的操作都比裸访问慢一个量级,热点路径慎用。
八、那些"不是普通对象"的对象
数组
数组是带 length 和索引语义的对象。length 是个可写的特殊属性,改小截断、改大补洞;整数索引的读写走引擎的快速通道(elements 存储),和字典属性分开管理。稀疏数组的"洞"(hole)和值为 undefined 的元素在 map/forEach/join 等方法里行为不同,写库的人需要在意,写业务的人保持数组致密即可。
函数
函数是可调用的对象,typeof 给它的返回值是独一份的 'function',但它确确实实是 Function 的实例,身上可以挂任意属性:
javascript
function fn(a, b) {}
fn.custom = 'yes'; // 合法
fn.name; // 'fn'
fn.length; // 2(第一个默认参数之前的形参个数)
库作者常利用这一点做函数元数据(缓存、标记、统计),React 内部就大量使用。
Map 与 Set
Map 解决普通对象做字典的四个硬伤:键类型任意(对象、数字、NaN 都行)、天然有序(严格插入序)、有 size、频繁增删的性能更优。Set 是值的唯一集合,数组去重的最短写法 [...new Set(arr)] 就靠它。
javascript
const cache = new Map();
const keyObj = { id: 1 };
cache.set(keyObj, 'value'); // 对象作键
cache.size; // 1
new Map(Object.entries(obj)); // 对象 → Map
Object.fromEntries(map); // Map → 对象(键会被转成字符串)
选型标准:结构固定的记录(一个用户、一份配置)用普通对象;键动态、键非字符串、需要 size 和迭代、高频增删、需要防原型污染------用 Map。
WeakMap 与 WeakSet
"Weak"指弱引用:键(WeakMap)或成员(WeakSet)如果没有其他强引用指着,GC 随时可以回收,对应条目自动消失。因此它们不可迭代、没有 size------"当前有哪些键"这个问题本身就与弱引用语义矛盾。键必须是对象(ES2023 起 Symbol 也可以)。
WeakMap 的主战场是给对象附加数据而不造成泄漏:
javascript
const nodeMeta = new WeakMap();
function track(element) {
nodeMeta.set(element, { visitedAt: Date.now() });
}
// 页面移除元素、所有引用释放后,WeakMap 里的条目自动消失
如果用普通 Map 干这事,已卸载的 DOM 节点会被 Map 的键强引用着,永远无法回收------这是前端内存泄漏的经典成因之一。另一个高频用途是算法内部的状态记录:深拷贝防循环引用、deepFreeze 防重复递归、记忆化缓存,都用 WeakMap 当"已处理集合"。
其他
Date(设计陈旧,新项目考虑 Temporal 或 date-fns/dayjs)、RegExp(有 lastIndex 状态,带 g 标志复用实例要小心)、Error(可继承自定义,cause 字段已标准化)、TypedArray/ArrayBuffer/DataView(二进制操作)、JSON 和 Math(严格说这俩不是构造函数,只是挂了静态方法的命名空间对象)。
九、拷贝、合并与序列化:数据流转的三个高危环节
浅拷贝
javascript
const copy = { ...source };
const copy2 = Object.assign({}, source);
两者几乎等价:只拷自身的可枚举属性,getter 会被求值成普通数据,嵌套对象仍是共享引用。展开语法的优势在于能与覆盖写法自然组合,这也是 React 状态更新的标准姿势:
javascript
const updated = { ...user, age: user.age + 1 };
需要记住展开运算符不保留 的东西:原型(类实例展开后变成普通对象,instanceof 失效)、描述符(不可枚举属性被丢弃)、访问器(getter 被求值)。
深拷贝
现代标准答案是 structuredClone():
javascript
const original = {
date: new Date(),
regex: /abc/g,
map: new Map([['k', 'v']]),
nested: { deep: { deeper: true } },
};
original.self = original; // 循环引用
const clone = structuredClone(original);
clone.self === clone; // true,循环引用被正确重建
clone.date instanceof Date; // true
它走的是 HTML 规范里的结构化克隆算法,Date、RegExp、Map、Set、TypedArray、ArrayBuffer、循环引用全都能处理。不能处理的:函数、Symbol、DOM 节点、Error 之外的类实例原型信息(克隆出来原型退化为 Object.prototype)、getter/setter(被求值)。
老方案 JSON.parse(JSON.stringify(obj)) 的坑值得完整列一遍,因为至今仍有人在生产代码里踩:
undefined、函数、Symbol 作为属性值时,键整个消失 ;作为数组元素时变null;Date变成 ISO 字符串,反序列化后不会变回来;NaN、Infinity变null;Map、Set变{};- 循环引用直接抛 TypeError;
BigInt直接抛 TypeError。
它只适合纯 JSON 数据的场景。需要兼容老环境或更精细的控制,用 lodash 的 cloneDeep,或者手写递归(顺便当作理解深拷贝本质的练习):
javascript
function deepClone(obj, seen = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj;
if (seen.has(obj)) return seen.get(obj); // 循环引用:返回已建的副本
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
const clone = Array.isArray(obj) ? [] : Object.create(Object.getPrototypeOf(obj));
seen.set(obj, clone); // 先登记再递归,顺序不能反
for (const key of Reflect.ownKeys(obj)) {
clone[key] = deepClone(obj[key], seen);
}
return clone;
}
注意其中 WeakMap 的用法------这正是第八节说的"算法内部状态记录"。
合并
浅合并用 assign 或展开。深合并没有原生 API,简单递归版:
javascript
function deepMerge(target, source) {
const out = { ...target };
for (const key of Object.keys(source)) {
const sv = source[key], tv = target[key];
out[key] =
sv && typeof sv === 'object' && !Array.isArray(sv) &&
tv && typeof tv === 'object' && !Array.isArray(tv)
? deepMerge(tv, sv)
: sv;
}
return out;
}
生产环境建议直接用 lodash 的 merge------但注意,正是这类递归合并函数历史上多次成为原型污染漏洞的载体,用之前确认版本已修复,或者干脆在入口过滤危险键(下一节详谈)。
函数默认配置是合并的高频场景:
javascript
function request(url, options = {}) {
const config = { method: 'GET', timeout: 5000, ...options };
}
十、解构与现代语法
对象解构
javascript
const user = { name: '张三', age: 30, address: { city: '北京' } };
const { name, age } = user; // 基本解构
const { name: userName } = user; // 重命名
const { role = 'guest' } = user; // 默认值
const { address: { city } } = user; // 嵌套
const { name: n, ...rest } = user; // 剩余属性(ES2018)
几个细节值得较真:
默认值只在 undefined 时生效 ,null 不触发。const { a = 1 } = { a: null } 的结果是 a === null。这是 ?? 和 || 之别在解构里的镜像。
剩余属性是 React 组件过滤透传属性的标准工具:
javascript
function Input({ label, ...inputProps }) {
return <label>{label}<input {...inputProps} /></label>;
}
函数参数解构 + 整体默认值构成"命名参数"模式,是设计多参数函数 API 的最佳实践:
javascript
function createUser({ name, age = 18, role = 'user' } = {}) {
return { name, age, role };
}
createUser({ name: '李四' }); // { name: '李四', age: 18, role: 'user' }
末尾那个 = {} 不能省------没有它,createUser() 不传参会在对 undefined 解构时抛 TypeError。库代码里这个防御性写法几乎是强制项。
解构的求值时机 :const { a, b } = obj 只在解构那一刻取值,之后 obj 变化不会同步到 a、b。别把解构当成"视图"。
可选链与空值合并
深层访问的判空地狱,ES2020 用两个运算符终结了:
javascript
const city = user?.address?.city; // 任何一环是 null/undefined,整体短路返回 undefined
fn?.(); // fn 不是可调用值时返回 undefined 而非抛错
arr?.[i]; // 索引访问同样适用
?? 与 || 的区别在于触发条件:|| 对所有假值(0、''、false、NaN)都取右值,?? 只对 null 和 undefined 取右值:
javascript
const config = { port: 0, debug: false };
config.port || 8080; // 8080 ← 0 被误伤
config.port ?? 8080; // 0 ← 正确
处理"可能不存在的配置"时,几乎永远应该用 ?? 而不是 ||。两者组合是现代代码访问嵌套数据的默认姿势:
javascript
const port = options?.server?.port ?? 3000;
几个高频的现代 API
Object.fromEntries (ES2019),entries 的逆运算,配合起来实现函数式的对象变换:
javascript
const prices = { apple: 5, banana: 3 };
const discounted = Object.fromEntries(
Object.entries(prices).map(([k, v]) => [k, v * 0.8])
);
// 顺带:URL 参数转对象
Object.fromEntries(new URLSearchParams('a=1&b=2')); // { a: '1', b: '2' }
Object.groupBy(ES2024),按分类函数分组,返回 null 原型对象:
javascript
Object.groupBy(people, p => p.dept);
// { eng: [...], hr: [...] }
Object.hasOwn (ES2022),前面已经讲过,这里再强调一次:新代码一律用它替代 hasOwnProperty。
十一、陷阱与工程守则
this:调用方式决定一切
对象方法的 this 不看定义在哪,只看怎么被调用的:
javascript
const user = {
name: '张三',
greet() { console.log(this.name); },
greetArrow: () => { console.log(this.name); } // this 是定义处的外层作用域
};
user.greet(); // '张三'
const fn = user.greet; fn(); // undefined,方法脱离了对象
setTimeout(user.greet, 100); // undefined,同上
user.greetArrow(); // 大概率 undefined,箭头函数根本不接这个 this
三条守则:对象方法用方法简写或 function,不要用箭头函数 (箭头函数的 this 在定义时就焊死在外层作用域了);方法要作为回调传递时,用箭头函数包一层或 bind :setTimeout(() => user.greet(), 100);方法内部需要回调时,箭头函数反而是正确选择------它的 this 自动继承方法的 this,这正是它的设计意图。
类组件时代 React 里满屏的 this.handleClick = this.handleClick.bind(this),本质就是这个问题;函数组件和工厂函数模式流行之后,这类代码大幅减少------这算是"规避 this"带来的架构收益。
原型污染:真实存在的安全漏洞
如果程序把不受信任的输入直接当键往对象上写,或者做递归合并,攻击者就可以注入 __proto__ 键,篡改全局 Object.prototype,让所有对象凭空多出属性:
javascript
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
unsafeMerge({}, malicious); // 危险的合并实现会把 __proto__ 当普通键处理
const victim = {};
victim.isAdmin; // true ------ 全局污染完成
后续任何 if (user.isAdmin) 式的判断都会被绕过。lodash、jQuery 等库的合并函数历史上都出过此类 CVE,实际攻击案例包括对 Node 服务的提权。防御手段按性价比排序:
- 动态键来自用户输入时,过滤
__proto__、constructor、prototype三个危险键; - 用
Object.create(null)或Map存放动态键数据,从根上消除污染路径; - 用
Object.entries+fromEntries这类只处理自身可枚举属性的 API 做合并,而非for...in裸赋值; - 依赖库保持更新。
多说一句为什么 JSON.parse('{"__proto__": ...}') 特殊:JSON.parse 产出的是自身 就带有 __proto__ 键的普通对象(它不走 setter),但把这个对象再往别处合并时,多数朴素实现会用赋值语法触发 setter,污染由此发生。Object.hasOwn(parsed, '__proto__') 可以检测出这种"带毒"数据。
JSON 序列化的完整行为清单
| 输入 | 输出 |
|---|---|
undefined、函数、Symbol(属性值) |
键被丢弃 |
| 同上(数组元素) | null |
Date |
ISO 字符串(单向,parse 不还原) |
NaN、Infinity、-Infinity |
null |
Map、Set |
{} |
BigInt |
抛 TypeError |
| 循环引用 | 抛 TypeError |
| getter | 求值后按值序列化 |
| 不可枚举属性、Symbol 键 | 跳过 |
需要控制序列化行为,给对象定义 toJSON():
javascript
class Point {
constructor(x, y) { this.x = x; this.y = y; }
toJSON() { return `${this.x},${this.y}`; }
}
JSON.stringify(new Point(1, 2)); // '"1,2"'
JSON.stringify 的第二个参数(replacer,可以函数或键名白名单数组)和第三个参数(缩进)在调试和安全输出时都很有用。
其余高频陷阱,一次列完
数字键与字符串键不分 。obj[1] 和 obj['1'] 是同一个键,obj[1.5] 会变成 '1.5' 字符串键。需要严格区分数字键用 Map。
typeof null === 'object'。历史 bug,永不修复,判空显式写。
字面量里的 __proto__: value 是特殊语法 ,它在创建时直接设置原型(规范附录 B 遗留特性),和 obj.__proto__ = value 走 setter 是两回事。两样都别用。
Object.assign 会触发源对象的 getter ,拿到的是求值结果,不是访问器本身。要保留访问器用 getOwnPropertyDescriptors + Object.create(第三节讲过)。
类实例经过展开或 JSON 往返后不再是实例 。{...instance} 的原型是 Object.prototype,方法如果定义在类上会直接丢失。跨边界传递领域对象时注意重建。
冻结不妨碍访问器的 setter,也不妨碍内层集合的可变性(第六节详述)。
工程守则
创建与设计:字面量和 class 优先;组合优于继承,继承层级超过两三层就该重新审视设计;动态键字典用 Map 或 null 原型对象。
属性操作:遍历用 Object.keys/entries,自身检测用 Object.hasOwn,完整键列表用 Reflect.ownKeys,for...in 从代码库里清除。
数据流转:深拷贝首选 structuredClone;不可变更新用展开,嵌套深了用 Immer;嵌套访问用 ?. 加 ??;序列化前过一遍上面的行为清单。
安全与内存:不信任的键一律过滤;给对象挂元数据用 WeakMap;对外暴露的内部结构考虑冻结或返回副本。