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

  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 的掌握能力,总之是必须要掌握的内容。本文没有涉及到“深拷贝继承”这种方式。

参考:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容