JavaScript的对象系统难点不在语法,而在运行时:变量保存的是引用、属性背后有描述符、继承依赖原型链查找。下面围绕对象创建、属性描述符、原型链和遍历边界,把常用代码逻辑与容易踩坑的因果关系串起来。
一、引用语义与键规则
JavaScript的值分两类:string、number、boolean、symbol、bigint、null、undefined按值存储,其余都是对象,变量中保存的是指向堆内存的引用。
- 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
复制代码
由此有三条直接推论:对象比较比的是引用,{} === {} 为 false;函数传参传的是引用的副本,内部改属性会影响外部对象,但重新赋值参数不影响外部;展开运算符和 Object.assign 只复制第一层,嵌套对象仍是共享引用。React 中直接修改 state 嵌套对象导致界面不更新,根源就在这里。
对象属性的键只有字符串和 Symbol 两种。数字键会被转成字符串,obj[1] 与 obj['1'] 写的是同一个键。对象不能直接当键,否则会转成 '[object Object]',这也是 Map 存在的现实原因。属性顺序在 ES2015 后已有规范:整数索引键排在最前并按数值升序,其余字符串键按插入顺序,Symbol 键最后也按插入顺序。
- const obj = {};
- obj.b = 1;
- obj[2] = 2;
- obj.a = 3;
- obj[1] = 4;
- console.log(Object.keys(obj)); // ['1','2','b','a']
复制代码
因此对象不是可靠的有序容器,键可能是时间戳、ID 等数字且顺序重要时,应使用 Map 或数组。
二、六种创建方式与选型
字面量是默认选择。V8 等引擎会为相同结构的对象字面量共享 Hidden Class,属性访问可走内联缓存,速度优于动态增删属性构造出的对象。ES6 还增强了属性简写、方法简写、计算属性名和访问器。
- const name = '李四';
- const obj = {
- name,
- greet() { return 'hi'; },
- ['user_' + name]: true,
- get info() { return this.name; }
- };
- const handlers = {
- [STATUS.PENDING]: handlePending,
- [STATUS.DONE]: handleDone
- };
复制代码
new Object() 与字面量功能等价但更啰嗦,没有使用理由。Object.create(proto) 是理解原型的钥匙,它创建新对象并以 proto 为原型;Object.create(null) 可造出没有原型的纯净字典。
- const animal = { eat() { console.log('eating...'); } };
- const dog = Object.create(animal);
- dog.eat();
- const dict = Object.create(null);
- console.log('toString' in dict); // false
- console.log(dict.__proto__); // undefined
复制代码
纯净字典适合键名来自用户输入或可能与 Object.prototype 属性撞车的场景,可天然免疫 __proto__ 污染。代价是它连 hasOwnProperty 都没有,只能用 Object.hasOwn、Object.keys 等静态方法操作。
构造函数加 new 必须拆开理解:创建空对象 obj;令 obj.__proto__ 等于构造函数 prototype;以 obj 为 this 执行函数体;若构造函数显式返回对象,则 new 表达式返回该对象,否则返回 obj。
- function Person(name, age) {
- this.name = name;
- this.age = age;
- }
- Person.prototype.greet = function () {
- console.log(`你好,我是${this.name}`);
- };
- function myNew(Ctor, ...args) {
- const obj = Object.create(Ctor.prototype);
- const ret = Ctor.apply(obj, args);
- return (ret !== null && typeof ret === 'object') ? ret : obj;
- }
复制代码
构造函数返回对象会覆盖 this,这是实际陷阱;返回原始值则被忽略。非严格模式下忘写 new 不会报错,this 会指向全局对象。class 从语法层面堵死了这个坑。
- function Weird() {
- this.a = 1;
- return { b: 2 };
- }
- console.log(new Weird()); // { b: 2 }
复制代码
class 底层仍是原型,Student.prototype.__proto__ 指向 Person.prototype,但存在五个实质差异:类声明有暂时性死区;类体自动严格模式;忘写 new 调用类抛 TypeError;类方法不可枚举;支持 #id 真正私有成员。extends 还会同时建立实例链和静态链,所以静态方法可继承。
- 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.school = school;
- }
- greet() {
- super.greet();
- console.log(`我来自${this.school}`);
- }
- }
复制代码
工厂函数用闭包换取私有状态,不依赖 this,方法解构后也不会丢上下文;代价是每个实例持有独立方法副本,但对多数应用影响很小。
- function createPerson(name) {
- let visitCount = 0;
- return {
- name,
- greet() {
- visitCount++;
- console.log(`你好,我是${name},第 ${visitCount} 次问候`);
- },
- getVisitCount: () => visitCount
- };
- }
复制代码
选型可记:一次性数据结构用字面量;需要 instanceof、方法共享、对外暴露类型用 class;要彻底封装和函数式风格用工厂函数;要控制原型或纯净字典用 Object.create。
三、属性描述符与访问器
每个属性背后都有一组元数据。数据属性有 value、writable、enumerable、configurable;访问器属性用 get、set 替代前两个,并同样有 enumerable、configurable。字面量和直接赋值创建的属性四个开关默认全开,而 Object.defineProperty 不写的特性默认是 false。
- const obj = {};
- Object.defineProperty(obj, 'a', { value: 1 });
- obj.a = 2; // 非严格模式静默失败
- console.log(Object.keys(obj)); // []
- delete obj.a; // 无效
复制代码
configurable 设为 false 后就是单向门,不能再改描述符,唯一例外是允许把 writable 从 true 收紧为 false。这也是 Object.freeze 不可逆的基础。
- const o = {};
- Object.defineProperty(o, 'locked', {
- value: 42,
- enumerable: true,
- configurable: false
- });
- Object.defineProperty(o, 'locked', { value: 43 }); // TypeError
复制代码
Object.getOwnPropertyDescriptors 配合 Object.create 可做保留访问器的完整拷贝,而 Object.assign 和展开运算符会把 getter 求值成普通数据属性。
- const source = {};
- Object.defineProperty(source, 'x', {
- get() { return 42; },
- enumerable: true,
- configurable: true
- });
- const copy1 = Object.assign({}, source); // x 变成普通数据属性 42
- const copy2 = Object.create(
- Object.getPrototypeOf(source),
- Object.getOwnPropertyDescriptors(source)
- ); // x 仍是 getter,原型也保留
复制代码
getter/setter 的工程用途包括:校验与规范化、派生属性实时计算、惰性求值缓存、响应式劫持。Vue 2 用 Object.defineProperty 遍历数据装 getter/setter,get 收集依赖、set 派发更新;其限制是新增删除属性侦测不到,数组索引赋值和 length 变化也侦测不到。Vue 3 换用 Proxy 后这些问题消失,这是理解两代响应式差异的关键切口。
四、原型链的查找与写入
原型部分先分清三件事:obj.__proto__ 或 Object.getPrototypeOf(obj) 是对象的原型;Fn.prototype 只有函数才有,是 new Fn() 实例的原型,并带 constructor 指回 Fn;obj.constructor 通常不是自身属性,而是沿原型链找到的。
- function Person(name) { this.name = name; }
- const p = new Person('张三');
- console.log(Object.getPrototypeOf(p) === Person.prototype); // true
- console.log(Person.prototype.constructor === Person); // true
- console.log(p.constructor === Person); // true
- console.log(Object.hasOwn(p, 'constructor')); // false
复制代码
属性读取是向上查找:自身有就返回,没有就去原型找,逐层直到 null,都没有返回 undefined。写入则通常原地创建自身属性,不会写到原型上,这就是遮蔽。
- const proto = { count: 0 };
- const obj = Object.create(proto);
- obj.count = 5;
- console.log(obj.count); // 5
- console.log(proto.count); // 0
复制代码
例外是原型上的同名属性为带 setter 的访问器:赋值会触发 setter,而不是创建自身属性。很多“明明赋值了却没生效”的问题就出在这里。
- const proto2 = {
- _v: 0,
- get v() { return this._v; },
- set v(val) { this._v = val; }
- };
- const obj2 = Object.create(proto2);
- obj2.v = 10;
- console.log(Object.hasOwn(obj2, 'v')); // false
- console.log(Object.hasOwn(obj2, '_v')); // true
- console.log(obj2.v); // 10
复制代码
一条完整继承链是:实例 → Dog.prototype → Animal.prototype → Object.prototype → null。方法重写本质是子类原型属性遮蔽父类同名属性;super.speak() 是引擎借助 [[HomeObject]] 跳过当前层向上查找,没有额外魔法。Function.prototype 本身是函数,Function 的 __proto__ 又指向 Function.prototype,形成自指结构,是对象模型里最烧脑的一环。
对象转原始值时触发 [[ToPrimitive]]:先看 Symbol.toPrimitive,没有则按 hint 选择 toString 或 valueOf 的顺序,都拿不到原始值就抛 TypeError。
- const magic = {
- [Symbol.toPrimitive](hint) {
- if (hint === 'number') return 42;
- if (hint === 'string') return 'magic!';
- return 'default';
- }
- };
- console.log(+magic); // 42
- console.log(`${magic}`); // 'magic!'
- console.log(magic + ''); // 'default'
复制代码
理解这套规则后,[] + {}、+[] 等怪题就是查找规则加转换规则的机械执行。Symbol.hasInstance 可接管 instanceof 判断,Symbol.toStringTag 可接管 Object.prototype.toString.call() 输出中的 [object X],写库时可用于自定义类型标识。
delete 只能删自身属性;删不存在的属性也返回 true,严格模式下删除不可配置属性才抛错;删原型属性会影响所有继承者;它还有性能代价,会让 V8 的 Hidden Class 退化成字典模式,热点路径上赋 undefined 往往比 delete 更友好,但两者在 in 检测上的语义不同。
for...in 会遍历自身加原型链上所有可枚举字符串键,存在扫出继承属性和历史顺序问题,现代代码基本只应配合 hasOwn 过滤,数组尤其不要用。Object.keys、Object.values、Object.entries 只返回自身可枚举字符串键,顺序符合规范,是遍历的默认选择。
- const data = { a: 1, b: 2 };
- for (const [key, value] of Object.entries(data)) {
- console.log(key, value);
- }
复制代码
工程守则可归纳为:优先字面量;需要方法共享和类型判断用 class;追求封装用工厂函数;用户输入键或纯字典用 Object.create(null);少用 for...in;热点路径慎用 delete;始终记住对象比较、传参和浅拷贝都围绕引用语义展开。 |