第四章:对象、原型与继承

第四章:对象、原型与继承

摘要: 在前几章中,我们将对象作为一种数据结构来使用。本章,我们将深入其底层,探索 JavaScript 作为一门面向对象语言的真正基石:原型 (Prototype)。我们将从对象属性的内部特性 (Property Descriptor) 讲起,理解构造函数模式的原理与局限,并最终揭示 原型链 是如何实现继承这一核心概念的。我们还将回顾从古至今各种继承模式的演进,最终落脚于 ES5 时代最优雅的实现方案。学完本章,您将对 JavaScript 的对象系统和继承机制有体系化的、深入的理解。


在本章中,我们将沿着一条从微观到宏观的路径,彻底解构 JavaScript 的对象世界:

  1. 首先,我们将深入一个 对象的内部,了解其属性的底层特性。
  2. 接着,我们将探讨 构造函数 这一批量创建对象的模式及其内存效率问题。
  3. 然后,我们将引出解决方案——原型与原型链,这是理解 JS 继承机制的唯一钥匙。
  4. 在此基础上,我们将回顾并推演 JS 中 经典继承模式的演进历史,理解每种模式解决的问题与引入的新问题。
  5. 最后,我们将学习现代 JS 提供的、用于操作对象和原型的 一系列工具方法

4.1. 对象基础回顾:属性的内部特性

我们通常通过点或方括号来为对象赋值,但这只是表象。在 JavaScript 引擎内部,每个属性都拥有一组 内部特性,它们描述了属性的行为。这些特性被封装在一个名为 属性描述符 的对象中。

属性分为两类:数据属性访问器属性

概念
最常见的属性,直接存储一个值,由 4 个描述符组成:

  • value 默认值
  • writable 是否可改
  • enumerable 是否可枚举
  • configurable 是否可删除/改特性

痛点示例
普通赋值无法做到只读:

1
2
3
4
const user = {};
user.id = 1;
user.id = 2; // 被成功改写
console.log(user.id); // 2

解决方案
使用 Object.defineProperty 设为只读:

1
2
3
4
5
6
7
8
9
10
11
12
const user = {}

Object.defineProperty(user, 'id', {
value: 1,
writable: false,
enumerable: true,
configurable: false
});

console.log('Initial ID:', user.id); // 1
user.id = 2; // 严格模式抛出 TypeError;非严格模式静默失败
console.log('Final ID:', user.id); // 1

概念
不直接存值,而是通过 get / set 函数拦截 读取 / 写入,同样拥有 enumerableconfigurable

痛点示例
需要 fullName 实时反映 firstName + lastName

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const user = {
firstName: 'Prorise',
lastName: 'Blog'
};

Object.defineProperty(user, 'fullName', {
enumerable: true,
configurable: true,
get() {
return `${this.firstName} ${this.lastName}`;
},
set(value) {
const [first, last] = value.split(' ');
this.firstName = first;
this.lastName = last;
}
});

console.log(user.fullName); // "Prorise Blog"

user.lastName = 'Docs';
console.log(user.fullName); // "Prorise Docs"

user.fullName = 'Awesome User';
console.log(user.firstName); // "Awesome"
console.log(user.lastName); // "User"

4.2. 构造函数模式

重要信息: 这本身是面向对象编程中的知识点,但是为了了解原型链,我们必须要了解构造函数

痛点背景: 如果我们需要创建多个结构相同的对象(例如多个用户),使用对象字面量会导致大量重复代码。

1
2
3
const user1 = { name: 'Alice', age: 25 };
const user2 = { name: 'Bob', age: 30 };
// ... 代码非常冗余

解决方案: 使用 构造函数 (Constructor) 模式。构造函数本质上是一个普通的函数,但按照惯例,我们用 new 关键字来调用它,用于“构造”一个新的对象实例。

工作原理: 当你使用 new 关键字调用一个函数时,会自动发生以下四件事:

  1. 在内存中创建一个新的空对象。
  2. 将这个新对象的内部 [[Prototype]] 属性(在代码中通过 __proto__ 访问)指向构造函数的 prototype 属性。
  3. 将构造函数内部的 this 指向这个新对象。
  4. 执行构造函数内部的代码(为新对象添加属性)。
  5. 如果构造函数没有显式返回其他对象,则隐式地返回这个新创建的对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 构造函数,首字母通常大写
function User(name, age) {
// this 指向新创建的实例
this.name = name;
this.age = age;
this.greet = function() {
console.log(`Hello, I am ${this.name}.`);
};
}

const user1 = new User('Alice', 25);
const user2 = new User('Bob', 30);

user1.greet();
user2.greet();

局限性: 构造函数模式虽然解决了代码复用问题,但引入了一个新的 性能问题。每个由 User 构造函数创建的实例,都在内存中拥有一个 全新的、独立的 greet 方法副本。如果有一万个用户实例,就会有一万个功能完全相同的 greet 函数,这造成了极大的内存浪费。

1
console.log(user1.greet === user2.greet); // false,它们是两个不同的函数

4.3. 原型 (prototype) 与原型链 (__proto__)

承上启下: 为了解决构造函数模式中方法无法共享的问题,JavaScript 引入了其继承模型的核心——原型 (Prototype)

核心原理: 在 JavaScript 中,每个函数 在创建时都会自动获得一个 prototype 属性。这个 prototype 属性的值是一个对象,我们称之为 原型对象。这个原型对象的作用就是作为一个公共的存储空间,存放所有需要被该构造函数创建的实例所 共享 的属性和方法。

解决方案: 我们可以将需要共享的方法(如 greet)从构造函数内部移到其 prototype 对象上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function User(name, age) {
this.name = name;
this.age = age;
}

// 将 greet 方法定义在 User 的原型对象上
User.prototype.greet = function() {
console.log(`Hello, I am ${this.name}.`);
};

const user1 = new User('Alice', 25);
const user2 = new User('Bob', 30);

user1.greet();
user2.greet();

// 验证方法是否共享
console.log(user1.greet === user2.greet); // true!

原型链 (Prototype Chain)

那么,user1 实例本身并没有 greet 方法,它是如何调用到 User.prototype.greet 的呢?答案就是 原型链

  1. 每个由构造函数创建的实例对象,都有一个内部属性 [[Prototype]](在代码中通常可以通过非标准的 __proto__ 访问),这个属性指向其构造函数的 prototype 对象。
    user1.__proto__ === User.prototype // true

  2. 当你试图访问一个对象的属性(如 user1.greet)时,JavaScript 引擎会首先在 对象自身 查找该属性。

  3. 如果找不到,引擎就会通过 __proto__ 链接,去其 原型对象 (User.prototype) 上查找。

  4. 如果原型对象上还找不到,而原型对象本身也是一个对象,它也有自己的 __proto__,引擎会继续沿着这条链向上查找,直到找到该属性或到达原型链的顶端 (Object.prototype__proto__ 指向 null)。

这条由 __proto__ 串联起来的对象链条,就是 原型链


4.4. 继承模式的演进

基于原型链,JavaScript 开发者在历史上探索出了多种继承模式,每种模式都试图解决前一种模式的缺陷。正是因为这些方式的不断迭代,后来各大编程语言才推出了 Class 关键字以及 Super 关键字,了解这些演进可以让你对面向对象有更深刻的认知

1. 原型链继承

实现: 将子类的原型直接赋值为父类的一个实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Parent(name) { 
this.parentProp = 'Parent';
this.sharedArray = []; // 引用类型属性
this.name = name || 'default';
}
Parent.prototype.getParentProp = function() { return this.parentProp; };

function Child() { this.childProp = 'Child'; }
Child.prototype = new Parent(); // 核心

const child1 = new Child();
const child2 = new Child();

// 缺点 1:父类实例的引用类型属性被所有子类实例共享
child1.sharedArray.push('item1');
console.log(child1.sharedArray); // ['item1']
console.log(child2.sharedArray); // ['item1'] - 被意外修改了!

// 缺点 2:无法向父类构造函数传递参数
console.log(child1.name); // 'default' - 无法传递自定义 name 参数
console.log(child1.getParentProp()); // 'Parent'
  • 优点: 简单,直观地利用了原型链。
  • 缺点:
    1. 父类实例的属性(parentProp)变成了子类原型的一部分,会被所有子类实例共享。如果这个属性是引用类型(如数组),一个实例的修改会影响所有其他实例。
    2. 创建子类实例时,无法向父类构造函数传递参数。

2. 构造函数窃取

实现: 在子类构造函数中,使用 .call().apply() 调用父类构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Parent(name) { this.name = name; this.colors = ['red']; }
Parent.prototype.sayHello = function () { return `Hello, I'm ${this.name}`; }; // 添加原型方法

function Child(name) {
Parent.call(this, name); // 核心
}

const child1 = new Child('c1');
const parent = new Parent();
child1.colors.push('blue');


console.log(child1.name, child1.colors); // c1, ['red', 'blue']
console.log(parent.colors); // 优点:他的属性没有被影响 [ 'red' ]
console.log(child1.sayHello); // undefined - 缺点:无法访问父类原型方法
  • 优点: 解决了原型链继承的两个主要缺点:可以向父类传参,且父类实例属性不会被共享。
  • 缺点: 父类原型上的方法没有被继承。子类实例无法访问 Parent.prototype 上的方法。

3. 组合继承

实现: 结合以上两种模式,用构造函数窃取继承实例属性,用原型链继承原型方法。

1
2
3
4
5
6
7
8
9
10
11
12
function Parent(name) { this.name = name; }
Parent.prototype.sayName = function() { console.log(this.name); };

function Child(name, age) {
Parent.call(this, name); // 第一次调用 Parent
this.age = age;
}
Child.prototype = new Parent(); // 第二次调用 Parent
Child.prototype.constructor = Child; // 修复 constructor 指向

const child = new Child('c1', 10);
child.sayName(); // 'c1'
  • 优点: 解决了之前两种模式的所有问题,是 JavaScript 中最常用的继承模式之一。
  • 缺点: 父类构造函数被调用了两次:一次在 Parent.call,一次在 new Parent()。这会创建一份多余的父类实例属性在子类原型上,造成了轻微的性能浪费,且还需要频繁的修复 constructor 指向

4. 寄生组合式继承

实现: 这是对组合继承的优化,被认为是 ES6 class 出现之前最理想的继承方案。它通过 Object.create() 来避免调用第二次父类构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Parent(name) { this.name = name; }
Parent.prototype.sayName = function() { console.log(this.name); };

function Child(name, age) {
Parent.call(this, name); // 继承属性
this.age = age;
}

// 核心优化:
// 1. 创建一个父类原型的副本
// 2. 增强这个副本
// 3. 将增强后的副本赋值给子类原型
const parentPrototype = Object.create(Parent.prototype);
parentPrototype.constructor = Child;
Child.prototype = parentPrototype;

const child = new Child('c1', 10);
child.sayName();
  • 优点: 高效。只调用一次父类构造函数,并且原型链保持完整,但这样的代码并没有办法称得上优雅

4.5. Object 的静态方法:操作对象的标准化工具箱

在上一节,我们历经了从“原型链”到“寄生组合式继承”的漫长探索。这个过程虽然揭示了 JavaScript 继承的本质,但也暴露了一个问题:开发者长期以来依赖一些非标准(如 __proto__)或较为繁琐的模式来操作原型。

为了解决这一问题,ES5 和 ES6 正式标准化了一套静态方法,它们如同一个官方提供的“瑞士军刀”,为我们提供了更安全、更可靠、更清晰的方式来与对象及其底层原型进行交互。

主题一:原型链的标准化操作

这些方法是直接针对我们刚刚探讨的原型继承问题的官方解决方案。

方法核心作用解决的问题
Object.create(proto)创建一个新对象,其原型指向 proto在实现继承时,需要一个干净继承原型、但又不执行父构造函数的中间对象。
Object.getPrototypeOf(obj)读取 一个对象的 [[Prototype]]长期依赖非标准的 __proto__ 属性来访问原型,存在兼容性风险。
Object.setPrototypeOf(obj, proto)写入 或修改一个已存在对象的 [[Prototype]]过去没有标准方法来动态修改一个对象的原型链。

这三个方法构成了现代 JavaScript 中直接操作对象 [[Prototype]](即对象原型)的标准 API。它们分别用官方、可靠的方式解决了原型继承、读取和写入这三个核心场景下的历史痛点,取代了过去非标准或有副作用的实现技巧。

尤其需要注意的是 Object.setPrototypeOf,在对象创建后修改其原型是一个对性能极不友好的操作,会破坏 JavaScript 引擎的内部优化,应在开发中极力避免使用。

主题二:属性的遍历与枚举

历史痛点: for...in 循环是遍历对象属性的传统方式,但它存在两个主要问题:1. 它会遍历到原型链上可枚举的属性;2. 必须配合 hasOwnProperty() 才能过滤出对象自身的属性,写法繁琐。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 痛点演示
function Parent() { this.parentProp = 'Parent'; }
const p = new Parent();

const obj = Object.create(p);
obj.ownProp = 'Own';

for (const key in obj) {
// 如果不加这个判断,'parentProp' 也会被打印出来
if (obj.hasOwnProperty(key)) {
console.log(key); // ownProp
}
}

解决方案: ES5/ES6 提供了一系列方法,用于精确地获取对象 自身的、可枚举的 属性,让遍历更安全、更直接。

  • Object.keys(obj): 返回一个由对象 自身可枚举属性名 组成的数组。
  • Object.values(obj): 返回一个由对象 自身可枚举属性值 组成的数组。
  • Object.entries(obj): 返回一个由对象 自身可枚举键值对 [key, value] 组成的二维数组。
1
2
3
4
5
6
const user = { name: 'Prorise', role: 'Admin' };
Object.defineProperty(user, 'id', { value: 1, enumerable: false }); // id 不可枚举

console.log(Object.keys(user));
console.log(Object.values(user));
console.log(Object.entries(user));

这些方法返回的是 真数组,可以无缝衔接使用 forEach, map, filter 等高阶数组方法,是现代 JavaScript 中遍历对象的首选方式。

主题三:对象的合并与克隆

历史痛点: 在 ES6 之前,要合并多个对象的属性,需要手动遍历源对象并逐一赋值给目标对象,代码冗长。

解决方案: Object.assign() 与现代的扩展运算符 ...

  • Object.assign(target, ...sources): 将一个或多个源(sources)对象中所有 可枚举的自有属性 复制到目标(target)对象。它会修改并返回 target 对象。这是一个 浅拷贝
  • 扩展运算符 ... (Spread Syntax): ES6 引入的语法糖,是目前 创建合并后新对象 的首选方式,语法更简洁,意图更清晰。

需求
不修改任何原始对象,而是生成一个全新的对象。

1
2
3
4
5
6
7
8
9
10
const defaults   = { theme: 'dark', version: '1.0' };
const userConfig = { version: '1.2', showAvatar: true };

// 扩展运算符(更简洁,社区首选)
const finalSpread = { ...defaults, ...userConfig };
console.log('Spread:', finalSpread);

// Object.assign(需传入空对象)
const finalAssign = Object.assign({}, defaults, userConfig);
console.log('Assign:', finalAssign);

二者结果相同,但 ... 可读性更高;Object.assign 必须第一个参数是空对象 {} 才能创建新副本。

需求
显式修改已存在的对象。

1
2
3
4
5
6
7
const user       = { name: 'Prorise' };
const permissions = { canEdit: true, canDelete: false };

// 直接在 user 上合并属性
Object.assign(user, permissions);

console.log('Mutated user:', user); // Mutated user: { name: 'Prorise', canEdit: true, canDelete: false }

扩展运算符无法原地修改。user = { ...user, ...permissions }; 会创建新对象并替换引用,而非修改原始对象。


4.6. 本章核心原理与课后小结

核心原理速查

概念核心原理关键点
属性描述符每个属性都有 value, writable, enumerable, configurable 等内部特性。Object.defineProperty() 是控制属性行为的底层工具。
构造函数通过 new 调用,用于批量创建实例。this 指向新实例,但方法不共享导致内存浪费。
原型 prototype函数的属性,一个对象,用于存放共享成员。解决了构造函数方法不共享的问题。
原型链 __proto__实例指向其构造函数原型的链接。属性查找沿着此链向上进行,是 JS 继承的根本。
继承方案寄生组合式继承(ES5 最佳实践) 通过 .call() 继承属性,通过 Object.create() 继承原型,避免了父构造函数的二次调用。

课后小结

面试官深度追问
2025-08-27

你能解释一下 __proto__prototype 的区别和联系吗?

当然。prototype 是函数特有的一个属性,它指向一个对象,即原型对象。这个原型对象的作用是让所有由该函数作为构造函数创建出来的实例,都能共享 prototype 对象上的属性和方法。

__proto__ 则是每个对象(包括函数)都有的一个内部属性,它指向该对象的原型。对于一个实例对象来说,它的 __proto__ 指向创建它的那个构造函数的 prototype

它们的联系就是,实例通过 __proto__ 这条链,找到了构造函数的 prototype,从而实现了属性和方法的继承。可以说,__proto__ 是原型链中实际的链接,而 prototype 是构造函数用来“播种”这条链的起点。

非常清晰。那么,Object.create(null) 创建出来的对象有什么特殊之处?

Object.create(null) 创建出来的是一个纯粹的空对象。它没有任何原型,即它的 __proto__null

这意味着它不继承自 Object.prototype,因此它身上没有任何内置的对象方法,比如 toString(), hasOwnProperty() 等。这让它成为一个非常干净的、用作数据字典(哈希表)的理想选择,可以完全避免与原型链上的属性发生意外冲突。