一道题弄清楚JavaScript继承演化史

作者: 许骁Charles | 来源:发表于2019-08-04 23:48 被阅读25次
  1. 写出一个构造函数 Animal
  • 输入:空
  • 输出:一个新对象,该对象的共有属性为 {行动: function(){}},没有自有属性
  1. 再写出一个构造函数 Human
  • Human 继承 Animal
  • 输入:一个对象,如 {name: 'Frank', birthday: '2000-10-10'}
  • 输出:一个新对象,该对象自有的属性有 name 和 birthday,共有的属性有物种(人类)、行动和使用工具
  1. 再写出一个构造函数 Asian
  • Asian 继承 Human
  • 输入:一个对象,如 {city: '北京', name: 'Frank', birthday: '2000-10-10' }
  • 输出:一个新对象,该对象自有的属性有 name city 和 bitrhday,共有的属性有物种、行动和使用工具和肤色

即,最后一个新对象是 Asian 构造出来的,Asian 继承 Human,Human 继承 Animal。

首次尝试

在 JavaScript 中,继承是基于原型来实现的。在 ES6 之前,没有类的概念,ECMAScript 2015 中引入的 JavaScript 类实质上是 JavaScript 现有的基于原型的继承的语法糖。类语法不会为JavaScript引入新的面向对象的继承模型。

JS 的作者为了吸引 Java 开发者来写 JS 代码,尽量在用函数模拟 Java 类的实现。但是 JS 中函数是一等公民,而不是像 Java 一样函数是类的附庸。在 JS 中,构造函数就是类。

而因为是基于原型的继承,我们得知道一个重要公式:
实例.__proto__ === 构造函数.prototype

那么首先,按需求写好构造函数的基本内容。

(1)构造函数 Animal

function Animal() {}
Animal.prototype.move = function() {
    console.log('I am moving...')
}

(2)构造函数 Human

function Human(person) {
    // 借用构造继承
    Animal.call(this)  // 继承Animal的私有属性
    this.name = person.name || 'Unnamed'
    this.birthday = person.birthday || '1970-01-01'
}

// 注意,如果写成 Human.prototype = {} 形式,会直接打破原型链,继承会出错
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {
    console.log('I can use tools!')
}

(3)构造函数 Asian

function Asian(person) {
    // 借用构造继承
    Human.call(this, person) // 继承Human的私有属性
    this.city = person.city || 'Beijing'
}
Asian.prototype.skin = 'yellow'

根据原型链的公式,如果要 Human 继承自 Animal,那么我们最快的实现方法就是在代码中 添加 Human.prototype.__proto__ = Animal.prototype 即可。如:

function Animal() {}
Animal.prototype.move = function() {
    console.log('I am moving...')
}

function Human(person) {
    Animal.call(this)  // 继承Animal的私有属性
    this.name = person.name || 'Unnamed'
    this.birthday = person.birthday || '1970-01-01'
}
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {}
Human.prototype.__proto__ = Animal.prototype

缺点:在生产环境中使用 __proto__ 会引起严重的性能问题
因为许多浏览器优化了原型,尝试在调用实例之前猜测方法在内存中的位置,但是动态设置原型干扰了所有的优化,甚至可能使浏览器为了运行成功,使用完全未经优化的代码进行重编译。

二次尝试(组合继承)

既然不能使用 __proto__,那么要怎样才将 Human 添加到 Animal 的原型链中去呢?

答案是,用 new

new 是 JS 之父为我们封装好的语法糖,当使用 new 来调用函数时,会自动执行下面的操作:

  1. 创建一个全新的对象
  2. 这个新对象会被执行 [[Prototype]] 连接
  3. 这个新对象会绑定到函数调用的 this
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象

其中第二步即能将新实例对象绑定到原型链中。

我们想得到的是 Human.prototype.__proto__ === Animal.prototype,那么 Human.prototype 整体作为 Animal 的一个实例就好了,即

Human.prototype = new Animal()
Human.prototype.constructor = Human

上面的第一行代码,Human 摈弃了自己的原型,强行赋了一个新值,而同时更改了 Human.prototypeconstructor 的指向,第二行代码就是为了修正它的指向。

回顾上面的代码,用到了组合继承,即原型链继承 + 借用构造继承

(1)原型链继承

核心思想:将父类的实例作为子类的原型
优点:父类方法可以复用
缺点:

  • 无法实现多继承
  • 多个实例对引用类型的操作会被篡改,因为包含引用类型的原型会被所有实例共享
  • 不能向父类构造函数传递参数

因此,原型链继承一般不单独使用。

(2)借用构造继承

核心思想:子类构造函数内部调用父类构造函数
优点:

  • 可以向父类构造函数传递参数
  • 可以实现多继承(call多个父类对象)
    缺点:
  • 只能继承父类的实例属性和方法,不能继承原型属性和方法
  • 无法实现复用,每个子类都有父类实例函数的副本,影响性能

这两个继承总体被称为组合继承

组合继承完整代码如下:

function Animal() {}
Animal.prototype.move = function() {
    console.log('I am moving...')
}

function Human(person) {
    // 第一次调用 Animal
    Animal.call(this) //借用构造函数,继承了Animal且向父类传参
    this.name = person.name || 'Unnamed'
    this.birthday = person.birthday || '1970-01-01'
}

Human.prototype = new Animal() // 第二次调用 Animal
Human.prototype.constructor = Human

// 注意,如果写成 Human.prototype = {} 形式,会直接打破原型链,继承会出错
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {
    console.log('I can use tools!')
}
function Asian(person) {
    Human.call(this, person)
    this.city = person.city || 'Beijing'
}

Asian.prototype = new Human({})
Asian.prototype.constructor = Asian

Asian.prototype.skin = 'yellow'

var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })

注意:当我们需要在子类中添加新的方法或者是重写父类的方法时候,切记一定要放到替换原型的语句之后。

测试一下:

基本功能算是实现了。但是,我们发现实例 jay 和它的原型 Asian.prototype 里面有重叠的属性。小结一下:

优点:

  • 父类的方法可以被复用
  • 父类的引用属性不会被共享
  • 子类构建实例时可以向父类传递参数
  • 实例属性/方法、原型属性/方法均可以继承

缺点:

  • 调用了两次父类的构造函数,第一次给子类的原型添加了父类的属性,第二次又给子类的构造函数添加了父类的属性,从而覆盖了子类原型中的同名参数,浪费性能

三次尝试(寄生组合式继承)

这里参考了由 Douglas Crockford 提出的原型式继承。他的想法是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。如下:

function object(o){
    function F(){}
    F.prototype = o;
    return new F();
}

本质上讲,原型式继承是对参数对象的一个浅复制,因此,它会有和原型链继承一样的问题,即引用属性所有实例共享。

我们将代码稍作变形,如下:

// 1.空函数 F
function F() {}

// 2.把 F 的原型指向 Animal.prototype
F.prototype = Animal.prototype

// 3.把 Human 的原型指向一个新的 F 对象,F 对象的原型正好指向 Animal.prototype
Human.prototype = new F()

// 4.将 Human 原型的构造函数修复为 Human
Human.prototype.constructor = Human

函数 F 仅用于桥接,我们仅创建了一个 new F() 实例,没有改变原有的原型链。并且由于 F 是空对象,所以几乎不占内存。

如果把继承这个动作用一个 inherits() 函数封装起来,还可以隐藏 F 的定义,并简化代码:

function inherits(Child, Parent) {
    function F() {}
    F.prototype = Parent.prototype
    Child.prototype = new F()
    Child.prototype.constructor = Child
}

所以寄生组合式继承的全部代码如下:

function Animal() {}

Animal.prototype.move = function() {
    console.log('I am moving...')
}

function Human(person) {
    Animal.call(this) //借用构造函数,继承了Animal且向父类传参
    this.name = person.name || 'Unnamed'
    this.birthday = person.birthday || '1970-01-01'
}

inherits(Human, Animal)

// 注意,如果写成 Human.prototype = {} 形式,会直接打破原型链,继承会出错
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {
    console.log('I can use tools!')
}

function Asian(person) {
    Human.call(this, person)
    this.city = person.city || 'Beijing'
}

inherits(Asian, Human)

Asian.prototype.skin = 'yellow'

/****************** Helper ***********************/
function inherits(Child, Parent) {
    function F() {}
    F.prototype = Parent.prototype
    Child.prototype = new F()
    Child.prototype.constructor = Child
}

测试:

var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })
var someone = new Asian({})
someone.move = function () {
    console.log('THIS FUCNTION HAS BEEN CHANGED!!')
}

总结一下,寄生组合式继承的优点:

  • 避免了父类的引用属性被共享
  • 可以复用继承函数在爷孙三代甚至多代,提高了代码的复用性
  • F 是空函数,几乎不占内存,相当于只在子类构造函数中调用了一次父类,更省内存
  • 能够正常使用 instanceofisPrototypeOf

开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

四次尝试(Object.create())

以上的方法,均是在远古时期,无可奈何做的一些妥协技巧。但是到了 ECMAScript 5,官方发糖,通过新增 Object.create() 方法规范化了原型式继承。

Object.create() 接收两个参数:

  • 一个用作新对象原型的对象
  • (可选的)一个为新对象定义额外属性的对象

直接在需要原型链继承的地方,改为 Child.prototype = Object.create(Father.prototype) 即可。

全部代码如下:

function Animal() {}

Animal.prototype.move = function() {
    console.log('I am moving...')
}

function Human(person) {
    Animal.call(this) //借用构造函数,继承了Animal且向父类传参
    this.name = person.name || 'Unnamed'
    this.birthday = person.birthday || '1970-01-01'
}

Human.prototype = Object.create(Animal.prototype)

// 注意,如果写成 Human.prototype = {} 形式,会直接打破原型链,继承会出错
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {
    console.log('I can use tools!')
}

function Asian(person) {
    Human.call(this, person)
    this.city = person.city || 'Beijing'
}

Asian.prototype = Object.create(Human.prototype)

Asian.prototype.skin = 'yellow'

var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })

五次尝试(ES6 extends)

版本来到 ECMAScript 6,引入了 class 类,同时引入了 extends 继承,这和传统的面向对象的语言更接近了。

基本上,ES6 的 class 可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的 class 写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。

ES6 继承的结果和寄生组合继承相似。

区别:

  • 寄生组合继承是先创建子类实例 this 对象,然后再对其增强
  • ES6先将父类实例对象的属性和方法,加到 this 上面(所以必须先调用 super 方法),然后再用子类的构造函数修改 this

代码如下:

class Animal {
    move() {
        console.log('I am moving...')
    }
}

class Human extends Animal {
    constructor(person) {
        super()
        this.name = person.name || 'Unnamed'
        this.birthday = person.birthday || '1970-01-01'
    }

    species = 'Human'

    toolManipulating() {
        console.log('I can use tools!')
    }
}

class Asian extends Human {
    constructor(person) {
        super(person)
        this.city = person.city || 'Beijing'
    }

    skin = 'Yellow'
}

var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })

但是测试我们发现:

skinspecies 都被作为实例属性处理了,查过了 API 才知道,这是实例属性的新写法罢了。

class A {
    name = 'Jay'
}

相当于

class A {
    constructor() {
        this.name = 'Jay'
    }
}

那么,要怎样才能写出 ES6 中类的公有属性(即原型属性)呢?

class Animal {
    move() {
        console.log('I am moving...')
    }
}

class Human extends Animal {
    constructor(person) {
        super()
        this.name = person.name || 'Unnamed'
        this.birthday = person.birthday || '1970-01-01'
    }

    toolManipulating() {
        console.log('I can use tools!')
    }
}

Human.prototype.species = 'Human'

class Asian extends Human {
    constructor(person) {
        super(person)
        this.city = person.city || 'Beijing'
    }
}

Asian.prototype.skin = 'Yellow'
var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })

嗯?那这不是回到 ES5 的老路上了么?就没有 ES6 的优雅写法么?

抱歉,目前真没有。

查询可知一些方法,诸如使用 getter 方法或者在 constructor 中添加如下代码:

if(!this.__proto__.species) {
       this.__proto__.species= "Human"
}

但是这些看起来都不是优雅的方案,希望未来跟进吧。

总结

继承是 JS 面试中常考的点,牵扯到你对原型、原型链的理解,对原型继承流派和面向对象流派的选择偏好,也能拓展出在错综复杂的继承关系中对 this 的掌握能力,总之是必须要掌握的内容。本文没有涉及到“深拷贝继承”这种方式。

参考:

相关文章

网友评论

    本文标题:一道题弄清楚JavaScript继承演化史

    本文链接:https://www.haomeiwen.com/subject/vzatdctx.html