深入理解原型链与继承

5,585 阅读17分钟

前言

JavaScript是一门面向对象的语言,所有的对象都从原型继承属性和方法,那么什么是原型?对象与对象之间如何实现继承?

本文就带大家来深入理解下JavaScript中的原型,欢迎各位感兴趣的开发者阅读本文。

原理解析

接下来我们来逐步分析下原型与对象之间的关系。

原型对象

我们使用function关键字来创建函数时,内存中会创建一个包含prototype属性的对象,这个属性指向函数的原型对象,如下所示:

function Person() {
    
}
Person.prototype // {constructor: Person(), __proto__}

上述代码中我们创建了一个名为Person的函数:

  • prototype属性指向的就是Person的原型对象(每个除null外的JS对象在创建的时候都会关联到另一个对象,这个关联的对象就是原型)
  • 每个对象都会从原型“继承”属性
  • 原型对象里包含constructor__proto__属性。

image-20210310173710555

我们画个图来描述下Personprototype之间的关系

image-20210310195733848

用new运算符调用的函数便为构造函数,建议构造函数命名时将首字母大写。

函数实例与原型对象的关系

上个章节我们捋清了构造函数原型对象的关系,接下我们来看下函数实例原型对象之间的关系。

我们用运算符new将上个章节创建的Person函数进行实例化,得到person实例,代码如下:

// 实例化对象
const person = new Persion();

在上个章节中,我们知道原型对象有2个属性,其中__proto__是每一个除null外的JavaScript对象都具有的一个属性,它指向该对象构造函数的原型对象。

接下来,我们来证明下person.__proto__是否和Persion.prototype相等,代码如下:

function Person() {
    
}
const person = new Persion();
console.log("函数实例的__proto__指针指向构造函数的原型对象: ", person.__proto__ === Person.prototype);

执行结果如下:

image-20210312172907318

除了使用__proto__来访问原型对象,我们还可以使用Object.getPrototypeOf()来获取。

证明出他们相等后,结合构造函数与原型对象可知他们三个之间的关系,如下所示:

image-20210310202939966

当我们实例化一个构造函数时,也会为这个实例创建一个__proto__属性,这个属性是一个指针,它指向构造函数的原型对象。

由于同一个构造函数创建的所有实例对象的__proto__属性都是指向其构造函数的原型对象,因此所有的实例对象都会共享构造函数原型对象上的属性和方法,因此,一旦原型对象上的属性或方法发生改变,所有的实例对象都会受到影响。

我们思考这样一个问题,既然每个除null外的JS对象都有一个__proto__属性,那么Person也是一个对象,它也包含了__proto__属性,那么它指向哪里?

答案很明显,和我们上面说的一样:__proto__指向构造函数的原型对象。

我们举个例子证明下:

function Person() {}
Person.__proto__ === Person.constructor.prototype // true
  • 上述代码中我们使用了constructor属性,它指向该对象的构造函数,下个章节我们将详细讲解。

执行结果如下:

image.png

原型对象与构造函数的关系

上个章节我们分析了原型对象中__proto__的指向,接下来我们来分析下constructor的指向。每个原型对象都有一个constructor属性,它指向该对象的构造函数。

接下来,我们来证明下Person.prototype.constructor是否和Person相等,代码如下:

function Person() {

}

const person = new Person();
console.log("原型对象与构造函数相等: ", Person.prototype.constructor === Person);

执行结果如下:

image-20210310211445102

证明出他们相等后,我们结合构造函数、函数实例、原型对象可知他们四个之间的关系,如下所示:

image-20210310212117231

获取对象原型,除了访问它的prototype外,我们还可以使用Object.getPrototypeOf()来获取。

实例属性的读取顺序

读取实例中的属性时,如果找不到,就会查找该对象原型中的属性,如果还查不到,就去找原型的原型,一直找到最顶层为止。

接下来,我们举个例子来证实下上述话语:

function Person() {

}
Person.prototype.name = "原型上的name属性";
const person = new Person();
person.name = "实例上的name属性";
console.log(person.name) // 实例上的name属性

delete person.name;
console.log(person.name); // 原型上的name属性

delete Person.prototype.name;
console.log(person.name); // undefined

我们来分析下上述例子:

  • 向原型上添加了name属性
  • 向实例上添加了name属性
  • 此时,name的属性值为实例上的name属性
  • 删除了实例上的name属性
  • 此时,它就会找原型上的name属性,因此值为原型上的name属性
  • 删除了原型上的name属性
  • 此时,他就会找原型的原型的name值,原型的原型不存在name属性,因此返回undefined

在上面的分析中,我们在Person的原型的原型中没找到name属性,那么Person的原型的原型是什么呢?我们在谷歌浏览器的控制台来打印下看看,如下所示:

image-20210310222010988

正如上图结果所示,Person的原型的原型是一个对象,证明出了原型也是一个对象,那么我们就可以用最原始的方式创建它,代码如下所示:

const object = new Object();
object.name = "对象中的属性";
console.log(object.name); // 对象中的属性
console.log("object实例的__proto__指向Object的实例对象", object.__proto__ === Object.prototype);
console.log("Object的原型对象与构造函数相等", Object.prototype.constructor === Object);

执行结果如下:

image-20210312175303383

知道原型也是对象后,结合我们上面所证明出来的内容,他们之间的关系如下所示:

image-20210310224603680

原型链

通过前面的分析我们知道了万物基于Object,那么Object的原型是什么呢?答案是null

我们在谷歌浏览器的控制台上验证下,结果如下所示:

image-20210310231037617

综合上述,他们最终的关系如下所示:

image-20210310232450892

图中橙色线条组成的链状结构就是原型链

重写原型对象

我们在实现实现一些功能时,经常会用一个包含所有属性和方法的对象字面量来重写整个原型对象。

如下所示:

Person.prototype = {
    name: "神奇的程序员",
    age: "20",
    job: "web前端开发",
    sayName: function () {
        console.log(this.name);
    }
}
  • 将Person的原型指向了一个新的对象
  • 原型上拥有三个属性和一个方法
  • 对象中不存在constructor属性

由于重写的对象中不存在constructor属性,那么它的constructor属性将会指向Object。

我们来验证下,代码如下所示:

console.log("Person的原型对象的构造函数与Person构造函数相等", Person.prototype.constructor === Person)

执行结果如下:

image-20210312211047400

如果constructor的值很重要,那么我们就需要特意将constructor的指向改为构造函数了,代码如下所示:

Person.prototype = {
    name: "神奇的程序员",
    age: "20",
    job: "web前端开发",
    sayName: function () {
        console.log(this.name);
    },
    constructor: Person
}
console.log("Person的原型对象与Person构造函数相等", Person.prototype.constructor === Person)

执行结果如下:

image-20210311000952986

原型链继承

前面的原理分析章节中,在最后的示意图中,我们很直观的看到了原型链的样子,接下来我们来捋一下原型链的具体概念。

  • 每个构造函数都有一个原型对象
  • 原型对象都包含一个指向构造函数的指针(constructor
  • 每个构造函数的实例都包含一个指向原型对象的内部指针(__proto__
  • 如果让原型对象等于另一个构造函数的实例,此时的原型对象将包含一个指向另一个构造函数原型的指针
  • 相应的,另一个构造函数的原型中也包含着一个指向另一个构造函数的指针
  • 如果另一个原型又是另一个构造函数的实例,那么上述关系依然成立
  • 如此层层递进,就构成了实例与原型的链条,也正是我们示意图中看到的橙色线条,这就是原型链的基本概念

接下来,我们通过一个例子来讲解下原型链的继承,代码如下:

function Super() {
    this.property = true;
}
Super.prototype.getSuperValue = function() {
    return this.property;
}
function Sub() {
    this.subProperty = false;
}

// Sub原型指向Super实例,constructor被重写,指向Super
Sub.prototype = new Super();
Sub.prototype.getSubValue = function () {
    return this.subProperty;
}

let sub = new Sub();
console.log("获取Super的属性值", sub.getSuperValue());
console.log("sub实例的原型对象等于Sub构造函数的原型对象", sub.__proto__ === Sub.prototype);
console.log("Sub构造函数的原型对象的原型对象等于Super构造函数的原型对象", Sub.prototype.__proto__ === Super.prototype)
console.log("Sub构造函数的原型对象constructor指向Super的构造函数", Sub.prototype.constructor === Super)

运行结果如下:

image-20210312215707154

  • 首先,我们创建了一个名为Super的函数,内部添加了一个名为property的属性,值为true
  • 随后,在Super的原型对象上添加了getSuperValue方法,返回函数内部的property属性
  • 随后,我们创建了一个名为Sub的函数,内部添加了一个名为subProperty的属性,值为false
  • 随后,我们将Sub的原型对象指向Super实例,此时就实现了继承,Sub原型上将会拥有Super原型上的方法
  • 随后,我们在Sub原型对象添加了getSubValue方法,返回函数内部的subProperty属性
  • 最后,我们实例化Sub对象,它与Super对象之间就组成了一条原型链,符合我们在原理解析中所讲的关系

接下来,我们将上述分析内容画成图,更好理解一点,如下所示(橙色线条为原型链):

image-20210311112200899

存在的问题

我们使用原型链来实现继承时,如果继承了原型对象上的引用类型,那么这个引用类型将会被所有实例共享。

接下来我们通过一个例子来说明下这个问题:

function Super() {
    this.list = ["a","b","c"];
}

function Sub() {

}
Sub.prototype = new Super();
const sub1 = new Sub();
sub1.list.push("d");
console.log(sub1.list);
const sub2 = new Sub();
console.log(sub2.list);

上述代码中:

  • 首先,声明了两个构造函数Super、sub
  • 随后,将Sub的原型对象指向Super的实例,实现继承
  • 随后,实例化Sub对象得到sub1实例
  • 向sub1实例的list中添加一个元素d
  • 此时,sub1实例的list数组元素为[ 'a', 'b', 'c', 'd' ]
  • 随后,再次实例化Sub对象得到sub2实例
  • 此时,sub2实例的list数组元素为[ 'a', 'b', 'c', 'd' ]

运行结果如下:

image-20210311141113229

问题很明显了,我们没有向sub2的list数组中添加元素,我们希望它的值是Super原型上定义的["a","b","c"]

由于Super构造函数中定义的list属性是引用类型,因此在实例化时它被共享了,sub1实例改了它的值后,sub2实例化时拿到的就是sub1实例改后的值,即[ 'a', 'b', 'c', 'd' ]

在下个章节中,我们将解决引用类型被实例共享的问题

构造函数继承

我们在子类的构造函数中,可以使用call将父类构造函数的所有属性和方法拷贝到当前构造函数中,这样我们在实例化后,修改属性和方法就是修改的复制过来的内容了,不会影响父类构造函数中的内容。

接下来,我们通过一个例子来讲解下上述话语:

function Super() {
    this.list = ["a","b","c"];
}

function Sub() {
    Super.call(this)
}
const sub1 = new Sub();
sub1.list.push("d");
console.log("sub1" ,sub1.list);
const sub2 = new Sub();
console.log("sub2", sub2.list);

上述代码,我们沿用的上个章节的的例子,这里只讲改变的部分:

  • 我们在Sub的构造函数中使用call将Super中的属性和方法拷贝了过来,实现了继承

  • 由于属性在每次实例化时,都会拷贝属性到当前实例,因此sub1中添加的元素不会影响到sub2

运行结果如下:

image-20210312112758516

存在的问题

我们借用构造函数实现继承,无法继承父类原型上的方法和属性,那么函数的复用性也就就没了。

我们继续拿上个章节的代码来举例:

function Super() {
    this.list = ["a","b","c"];
}

Super.prototype.newList = [];

function Sub() {
    Super.call(this)
}
const sub1 = new Sub();
console.log("sub1" ,sub1.newList);
  • 我们向Super的原型上添加了newList属性
  • 在sub1实例中访问时是不存在的

运行结果如下:

image-20210311160013667

组合继承

经过前两个章节的分析,我们知道了原型链继承方式可以继承原型对象上的属性和方法,构造函数继承可以继承构造函数中的属性和方法,他们彼此互补,那么我们将它俩的长处结合起来,就实现了组合继承,也完美的弥补了它们各自的短板。

接下来,我们通过一个例子来讲解下组合继承:

// 组合继承
function Super(name) {
    this.name = name;
    this.list = ["a","b","c"];
}
Super.prototype.getName = function () {
    return this.name;
}
function Sub(name, age) {
    // 构造函数继承,第二次调用父类构造函数
    Super.call(this,name);
    this.age = age;
}
// 原型链继承,第一次调用父类构造函数
Sub.prototype = new Super();
Sub.prototype.constructor = Sub;
Sub.prototype.getAge = function () {
    return this.age;
}

const sub1 = new Sub("神奇的程序员","20");
sub1.list.push("d");
console.log("sub1", sub1.getName());
console.log("sub1", sub1.getAge());
console.log("sub1", sub1.list);
const sub2 = new Sub("大白","20");
console.log("sub2", sub2.getName());
console.log("sub2", sub2.getAge());
console.log("sub2", sub2.list);

上述代码中:

  • 首先,创建了一个名为Super的函数,接受一个name参数,构造函数中有两个属性name和list
  • 随后,我们向Super的原型对象上添加getName方法,返回Super中的name属性值
  • 随后,我们创建一个名为Sub的函数,接受两个参数:name、age,在构造函数中添加age属性,继承父类构造函数中的属性与方法
  • 随后,我们重写Sub构造函数的原型对象为Super的实例,修正constructor指向
  • 随后,我们向Sub的原型对象上添加getAge方法,返回Sub中的age属性
  • 最后,我们实例化两个Sub构造函数的实例,测试继承下来的方法

运行结果如下:

image-20210311175629506

寄生式组合继承

我们在实现组合继承时,调用了两次父类的构造函数。

第一次调用父类构造函数时:

  • 我们重写了Sub的原型对象,将其指向了Super的实例
  • 此时,父类构造函数实例上的属性和方法会赋值给Sub.prototype

这一次调用就是我们在原型链继承章节所讲的内容,子类已经继承了父类构造函数中的属性与方法和原型对象上的属性与方法。

第二次调用父类构造函数时:

  • 我们在Sub的构造函数内部,使用call会将Super中的属性和方法赋值给Sub的实例上
  • 原型链在搜索属性时,实例上的属性会屏蔽原型链上的属性

因此,第二次调用时,我们就没有必要将构造函数中的属性和方法赋值给Sub构造函数的实例了,这是无意义的。

接下来,我们来看下优化后的组合继承:

function Super(name) {
    this.name = name;
    this.list = ["a", "b", "c"];
}

Super.prototype.getName = function () {
    return this.name;
}

function Sub(name, age){
    Super.call(this, name);
    this.age = age;
}

// 创建一个中间函数,用于继承Super的原型对象
function F() {

}
// 将F的原型对象指向Super的原型对象
F.prototype = Super.prototype;
// 将Sub的原型对象指向F的实例
Sub.prototype = new F();
Sub.prototype.constructor = Sub;
Sub.prototype.getAge = function () {
    return this.age;
}

const sub1 = new Sub("神奇的程序员","20");
sub1.list.push("d");
console.log("sub1", sub1.getName());
console.log("sub1", sub1.getAge());
console.log("sub1", sub1.list);
const sub2 = new Sub("大白","20");
console.log("sub2", sub2.getName());
console.log("sub2", sub2.getAge());
console.log("sub2", sub2.list);

上述代码中:

  • 我们先创建了一个中间函数F
  • 随后,重写了F的原型对象,将F的原型对象直接指向了Super的原型对象
  • 最后,我们将Sub的原型对象指向F的实例,从而实现原型链继承。

他的高效之处在于只有在实例化时才会调用一次Super构造函数,并且保持原型链的不变。

运行结果如下:

image-20210311212111885

优化后的组合继承又名寄生式组合继承,在上面的实现代码中,我们使用一个中间函数实现原型链的继承,这个中间函数也可以可以使用Object.create()来替代,他们的实现原理都一样。

那么,在重写Sub构造函数的原型对象时,我们就可以这样写:Sub.prototype = Object.create(Super.prototype, {constructor: {value: Sub}})

修改原型对象指向实现继承

上个章节中,我们使用一个中间函数实现了原型链的继承。我们还可以直接将子类的原型对象通过__proto__属性将其指向父类的原型对象,这种方式没有改变子类的原型对象,所以子类原型对象上的constructor属性还是指向父类的构造函数。

接下来,我们通过一个例子来讲解下上述话语:

function Super(name) {
    this.name = name;
    this.list = ["a", "b", "c"];
}

Super.prototype.getName = function () {
    return this.name;
}

function Sub(name, age){
    Super.call(this, name);
    this.age = age;
}
// 修改Sub构造函数的原型对象指向改为Super的原型对象
Sub.prototype.__proto__ = Super.prototype;
Sub.prototype.getAge = function () {
    return this.age;
}

const sub1 = new Sub("神奇的程序员","20");
sub1.list.push("d");
console.log("sub1", sub1.getName());
console.log("sub1", sub1.getAge());
console.log("sub1", sub1.list);
const sub2 = new Sub("大白","20");
console.log("sub2", sub2.getName());
console.log("sub2", sub2.getAge());
console.log("sub2", sub2.list);

上述代码中:

  • 我们使用__proto__属性修改了Sub原型对象指向

原理解析章节中,我们知道每一个除null外的javascript对象都有一个__proto__属性,默认指向这个对象的原型对象,因此我们可以通过这个属性来修改它指向的原型对象。

我们还可以使用ES6中的Object.setPrototypeOf()方法来修改对象的原型。

那么,上述例子中的代码就可以改为:Object.setPrototypeOf(Sub.prototype, Super.prototype)

构造函数的静态方法继承

直接向构造函数上添加一个方法,这个方法就是静态方法

我们前面讲的所有的继承方法,都没有实现构造函数上的静态方法继承,然而在ES6的class继承中,子类是可以继承父类的静态方法的。

我们可以通过Object.setPrototypeOf()方法实现静态方法的继承。

接下来,我们通过一个具体的例子来讲解下:

function Super(name) {
    this.name = name;
    this.list = ["a", "b", "c"];
}

Super.prototype.getName = function () {
    return this.name;
}

// 添加静态方法
Super.staticFn = function () {
    return "Super的静态方法";
}

function Sub(name, age) {
    // 继承Super构造函数中的数据
    Super.call(this, name);
    this.age = age;
}

// 修改Sub的原型指向
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 继承父类的静态属性与方法
Object.setPrototypeOf(Sub, Super);

Sub.prototype.getAge = function () {
    return this.age;
}

console.log(Sub.staticFn());
const sub1 = new Sub("神奇的程序员", "20");
sub1.list.push("d");
console.log("sub1", sub1.list);
console.log("sub1", sub1.getName());
console.log("sub1", sub1.getAge());
const sub2 = new Sub("大白", "20");
console.log("sub2", sub2.list);
console.log("sub2", sub2.getName());
console.log("sub2", sub2.getAge());

运行结果如下:

image-20210312000444311

上述代码中的其他部分与之前举的例子相同,我们来分析下不同之处:

  • 首先,我们向Super构造函数添加了一个名为staticFn的静态方法
  • 随后,我们通过Object.setPrototypeOf(Sub, Super)函数继承了Super构造函数的静态属性与方法

至此,我们就实现了一个完美的继承,也正是ES6的class语法糖的底层实现。

class语法糖

ES6中新增了一个class修饰符,我们用class修饰符创建两个对象后,我们就可以使用extends关键词来实现继承了。它的底层实现就是我们上面所讲的寄生式组合继承结合构造函数的静态方法继承来实现的。

接下来,我们来看下上个章节中举的例子,如何使用class实现,代码如下:

class Super {
    constructor(name) {
        this.name = name;
        this.list = ["a","b","c"];
    }

    getName() {
        return this.name;
    }
}
// 向Super添加静态方法
Super.staticFn = function () {
    return "Super的静态方法";
}

class Sub extends Super{
    constructor(name, age) {
        super(name);
        this.age = age;
    }

    getAge() {
        return this.age;
    }
}

console.log(Sub.staticFn())
const sub1 = new Sub("神奇的程序员", "20");
sub1.list.push("d");
console.log("sub1", sub1.list);
console.log("sub1", sub1.getName());
console.log("sub1", sub1.getAge());
const sub2 = new Sub("大白", "20");
console.log("sub2", sub2.list);
console.log("sub2", sub2.getName());
console.log("sub2", sub2.getAge());

运行结果如下:

image-20210312004416434

代码地址

本系列文章的所有示例代码,请移步:js-learning

写在最后

本文为《JS原理学习》系列的第2篇文章,本系列的完整路线请移步:JS原理学习 (1) 》学习路线规划

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于掘金,未经许可禁止转载💌