查看: 165|回复: 3

JavaScript对象创建与原型链继承机制代码实践

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
JavaScript的对象系统难点不在语法,而在运行时:变量保存的是引用、属性背后有描述符、继承依赖原型链查找。下面围绕对象创建、属性描述符、原型链和遍历边界,把常用代码逻辑与容易踩坑的因果关系串起来。

一、引用语义与键规则

JavaScript的值分两类:string、number、boolean、symbol、bigint、null、undefined按值存储,其余都是对象,变量中保存的是指向堆内存的引用。
  1. let a = 1;
  2. let b = a;
  3. b = 2;
  4. console.log(a); // 1
  5. let o1 = { x: 1 };
  6. let o2 = o1;
  7. o2.x = 2;
  8. console.log(o1.x); // 2
复制代码

由此有三条直接推论:对象比较比的是引用,{} === {} 为 false;函数传参传的是引用的副本,内部改属性会影响外部对象,但重新赋值参数不影响外部;展开运算符和 Object.assign 只复制第一层,嵌套对象仍是共享引用。React 中直接修改 state 嵌套对象导致界面不更新,根源就在这里。

对象属性的键只有字符串和 Symbol 两种。数字键会被转成字符串,obj[1] 与 obj['1'] 写的是同一个键。对象不能直接当键,否则会转成 '[object Object]',这也是 Map 存在的现实原因。属性顺序在 ES2015 后已有规范:整数索引键排在最前并按数值升序,其余字符串键按插入顺序,Symbol 键最后也按插入顺序。
  1. const obj = {};
  2. obj.b = 1;
  3. obj[2] = 2;
  4. obj.a = 3;
  5. obj[1] = 4;
  6. console.log(Object.keys(obj)); // ['1','2','b','a']
复制代码

因此对象不是可靠的有序容器,键可能是时间戳、ID 等数字且顺序重要时,应使用 Map 或数组。

二、六种创建方式与选型

字面量是默认选择。V8 等引擎会为相同结构的对象字面量共享 Hidden Class,属性访问可走内联缓存,速度优于动态增删属性构造出的对象。ES6 还增强了属性简写、方法简写、计算属性名和访问器。
  1. const name = '李四';
  2. const obj = {
  3.   name,
  4.   greet() { return 'hi'; },
  5.   ['user_' + name]: true,
  6.   get info() { return this.name; }
  7. };
  8. const handlers = {
  9.   [STATUS.PENDING]: handlePending,
  10.   [STATUS.DONE]: handleDone
  11. };
复制代码

new Object() 与字面量功能等价但更啰嗦,没有使用理由。Object.create(proto) 是理解原型的钥匙,它创建新对象并以 proto 为原型;Object.create(null) 可造出没有原型的纯净字典。
  1. const animal = { eat() { console.log('eating...'); } };
  2. const dog = Object.create(animal);
  3. dog.eat();
  4. const dict = Object.create(null);
  5. console.log('toString' in dict); // false
  6. console.log(dict.__proto__); // undefined
复制代码

纯净字典适合键名来自用户输入或可能与 Object.prototype 属性撞车的场景,可天然免疫 __proto__ 污染。代价是它连 hasOwnProperty 都没有,只能用 Object.hasOwn、Object.keys 等静态方法操作。

构造函数加 new 必须拆开理解:创建空对象 obj;令 obj.__proto__ 等于构造函数 prototype;以 obj 为 this 执行函数体;若构造函数显式返回对象,则 new 表达式返回该对象,否则返回 obj。
  1. function Person(name, age) {
  2.   this.name = name;
  3.   this.age = age;
  4. }
  5. Person.prototype.greet = function () {
  6.   console.log(`你好,我是${this.name}`);
  7. };
  8. function myNew(Ctor, ...args) {
  9.   const obj = Object.create(Ctor.prototype);
  10.   const ret = Ctor.apply(obj, args);
  11.   return (ret !== null && typeof ret === 'object') ? ret : obj;
  12. }
复制代码

构造函数返回对象会覆盖 this,这是实际陷阱;返回原始值则被忽略。非严格模式下忘写 new 不会报错,this 会指向全局对象。class 从语法层面堵死了这个坑。
  1. function Weird() {
  2.   this.a = 1;
  3.   return { b: 2 };
  4. }
  5. console.log(new Weird()); // { b: 2 }
复制代码

class 底层仍是原型,Student.prototype.__proto__ 指向 Person.prototype,但存在五个实质差异:类声明有暂时性死区;类体自动严格模式;忘写 new 调用类抛 TypeError;类方法不可枚举;支持 #id 真正私有成员。extends 还会同时建立实例链和静态链,所以静态方法可继承。
  1. class Person {
  2.   #id;
  3.   constructor(name, age) {
  4.     this.name = name;
  5.     this.age = age;
  6.     this.#id = Math.random();
  7.   }
  8.   greet() { console.log(`你好,我是${this.name}`); }
  9.   static create(name) { return new Person(name, 0); }
  10.   get info() { return `${this.name} (${this.age})`; }
  11. }
  12. class Student extends Person {
  13.   constructor(name, age, school) {
  14.     super(name, age);
  15.     this.school = school;
  16.   }
  17.   greet() {
  18.     super.greet();
  19.     console.log(`我来自${this.school}`);
  20.   }
  21. }
复制代码

工厂函数用闭包换取私有状态,不依赖 this,方法解构后也不会丢上下文;代价是每个实例持有独立方法副本,但对多数应用影响很小。
  1. function createPerson(name) {
  2.   let visitCount = 0;
  3.   return {
  4.     name,
  5.     greet() {
  6.       visitCount++;
  7.       console.log(`你好,我是${name},第 ${visitCount} 次问候`);
  8.     },
  9.     getVisitCount: () => visitCount
  10.   };
  11. }
复制代码

选型可记:一次性数据结构用字面量;需要 instanceof、方法共享、对外暴露类型用 class;要彻底封装和函数式风格用工厂函数;要控制原型或纯净字典用 Object.create。

三、属性描述符与访问器

每个属性背后都有一组元数据。数据属性有 value、writable、enumerable、configurable;访问器属性用 get、set 替代前两个,并同样有 enumerable、configurable。字面量和直接赋值创建的属性四个开关默认全开,而 Object.defineProperty 不写的特性默认是 false。
  1. const obj = {};
  2. Object.defineProperty(obj, 'a', { value: 1 });
  3. obj.a = 2; // 非严格模式静默失败
  4. console.log(Object.keys(obj)); // []
  5. delete obj.a; // 无效
复制代码

configurable 设为 false 后就是单向门,不能再改描述符,唯一例外是允许把 writable 从 true 收紧为 false。这也是 Object.freeze 不可逆的基础。
  1. const o = {};
  2. Object.defineProperty(o, 'locked', {
  3.   value: 42,
  4.   enumerable: true,
  5.   configurable: false
  6. });
  7. Object.defineProperty(o, 'locked', { value: 43 }); // TypeError
复制代码

Object.getOwnPropertyDescriptors 配合 Object.create 可做保留访问器的完整拷贝,而 Object.assign 和展开运算符会把 getter 求值成普通数据属性。
  1. const source = {};
  2. Object.defineProperty(source, 'x', {
  3.   get() { return 42; },
  4.   enumerable: true,
  5.   configurable: true
  6. });
  7. const copy1 = Object.assign({}, source); // x 变成普通数据属性 42
  8. const copy2 = Object.create(
  9.   Object.getPrototypeOf(source),
  10.   Object.getOwnPropertyDescriptors(source)
  11. ); // 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 通常不是自身属性,而是沿原型链找到的。
  1. function Person(name) { this.name = name; }
  2. const p = new Person('张三');
  3. console.log(Object.getPrototypeOf(p) === Person.prototype); // true
  4. console.log(Person.prototype.constructor === Person); // true
  5. console.log(p.constructor === Person); // true
  6. console.log(Object.hasOwn(p, 'constructor')); // false
复制代码

属性读取是向上查找:自身有就返回,没有就去原型找,逐层直到 null,都没有返回 undefined。写入则通常原地创建自身属性,不会写到原型上,这就是遮蔽。
  1. const proto = { count: 0 };
  2. const obj = Object.create(proto);
  3. obj.count = 5;
  4. console.log(obj.count); // 5
  5. console.log(proto.count); // 0
复制代码

例外是原型上的同名属性为带 setter 的访问器:赋值会触发 setter,而不是创建自身属性。很多“明明赋值了却没生效”的问题就出在这里。
  1. const proto2 = {
  2.   _v: 0,
  3.   get v() { return this._v; },
  4.   set v(val) { this._v = val; }
  5. };
  6. const obj2 = Object.create(proto2);
  7. obj2.v = 10;
  8. console.log(Object.hasOwn(obj2, 'v')); // false
  9. console.log(Object.hasOwn(obj2, '_v')); // true
  10. 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。
  1. const magic = {
  2.   [Symbol.toPrimitive](hint) {
  3.     if (hint === 'number') return 42;
  4.     if (hint === 'string') return 'magic!';
  5.     return 'default';
  6.   }
  7. };
  8. console.log(+magic); // 42
  9. console.log(`${magic}`); // 'magic!'
  10. 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 只返回自身可枚举字符串键,顺序符合规范,是遍历的默认选择。
  1. const data = { a: 1, b: 2 };
  2. for (const [key, value] of Object.entries(data)) {
  3.   console.log(key, value);
  4. }
复制代码

工程守则可归纳为:优先字面量;需要方法共享和类型判断用 class;追求封装用工厂函数;用户输入键或纯字典用 Object.create(null);少用 for...in;热点路径慎用 delete;始终记住对象比较、传参和浅拷贝都围绕引用语义展开。
回复

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: JavaScript对象创建与原型链继承机制代码实践

感谢楼主整理,这篇把对象创建和原型链的因果关系串得很顺,几个坑点总结得也到位。引用语义那部分用 React 嵌套 state 举例很典型,展开运算符和 Object.assign 只做浅拷贝这点确实容易在项目里翻车。 Object.create(null) 做纯净字典这个思路很实用,尤其键名来自用户输入或配置解析时,能天然避开 __proto__ 污染。补充一点小经验:这种对象上没有 hasOwnProperty,除了 Object.hasOwn,也可以用 Object.prototype.hasOwnProperty.call 兜底,但前者更直观。 构造函数返回对象会覆盖 this 那个 Weird 例子很直观,myNew 手写实现也把 new 的四步讲清楚了。class 的五个差异总结得简洁,静态方法可继承和 #私有字段这两点在实际代码里收益挺大。 不过帖子好像结尾断了,工厂函数那段停在“代价是每”,期待楼主补完。另外标题提到遍历边界,正文里目前主要讲了键顺序,如果后续能展开 for...in 会沿原型链找可枚举属性、Object.keys 只看自身可枚举、getOwnPropertyNames 包含不可枚举、Reflect.ownKeys 连 Symbol 一起拿这些区别,配上原型链上有可枚举属性时的实际例子,这篇就更完整了。
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: JavaScript对象创建与原型链继承机制代码实践

这篇整理挺系统的,把“引用语义—属性键规则—创建方式—原型链—class 差异”串成了一条线,尤其适合从语法转向运行时理解。几个点很认同:对象比较看引用、展开和 Object.assign 只浅复制第一层,确实是 React 状态更新和日常赋值里最容易踩的坑;对象键只有字符串和 Symbol,数字键会转字符串,也解释了为什么键顺序重要时不该依赖普通对象。Object.create(null) 做纯净字典这个例子很实用,既避开原型属性撞车,也能降低 __proto__ 污染风险,代价是得改用 Object.hasOwn、Object.keys 这类静态方法。new 的四步拆解和“构造函数返回对象会覆盖 this”也讲得很清楚,myNew 这个实现能把原理落到代码上。class 与构造函数的差异列得比较全,暂时性死区、严格模式、必须 new、方法不可枚举和私有字段这几点,确实是语法层面直接堵坑。结尾停在“工厂函数用闭包换取私有状态……代价是每”,看起来还没发完,挺期待后面关于闭包私有状态和 this 解绑代价的部分。另外首帖提到属性描述符但正文还没展开,如果后续能补上 getter/setter、writable/enumerable/configurable 与原型链查找的关系,会更完整。
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: JavaScript对象创建与原型链继承机制代码实践

整理得挺系统,尤其是把“引用语义—描述符—原型链—遍历边界”这条因果线串起来,比单纯罗列 API 好理解多了。几个点我平时也踩过,补充一下自己的体会。 关于 Object.create(null) 做字典,确实能天然避开 __proto__ 污染,但实际项目里要注意它和普通对象混用时的表现差异,比如 JSON.stringify 没问题,可一旦传给某些库做深合并或类型判断,可能会因为缺少原型方法而报错,边界要提前想清楚。 myNew 那段写得很直观,不过真实场景里还要留意构造函数返回函数的情况——typeof 判断为 function 时不会被当成对象覆盖,这个细节在模拟实现时容易漏。 class 和构造函数的差异里,我印象最深的是类字段初始化的时机:类字段是在 super() 之后按声明顺序逐个赋值的,如果子类字段和父类构造器里对同一属性的赋值有交叉,顺序不对结果会很反直觉,这点比“忘写 new 报错”更容易埋雷。 最后工厂函数那段被截断了,我猜你想说的代价应该是每个实例都持有一份方法副本,内存开销比原型共享大,而且 instanceof 会失效,需要手动设原型或改用 Symbol.hasInstance。如果后续能补上工厂函数、class、Object.create 三者在“私有状态 + 继承 + 内存”上的选型对比就更完整了。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-31 03:12 , Processed in 0.024909 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部