基于原型链的对象继承

Wu-JunHui大约 11 分钟JavaScript原型链原型对象对象继承

基于原型链的对象继承

前言

A 对象通过继承 B 对象,就能直接拥有 B 对象的所有属性和方法,这是面向对象编程很重要的一个方面,也对代码复用非常有用

大部分面向对象的编程语言,都是通过 “类”(class)实现对象的继承,而 JavaScript 则是通过原型对象(prototype)实现对象的继承,因此本文将介绍原型链的继承机制以及构造函数的继承方法

原型链

原型链是建立在构造函数的原型对象上的,即 prototype 属性,同时依赖于所有对象都拥有的 __proto__ 属性以及原型对象上的 constructor 属性

一、构造函数的缺点

在了解原型对象前,我们必须知道为什么需要原型对象

在 ES5.1 中,由构造函数创建的实例对象,其自身的属性或方法的来源有两个:

相关信息

  • 继承自构造函数:
    所有通过该构造函数创建的实例对象都拥有的属性和方法(公共属性/方法
  • 自定义赋值: 个别实例对象通过自定义赋值定义自身特有的属性和方法(私有属性/方法

通过这两个来源我们可以发现,实例对象的 属性/方法 要么只能全盘接收来自构造函数的,要么只能自己定义

那当有一个 属性/方法 只需要在某几个实例对象中使用时,通过构造函数继承会使其他实例对象添加不必要的 属性/方法,而每个实例对象都进行自定义赋值显然不明智,因此我们可得出构造函数的缺点:

相关信息

  1. 继承自构造函数的属性和方法全部直接定义在实例对象的内部,无论该实例对象是否需要
  2. 即便各实例对象继承的 属性/方法 完全一样,但它们使用全等符号 === 却不相等,因为它们各自引向的内存地址不一样

上述缺点最直观的表现就是会造成系统内存资源的浪费,同时我们可推论出,实例对象所调用的继承自构造函数的属性和方法,没必要都定义在实例对象内部,只要能调用就可以

因此,针对构造函数的缺点的一个解决思路就是,将公共的属性或方法提取出来,放到一个对象中,让所有实例对象都可自主调用,从而实现共享属性或方法,节省系统资源,而这个对象,就是构造函数的 prototype 属性

ES2015 引入 class 关键字

ES5.1 生成实例对象的传统方法是使用构造函数,而在其他面向对象语言中则是使用 "类" 作为对象模板,但 ES5.1 并没有"类"的概念,且构造函数的写法与传统的面向对象语言(如 C++,Java)差异很大

因此 ES2015 提供了更接近传统语言的写法,引入了关键字 class 作为对象的模板:

  1. 类是 “特殊的函数”(typeof 返回 function),也可简单认为类就是构造函数的另外一种写法
  2. 基本上,ES2015 的 class 可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的 class 写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已
  3. ES2015 中的类也是基于原型继承的,但由 ES2015 定义的类的某些语法语义,未与 ES5 类相关的语义共享

二、原型对象(prototype 属性)

JavaScript 继承机制的设计思想,是使原型对象的所有属性和方法,都能被实例对象共享,即如果属性和方法定义在原型对象上,那么所有实例对象就能共享,这样不仅节省了内存,还体现了实例对象之间的联系

因此 JavaScript 规定,每个对象的构造函数都有一个 prototype 属性,指向一个对象,即原型对象

提示

  1. 函数本身也是一种对象,普通函数的 prototype 属性很少用到
    但构造函数在生成实例时,其 prototype 属性会自动成为实例对象的原型对象,换言之,prototype 属性必须要有实例对象生成才是指向原型对象
  2. 原型对象的属性并不是实例对象自身的属性,即实例对象可调用且不需要拥有
  3. 只要修改原型对象,变动就立刻会体现在所有实例对象上
  4. prototype 属性就是用于定义所有实例对象共享的属性和方法,这也是它被称为原型对象的原因,而实例对象可以视作从原型对象衍生出来的子对象

三、__proto__ 属性

在 JavaScript 中,每个对象都有一个 __proto__ 属性,指向其构造函数的 prototype 属性
也就是说,对象的 __proto__ 属性 与其构造函数的 prototype 属性是等价的

let obj = new Object()
obj.__proto__ === Object.prototype // true

__proto__ 属性为调用对象属性/方法时的查找机制提供一个方向,它也是原型链建立的基础

非标准属性

__proto__ 是一个非标准属性,实际开发中不能使用,只用于内部指向原型对象 prototype,若需获取对象原型应使用 Object.getPrototypeOf()

四、constructor 属性

该属性定义在 prototype 属性上,默认指向 prototype 对象所在的构造函数,这意味着 constructor 属性可以被所有实例对象继承,继而通过该属性得知某个实例对象由哪一个构造函数产生的

function Father() {
  this.a = 1
  this.b = 2
}
Father.prototype.constructor === Father // true
let son = new Father()

son.constructor === Father // true
// 注意:因为实例对象son本身没有constructor属性,是继承自构造函数的prototype属性
// 所以实际是读取 Father.prototype.constructor

提示

对象的属性__proto__ 也有 constructor 属性,但因其为非标准属性,此处不讨论

与原型对象同步修改

constructor 属性表示原型对象构造函数之间的关联关系

如果原型对象,即构造函数的 prototype 属性被赋值修改,那么 constructor 属性将不再指向原来的构造函数,而是修改后的对象的构造函数

因此,修改原型对象时,一般要同步修改 constructor 属性的指向

// 修改原型对象的两种方法:
function Constr(name) {
  this.name = name
}
// 方法一:若以整个对象赋值,需重新定义constructor属性的指向
Constr.prototype = {
  constructor: Constr,
  method1: function () {}
}
// 或者赋值后单独重新定义constructor属性的指向:
Constr.prototype.constructor = 新原型对象

// 方法二:不直接赋值更改原型对象,而是添加原型对象的方法(推荐)
Constr.prototype.method1 = function () {}

//  利用方法二可扩展内置对象的功能,比如给数组添加求和的方法
Array.prototype.sum = function () {
  let sum = 0
  for (let i = 0; i < this.length; i++) {
    sum += this[i]
  }
  return sum
}

提示

在复杂的对象继承场景中,如果不清楚修改原型对象后的构造函数是哪个,可使用 constructor 属性的 name 属性获取修改后的构造函数名称

五、原型链

prototype__proto__constructor 属性就是原型链的三大元素

在 JavaScript 中,每个对象都拥有一个原型对象作为模板,从中继承属性和方法
而原型对象也可能拥有它自身的原型,并从中继承方法和属性,这样一层一层、以此类推,这种关系常就称为原型链(prototype chain)

原型链是建立在对象的构造函数的 prototype 属性上,而并非实例对象本身,它解释了为何一个对象可调用定义在其他对象中的属性和方法(继承机制)

原型链顶端

所有对象的原型最终都可上溯到 Object.prototype,即 Object 构造函数的 prototype 属性

也就是说,所有对象都继承了 Object.prototype 的属性,这就是所有对象都有 valueOf()toString() 方法的原因

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

实例对象的属性/方法调用优先级

  • 实例对象调用自身没有的某个属性或方法:

    • JavaScript 引擎会到原型对象去寻找该属性或方法,如果找不到就到原型的原型,直到最顶层Object.prototype 还是找不到,就返回 undefined
  • 实例对象调用自身拥有的某个属性或方法:

    • 如果原型链中有同名的,则优先读取对象自身的属性或方法,称为 “覆盖”(overriding)
    • 如果原型链中没有同名的,直接读取对象自身的属性或方法

提示

所寻找的属性在越上层的原型对象,对性能的影响越大,如果寻找某个不存在的属性,将会遍历整个原型链

六、原型链图解

构造函数的继承

让一个构造函数继承另一个构造函数的需求很常见(本质也是实现对象的继承)

ES5.1 继承限制

在 ES 5.1 中,原生构造函数是无法继承的,即不能以内置对象作为父构造函数
比如,不能自己定义一个 Array 的子构造函数,即便没报错,继承后的子构造函数的实例根本不能调用原生构造函数的属性或方法,因为根本没继承到

ES2015 类继承

ES2015 的 class 继承使用的是 extends 关键字,用于继承另一个类

一、整体继承

整体继承表示子构造函数同时继承

  • 父构造函数自身的属性和方法
  • 父构造函数的原型对象

因此继承步骤也大致分为两步:

相关信息

步骤一:继承父构造函数自身的属性和方法

  1. 在子构造函数中调用父类构造函数,继承父类的实例属性/方法
  2. 通过 call 方法指定父构造函数中的 this 的运行环境为当前子构造函数的实例

相关信息

步骤二:继承父构造函数的原型对象

方法 1:以父构造函数原型对象作为原型,创造新的子构造函数原型并赋值给子构造函数的原型对象
Son.prototype = Object.create(Father.prototype)

方法 2:通过 new 创建新的父类构造函数实例并将其赋值给子构造函数的原型对象
Son.prototype = new Father()

提示

  1. 子构造函数的原型改变了,上述两种方法都必须将子构造函数的 constructor 属性指向原来的构造函数:
    Son.prototype.constructor = Son

  2. 如果直接将父构造函数的原型对象赋值给子构造函数的原型对象,后续子构造函数原型对象新增属性/方法或操作其 constructor 属性,会同步改变父构造函数的原型对象

示例

function Father() {
  this.x = 1
  this.y = 2
}
// 继承父构造函数自身属性/方法
function Son() {
  // 参数中的this指向子构造函数的实例对象
  Father.call(this)
}
// 继承父构造函数的原型对象
Son.prototype = Object.create(Father.prototype)
Son.prototype.constructor = Son

// instanceof会对父类和子类的构造函数都返回true
let h1 = new Son()
h1 instanceof Father // true
h1 instanceof Son // true

二、单独继承某个属性/方法

只需在调用父构造函数自身或其原型对象上的属性/方法时,通过 callapply 方法改变 this 指向即可

示例

Son.prototype.Getsum = function () {
  Father.prototype.Getsum.call(this)
  // 子类其他代码
}
// 子类继承了父类的Getsum方法后,还可以部署自己的代码

多重继承

JavaScript 中一个对象无法直接继承多个对象,但通过一些方法可实现,与构造函数的继承类似,下面以继承两个对象为例

相关信息

步骤一:
在子构造函数中同时调用需继承的两个父构造函数,同时通过 call 方法修改 this 指向,继承父构造函数自身的属性/方法

相关信息

步骤二:
先继承 父构造函数1 的原型对象,再通过 Object.assign() 方法复制 父构造函数2 的原型对象到子构造函数的原型对象中

同步修改 constructor 属性

子构造函数的原型改变了,constructor 属性也要指回子构造函数自己

示例

function M1() {
  this.hello = 'hello'
}
function M2() {
  this.world = 'world'
}
function S() {
  M1.call(this)
  M2.call(this)
}
// 继承 M1 原型对象
S.prototype = Object.create(M1.prototype)
// 继承链上加入 M2
Object.assign(S.prototype, M2.prototype)
// 同步修改子构造函数的constructor
S.prototype.constructor = S

var s = new S()
s.hello // 'hello'
s.world // 'world'

子类 S 同时继承了父类 M1M2,这种模式又称为 Mixin(混入)

上次编辑于:
贡献者: Wu-JunHui
Loading...