JavaScript 对象完全指南

写在前面

如果要用一句话概括 JavaScript,我会说:这是一门"万物皆对象,唯独对象最不讲道理"的语言。

数组是对象,函数是对象,日期是对象,正则是对象,连 ObjectFunction 这两个看起来像"类型"的东西,本质上也是对象,而且它们互为对方的构造产物,绕成了一个先有鸡还是先有蛋的循环。很多写了几年 JavaScript 的人,遇到 {} + []a.__proto__.__proto__ 这类问题时依然会栽跟头,原因不在于记性差,而在于对对象系统的理解停留在语法层,没有下沉到运行时机制。


一、先搞清楚:对象到底是什么

引用语义,一切困惑的起点

JavaScript 的值分两类。七个原始类型------stringnumberbooleansymbolbigintnullundefined------是按值存储的,变量里装的就是值本身。剩下的一切都是对象,变量里装的不是对象,而是一个指向堆内存的引用

这个区别在日常代码里无处不在:

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,至今没有落地)。

第二,函数传参传的是引用的副本 。函数内部修改传入对象的属性,外面的对象会变;但函数内部把参数重新赋值为另一个对象,外面不受影响。这一点对 nullundefined 之外的所有对象类型都成立。

第三,浅拷贝解决不了嵌套问题 。展开运算符和 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 之后规范做了明确规定,现在所有引擎行为一致:

  1. 整数索引键("0""1""42" 这种能被解析为非负整数的字符串)排在最前,按数值升序;
  2. 其余字符串键按插入顺序;
  3. 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 上的属性撞车(比如某个键就叫 constructortoString,普通对象上 '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(...) 在引擎内部执行的是四步:

  1. 创建一个空对象 obj
  2. obj.__proto__ === Person.prototype
  3. objthis 执行 Person 函数体;
  4. 如果构造函数显式返回了一个对象,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 丢了

返回原始值(数字、字符串、nullundefined)则会被忽略,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,方法共享、继承查找的机制与构造函数模式完全一致。但两者有五个实质性差异,都不是纯语法层面的:

  1. 类声明有暂时性死区 。函数声明整体提升,class 声明虽然也"提升"但在初始化前访问会抛 ReferenceError
  2. 类体自动运行在严格模式下 。忘了写 new 调用类会直接抛 TypeError,而不是把属性挂到全局。
  3. 类的方法不可枚举Object.keys(Person.prototype) 是空数组;构造函数模式下手动赋值的原型方法默认可枚举,会被 for...in 扫出来,当年很多库为此手动改描述符。
  4. 支持真正的私有成员#id 是语言层面的私有,外部访问直接语法报错,不是 _id 那种"君子协定"。私有方法、私有静态成员、#field in obj 存在性检查也都已标准化。
  5. 实例字段语法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...inObject.keys 扫到;
  • configurable:能否删除该属性、能否再次修改描述符。

访问器属性用一对函数替代了前两个:

  • get:读取时调用;
  • set:写入时调用;
  • 外加同样的 enumerableconfigurable

通过字面量或直接赋值创建的属性,四个开关默认全开(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,也不能再修改描述符------唯一的例外是允许把 writabletrue 收紧为 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() 产出的所有实例的原型。它本身是个普通对象,出厂时带一个指回 Fnconstructor 属性。

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.prototypeFunction 构造函数自己的 __proto__ 指向 Function.prototype------Function 是自己的实例。这个自指结构是 JavaScript 对象模型里最烧脑的一环,画几遍图就通了。

对象如何变成原始值

当对象出现在字符串拼接、数值运算这类需要原始值的场合,引擎触发 [[ToPrimitive]],顺序是:

  1. 对象定义了 [Symbol.toPrimitive] 方法?调它,传入 hint("number" / "string" / "default");
  2. 没有的话,hint 为 "string" 时依次试 toString()valueOf();hint 为 "number""default" 时顺序反过来;
  3. 都拿不到原始值,抛 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 会让对象退化成字典模式,属性访问失去内联缓存优化。热点路径上如果只是想"清空"一个值,赋 undefineddelete 对引擎友好得多(当然,两者的语义有差别: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 一次性覆盖 nullundefined== 的少数正当用法之一)。

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 的渲染优化(memoPureComponent)靠引用比较判断"变没变",直接修改 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 种,覆盖了对象的完整生命周期:getsethasdeletePropertyownKeysgetOwnPropertyDescriptordefinePropertygetPrototypeOfsetPrototypeOfisExtensiblepreventExtensions,以及函数专属的 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(二进制操作)、JSONMath(严格说这俩不是构造函数,只是挂了静态方法的命名空间对象)。


九、拷贝、合并与序列化:数据流转的三个高危环节

浅拷贝

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 字符串,反序列化后不会变回来;
  • NaNInfinitynull
  • MapSet{}
  • 循环引用直接抛 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''falseNaN)都取右值,?? 只对 nullundefined 取右值:

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 在定义时就焊死在外层作用域了);方法要作为回调传递时,用箭头函数包一层或 bindsetTimeout(() => 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 服务的提权。防御手段按性价比排序:

  1. 动态键来自用户输入时,过滤 __proto__constructorprototype 三个危险键;
  2. Object.create(null)Map 存放动态键数据,从根上消除污染路径;
  3. Object.entries + fromEntries 这类只处理自身可枚举属性的 API 做合并,而非 for...in 裸赋值;
  4. 依赖库保持更新。

多说一句为什么 JSON.parse('{"__proto__": ...}') 特殊:JSON.parse 产出的是自身 就带有 __proto__ 键的普通对象(它不走 setter),但把这个对象再往别处合并时,多数朴素实现会用赋值语法触发 setter,污染由此发生。Object.hasOwn(parsed, '__proto__') 可以检测出这种"带毒"数据。

JSON 序列化的完整行为清单

输入 输出
undefined、函数、Symbol(属性值) 键被丢弃
同上(数组元素) null
Date ISO 字符串(单向,parse 不还原)
NaNInfinity-Infinity null
MapSet {}
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.ownKeysfor...in 从代码库里清除。

数据流转:深拷贝首选 structuredClone;不可变更新用展开,嵌套深了用 Immer;嵌套访问用 ?.??;序列化前过一遍上面的行为清单。

安全与内存:不信任的键一律过滤;给对象挂元数据用 WeakMap;对外暴露的内部结构考虑冻结或返回副本。

相关推荐
张小姐的猫1 小时前
【AI大模型接入SDK】 —— Ollama本地接入Deepseek
java·linux·开发语言·网络·c++·人工智能
Nuanyt1 小时前
JUC常见核心知识梳理01 线程 并发 JMM volatile 管程 锁 synchronized
java·开发语言·网络·jvm
adaierya1 小时前
用 AI 解决音频转换编程问题
开发语言·人工智能·python·分类·ai编程
何何____1 小时前
web方向js考核题解
开发语言·前端·javascript
liangshanbo12151 小时前
面试题终极整理:手写 Promise.all / Promise.allSettled
前端·javascript·promise
ThornArmor1 小时前
重铸1996|首秀点亮:编写 helloworld 并用 Makefile 自动化收割第一个 .z64 卡带镜像
开发语言
船厂电气自动化ai大模型1 小时前
AI大模型与数学 第73课 秩的核心定义、矩阵秩的通用求解方法
开发语言·数据结构·机器学习
单线程121381 小时前
从案例分析 Vue3 Tokenizer+Parser 源码五
前端·javascript·vue.js
小白学大数据2 小时前
Codex 里的 GPT 6 Astra、GPT 5.6 Sol、Terra、Luna 怎么选
开发语言·gpt·microsoft