进阶篇 01 Prototype对象和继承

JavaScript 常被描述为一种基于原型的语言——每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链,它解释了为何一个对象会拥有定义在其他对象中的属性和方法。

在传统的面向对象编程(Object Oriented Programming,OOP)语言中,首先定义类,然后在创建对象实例时,将类定义的所有属性和方法都复制到实例,而在 JavaScript 中并不如此复制——是在构造函数和对象实例之间建立一个链接(对象实例的 __proto__ 属性,指向其构造函数的 prototype 对象),之后通过上溯原型链,在构造器中找到这些属性和方法。

function Person() { }
Person.prototype.hi = 'Hello,'
Person.prototype.greeting = function () {
    console.log(`${this.hi} ${this.name}!`)
}

const person = new Person();
person.name = 'lizhao';
person.greeting() // Hello, lizhao!

console.log(Person.prototype) // {hi: 'Hello,', greeting: ƒ, constructor: ƒ}
console.log(Person.prototype === person.__proto__) // true

注意: ES6 引入的 class 关键字只是语法糖,JavaScript 仍然是基于原型的。

构造器(构造函数)

传统的面向对象编程语言(C++、Java...)都有的概念。所谓就是对象的模板,对象就是类的实例。但是,JavaScript 语言的对象体系,不是基于类的,而是基于构造函数原型链

在 JavaScript 中,构造函数是对象的模板,描述实例对象的基本结构,用于生成多个具有相同结构的实例对象。本质上说,构造函数就是一个普通的函数,当使用 new 操作符调用该函数时,就可以称为构造器(构造函数)。

构造函数有以下特征:

  • 用 new 操作符来调用。

  • 函数体的 this 指向生成的对象实例。

构造函数的缺点: 同一个构造函数创建的多个实例之间,无法共享属性和方法。这样就会导致两个问题:一是系统资源的浪费,如上代码,每个实例都会创建 hi、name 属性和 greeting 方法,而 hi 属性和 greeting 方法在每个实例中都是一样的;二是构造函数与实例之间缺少联接,比如,想要统一更新所有已生成实例的 hi 属性。

new操作符

new 操作符用于创建一个对象类型或者构造函数的实例。

new 操作符进行如下的操作:

  • 创建一个空的新对象(即 {});

  • 为新对象添加属性 __proto__,将该属性指向构造函数的 prototype 属性;

  • 将新对象作为 this 的上下文 ;

  • 如果该函数没有返回对象,则返回 this。

函数内部可以使用 new.target 属性。如果当前函数是 new 操作符调用,target 属性指向当前函数,否则为undefined。

原型

JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。这样不仅节省了内存,还建立了实例和对象之间的联系。

原型对象的属性不是实例对象自身的属性:

修改原型对象会立刻体现到所有实例对象上:

实例对象可以重写原型对象中的属性和方法。当访问一个对象的属性和方法时,JavaScript 引擎先会查找自身是否有该属性或方法,如果没有,再去原型对象查找。

仅个人理解: 原型(对象)、prototype(对象)的概念

prototype 的翻译是“原型”,所以有时候容易误将 “原型对象” 和 “prototype 对象” 认为是同一个东西。

一个对象的原型(对象)指的是一个对象可以从另一个对象继承的、共享的属性和方法的集合。在 Javascript 中,所有对象(Object.create(null) 创建的对象除外)都有一个原型(对象)。

一个对象的 prototype(对象)指的是该对象可以被继承的、被共享的属性和方法的集合。在 Javascript 中,并不是所有对象都有 prototype(对象) 。

一个对象的原型(对象),指的就是该对象构造函数的 prototype(对象)。

constructor属性

构造函数的 prototype 对象有一个 constructor 属性,默认指向该构造函数。

所有实例对象(Object.create(null) 创建的对象除外)都将从其构造函数的原型中继承了一个 constructor 属性,默认情况下,该属性指向实例对象的构造函数。

注意: 实例的 constructor 属性是继承而来的,不是自身属性。当获取实例的 constructor 时,其实是从实例的原型,也就是其构造函数的 prototype 对象中读取。

之所以构造函数要在 prototype 对象有这样一个指向自身的属性,就是为了给实例对象继承,让实例对象知道自己是哪一个构造函数创建的。

constructor 属性应用: 一是获取实例对象的构造函数的名称,二是用于从一个实例对象创建另一个属于同一构造函数的实例对象。

可以为任何数据类型(null、undefined 除外)指定 constructor 属性,但基本类型(String、Number、Boolean 等)不会保留更改,也不会抛出异常(基本类型设置其他属性也是如此)。也就是,每次将基本类型当成对象使用时,都是根据原始值实时创建实例对象,其他属性被忽略了。

基本上,除了基本类型外,任何对象都可以更改 constructor 属性的值。

改变构造函数 prototype 对象或者实例对象的 constructor 属性不会影响 instanceof 运算符。

注意: 如果构造函数的 prototype 对象修改为其他构造函数的 prototype 对象,建议修改其 constructor 属性,也就是重新将 constructor 属性指向构造函数。

如上,person 实例本来是 Person 构造函数创建的,但是,由于 Person.prototype 对象修改为 Student.prototype 对象,导致 person 实例的 constructor 属性继承的是 Student.prototype.constructor,指向的是 Student 构造函数。

这样可能会引发一些问题,比如,当我们依赖实例对象的 constructor 属性创建新实例时:

如上代码,本意是创建另一个 Person 实例,而实际创建的是一个 Student 实例。这是由于 person.constructor 指向的并不是真正创建 person 实例的构造函数。

因此,为了避免不必要的问题,推荐如下做法:

instanceof运算符

instanceof 运算符返回一个布尔值,用于判断一个实例对象的原型链上是否包含某个对象的 prototype 属性。

需要注意的是: person instanceof Person 返回 true,并不意味着永远返回 true,因为 Person.prototype 属性的值是可变的,同样,person 的原型链也是可变的。

注意: 在多个全局对象的情况下(如:多个 frame 或多个窗口),不同的全局环境拥有不同的全局对象,从而拥有不同的内置类型构造函数。这可能会引发一些问题。比如,[] instanceof window.frames[0].Array 会返回 false,因为 window.Array.prototype !== window.frames[0].Array.prototype

注意: 一般来说,Javascript 中所有对象都是 Object 的实例,也就是说, anyObject instanceof Object 都应该返回 true,但也有两种例外的情况:一是以字面量方式创建的原始值(非对象),二是用 Object.create(null) 创建的对象。

其他相关的属性和方法

Object.create()

ECMAScript 5 中引入了 Object.create(),用来创建一个指定原型的新对象

Object.create() 的主要用于从一个实例对象生成另一个实例对象,这是推荐的做法,比依赖实例对象的 constructor 属性更安全:

getPrototypeOf()

返回指定对象的原型(内部[[Prototype]]属性的值)。这是获取对象的原型的标准方法,与对象的 __proto__ 属性返回的是同一原型对象。

setPrototypeOf()

为对象设置一个指定的原型 (一个对象或 null),即修改对象 [[Prototype]] 属性的值。

isPrototypeOf()

返回布尔值,判断对象是否在另一个对象的原型链上,其作用与 instanceof 运算符相同,只是调用方式不同。

__proto__

返回实例对象内部 [[Prototype]] 属性,也就是构造函数的 prototype 对象。

注意: __proto__ 属性是一个访问器属性(一个 getter 和一个 setter 函数),是可读写的

注意: __proto__ 已被大多数浏览器厂商所支持,但仅在 ES6 规范中被标准化,也就是说,ES6 以前,__proto__ 不是 ECMAScript 语言规范。建议使用 Object.getPrototypeOf() 获取对象的原型。

原型链

在 Javascript 中,每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链,它解释了为何一个对象会拥有定义在其他对象中的属性和方法。

所有对象(Object.create(null) 创建的对象除外)的原型最终都可以上溯到 Object.prototype,也就是说,所有对象都继承了 Object.prototype 的属性,如常见的 valueOf()、toString() 方法。

那么,Object.prototype 对象有没有原型呢?

有,Object.prototype 的原型是 null。

null 没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是 null。

读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的 Object.prototype 还是找不到,则返回 undefined。

如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做 “覆盖”。

注意: 在原型链上查找属性是比较耗时的,对性能有影响。所查找的属性在原型链层次越高,对性能的影响越大。另外,查找不存在的属性时会遍历整个原型链,也就是,遍历原型链上的所有可枚举属性。

注意: 改变原型对象的属性,在每一个 JavaScript 引擎和浏览器中都是一个非常慢且影响性能的操作,原型对象的属性修改会影响到所有继承自该原型的实例对象。一般建议是,创建一个新的且可以继承该原型的对象(推荐使用 Object.create()),再重写对象的属性和方法。

继承

继承是面向对象编程中的一个概念。继承可以使得子类具有父类的各种属性和方法,而不需要再次编写相同的代码。在子类继承父类的同时,还可以重新定义某些属性,并重写某些方法,即覆盖父类的原有属性和方法,使其获得与父类不同的功能。另外,为子类追加新的属性和方法也是常见的做法。

一个简单示例

JavaScript 的继承是基于构造函数,本质上是让一个构造函数的 prototype 对象接收另一个对象上的属性和方法。

多重继承

JavaScript 不提供多重继承功能,但可以通过扩展构造函数的 prototype 对象来实现多重继承。

class 关键字

ES6 引入了一套新的关键字来定义类,包括 class、constructor、static、extends 和 super。

注意: ES6 中的类仍然是基于原型的,class 关键字只是语法糖。

constructor 方法是一个特殊的方法,用于创建和初始化一个由 class 创建的对象。

super 关键字指向父类,可以用于在 constructor 方法内调用父类的构造函数,或者调用父类的其他函数,但不能访问父类中的属性。如果子类中定义了构造函数,那么它必须先调用 super() 才能使用 this 。

static 关键字用来定义一个类的静态属性和静态方法,静态属性和静态方法不会出现在类实例中。

extends 关键字用于创建一个类作为另一个类的一个子类。

常见问题

prototype、__proto__、getPrototypeOf?

prototype 对象大概是 JavaScript 中最容易混淆的名称之一。

prototype 的翻译是 “原型”。所以,你可能认为,prototype 对象指向当前对象的原型对象,其实不是。

一个对象的 prototype 对象的属性和方法,是用于被其他对象(子对象、实例)继承的,而一个对象的原型对象定义的是该对象可以从其他对象(父对象、构造函数)承继而来的属性和方法。

遵循 ECMAScript 标准,每一个对象都有一个内部 [[Prototype]] 符号,用于指向对象的原型,也就是,该对象的构造函数的 prototype 对象。

在 ES6 之前,[[Prototype]] 是通过 __proto__ 属性来获取的。这个属性不是 ECMAScript 标准,但绝大部分浏览器都支持。

从 ES6 开始,[[Prototype]] 可以通过 Object.getPrototypeOf() 方法来获取,这是获取对象原型的标准方法。该方法和 __proto__ 属性的返回是相同的。

为什么要修改 A.prototype.constructor = A ?

在原型链继承中,经常会看到类似 Student.prototype.constructor = Student 的代码。

为什么要这样修改呢?不修改会有什么后果呢?

事实上,就算不修改,new Student() 也不会有什么问题。

这一行代码的作用仅仅是,让实例对象能够找到它真正的构造函数

比如,从一个实例对象生成另一个实例对象时,如果没有这行代码,会出现如下情况:

参考资料

MDN - 对象原型

MDN - new 运算符

阮一峰 - prototype 对象

最后更新于

这有帮助吗?