深入理解JavaScript-继承

JavaScript 的继承是基于原型实现,在前文 原型 里,笔者讲到了原型继承,并详细介绍了显式原型继承和隐式原型继承各自的两种方法。现在我们以继承的视角切入,并以案例的形式来介绍 8 种常见的 JavaScript 继承方法

在看这篇之前,建议先看 new ,或者记住一句话,new 关键字所形成的原型链关系是:实例.__proto__ === 构造函数.prototype

原型链继承

function Person(){
    this.brain = 'smart'
}

Person.prototype.getBrain = function () {
    console.log(this.brain)
}

Person.prototype.age = 100;
Person.prototype.like = {
    color: 'red',
}

function JoestarFamily(name) {
    this.name = name
    this.sayName = function() {
        console.log(this.name)
    }
}

JoestarFamily.prototype = new Person()
// 等同于 JoestarFamily.prototype === 实例.__proto__ === Person.prototype
var johnny = new JoestarFamily('johnny')
// 等同于 johnny.__proto__ === JoestarFamily.prototype
// 也就是说 johnny.__proto__.__proto__ === Person.prototype
var elaine = new JoestarFamily('elaine')

console.log(johnny, elaine)

深入理解JavaScript-继承_第1张图片

原型链继承使用 new 关键字,将 JoestarFamily 的原型指向 Person 的实例,即子类的原型指向父类的实例,等同于 JoestarFamily.prototype === 实例.__proto__ === Person.prototype ,当它 new JoestarFamily 后,相当于 johnny.__proto__ === JoestarFamily.prototype,按照「等价交换原则」JoestarFamily.prototype 为共同值,换算后可以得出:johnny.__proto__.__proto__ === Person.prototype

其原型链结构如下:

深入理解JavaScript-继承_第2张图片

也许你会感到奇怪,怎么 JoestarFamily 无了,因为 JoestarFamily.prototype 直接赋值给了 new Person,其 JoestarFamily.prototype 上的 constructor 属性也没了

前文我们在 JavaScript由什么组成 中讲过基本类型能直接拷贝,而引用类型则不能直接拷贝,而是拷贝引用地址,所以我们写了一篇——拷贝的秘密 来实现对象的拷贝

在原型链继承中,如果原型上有对象(如like对象),所有实例都会跟着修改:

johnny.age = 1;
console.log(johnny) // 1
console.log(elaine.age) // 100

johnny.like.color = 'yellow';
console.log(johnny.like.color) // yellow
console.log(elaine.like.color) // yellow

优点:

  • 父类/父类原型新增属性和方法,子类实例可访问
  • 简单,易于实现

缺点:

  • 无法实现多继承
  • 原型对象的引用属性都被多个实例共享,不管是私有还是共有属性
  • 创建子类实例,无法像父类构造函数传参

借用构造函数继承(经典继承)

此方法的关键在于,在子类的构造函数中通过 call/apply 之类的方法调用父类的构造函数

其原理是 this 的应用

function Person(brain) {
    this.brain = brain;

    this.others = {
        other1: 1,
        other2: 2
    };
    this.setBrain = function () {
        console.log("set brain");
    }
}

Person.prototype.getBrain = function () {
    console.log(this.brain)
}

Person.prototype.age = 100;
Person.prototype.like = {
    color: 'red',
}

function JoestarFamily(name) {
    this.name = name
    this.sayName = function() {
        console.log(this.name)
    }
    Person.call(this, "smart")
}

var johnny = new JoestarFamily('johnny')
var elaine = new JoestarFamily('elaine')

console.log(johnny, elaine)

深入理解JavaScript-继承_第3张图片

看到没,所有的属性和方法都在实例上,Person 构造函数中的属性和方法和 JoestarFamily 上的属性和方法都作用在实例 johnny 和 elaine

我的理解是「拿来主义」:

深入理解JavaScript-继承_第4张图片

call 也好, apply 也罢,它们的作用都是为了修改 this 的指向。在这里,构造函数JoestarFamily 中调用 Person.call(this, "smart"),意思就是:

function JoestarFamily(name) {
    this.name = name
    this.sayName = function() {
        console.log(this.name)
    }
    
    this.brain = "smart";

    this.others = {
        other1: 1,
        other2: 2
    };
    this.setBrain = function () {
        console.log("set brain");
    }
}

如此一来,两个实例的属性互不干扰,就不存在修改原型链上的对象值而影响到其他实例

johnny.others.other1 = 123;
console.log(johnny.others.other1) // 123
console.log(elaine.others.other1 ) // 1
注意:所谓继承,是继承父类属性和方法。如果你在子类原型上添加对象属性,并修改对象属性中的某值,照样会影响所有的实例

但子类实例 johnny 和 elaine 却无法继承 Person.prototype 上的属性和方法(毕竟没有继承,只是拿了 Person 中的属性和方法),如下:

johnny.getBrain() // Uncaught TypeError: johnny.getBrain is not a function
johnny.age // undefined

优点:

  • 解决了原型链中子类实例共享父类引用属性的问题
  • 创建子类实例,可以向父类传递参数
  • 可以实现多继承(call 多个父类对象)

缺点:

  • 实例并不是父类的实例,只是子类的实例

    • johnny instanceof JoestarFamily 为 true
    • johnny instanceof Person 为 false
    • 因为只是借用父类的函数和方法而非继承它
  • 只能继承父类的属性和方法,不能继承父类原型属性和方法
  • 占用内存,每个子类都有父类的属性和方法(一模一样),影响性能

原型链+借用构造函数的组合继承

既想又要,鱼和熊掌皆想取得。既想使用原型链(提取公共方法至原型上,减少内存开销),又想让实例调用原型对象属性时不影响其他实例

怎么做呢?

function Person(brain) {
    this.brain = brain;

    this.others = {
        other1: 1,
        other2: 2
    };
    this.setBrain = function () {
        console.log("set brain");
    }
}


Person.prototype.getBrain = function () {
    console.log(this.brain)
}

Person.prototype.age = 100;
Person.prototype.like = {
    color: 'red',
}

function JoestarFamily(name) {
    this.name = name
    this.sayName = function() {
        console.log(this.name)
    }
    Person.call(this, "smart")
}

JoestarFamily.prototype = new Person();
// 等同于 JoestarFamily.prototype  === 实例.__proto__ === Person.prototype
JoestarFamily.prototype.constructor = JoestarFamily; // 原型的 constructor 指回原来的构造函数

JoestarFamily.prototype.sayHello = function() {}

var johnny = new JoestarFamily('johnny')
var elaine = new JoestarFamily('elaine')

console.log(johnny, elaine)

深入理解JavaScript-继承_第5张图片

它的原型链关系图如下:

深入理解JavaScript-继承_第6张图片

如此,就看到了一个结构清晰的继承模式

它与原型链继承比:因为在子类构造函数中调用 call 获取到父类构造函数中的属性(借用构造函数继承),所以实例化时会现在自身属性上找,这些值是独一份的;

johnny.others.other1 = 123
console.log(johnny.others.other1); // 123
console.log(elaine.others.other1); // 1

与借用构造函数继承比:JoestarFamily 的原型继承了 Person 的原型,能使用 Person 原型上的属性和方法

johnny.getBrain() // smart
johnny.age // 100

这种方式结合了原型链继承和借用构造函数继承的优点,是 JavaScript 中最常见的继承模式,不过也存在缺点,就是无论什么时候都会调用两次父类构造函数

一次是设置子类原型时:

JoestarFamily.prototype = new Person();

一次是创建子类实例的时候:

var johnny = new JoestarFamily('johnny')
//  Person.call(this, "smart")

优点:

  • 可以继承父类的属性和方法,也可以继承父类原型的属性和方法
  • 不存在引用数据共享问题
  • 可以传参给父类构造函数
  • 函数可以复用

缺点:

  • 调用了两次构造函数,生成了两份实例(造成不必要地内存开销)

原型式继承

笔者在 原型 曾介绍过,原型继承分为显式原型继承和隐式原型继承,隐式原型继承是语言内部帮我们实现,而显式原型继承则需要我们动手实现

Object.create

function Person(brain) {
    this.brain = brain;

    this.others = {
        other1: 1,
        other2: 2
    };
    this.setBrain = function () {
        console.log("set brain");
    }
}

Person.prototype.getBrain = function () {
    console.log(this.brain)
}

Person.prototype.age = 100;
Person.prototype.like = {
    color: 'red',
}

function JoestarFamily(name) {
    this.name = name
    this.sayName = function() {
        console.log(this.name)
    }
}

JoestarFamily.prototype = Object.create(Person.prototype); // 重新赋值原型,原先 JoestarFamily.prototype 上的 constructor 被抹除

JoestarFamily.prototype.constructor = JoestarFamily // 指回构造函数

var johnny = new JoestarFamily('johnny')
var elaine = new JoestarFamily('elaine')
console.log(johnny, elaine)

深入理解JavaScript-继承_第7张图片

原型链的关系如:

深入理解JavaScript-继承_第8张图片

关键在于 Object.create 和 new 的不同,笔者还是要不厌其烦地多说一句:

  • new带来的原型链关系是:实例.__proto__ === 构造函数.prototype
  • Object.create 的则是:实例.__proto__ === 传入的对象

这个案例中传入的对象是 Person.prototype,所以就有了 johnny.__proto__ === Person.prototype,它继承自传入的对象,而不像前三种继承,继承构造函数的原型(因为 new)

console.log(johnny.__proto__) // === Person.prototype
console.log(johnny.others.other1) // Cannot read properties of undefined (reading 'other1')
如果我们传入Person,会有另一番奇妙关系

所以经过 Object.create 创建的对象,它能继承传入对象的属性和方法

上诉例子中使用构造函数,构造函数中的属性和方法由 this 控制,所以没法被继承

优点:

  • 易于理解继承

缺点:

  • 原型重新赋值后需要将属性 constructor 重新赋值回来
  • 不能实现(子)类与(父)类的继承,只能实现对象的继承
  • 原型对象的引用属性会被多实例共享,不管是私有还是共有属性
  • 构造函数中的属性和方法无法被继承

Object.setPrototypeOf

世人皆知李清照,无人念我朱淑真

想必 Object.setPrototypeOf 会像朱淑真那样说一句,我只不过晚出生几年,”才气“不比 Object.create 差,为什么没人记得我

function Person(brain) {
    this.brain = brain;

    this.others = {
        other1: 1,
        other2: 2
    };
    this.setBrain = function () {
        console.log("set brain");
    }
}

Person.prototype.getBrain = function () {
    console.log(this.brain)
}

Person.prototype.age = 100;
Person.prototype.like = {
    color: 'red',
}

function JoestarFamily(name) {
    this.name = name
    this.sayName = function() {
        console.log(this.name)
    }
}

Object.setPrototypeOf(JoestarFamily.prototype, Person.prototype);

var johnny = new JoestarFamily('johnny')
var elaine = new JoestarFamily('elaine')
console.log(johnny, elaine)

深入理解JavaScript-继承_第9张图片

其原型链关系图:

深入理解JavaScript-继承_第10张图片

它与 Object.create 不同的是,它能传入两个对象,这样,我们就能让子类原型继承自父类原型,实现继承

优点:

  • 易于理解继承

缺点:

  • 原型对象的引用属性会被多实例共享,不管是私有还是共有属性
  • 构造函数中的属性和方法无法被继承

寄生式继承

故名思意,创建一个函数,该函数内部以某种方式增强对象,最后返回对象

function createObj(obj) {
    var clone = Object.create(obj)
    clone.sayHello = function() {
        console.log('hello')
    }
    return clone
}
let person = {
    name: 'johnny',
    age: 22
}

let anotherPerson = createObj(person)
anotherPerson.sayHello()

原型式继承的一种拓展

优点:

缺点:

  • 只是适用对象,与构造函数继承无关
  • 和借用构造函数模式一样,无法实现函数复用,每次创建对象都会创建一遍方法

寄生组合式继承

原型链+借用构造函数的组合继承中,最大的缺点是会调用两次父类构造函数,如果解决这个问题那是不是就成了最佳继承呢?

不使用 joestarFamily.prototype = new Person() ,而是用显式原型继承让 JoestarFamily.prototype 访问到 Person.prototype 呢

function Person(brain) {
    this.brain = brain;

    this.others = {
        other1: 1,
        other2: 2
    };
    this.setBrain = function () {
        console.log("set brain");
    }
}


Person.prototype.getBrain = function () {
    console.log(this.brain)
}

Person.prototype.age = 100;
Person.prototype.like = {
    color: 'red',
}

function JoestarFamily(name) {
    this.name = name
    this.sayName = function() {
        console.log(this.name)
    }
    Person.call(this, "smart")
}

var F = function () {} // 核心代码
F.prototype = Person.prototype // 核心代码

JoestarFamily.prototype = new F()

JoestarFamily.prototype.constructor = JoestarFamily; // 原型的 constructor 指回原来的构造函数

JoestarFamily.prototype.sayHello = function() {}

var johnny = new JoestarFamily('johnny')

console.log(johnny)

深入理解JavaScript-继承_第11张图片

它的原型链关系图和组合继承一样:

深入理解JavaScript-继承_第12张图片

不同的是,它少了因为 JoestarFamily 的原型上少了因 new Person 而产生的 brain、others 、setBrain 等 Person 构造函数的内置属性和方法

其实现的秘诀在于这几行代码

var F = function () {} // 创建一个空构造函数
F.prototype = Person.prototype // 将Person原型赋值给空构造函数的原型
// 即 F.prototype 拥有了 Person.prototype 上所有的属性和方法,包括 constructor,getBrain,age,like,__proto__
JoestarFamily.prototype = new F()
// new F,等于JoestarFamily.prototype.__proto__ === F.prototype

这个方法就是引用了 Object.create 的核心代码,其本质是不用 new 的特性,而是用显式原型继承的法子,这样就不用因使用 new 而产生副作用

显式原型继承不止一种,你 Object.create 能做的,我 Object.setPrototypeOf 也能实现

Object.setPrototypeOf(JoestarFamily.prototype, Person.prototype)

- var F = function () {} 
- F.prototype = Person.prototype 
- JoestarFamily.prototype = new F()

注意到没有,new 是会有副作用的,它不仅会建立原型链关系,而且会执行构造函数中的代码,将其赋予内存中生成的一个对象,并返回它成为实例

而像显式原型继承则只做关系(原型链)的链接,比较纯粹

优点:同组合继承

缺点:暂无

类继承

除了以上几种继承外,ES6 的类继承,是模拟类继承而出现的一语法糖 ,它的底层实现还是基于 prototype

class Person {
    constructor(brain) {
        this.brain = brain;
        this.others = {
            other1: 1,
            other2: 2
        };
        this.setBrain = function () {
            console.log("set brain");
        }
    }
    getBrain() {
        console.log(this.brain)
    }
    age = 100
    like = {
        color: 'red'
    }
}

class JoestarFamily extends Person{
    constructor(name) {
        super('smart')
        this.name = name
        this.sayName = function() {
            console.log(this.name)
        }
    }
    sayHello() {}
}
var johnny = new JoestarFamily('johnny')

console.log(johnny)

深入理解JavaScript-继承_第13张图片

原型链关系图:

深入理解JavaScript-继承_第14张图片

class 继承相比传统继承(以寄生组合继承为对比),它的不同点是

  • 构造函数也继承了:JoestarFamily.__proto__ === Person
  • 父类原型对象的属性继承无法继承,如 Person.prototype.agePerson.prototype.like

class 的职责是充当创建对象的模板,通常来说,data 数据由 instance 承载,而行为/方法则写在 class 里

也就是说,基于 class 的继承,继承的是行为和结构,但没有继承数据

而基于 prototype 的继承,则继承了数据、结构和行为三者

而为什么 class 不能继承数据呢?这是为了迎合 class 的基本行为,故意将其屏蔽

行为/方法可以公用,放在原型对象中,而数据则是独一份的,则可在构造函数中,这样就能解决大多数场景了,毕竟其他语言都是这样做的

案例分析

来两题练练腿

按照如下要求实现 Person 和 Student 对象

  • Student 继承 Person
  • Person 包含一个实例变量 name, 包含一个实例方法 printName
  • Student 包含一个实例变量 score, 包含一个实例方法 printScore
  • Person 和 Student 之间共享一个方法 printCommon
function Person(name) {
    this.name = name
    this.printName = function() {
        console.log(this.name)
    }
}

Person.prototype.commonMethods = function() {
    console.log('共享方法')
}

function Student(name, score) {
    this.score = score
    this.printScore = function() {
        console.log(this.score)
    }
    Person.call(this, name)
}

var F = function() {}
F.prototype = Person.prototype

Student.prototype = new F()

var johnny = new Person('johnny')
var elaine = new Student('elaine', 99)
console.log(johnny.commonMethods === elaine.commonMethods)

这题较简单,再来一题,绘制原型链关系图

class A {}
class B extends A {}

const b = new B();

如果单单这几个原型链关系图的话还是简单的:

深入理解JavaScript-继承_第15张图片

但如果要做全的话

深入理解JavaScript-继承_第16张图片

这里就涉及到 Object 和 Function 鸡生蛋蛋生鸡问题,具体可以看看这篇文章了解一二——JavaScript中的始皇(后续文章更新)

总结

按照我们在 原型 中所说,继承可分为显式原型继承和隐式原型继承,显式原型继承分别是Object.create、Object.setPrototypeOf,隐式原型继承则是 new、对象字面量

这节我们从传统继承理解角度看,分别讲解了原型链继承、借用构造函数继承、组合继承(原型链+借用构造函数)、原型式继承(Object.create、Object.setPrototypeOf)、寄生式继承、寄生式组合继承(原型式+借用构造函数)、类继承等多种继承方式

也明白原型式继承就是显式原型继承

而与原型相关的继承如:原型链继承、Object.create、Object.setPrototypeOf 继承,有一个通病,即构造函数中的属性和方法无法被继承,,并且它们原型对象的引用属性会被实例共享

而唯一的解决方案就是借用构造函数继承,即在子类构造函数中调用 this 指针,而组合继承和寄生组合继承都能实现完美的原型链关系,两者的区别在于组合继承调用 了两次构造函数,其原因是因为 new 的副作用,而寄生组合继承能胜过一筹的原因就是显式原型继承是不会产生副作用,只做简单的原型关系关联

虽然现在原型的知识点在前端已显得不那么重要,理由也很简单,JavaScript 的各种框架开始抛弃混入模式,而转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。”组合胜过继承“的设计模式已经让很多人遵循了,没必要理解原型也很正常,组合模式的运行也让函数式编程开始流行,不过这是后话

参考资料

系列文章

你可能感兴趣的:(深入理解JavaScript-继承)