首页 阅读DAY11 JavaScript高级程序设计 8章中 对象、类与面向对象编程
文章
取消

阅读DAY11 JavaScript高级程序设计 8章中 对象、类与面向对象编程

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否

创建对象:

虽然使用Object构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。

概述:

综观ECMAScript规范的历次发布,每个版本的特性似乎都出人意料。ECMAScript 5.1并没有正式支持面向对象的结构,比如类或继承。但是,正如接下来几节会介绍的,巧妙地运用原型式继承可以成功地模拟同样的行为

ECMAScript 6开始正式支持类和继承。ECMAScript的类旨在完全涵盖之前规范设计的基于原型的继承模式。不过,无论从哪方面看,类都仅仅是封装了ES5.1构造函数加原型继承的语法糖而已。

编写面向对象编程模式的JavaScript代码还是应该使用ECMAScript类。但不管怎么说,理解ES6类出现之前的惯例总是有益无害的。特别是ECMAScript类定义本身就相当于对原有结构的封装。因此,在介绍类之前,本书会循序渐进地介绍被类取代的那些底层概念。

构造函数模式:

前几章提到过,ECMAScript中的构造函数是用于创建特定类型对象的。像Object和Array这样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

来看一个使用构造函数模式的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person(name, age, job){
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function() {
    console.log(this.name);
  };
}

let person1 = new Person("Alice", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");

person1.sayName();  // Alice
person2.sayName();  // Greg

如果不用new,this会指向window:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Person(name) {
  console.log(this);  // window (全局对象)
  this.name = name;
  // 没有返回值
}

let p2 = Person("Bob");
console.log(p2);  // undefined
console.log(window.name);  // Bob (污染了全局)
console.log(p2 instanceof Person);  // false


// 当我们写 new Person() 时,JavaScript 底层做了这些事:
function Person(name) {
  // 1. 隐式创建一个新对象:let this = {}
  // 2. 设置原型:this.__proto__ = Person.prototype
  // 3. 执行函数体
  this.name = name;
  // 4. 隐式返回 this
  // return this;
}

对于这个例子,要注意以下几点:

​ 没有显式地创建对象。

​ 属性和方法直接赋值给了this。

​ 没有return。

另外,要注意函数名Person的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。这是从面向对象编程语言那里借鉴的,有助于在ECMAScript中区分构造函数和普通函数。毕竟ECMAScript的构造函数就是能创建对象的函数。

要创建Person的实例,应使用new操作符。以这种方式调用构造函数会执行如下操作。

(1)在内存中创建一个新对象。

(2)这个新对象内部的 [[Prototype]] 特性被赋值为构造函数的prototype属性。

(3)构造函数内部的this被赋值为这个新对象(即this指向新对象)。

(4)执行构造函数内部的代码(给新对象添加属性)。

(5)如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

上一个例子的最后,person1和person2分别保存着Person的不同实例。这两个对象都有一个constructor属性指向Person,如下所示:

console.log(person1.constructor == Person);  // true
console.log(person2.constructor == Person);  // true

constructor本来是用于标识对象类型的。不过,一般认为instanceof操作符是确定对象类型更可靠的方式。前面例子中的每个对象都是Object的实例,同时也是Person的实例,如下面调用instanceof操作符的结果所示:

constructor属性是每个对象通过原型链继承的属性,它指向创建该对象的构造函数。

1
2
3
4
console.log(person1 instanceof Object);  // true
console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Object);  // true
console.log(person2 instanceof Person);  // true

定义自定义构造函数可以确保实例被标识为特定类型。在这个例子中,person1和person2之所以也被认为是Object的实例,是因为所有自定义对象都继承自Object(后面再详细讨论这一点)。

构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以作为构造函数:

任何函数(除了箭头函数)都可以作为构造函数,只要它被设计用来与 new关键字配合使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let Person = function(name, age, job) {
    // 和最一开始function Person(name, age, job)的例子相比
    // 最大区别是let Person = function(name, age, job)不会被提升
    // 任何函数(除了箭头函数)都可以作为构造函数,只要它被设计用来与 new关键字配合使用
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function() {
    console.log(this.name);
  };
}

let person1 = new Person("Alice", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");

person1.sayName();  // Alice
person2.sayName();  // Greg

console.log(person1 instanceof Object);  // true
console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Object);  // true
console.log(person2 instanceof Person);  // true

在实例化时,如果不想传参数,那么构造函数后面的括号可加可不加。只要有new操作符,就可以调用相应的构造函数:

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

let person1 = new Person();
let person2 = new Person; // 可以不加

person1.sayName();  // Jake
person2.sayName();  // Jake

console.log(person1 instanceof Object);  // true
console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Object);  // true
console.log(person2 instanceof Person);  // true

构造函数也是函数:

构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。并没有把某个函数定义为构造函数的特殊语法。

任何函数只要使用new操作符调用就是构造函数,而不使用new操作符调用的函数就是普通函数。比如,前面的例子中定义的Person()可以像下面这样调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 作为构造函数
let person = new Person("Alice", 29, "Software Engineer");
person.sayName();    // "Alice"

// 作为函数调用
Person("Greg", 27, "Doctor");   // 添加到window对象
window.sayName();    // "Greg"

// 在另一个对象的作用域中调用
let o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName();   // "Kristen"



// call 方法的语法
function.call(thisArg, arg1, arg2, ...)

// 作用:调用函数,并显式指定 this 的值
// 1. thisArg:函数执行时 this 的指向
// 2. arg1, arg2, ...:传递给函数的参数

这个例子展示了典型的构造函数调用方式,即使用new操作符创建一个新对象。然后是普通函数的调用方式,这时候没有使用new操作符调用Person(),结果会将属性和方法添加到window对象。

这里要记住,在调用一个函数而没有明确设置this值的情况下(即没有作为某个对象的方法调用,或者没有使用call()/apply()调用),this始终指向Global对象(在浏览器中就是window对象)。因此在上面的调用之后,window对象上就有了一个sayName()方法,调用它会返回 “Greg”。

最后展示的调用方式是通过call()(或apply())调用函数,同时将特定对象指定为作用域。这里的调用将对象o指定为Person()内部的this值,因此执行完函数代码后,所有属性和sayName()方法都会添加到对象o上面。步骤可以解释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 第一步:创建一个空对象
let o = {};  // 等价于 new Object()

// 第二步:通过 call 调用 Person 函数
Person.call(o, "Kristen", 25, "Nurse");

// 这相当于手动执行了以下代码:
o.name = "Kristen";
o.age = 25;
o.job = "Nurse";
o.sayName = function() {
  console.log(this.name);  // 这里的 this 指向 o
};

构造函数的问题:

构造函数虽然有用,但有一个核心问题:方法在每个实例上都创建一遍。 每个实例的 sayName 看起来是同一个函数,实际上各自都是一个新的 Function 实例:

1
2
3
4
5
6
7
8
function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = new Function("console.log(this.name)"); // 逻辑等价
}

console.log(person1.sayName == person2.sayName); // false

函数是对象,每次定义都会初始化一个新实例。两个实例做的是同样的事,没必要创建两个不同的 Function。

关键在于内存浪费。每次 new Person() 都会创建一个新的 Function 对象,每个 Function 对象都要占用独立的内存空间。1000 个实例就有 1000 个一模一样的 sayName 函数,但它们做的事情完全相同。

解决思路及其新问题:

既然方法逻辑相同,可以把函数定义移到构造函数外部,让所有实例共享同一个函数引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = sayName; // 只保存指向外部函数的指针
}

function sayName() {
  console.log(this.name);
}

let person1 = new Person("Alice", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Alice
person2.sayName(); // Greg

方法不再重复创建了,但带来了新问题:sayName 被定义在了全局作用域,却只能被 Person 实例调用。如果对象需要多个方法,就要在全局定义多个函数,自定义类型的代码被拆散到各处,无法聚拢。

构造函数的两难:方法放内部 → 每个实例重复创建;移到外部 → 污染全局作用域。这个困境,正是原型模式要解决的。

原型模式:

每个函数都会创建一个 prototype 属性,它是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。在原型上定义的属性和方法可以被所有实例共享,解决了构造函数模式中方法重复创建的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Person() {}

Person.prototype.name = "Alice";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
  console.log(this.name);
};

let person1 = new Person();
person1.sayName(); // "Alice"

let person2 = new Person();
person2.sayName(); // "Alice"

console.log(person1.sayName == person2.sayName); // true

与构造函数模式的区别:构造函数中属性和方法直接赋给实例,每个实例各有一份;原型模式中,属性和方法定义在 prototype 上,所有实例共享同一份。构造函数体可以为空,但新对象仍然拥有对应的属性和方法。

原型模式的核心:不在实例上重复创建,而是把共享内容放到原型对象上,所有实例通过原型链访问同一份属性和方法。要理解这个过程,就需要理解 ECMAScript 中原型的本质。

理解原型:

无论何时创建一个函数,就会按特定规则为它创建 prototype 属性,指向原型对象。默认情况下,所有原型对象自动获得一个 constructor 属性,指回与之关联的构造函数,即 Person.prototype.constructor === Person。除此之外,原型对象的其他方法都继承自 Object。

原型对象就是 prototype 属性指向的那个对象。

1
function Person() {}

写完这一行,引擎自动做了这件事:

1
2
3
4
// 引擎内部自动创建
Person.prototype = {
  constructor: Person // 默认唯一的自有属性,指回构造函数
};

原型对象用来存放所有实例共享的属性和方法。名字叫”原型对象”是因为它是实例的原型(模板),实例通过 [[Prototype]] 链接到它,从而访问上面定义的属性和方法。

此外,箭头函数没有 prototype

每次调用构造函数创建新实例,实例内部会有一个指针指向构造函数的原型对象。在 ECMA-262 规范中,这个指针叫 [[Prototype]],脚本中不能直接访问,但浏览器在每个对象上暴露了 __proto__ 属性来访问原型。

实例与构造函数的原型之间有直接联系,但实例与构造函数之间没有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Person (构造函数)
 │
 ├── prototype ──→ Person.prototype (原型对象)
 │ │
 │ ├── constructor ──→ Person
 │ ├── name: "Alice"
 │ └── sayName()
 │
 └── new Person()
 │
 ├── person1
 │ └── [[Prototype]] ──→ Person.prototype
 │
 └── person2
 └── [[Prototype]] ──→ Person.prototype

实例通过 [[Prototype]] 指向原型对象,原型对象通过 constructor 指回构造函数,但实例和构造函数之间没有直接链接。

这种关系不好可视化,但我们可以通过下面的代码片段表格来理解构造函数、原型与实例的关系。

image-20260501000057482

对于前面例子中的Person构造函数和Person.prototype,可以通过图8-1看出各个对象之间的关系。

构造函数、原型对象和实例。

image-20260501000121234

上图展示了Person构造函数、Person的原型对象和Person现有两个实例之间的关系。

注意,Person.prototype指向原型对象,而Person.prototype.contructor指回Person构造函数。原型对象包含constructor属性和其他后来添加的属性。Person的两个实例person1和person2都只有一个内部属性指回Person.prototype,而且两者都与构造函数没有直接联系。

另外要注意,虽然这两个实例都没有属性和方法,但person1.sayName()可以正常调用。这是由于对象属性查找机制的原因。

虽然 [[Prototype]] 不能直接访问,但 ECMAScript 提供了几个 API 来检测和操作原型关系。

isPrototypeOf():判断一个对象是否是另一个对象的原型。本质上,如果传入参数的 [[Prototype]] 指向调用它的对象,就返回 true:

1
2
console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Person.prototype.isPrototypeOf(person2)); // true

person1 和 person2 内部都有链接指向 Person.prototype,所以结果都返回 true。

Object.getPrototypeOf():返回参数的 [[Prototype]] 值,即其原型对象:

1
2
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
console.log(Object.getPrototypeOf(person1).name); // "Alice"

第一行确认了 getPrototypeOf 返回的就是传入对象的原型对象,第二行则取得了原型对象上 name 属性的值。这个方法在通过原型实现继承时尤为重要(本章后面会介绍)。

Object.setPrototypeOf():修改已有对象的 [[Prototype]],重写其原型继承关系:

1
2
3
4
5
6
7
let biped = { numLegs: 2 };
let person = { name: 'Matt' };

Object.setPrototypeOf(person, biped);

console.log(person.numLegs); // 2
console.log(Object.getPrototypeOf(person) === biped); // true

性能警告:Object.setPrototypeOf() 会严重影响代码性能。修改继承关系的影响不仅是执行这条语句本身,而是涉及所有访问被修改过 [[Prototype]] 的对象的代码。Mozilla 文档明确指出这种影响是微妙且深远的。

Object.create():为避免使用Object.setPrototypeOf()可能造成的性能下降,可以通过Object.create()来创建一个新对象,同时为其指定原型:

1
2
3
4
5
6
let biped = { numLegs: 2 };
let person = Object.create(biped);
person.name = 'Matt';

console.log(person.numLegs); // 2
console.log(Object.getPrototypeOf(person) === biped); // true

需要指定原型时优先用 Object.create(),尽量避免在运行时用 setPrototypeOf() 修改已有对象的原型。

原型层级:

通过对象访问属性时,搜索过程是:先查实例本身,再查原型对象。

person1.sayName() 为例,引擎先问”person1 实例上有 sayName 吗?”没有,再问”person1 的原型上有 sayName 吗?”有,返回该函数。

这就是原型能在多个实例间共享属性和方法的原理:实例自身没有的属性,沿着 [[Prototype]] 到原型上找。

属性遮盖:

虽然可以通过实例读取原型上的值,但不能通过实例重写原型的值。在实例上添加与原型同名的属性,会在实例上创建该属性,遮盖(shadow)原型上的同名属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person() {}
Person.prototype.name = "Alice";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() { console.log(this.name); };

let person1 = new Person();
let person2 = new Person();

person1.name = "Greg";

console.log(person1.name); // "Greg",来自实例,找到即停,不再搜索原型
console.log(person2.name); // "Alice",来自原型,实例上没有,继续搜索原型

遮盖只是屏蔽了对原型属性的访问,不会修改原型上的值。即使在实例上把属性设为 null,也不会恢复与原型的联系,因为实例上仍然存在该属性,只是值为 null,搜索在实例这一层就停了。

只有用 delete 彻底删除实例上的属性,才能重新访问到原型上的同名属性:

1
2
delete person1.name;
console.log(person1.name); // "Alice",重新来自原型

区分属性来源:hasOwnProperty()

那怎么知道一个属性是实例自己的还是来自原型?

hasOwnProperty()(继承自 Object)可以判断:只有属性存在于调用它的对象实例上时才返回 true

1
2
3
4
5
6
7
8
9
let person1 = new Person();

console.log(person1.hasOwnProperty("name")); // false,实例上还没有

person1.name = "Greg";
console.log(person1.hasOwnProperty("name")); // true,现在在实例上了

delete person1.name;
console.log(person1.hasOwnProperty("name")); // false,实例属性已删除

下图形象地展示了上面例子中各个步骤的状态。(为简单起见,图中省略了Person构造函数。)

image-20260501010721105

Object.hasOwn()方法是Object.prototype.hasOwnProperty()的替代简写方法。因此下面两行代码是等价的:

1
2
person.hasOwnProperty("name");
Object.hasOwn(person, "name");

ECMAScript的Object.getOwnPropertyDescriptor()方法只对实例属性有效。要取得原型属性的描述符,必须直接在原型对象上调用Object.getOwnPropertyDescriptor()。

原型和in操作符:

in 操作符有两种使用方式:单独使用和在 for-in 循环中使用。

单独使用时,只要能通过对象访问到指定属性就返回 true,无论该属性在实例上还是原型上。这和 hasOwnProperty() 形成了互补,hasOwnProperty() 只认实例,in 不区分来源:

1
2
3
4
5
6
7
8
9
let person1 = new Person(); // 原型上有 name

console.log(person1.hasOwnProperty("name")); // false,不在实例上
console.log("name" in person1); // true,可以通过实例访问

person1.name = "Greg"; // 在实例上遮盖

console.log(person1.hasOwnProperty("name")); // true,现在在实例上了
console.log("name" in person1); // true,依然可以访问

既然 hasOwnProperty() 只认实例、in 不区分来源,那两者组合就能判断属性是否只存在于原型上:

1
2
3
function hasPrototypeProperty(object, name) {
 return !object.hasOwnProperty(name) && (name in object);
}

in 返回 true(属性可访问)且 hasOwnProperty 返回 false(不在实例上)→ 属性一定在原型上。实例上遮盖后,hasOwnProperty 变为 true,hasPrototypeProperty 就返回 false,即使原型上还有这个属性,但已经被遮盖,不再是原型属性在生效了。

for-in 循环中,所有可通过对象访问且可枚举的属性都会返回,包括实例属性和原型属性。遮盖原型中不可枚举属性的实例属性也会被返回,因为手动定义的属性默认可枚举。

属性枚举的 API:

for-in 循环虽然方便,但有时需要更精确的控制。ECMAScript 提供了三个方法,按需选择:

方法范围可枚举性
Object.keys(obj)自有属性仅可枚举
Object.getOwnPropertyNames(obj)自有属性包含不可枚举(如 constructor)
Object.getOwnPropertySymbols(obj)自有符号键属性包含不可枚举
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 原型上的可枚举自有属性
Object.keys(Person.prototype); // ["name","age","job","sayName"]

// 原型上的所有自有属性,注意 constructor 也出来了
Object.getOwnPropertyNames(Person.prototype); // ["constructor","name","age","job","sayName"]

// 实例上的可枚举自有属性
let p1 = new Person();
p1.name = "Rob"; p1.age = 31;
Object.keys(p1); // ["name","age"]

// 符号键属性,字符串键的方法拿不到符号键,需要专用的 API
let k1 = Symbol('k1'), k2 = Symbol('k2');
let o = { [k1]: 'k1', [k2]: 'k2' };
Object.getOwnPropertySymbols(o); // [Symbol(k1), Symbol(k2)]

属性枚举顺序:

for-in循环、Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()以及Object.assign()在属性枚举顺序方面有很大区别。for-in循环和Object.keys()的枚举顺序是不确定的,取决于JavaScript引擎,可能因浏览器而异

Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序(属性被添加到对象上的先后顺序)枚举字符串键和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let k1 = Symbol('k1'),
    k2 = Symbol('k2');

let o = {
  1: 1,
  first: 'first',
  [k1]: 'sym2',
  second: 'second',
  0: 0
};

o[k2] = 'sym2';
o[3] = 3;
o.third = 'third';
o[2] = 2;

console.log(Object.getOwnPropertyNames(o));
// ["0", "1", "2", "3", "first", "second", "third"]

console.log(Object.getOwnPropertySymbols(o));
// [Symbol(k1), Symbol(k2)]

对象迭代:

静态方法Object.values()和Object.entries()用于将对象内容转换为序列化且可迭代的格式。这两个方法都接收对象,返回数组。Object.values()返回对象值的数组,Object.entries()返回键-值对的数组。

1
2
3
4
5
6
7
8
9
10
11
const o = {
  foo: 'bar',
  baz: 1,
  qux: {}
};

console.log(Object.values(o));
// ["bar", 1, {}]

console.log(Object.entries((o)));
// [["foo", "bar"], ["baz", 1], ["qux", {}]]

注意,非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制:

1
2
3
4
5
6
7
8
9
const o = {
  qux: {}
};

console.log(Object.values(o)[0] === o.qux);
// true

console.log(Object.entries(o)[0][1] === o.qux);
// true,即使只有一个键值对,返回的也是一个二维数组

符号属性会被忽略:

1
2
3
4
5
6
7
8
9
10
const sym = Symbol();
const o = {
  [sym]: 'foo'
};

console.log(Object.values(o));
// []

console.log(Object.entries((o)));
// []

ECMAScript也提供了静态方法Object.fromEntries(),可以基于键-值对的集合构建对象。这个方法执行与Object.entries()相反的操作,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
  foo: 'bar',
  baz: 'qux'
};

const objEntries = Object.entries(obj);

console.log(objEntries);
// [["foo", "bar"], ["baz", "qux"]]

console.log(Object.fromEntries(objEntries));
// { foo: "bar", baz: "qux" }

这个静态方法的参数是一个可迭代对象,包含任意个数大小为2的可迭代对象。在需要把Map实例转换为Object实例时,这个方法非常方便。因为Map迭代器的输出恰好与fromEntries()参数的签名完全匹配:

1
2
3
4
const map = new Map().set('foo', 'bar');

console.log(Object.fromEntries(map));
// { foo: "bar" }

原型的动态性:

实例与原型之间是指针链接而非副本,这意味着实例并不保存原型属性的快照,而是在访问时沿指针动态搜索。所以随时给原型追加属性和方法,会立即反映在所有实例上,即使实例在修改之前就已创建:

1
2
3
4
5
6
7
let friend = new Person();

Person.prototype.sayHi = function() {
  console.log("hi");
};

friend.sayHi(); // "hi"——没问题

调用 friend.sayHi() 时,实例上找不到,沿指针搜索原型,找到了新添加的方法。能访问到,是因为追加修改没有改变 Person.prototype 这个对象本身,只是在它上面加了属性,而 friend 的 [[Prototype]] 指针还指向它。

虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但重写整个原型是另一回事。实例的 [[Prototype]] 指针在调用构造函数时自动赋值,指向的是当时的原型对象。重写原型等于创建了一个新对象替换掉 Person.prototype,但已有实例的指针不会跟着变,它还指向旧原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person() {}

let friend = new Person(); // friend.[[Prototype]] → 旧原型对象

// 重写整个原型——Person.prototype 现在指向一个全新对象
Person.prototype = {
  constructor: Person,
  name: "Alice",
  age: 29,
  job: "Software Engineer",
  sayName() {
    console.log(this.name);
  }
};

// friend.[[Prototype]] 仍然 → 旧原型对象(只有默认 constructor)
friend.sayName(); // 错误!旧原型上没有 sayName

在这个例子中,Person的新实例是在重写原型对象之前创建的。在调用friend.sayName()的时候,会导致错误。这是因为firend指向的原型还是最初的原型,而这个原型上并没有sayName属性。

下图展示了这里面的原因。

image-20260501120807835

重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。

原生对象原型:

原型模式不仅用于自定义类型,所有原生引用类型(Object、Array、String 等)的实例方法也都定义在原型上:

1
2
console.log(typeof Array.prototype.sort); // "function"
console.log(typeof String.prototype.substring); // "function"

通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法

既然是原型,就可以像修改自定义对象原型一样给原生类型添加方法。比如给 String 添加一个 startsWith()

1
2
3
4
5
6
String.prototype.startsWith = function(text) {
  return this.indexOf(text) === 0;
};

let msg = "Hello world!";
console.log(msg.startsWith("Hello")); // true

msg 是原始字符串,读取属性时后台会自动创建 String 包装实例,从而在原型上找到并调用 startsWith()。定义在 String.prototype 上,当前环境下所有字符串都能使用。

不推荐在生产环境中修改原生对象原型。因为可能造成命名冲突(某方法在一个浏览器中不存在、另一个中已存在),也可能意外重写原生方法。推荐做法是创建自定义类继承原生类型。

原型的问题:

原型模式有两个问题:一是弱化了构造函数传参能力,所有实例默认取得相同的属性值,但这还不是最致命的;二是引用值属性的共享问题,这才是原型的最大问题。

弱化构造函数传参能力的意思是构造函数是空的,创建实例时无法传入不同的初始值。

构造函数模式可以在创建时传参,每个实例有不同的初始值:

1
2
3
4
5
6
7
function Person(name, age) {
 this.name = name;
 this.age = age;
}

let person1 = new Person("Alice", 29);
let person2 = new Person("Bob", 30);

原型模式的构造函数是空的,属性全定义在原型上:

1
2
3
4
5
6
7
8
9
10
function Person() {} // 没有参数

Person.prototype = {
 name: "Alice", // 所有实例共享同一个值
 age: 29,
 ...
};

let person1 = new Person(); // 没法传参,只能拿到原型的默认值
let person2 = new Person(); // 一样

创建时无法做到 new Person("Bob", 30),因为构造函数根本不接收参数。想区分实例,只能在创建之后手动覆写:

1
2
3
let person1 = new Person();
person1.name = "Bob"; // 创建后再改
person1.age = 30;

原型上的属性在实例间共享,函数类型没问题,原始值属性也还好(可以通过实例同名属性遮盖)。但引用值属性就出问题了,实例修改的是同一个引用,所有实例都会受影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Person() {}

Person.prototype = {
  constructor: Person,
  name: "Alice",
  age: 29,
  job: "Software Engineer",
  friends: ["Shelby", "Court"],
  sayName() {
    console.log(this.name);
  }
};

let person1 = new Person();
let person2 = new Person();

person1.friends.push("Van");

console.log(person1.friends); // "Shelby,Court,Van"
console.log(person2.friends); // "Shelby,Court,Van",person2也被改了
console.log(person1.friends === person2.friends); // true,指向同一个数组

friends 存在于 Person.prototype 上,person1 和 person2 指向同一个数组。person1.push 修改了那个数组,person2 也看到了变化。除非有意在实例间共享引用值,否则不同实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。

原型继承:

继承是面向对象编程中讨论最多的话题

很多面向对象语言支持两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法

接口继承在ECMAScript中是不可能的,因为函数没有签名。实现继承是ECMAScript唯一支持的继承方式,而这主要是通过原型链实现的

原型链:

ECMA-262 把原型链定义为 ECMAScript 的主要继承方式,核心思想是通过原型继承多个引用类型的属性和方法。

先回顾构造函数、原型和实例的关系:构造函数有 prototype 指向原型,原型有 constructor 指回构造函数,实例有 [[Prototype]] 指向原型。这是一层关系。

关键问题来了,如果原型本身是另一个类型的实例呢? 那这个原型就有一个 [[Prototype]] 指向另一个原型,而那个原型又指向另一个构造函数。实例 → 原型 → 原型的原型,层层向上,这就是原型链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function SuperType() {
  this.property = true;
}

SuperType.prototype.getSuperValue = function() {
  return this.property;
};

function SubType() {
  this.subproperty = false;
}

// 继承 SuperType,关键一步
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function() {
  return this.subproperty;
};

let instance = new SubType();
console.log(instance.getSuperValue()); // true

关键一步是 SubType.prototype = new SuperType():用 SuperType 的实例替换了 SubType 原来的原型。这样一来,SubType.prototype 既是 SuperType 的实例(拥有 property 属性),其 [[Prototype]] 又指向 SuperType.prototype(拥有 getSuperValue 方法)。之后再给 SubType.prototype 添加新方法,注意必须在替换原型之后添加,否则会被重写覆盖。

搜索 instance.getSuperValue() 的过程:实例上没有 → SubType.prototype(SuperType 实例)上没有 → SuperType.prototype 上找到了。

这正是原型链在起作用,逐层向上搜索,直到找到属性或到达链的末端。函数及其对应的原型之间的关系。

原型链的本质就是原型的原型,通过让一个构造函数的原型等于另一个类型的实例,从而沿着 [[Prototype]] 逐层向上搜索,实现继承。

下图展示了子类的实例与两个构造函数及其对应的原型之间的关系。

image-20260501150848031

继承的关键在于 SubType 用 SuperType 的实例替换了默认原型。作为 SuperType 的实例,SubType.prototype 的 [[Prototype]] 自然指向 SuperType.prototype,链条由此形成:

1
instance → SubType.prototype(SuperType实例) → SuperType.prototype

注意属性分布的位置:getSuperValue() 在 SuperType.prototype 上,而 property 在 SubType.prototype 上。因为 getSuperValue 是原型方法,定义在 SuperType.prototype 上;property 是实例属性,而 SubType.prototype 现在就是 SuperType 的实例,所以 property 存储在它上面。

还有一个副作用:SubType.prototype 的 constructor 被重写为指向 SuperType,因此 instance.constructor 也指向 SuperType。

原型链扩展了搜索机制:

读取实例属性时,先搜实例,再搜原型,原型链让搜索可以继续向上,搜原型的原型。以 instance.getSuperValue() 为例,经过三步搜索,搜索会一直持续到原型链的末端才停止:

  1. instance,没找到
  2. SubType.prototype,没找到
  3. SuperType.prototype,找到了

默认原型:

到现在为止,我们一直未提及原型链的最后一环。默认情况下,所有引用类型都继承自Object,这也是通过原型链实现的。

任何函数的默认原型都是一个Object的实例,这意味着这个实例有一个内部指针指向Object.prototype。这也是为什么自定义类型能够继承包括toString()、valueOf()在内的所有默认方法的原因。因此前面的例子还有额外一层继承关系,图8-5展示了完整的原型链。

image-20260501214355160

SubType继承SuperType,而SuperType继承Object。在调用instance.toString()时,实际上调用的是保存在Object.prototype上的方法。

原型与继承关系:

原型与实例的关系可以通过两种方式来确定。第一种方式是使用instanceof操作符,如果一个实例的原型链中出现过相应的构造函数,则instanceof返回true。如下例所示:

1
2
3
console.log(instance instanceof Object);     // true
console.log(instance instanceof SuperType);  // true
console.log(instance instanceof SubType);    // true

严格来讲,instance是Object、SuperType和SubType的实例,因为instance的原型链中包含这些构造函数的原型。结果就是instanceof对所有这些构造函数都返回true。

确定这种关系的第二种方式是使用isPrototypeOf()方法。原型链中的每个原型都可以调用这个方法,如下例所示,只要原型链中包含这个原型,这个方法就返回true

1
2
3
console.log(Object.prototype.isPrototypeOf(instance));     // true
console.log(SuperType.prototype.isPrototypeOf(instance));  // true
console.log(SubType.prototype.isPrototypeOf(instance));    // true

关于方法:

子类需要覆盖父类方法或添加新方法时,必须在原型赋值之后再定义,否则会被重写覆盖掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 继承 SuperType
SubType.prototype = new SuperType();

// 添加新方法,必须在原型赋值之后
SubType.prototype.getSubValue = function() {
  return this.subproperty;
};

// 覆盖父类方法,同样必须在原型赋值之后
SubType.prototype.getSuperValue = function() {
  return false;
};

let instance = new SubType();
console.log(instance.getSuperValue()); // false,调用的是覆盖后的版本

getSubValue() 是 SubType 的新方法,getSuperValue() 遮盖了原型链上父类的同名方法。注意 SuperType 的实例不受影响,仍然调用最初的版本。

SubType.prototype = new SuperType() 是重写整个原型,之后添加的方法才能保留在新的原型对象上。写在赋值之前的方法会随旧原型一起丢失。

原型链的问题:

原型链有两个问题,这也是它基本不会被单独使用的原因。

问题一:引用值共享

之前讲过,原型上的引用值会在所有实例间共享,这正是属性通常定义在构造函数中而非原型上的原因。原型链继承会让这个问题更严重:SubType.prototype = new SuperType() 让原型变成了另一个类型的实例,原先的实例属性摇身一变成为了原型属性

1
2
3
4
5
6
7
8
9
10
11
12
13
function SuperType() {
  this.colors = ["red", "blue", "green"];
}

function SubType() {}

SubType.prototype = new SuperType();

let instance1 = new SubType();
instance1.colors.push("black");

let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green,black"——也被改了

SuperType 构造函数里的 this.colors 本来是每个实例各自拥有的,但 SubType.prototype 现在就是那个 SuperType 实例,colors 就变成了原型属性,所有 SubType 实例共享同一个数组。

问题二:不能向父类构造函数传参

子类型实例化时无法给父类型的构造函数传参,因为继承是通过 SubType.prototype = new SuperType() 实现的,没有办法在不影响所有实例的情况下把参数传进去。

对于原型链指向的补充:

对于原型链部分的例子,SubType 和 SuperType,这两个函数各自具有自己的原型,然后通过new一个实例,让一个函数的原型指向了另一个函数的原型(也就是让一个函数的原型等于另一个函数的实例),但不能让一个函数的原型指向另一个函数,而是只能成为另一个函数的实例。

如果直接指向原型会怎样:

1
SubType.prototype = SuperType.prototype;

这意味着 SubType 和 SuperType 共享同一个原型对象。任何对 SubType.prototype 的修改都会直接影响 SuperType.prototype:

1
2
3
4
5
6
SubType.prototype.getSubValue = function() {
  return this.subproperty;
};

// 问题来了:SuperType.prototype 上也多了这个方法
// SuperType 的实例也能调用 getSubValue(),这不合理

而且 instanceof 也会失效,SubType 的实例会被认为同时是 SuperType 的直接实例,因为它们的原型是同一个对象。

为什么必须是实例:

new SuperType() 创建的是一个中间对象

  1. 有自己的 [[Prototype]] 指向 SuperType.prototype → 能访问父类原型方法
  2. 有自己的实例属性(如 property)→ 独立于 SuperType.prototype
  3. 是一个独立的对象 → 对它添加方法不会污染 SuperType.prototype
1
2
3
4
5
6
7
8
9
10
SubType.prototype = new SuperType() 产生的中间对象:

 SubType.prototype (中间对象)
 ├── [[Prototype]] → SuperType.prototype (链到父原型)
 ├── property: true (父类实例属性)
 └── getSubValue: function() {...} (子类添加的方法)

 SuperType.prototype (不受影响)
 ├── constructor: SuperType
 └── getSuperValue: function() {...}

直接指向原型会让两个类型共享同一个对象,互相污染;用实例做中间层,既建立了链式关系,又保持了各层原型的独立性。

理解原型链的关键在于是一个函数的原型指向了另一个函数的原型,而不是一个函数的原型对象成为了另一个函数的实例。

换句话说,原型链的核心是 [[Prototype]] 指针把对象串成一条链,属性搜索沿链向上查找,这才是继承的机制。

让一个函数的原型成为另一个函数的实例只是建立这条链的手段,不是目的,而且是个有缺陷的手段,它顺带把父类的实例属性也带下来了,这就是之前说的引用值共享问题。

如果只想要链条,不要实例属性的副作用,用 Object.create() 更简洁干净

1
2
3
4
5
// 传统方式,建立链,但带上了实例属性(副作用)
SubType.prototype = new SuperType();

// 另一种方式,只建立链,不带实例属性
SubType.prototype = Object.create(SuperType.prototype);

Object.create(SuperType.prototype) 创建一个新对象,其 [[Prototype]] 指向 SuperType.prototype,效果和 new SuperType() 一样建立了链条,但不会执行 SuperType 构造函数,不会带上那些实例属性。

本文由作者按照 CC BY 4.0 进行授权

阅读DAY10 JavaScript高级程序设计 8章上 对象、类与面向对象编程

阅读DAY12 JavaScript高级程序设计 8章下 对象、类与面向对象编程