第51课:对象与原型(下)——原型链、prototype、__proto__、继承与 class 语法糖
在 JavaScript 中,继承不是通过“类”实现的,而是通过原型(Prototype) 实现的。每个对象都有一个内部属性 [[Prototype]],指向另一个对象,这个被指向的对象就是原型。当试图访问一个对象的属性时,如果该对象自身没有这个属性,JavaScript 就会沿着原型链向上查找,直到找到该属性或到达原型链的终点 null。这套机制是所有现代框架和语言特性的底层基础。本节课将系统讲解原型链的构建方式、构造函数与 prototype 的关系、ES5 继承模式以及 ES6 class 语法的本质。
1. 构造函数、prototype 与 __proto__ 的关系
1.1 构造函数与 new 操作符
在 JavaScript 中,任何普通函数都可以作为构造函数——只需在调用时加上 new 关键字。使用 new 调用函数时,引擎会执行以下步骤:
- 创建一个新的空对象。
- 将该空对象的
[[Prototype]]链接到构造函数的prototype属性所指的对象。 - 将构造函数的
this绑定到新对象上,执行函数体。 - 如果函数没有显式返回对象,则返回这个新对象。
1 | function Person(name) { |
当 new Person('Alice') 执行后,alice 对象的内部 [[Prototype]] 指向 Person.prototype。
1.2 prototype 属性(属于函数)
每个函数(箭头函数除外)都有一个 prototype 属性,它是一个普通对象,包含一个 constructor 属性指回函数自身。这个 prototype 对象就是通过 new 创建的实例的原型。
1 | function Person(name) { |
prototype 属性只存在于函数对象上,且只有函数作为构造函数时其 prototype 才有意义。
1.3 __proto__([[Prototype]] 的访问器)
每个普通对象都有一个内部属性 [[Prototype]],现代浏览器通过 __proto__ 访问器暴露它(ES6 将其标准化为遗留特性)。更推荐使用 Object.getPrototypeOf(obj) 和 Object.setPrototypeOf(obj, proto)。
1 | const alice = new Person('Alice'); |
1.4 关系总结
1 | function Person(name) { |
关系图:
1 | Person (构造函数) |
2. 原型链查找机制
当访问 obj.prop 时,引擎按照以下顺序查找:
- 检查
obj自身是否有名为prop的自有属性(hasOwnProperty('prop')返回true)。 - 如果没有,检查
obj.__proto__(即Object.getPrototypeOf(obj))。 - 如果还没有,继续沿着
__proto__向上查找,直到找到该属性或__proto__为null。 - 若直到
null都未找到,则返回undefined。
1 | const grandparent = { family: 'Smith' }; |
**原型链的终点是 null**。默认情况下,所有普通对象的原型链最终都会指向 Object.prototype,而 Object.prototype.__proto__ === null。
1 | console.log(Object.prototype.__proto__); // null |
3. Object.create() —— 以指定原型创建对象
Object.create(proto, propertiesObject?) 创建一个新对象,并将该对象的 [[Prototype]] 设置为 proto。这是比构造函数更纯粹的原型继承方式。
1 | const user = { |
与构造函数模式相比,Object.create 不需要定义构造函数,直接指定原型对象。它常被用于实现原型式继承和行为委托。
3.1 使用 Object.create(null) 创建无原型对象
1 | const dict = Object.create(null); |
这种“纯净”对象适合用作哈希表,不受原型属性污染(如 constructor、toString 等)。
4. ES5 继承模式
在 class 语法出现之前,开发者使用原型实现继承。主要有以下几种模式。
4.1 原型链继承
直接将父类实例赋值给子类原型。
1 | function Animal(name) { |
缺陷:
- 父类构造函数中的引用类型属性会被所有子类实例共享。
- 无法向父类构造函数传递参数(
Dog.prototype = new Animal()时没有参数)。
4.2 组合继承(伪经典继承)
结合构造函数窃取和原型链继承,解决上述两个问题。
1 | function Animal(name) { |
缺陷:父类构造函数被调用了两次(一次原型继承,一次构造窃取),导致性能浪费和原型上存在多余的属性。
4.3 寄生组合继承(最理想的 ES5 继承)
使用 Object.create 来避免重复调用父类构造函数。
1 | function Animal(name) { |
这是 ES5 下最优雅的继承方式,也是 Babel 转译 ES6 class extends 所采用的模式。
5. ES6 class 语法:原型继承的语法糖
ES6 引入了 class、constructor、extends 和 super 关键字。它们并没有改变 JavaScript 基于原型的本质,只是提供了一种更清晰、更接近传统 OOP 的书写方式。
5.1 类声明与构造函数
1 | class Animal { |
底层真相:class 定义的 eat 方法存在于 Animal.prototype 上,是不可枚举的(enumerable: false),这与传统原型方法不同。constructor 是构造函数本身。
1 | const descriptor = Object.getOwnPropertyDescriptor(Animal.prototype, 'eat'); |
5.2 extends 与 super
1 | class Dog extends Animal { |
super 的两层含义:
- 作为函数调用
super(args):调用父类的构造函数,等价于Animal.call(this, args)。必须在this使用之前调用。 - 作为对象使用
super.method():访问父类原型上的方法。
extends 背后的原型链:
1 | class Animal {} |
这是 class extends 与 ES5 寄生组合继承的主要区别——class 同时建立了实例原型链和构造函数原型链(静态方法继承),而 ES5 继承只关注实例原型链。
5.3 静态方法与静态属性
1 | class MathUtils { |
静态方法和属性直接定义在类(构造函数)自身上,而不在原型上。实例无法访问。
5.4 私有字段(ES2022)
1 | class Counter { |
私有字段 # 是真正的硬隐私,无法在类外部访问或通过原型链获取,这与 TypeScript 的 private 关键字(仅在编译时检查)不同。
6. instanceof 运算符的原理
obj instanceof Constructor 检查 Constructor.prototype 是否出现在 obj 的原型链上。
1 | function myInstanceof(obj, Constructor) { |
由于原型链可被动态修改(不推荐),instanceof 的结果在极少数情况下可能不准确。准确判断对象类型通常使用 Object.prototype.toString.call(obj)。
7. 最佳实践与常见陷阱
7.1 不要直接操作 __proto__
__proto__ 是遗留特性,现代代码应使用 Object.getPrototypeOf 和 Object.setPrototypeOf。直接修改 [[Prototype]] 会导致严重的性能退化,因为引擎会丢弃所有优化过的内联缓存。
7.2 使用 class 还是原型直接操作?
- 新项目:推荐使用
class语法。它清晰、易读、不易出错,且工具链(TypeScript、ESLint)支持更好。 - 遗留代码维护:必须理解原型链,因为大量老代码使用构造函数和原型模式。
- 框架/库开发:部分场景下
Object.create更灵活(如 Vue 3 的响应式系统大量使用Object.create(null)创建纯净对象)。
7.3 for...in 会遍历原型链上的可枚举属性
1 | function Person() {} |
为了避免意外遍历原型属性,始终配合 hasOwnProperty 过滤:
1 | for (const key in alice) { |
或者使用 Object.keys(),它只返回自有可枚举属性。
课后练习
一、概念自测(选择题 / 填空题)
(单选) 关于 JavaScript 中
class的说法,正确的是?
A.class是 JavaScript 的全新类型,不同于函数。
B.class中定义的方法默认是可枚举的。
C.class声明会被提升(类似于函数声明)。
D.class本质上是基于原型继承的语法糖。(单选) 以下代码的输出是?
1
2
3
4
5
6const parent = { value: 1 };
const child = Object.create(parent);
child.value = 2;
console.log(child.value);
delete child.value;
console.log(child.value);A.
2然后undefined
B.2然后1
C.1然后2
D.1然后undefined(填空) 要创建一个没有任何原型(
[[Prototype]]为null)的对象,应使用Object.create(______)。(多选) 以下哪些是
class extends相比 ES5 寄生组合继承的额外特性?
A. 静态方法的继承(Object.getPrototypeOf(Dog) === Animal)。
B. 构造函数体内必须先调用super()才能使用this。
C. 实例方法的不可枚举性。
D. 可以继承Array、Error等内置对象。
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要使用 class 语法实现一个简单的事件发布-订阅模式(EventEmitter)。要求如下:
- 使用
class EventEmitter定义类,内部用私有字段#events存储事件名到回调数组的映射。 - 提供
on(eventName, callback)方法:注册事件监听器,返回取消订阅的函数(调用该函数可移除监听器)。 - 提供
emit(eventName, ...args)方法:触发事件,按注册顺序同步调用监听器。 - 提供
off(eventName, callback)方法:移除指定监听器。 - 提供
once(eventName, callback)方法:注册一次性监听器,触发一次后自动移除。 - 在代码末尾写一个简短的使用示例。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 JavaScript 代码。提示词中需明确指定 class 语法、私有字段的使用、以及各方法的行为。
三、面试真题与参考答案
题目(美团前端面试题):
请手写一个 JavaScript 的寄生组合继承,并详细解释它与 ES6
class extends在原型链建立上的差异。包括instanceof的判断逻辑。
参考答案:
1 | // 寄生组合继承(ES5) |
原型链差异:
- 寄生组合继承仅建立了实例原型链:
dog → Dog.prototype → Animal.prototype → Object.prototype,但Dog和Animal之间没有原型关联(Object.getPrototypeOf(Dog) === Function.prototype)。 class extends不仅建立实例原型链,还建立构造函数之间的原型链:Object.getPrototypeOf(Dog) === Animal,这意味着静态属性和方法也能被继承。class方法自动设为不可枚举,class声明不会提升,且内部默认严格模式。
instanceof 判断逻辑:obj instanceof Constructor 检查 Constructor.prototype 是否在 obj 的原型链上。在两种方式中,dog instanceof Animal 都为 true,因为 Animal.prototype 都在 dog 的原型链上。
课后练习答案
一、概念自测答案
D
- 解析:
class本质上是原型继承的语法糖,底层仍是函数和原型。A 错误,typeof class{} === 'function';B 错误,class 方法默认可枚举为false;C 错误,class 声明不会提升,与let/const行为一致。
- 解析:
B
- 解析:
child.value = 2在自有属性上设置值,输出 2。delete child.value删除自有属性后,child.value通过原型链访问parent.value,得到 1。
- 解析:
null- 解析:
Object.create(null)创建无原型的纯对象,常用于字典或哈希表。
- 解析:
A、B、C、D
- 解析:A 正确,
extends建立了构造函数的原型链用于静态方法继承;B 正确,super()是必须的;C 正确,class 方法不可枚举;D 正确,ES6class extends可以继承内置对象(ES5 寄生组合继承无法完美继承Array等)。
- 解析:A 正确,
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请使用 JavaScriptclass语法实现一个事件发布-订阅类EventEmitter。要求:
- 使用私有字段
#events(#events = new Map())存储事件名到Set<Function>的映射。on(eventName, callback):将 callback 加入#events对应的 Set,返回一个取消订阅的函数(调用该函数即从 Set 中删除该 callback)。emit(eventName, ...args):获取该事件名的 Set,遍历并调用每个 callback 传入...args。off(eventName, callback):从对应 Set 中删除 callback。once(eventName, callback):内部创建一个包装函数,该函数在调用后调用off移除自身,并调用原 callback。将此包装函数加入#events。- 使用
const声明实例,提供使用示例:创建 emitter,注册事件,触发,取消订阅,再次触发验证。- 代码中注释清晰,使用 JSDoc 风格注释类和方法。输出完整代码。”