JavaScript / 前端 · 10月 7, 2020 0

JavaScript 继承与原型

继承的概念

继承是面向对象软件技术当中的一个概念,与多态、封装共为面向对象的三个基本特征。 继承可以使得子类具有父类的属性和方法或者重新定义、追加属性和方法等。
继承一般可分为单继承、多继承和多重继承。
继承可以实现父类的功能,而不用重写逻辑代码,实际上是一种重用逻辑的特性。
所谓实现父类的功能,即拥有全部或部分父类的属性或者方法。
JS中的继承不同于Java和C++,它是基于原型的。如果A对象的原型是B对象,我们就可以称之为A继承于B。

原型链

JS中所有的对象(null除外)大部分都有一个原型,原型本身也是对象。对象将自己的原型的保存在私有属性_proto_ 上,默认原型因对象的种类不同而不同。例如:
普通对象字面量{} 的原型_proto__默认指向顶级的Object;
数组[] 的原型是_proto_默认指向Array;
函数的原型是_proto_默认指向Function;

Tip:_proto_不属于ES5标准,请慎用。现代浏览器以及ES6都已经支持。

在实际使用中,我们已经用到了原型继承:

var x = []
x.push(1)

我们声明了一个数组,push方法就是来自的它的原型Array。作为所有数组的共享原型,Array拥有许多内置的方法可供使用,上述的push方法,就是来自于Array.prototype.push(稍后我们会讲为什么是.prototype)。
一个对象如果有原型,它会继承原型上的属性和方法。 对象自身不存在的方法,就会去它的原型上遍历寻找,如果还是没有,就会在原型的原型上遍历,知道没有原型为止。这样一个访问对象属性和方法的方式就叫做原型链

Tip:this的值总是开始查找方法时所在的那个对象,而不是找到方法时的那个对象。

有如下继承关系:

A => B => C.c

A继承于B继承于C。按照传统继承的概念,A会拥有所有B和C的属性和方法。若要访问A.c,JavaScript会先遍历B,然后遍历C,从而找到c。

精确的定义

我们可见的访问原型的方法是通过_proto_。在内部,是通过对象的内部属性[[Prototype]]指定原型的。每个对象都有这个属性,它也可以是null通过[[Prototype]]属性连接成的对象链,就称为原型链。

原型继承的数据共享

原则一

设置属性只会影响原型链的第一个对象,而获取属性需要考虑整个原型链。

var x = {
    name : "suxue",
   voids : function () {}
}
var z = Object.create(x)
z.voids // 获取 voids 会遍历原型链
z.name = 1 // 设置 name 不会对原型链有任何操作

原则二

私有数据保存在原型链的第一对象中,而公有数据方法保存在后面的对象中。
对象x和y都有相同的方法log

var x = {
    name : "suxue",
    log  : function (){
        console.log(this.name)
    }
}
var y = {
    name : "lipu",
    log  : function (){
        console.log(this.name)
    }
}

我们可以通过原型的方式来避免重写代码:

var proto = {
    log  : function (){
        console.log(this.name)
    }
}
var x = {
    name : "suxue",
    [[Prototype]] : proto //实际代码不会生效,仅表示原型指向。
}
var y = {
    name : "lipu",
    [[Prototype]] : proto
}

这样x,y依然可以访问到log,而不同再次定义log方法。
继承不就是为了实现这些而存在的吗?

原型操作与遍历

创建

创建原型可以使用Object.create(proto,propDesObj),这个方法返回的就是以proto为原型的对象。第二个参数是为这个对象添加属性描述符:

var A = {}
var B = Object.create(A)
B._proto_ === A // true

Tip:由Object.create( proto, propDesObj )创建的对象,它自身是没有构造函数的,访问它的构造函数会是原型链上最近的对象的构造函数。

前面讲,有些对象是没有原型的,比如null。通过该方法,可以创建没有原型的纯对象,B什么属性也没有:

var B = Object.create(null)

另一个创建原型的办法是通过构造函数:

var Fn = function (){}
var x = new Fn()
x._proto_ === Fn.prototype // true

此时x._proto_指向Fn.prototype
其他情况下都是一开始讲的默认指向。

检查

可以使用Object.prototype.isPrototypeOf(Obj)来检查一个对象是否是另一个对象的原型:

//上面A和B的例子
A.isPrototypeOf(B) // true

可以使用Object.getPrototypeOf(obj)来获取对象的原型:

Object.getPrototypeOf(B) // A

遍历

遍历和检测对象的属性受如下条件影响:

  • 继承(自有属性和继承属性)
  • 枚举(枚举属性和非枚举属性)
列出自有属性键

可以使用Object.getOwnPropertyNames()返回所有的自有属性键(包括不可枚举属性但不包括Symbol值作为名称的属性):

// MDN
var arr = ["a", "b", "c"];
console.log(Object.getOwnPropertyNames(arr).sort()); // ["0", "1", "2", "length"]
包括继承的属性键

for...in..循环会列出对象的所有可枚举属性键,包括继承,但是只是可枚举。

Tip:Object.keys()返回自身的可枚举属性

var x = {
    a : 1
}
var y = Object.create(x)
Object.keys(y) // []
for (let i in y) {
    console.log
}

若想遍历所有属性(不仅是可枚举),需要对对象原型链上的所有对象执行Object.getOwnPropertyNames()

funciton getAllPropertyNames(obj) {
    var result = []
    while (obj) {
        Array.prototype.push.apply(result, Object.getOwnPropertyNames(obj))
        obj = Object.getPrototypeOf(obj)
    }
    return result
}

函数的prototype

js没有类。但是有和类功能相似的函数,所以类是以函数的形式来定义的。当一个函数作为类的功能使用时,我们称它为构造函数
任何一个函数对象有一个特殊的属性prototype。译文为原型,但是并不代表它的指向是函数的原型。对象的原型一定是在_proto_上指定的,函数也是如此。
如果你仅仅把一个函数当做普通函数来使用,prototype是没有其他意义的。只有在作为构造函数使用时,prototype才被赋予真正的意义。 下面仅讨论构造函数:
prototype的意义是为构造函数的实例对象指定了原型。 什么意思呢?构造函数实例对象的原型将是构造函数的prototype属性值:

//谨记此例子
var Fn = function (){}
var x = new Fn()
x._proto_ === Fn.prototype // true

学习js中,你肯定知道这样的语法:

var x = new Object()
var x = new Function()
var x = new Array()
var x = new Date()
var x = new Number()
...

结合上一段代码,你可以这么看:

//实际中不可这么做
var Object = function(){}
var x = new Object()
x._proto_ === Object.prototype  //true

var Function = function(){}
var x = new Function()
x._proto_ === Function.prototype  //true

实际上ObjectArray等都是一个内置构造函数,只是我们习惯于用对象字面量的方式创建。作为函数,它拥有prototype属性,定义了JS内置对象的常用方法和属性。由它实例化的对象,原型都指向prototype属性。
再者,ObjectArray等都作为构造函数,都是由Function构造函数创建的,包括Function自己。所以又有:

Function._proto_ === Function.prototype
Object._proto_ === Funciton.prototype
//自定义函数也是一样
var x = function(){}
x._proto_ === Funciton.prototype
...

函数的prototype属性大部分都是对象,但是也有例外,作为内置构造函数的原型,typeof Function.prototype会是一个函数。如果继续按照以上推理,它自己的原型是它自己,将得到:

// Function.prototype 是函数,所以:
Function.prototype._proto_ === Function.prototype // fasle

这没什么意义,所以,特殊地:

Function.prototype._proto_ === Object.prototype
Object.prototype._proto_ === null

函数对象作为特例,即继承了函数方法,也继承了对象方法。
null,原型链就到了终端了。
所有的原型链有共同的终端:

Object.prototype => null

JavaScript 继承

我们要先明确JavaScript继承的意义,即在可创建实例的构造函数或者类之间的继承。通俗的讲,就是如何让一个构造函数的实例,继承另一个构造函数的实例的所有属性。通常,是通过改变构造函数之间关系实现的。

原型链继承

定义一个父类构造函数:

function Super(){
    this.name = "super"
}
Super.prototype = {
    superVoid: function(){
        return this.name
    }
}

定一个一个子类,通过原型链继承父类:

function Sub(){
    this.subName = "sub"
}

Sub.prototype = new Super()

Sub.prototype.subVoid = function(){}

var sub = new Sub()
sub.superVoid() // "sub"

根据上面的知识,我们知道,sub作为子类的实例,其原型指向了Sub.prototypeSub.prototype的值是父类Super的实例,所以Sub.prototype的原型指向了Super.prototype,所以有以下原型链:

sub => Sub.prototype => Super.prototype

<<value>> instanceof <<Constr>>操作符用于检测value是否是由构造函数Constr创建的或者是否为它的一个子类:

sub instanceof Sub // true
sub instanceof Super //true
sub instanceof Object // true 所有引用值均是Object的子类

缺点

  1. 你必须谨慎的为子类的原型添加新属性。

在定义子类时,我们为子类的原型加了新方法:

Sub.prototype.subVoid = function(){}

如果你采用字面量的形式,将会导致继承失败:

Sub.prototype = {
    subVoid : function(){}
}

这行代码的执行会覆盖Sub.prototype = new Super(),这是显而易见的不合理。

  1. 引用值类型的属性被所有实例共享

如果父类有一个引用类型实例属性,子类实例也会继承:

function Super(){
    this.name = ["super"]
}
sub.name // ["super"]

同时还有一个父类实例:

var sup = new Super()
sup.name // ["super"]

只要我们对subsup其中之一的name属性执行破坏性地改变,另一个一会跟着改变:

sub.name.push("suxue")
sup.name // ["super", "suxue"]

这显然是我们不想看到的。

  1. 在创建子类实例时,没办法向父类构造函数传参。一旦传递,将影响所有实例。

    引入构造函数的组合继承

    上述继承最不能容忍的是第二个缺点。为了解决它,引入了构造函数继承。
    核心是在子类实例化时将父类构造函数调用的this,改变为子类:

    
    // 父类
    function Super(){
    this.name = ["super"]
    }
    //子类
    function Sub(){
    Super.call(this) //改变this,可以传参
    this.subName = "sub"
    }

Sub.prototype = new Super() //继承
Sub.prototype.constructor = Sub //将构造函数指回子类,可以不写
Sub.prototype.subVoid = function(){}

//实例化
var sub = new Sub()
var sup = new Super()
sub.name.push("suxue")
sub.name // ["super", "suxue"]
sup.name // ["super"]

同时,在调用父类构造函数时,你还可以传递参数。这种模式是最常用的。
#### 原型式继承
由道格拉斯·克罗克福德首先提出。借助原型可以基于已有的对象创建新的对象,同时还不必因此创建自定义类型:
```js
function object(o){
    function F(){}
    F.prototype = o
    return new F()
}

本质上,object(o)的作用就是返回以o为原型的一个对象。是不是很熟悉?没错,就是Object.create()的早期实现。ES5将其规范化,并且更加实用。

寄生式继承

上面我们讲到,组合继承对子类的prototype的自定义扩展必须放在指定子类的prototype的原型之后,且不可采用字面量的方式。寄生式就是把这个过程封装起来:

function createAnother(original){
    var clone = object(original) 
    clone.hello = function (){

    }
    return clone
}

返回的是经过扩展的,且以original为原型的对象。

寄生组合式继承

没有Object.create()之前的组合继承,已经很实用。但是不论什么情况下,都要两次调用父类构造函数。寄生组合式继承可以只调一次:

function changePrototype(sub,super){
    var proto = object(super.prototype) //创建以父类原型属性为原型的对象
    proto.hello = function(){} //扩展该对象
    sub.prototype = proto //将子类的原型设置为该对象
}
// 父类
function Super(){
    this.name = ["super"]
}
//子类
function Sub(){
    Super.call(this) //改变this,可以传参
    this.subName = "sub"
}
changePrototype(Sub,Super)
//在这之后可以继续扩展

总结

为了实现继承,我们需要:

  1. 继承实例属性

通过在子类构造函数内调用父类构造函数实现:

function Sub(){
    Super.call(this) //继承实例属性
    this.subName = "sub"
}
  1. 继承原型属性

最终目的是实现sup.prototype._proto_ === Super.prototype,为了实现它,我们先后使用了:

  • 巧妙的借用构造函数的实例会继承prototype对象(组合/原型链):
    Sub.prototype = new Super()

    缺点:多调用一次父类,且Sub.prototype存在父类的实例属性,这是多余的。扩展方法受到限制。

  • 巧妙地借用空构造函数(原型式/寄生式/寄生组合式):
    function object(o){
    function F(){}
    F.prototype = o
    return new F()
    }

    以该方法的返回作为sup.prototype
    更加推荐ES5的Object.create()方法,简单方便

Sub.prototype = Object.create(Super.prototype)

ES6 类继承

ES6 实现继承更加方便:

class A {

}
class B extends A{
    constructor() {
        super();
  }

}
var a = new A()
var b = new B()

类只是构造函数的语法糖typeof A === "function"。类同时存在两条继承链:

(1)子类的_proto_属性,表示构造函数的继承,总是指向父类。

(2)子类prototype属性的_proto_属性,表示方法的继承,总是指向父类的prototype属性。

B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true

如果你要为子类加上constructor方法,就必须要先调用super(),代表调用父类的构造函数,等同于上述的:

function Sub(){
    Super.call(this) //等同于此
    this.subName = "sub"
}

参考

《ES6标准入门》
《JavaScript高级程序设计》
《深入理解JavaScrip》

冀ICP备19028007号