JS基础-深入浅出继承

前言

上篇文章详细解析了原型、原型链的相关知识点,这篇文章讲的是和原型链有密切关联的继承,它是前端基础中很重要的一个知识点,它对于代码复用来说非常有用,本篇将详细解析JS中的各种继承方式和优缺点进行,希望看完本篇文章能够对继承以及相关概念理解的更为透彻。

本篇文章需要先理解原型、原型链以及call的相关知识:

JS基础-函数、对象和原型、原型链的关系

js基础-面试官想知道你有多理解call,apply,bind?

何为继承?

维基百科:继承可以使得子类具有父类别的各种属性和方法,而不需要再次编写相同的代码。

继承是一个类从另一个类获取方法和属性的过程

PS:或者是多个类

JS实现继承的原理

记住这个概念,你会发现JS中的继承都是在实现这个目的,差异是它们的实现方式不同。

复制父类的属性和方法来重写子类原型对象

原型链继承(new):

function fatherFn() {
  this.some = '父类的this属性';
}
fatherFn.prototype.fatherFnSome =  '父类原型对象的属性或者方法';
// 子类
function sonFn() {
  this.obkoro1 = '子类的this属性';
}
// 核心步骤:重写子类的原型对象
sonFn.prototype = new fatherFn(); // 将fatherFn的实例赋值给sonFn的prototype
sonFn.prototype.sonFnSome = '子类原型对象的属性或者方法' // 子类的属性/方法声明在后面,避免被覆盖
// 实例化子类
const sonFnInstance = new sonFn();
console.log('子类的实例:', sonFnInstance);

原型链子类实例

原型链子类实例

原型链继承获取父类的属性和方法

  1. fatherFn通过this声明的属性/方法都会绑定在new期间创建的新对象上。
  2. 新对象的原型是father.prototype,通过原型链的属性查找到father.prototype的属性和方法。

理解new做了什么:

new在本文出现多次,new也是JS基础中很重要的一块内容,很多知识点会涉及到new,不太理解的要多看几遍。

  1. 创建一个全新的对象。
  2. 这个新对象的原型(__proto__)指向函数的prototype对象。
  3. 执行函数,函数的this会绑定在新创建的对象上。
  4. 如果函数没有返回其他对象(包括数组、函数、日期对象等),那么会自动返回这个新对象。
  5. 返回的那个对象为构造函数的实例。

构造调用函数返回其他对象

返回其他对象会导致获取不到构造函数的实例,很容易因此引起意外的问题

我们知道了fatherFnthisprototype的属性/方法都跟new期间创建的新对象有关系

如果在父类中返回了其他对象(new的第四点),其他对象没有父类的thisprototype,因此导致原型链继承失败

我们来测试一下,修改原型链继承中的父类fatherFn

function fatherFn() {
  this.some = '父类的this属性';
  console.log('new fatherFn 期间生成的对象', this)
  return [ '数组对象', '函数对象', '日期对象', '正则对象', '等等等', '都不会返回new期间创建的新对象' ]
}
原型链继承返回其他对象,将导致原型链继承失败

PS: 本文中构造调用函数都不能返回其他函数,下文不再提及该点。

不要使用对象字面量的形式创建原型方法:

这种方式很容易在不经意间,清除/覆盖了原型对象原有的属性/方法,不该为了稍微简便一点,而使用这种写法。

有些人在需要在原型对象上创建多个属性和方法,会使用对象字面量的形式来创建:

sonFn.prototype = new fatherFn();
// 子类的prototype被清空后 重新赋值, 导致上一行代码失效
sonFn.prototype = {
    sonFnSome: '子类原型对象的属性',
    one: function() {},
    two: function() {},
    three: function() {}
}

还有一种常见的做法,该方式会导致函数原型对象的属性constructor丢失:

function test() {}
test.prototype = {
    ...
}

原型链继承的缺点

  1. 父类使用this声明的属性被所有实例共享

    原因是:实例化的父类(sonFn.prototype = new fatherFn())是一次性赋值到子类实例的原型(sonFn.prototype)上,它会将父类通过this声明的属性也在赋值到sonFn.prototype上。

值得一提的是:很多博客中说,引用类型的属性被所有实例共享,通常会用数组来举例,实际上数组以及其他父类通过this声明的属性也只是通过原型链查找去获取子类实例的原型(sonFn.prototype)上的值。

  1. 创建子类实例时,无法向父类构造函数传参,不够灵活。

这种模式父类的属性、方法一开始就是定义好的,无法向父类传参,不够灵活。

sonFn.prototype = new fatherFn()

借用构造函数继承(call)

 function fatherFn(...arr) {
  this.some = '父类的this属性';
  this.params = arr // 父类的参数
}
fatherFn.prototype.fatherFnSome = '父类原型对象的属性或者方法';
function sonFn(fatherParams, ...sonParams) {
  fatherFn.call(this, ...fatherParams); // 核心步骤: 将fatherFn的this指向sonFn的this对象上
  this.obkoro1 = '子类的this属性';
  this.sonParams = sonParams; // 子类的参数
}
sonFn.prototype.sonFnSome = '子类原型对象的属性或者方法'
let fatherParamsArr = ['父类的参数1', '父类的参数2']
let sonParamsArr = ['子类的参数1', '子类的参数2']
const sonFnInstance = new sonFn(fatherParamsArr, ...sonParamsArr); // 实例化子类
console.log('借用构造函数子类实例', sonFnInstance)

借用构造函数继承的子类实例

借用构造函数继承的子类实例

借用构造函数继承做了什么?

声明类,组织参数等,只是辅助的上下文代码,核心是借用构造函数使用call做了什么:

一经调用call/apply它们就会立即执行函数,并在函数执行时改变函数的this指向

fatherFn.call(this, ...fatherParams); 
  1. 在子类中使用call调用父类,fatherFn将会被立即执行,并且将fatherFn函数的this指向sonFnthis
  2. 因为函数执行了,所以fatherFn使用this声明的函数都会被声明到sonFnthis对象下。
  3. 实例化子类,this将指向new期间创建的新对象,返回该新对象。
  4. fatherFn.prototype没有任何操作,无法继承。

该对象的属性为:子类和父类声明的this属性/方法,它的原型是

PS: 关于call/apply/bind的更多细节,推荐查看我的博客:js基础-面试官想知道你有多理解call,apply,bind?[不看后悔系列]

借用构造函数继承的优缺点

优点:

  1. 可以向父类传递参数
  2. 解决了原型链继承中:父类属性使用this声明的属性会在所有实例共享的问题。

缺点:

  1. 只能继承父类通过this声明的属性/方法,不能继承父类prototype上的属性/方法。
  2. 父类方法无法复用:因为无法继承父类的prototype,所以每次子类实例化都要执行父类函数,重新声明父类this里所定义的方法,因此方法无法复用。

组合继承(call+new)

原理:使用原型链继承(new)将thisprototype声明的属性/方法继承至子类的prototype上,使用借用构造函数来继承父类通过this声明属性和方法至子类实例的属性上。

function fatherFn(...arr) {
  this.some = '父类的this属性';
  this.params = arr // 父类的参数
}
fatherFn.prototype.fatherFnSome = '父类原型对象的属性或者方法';
function sonFn() {
  fatherFn.call(this, '借用构造继承', '第二次调用'); // 借用构造继承: 继承父类通过this声明属性和方法至子类实例的属性上
  this.obkoro1 = '子类的this属性';
}
sonFn.prototype = new fatherFn('原型链继承', '第一次调用'); // 原型链继承: 将`this`和`prototype`声明的属性/方法继承至子类的`prototype`上
sonFn.prototype.sonFnSome = '子类原型对象的属性或者方法'
const sonFnInstance = new sonFn();
console.log('组合继承子类实例', sonFnInstance)

组合继承的子类实例

组合继承的子类实例

从图中可以看到fatherFn通过this声明的属性/方法,在子类实例的属性上,和其原型上都复制了一份,原因在代码中也有注释:

  1. 原型链继承: 父类通过thisprototype声明的属性/方法继承至子类的prototype上。
  2. 借用构造继承: 父类通过this声明属性和方法继承至子类实例的属性上。

组合继承的优缺点

优点:

完整继承(又不是不能用),解决了:

  1. 父类通过this声明属性/方法被子类实例共享的问题(原型链继承的问题)
    每次实例化子类将重新初始化父类通过this声明的属性,实例根据原型链查找规则,每次都会
  2. 父类通过prototype声明的属性/方法无法继承的问题(借用构造函数的问题)。

缺点:

  1. 两次调用父类函数(new fatherFn()fatherFn.call(this)),造成一定的性能损耗。
  2. 因调用两次父类,导致父类通过this声明的属性/方法,生成两份的问题。
  3. 原型链上下文丢失:子类和父类通过prototype声明的属性/方法都存在于子类的prototype上

原型式继承(Object.create())

继承对象原型-Object.create()实现

以下是Object.create()的模拟实现,使用Object.create()可以达成同样的效果,基本上现在都是使用Object.create()来做对象的原型继承。

function cloneObject(obj){
  function F(){}
  F.prototype = obj; // 将被继承的对象作为空函数的prototype
  return new F(); // 返回new期间创建的新对象,此对象的原型为被继承的对象, 通过原型链查找可以拿到被继承对象的属性
}

PS:上面Object.create()实现原理可以记一下,有些公司可能会让你讲一下它的实现原理。

例子:

let oldObj = { p: 1 };
let newObj = cloneObject(oldObj)
oldObj.p = 2
console.log('oldObj newObj', oldObj, newObj)
原型式继承

原型式继承优缺点:

优点: 兼容性好,最简单的对象继承。

缺点:

  1. 因为旧对象(oldObj)是实例对象(newObj)的原型,多个实例共享被继承对象的属性,存在篡改的可能。
  2. 无法传参

寄生式继承(封装继承过程)

创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后返回对象。

function createAnother(original){
  var clone = cloneObject(original); // 继承一个对象 返回新函数
  // do something 以某种方式来增强对象
  clone.some = function(){}; // 方法
  clone.obkoro1 = '封装继承过程'; // 属性
  return clone; // 返回这个对象
}

使用场景:专门为对象来做某种固定方式的增强。

寄生组合式继承(call+寄生式封装)

寄生组合式继承原理:

  1. 使用借用构造函数(call)来继承父类this声明的属性/方法
  2. 通过寄生式封装函数设置父类prototype为子类prototype的原型来继承父类的prototype声明的属性/方法
function fatherFn(...arr) {
  this.some = '父类的this属性';
  this.params = arr // 父类的参数
}
fatherFn.prototype.fatherFnSome = '父类原型对象的属性或者方法';
function sonFn() {
  fatherFn.call(this, '借用构造继承'); // 核心1 借用构造继承: 继承父类通过this声明属性和方法至子类实例的属性上
  this.obkoro1 = '子类的this属性';
}
// 核心2 寄生式继承:封装了son.prototype对象原型式继承father.prototype的过程,并且增强了传入的对象。
function inheritPrototype(son, father) {
  const fatherFnPrototype = Object.create(father.prototype); // 原型式继承:浅拷贝father.prototype对象 father.prototype为新对象的原型
  son.prototype = fatherFnPrototype; // 设置father.prototype为son.prototype的原型
  son.prototype.constructor = son; // 修正constructor 指向
}
inheritPrototype(sonFn, fatherFn)
sonFn.prototype.sonFnSome = '子类原型对象的属性或者方法'
const sonFnInstance = new sonFn();
console.log('寄生组合式继承子类实例', sonFnInstance)

寄生组合式继承子类实例

寄生组合式继承子类实例

寄生组合式继承是最成熟的继承方法:

寄生组合式继承是最成熟的继承方法, 也是现在最常用的继承方法,众多JS库采用的继承方案也是它。

寄生组合式继承相对于组合继承有如下优点:

  1. 只调用一次父类fatherFn构造函数。

  2. 避免在子类prototype上创建不必要多余的属性。

  3. 使用原型式继承父类的prototype,保持了原型链上下文不变。

    子类的prototype只有子类通过prototype声明的属性/方法和父类prototype上的属性/方法泾渭分明。

ES6 extends继承:

ES6继承的原理跟寄生组合式继承是一样的。

ES6 extends核心代码:

这段代码是通过babel在线编译成es5, 用于子类prototype原型式继承父类prototype的属性/方法。

// 寄生式继承 封装继承过程
function _inherits(son, father) {
  // 原型式继承: 设置father.prototype为son.prototype的原型 用于继承father.prototype的属性/方法
  son.prototype = Object.create(father && father.prototype);
  son.prototype.constructor = son; // 修正constructor 指向
  // 将父类设置为子类的原型 用于继承父类的静态属性/方法(father.some)
  if (father) {
    Object.setPrototypeOf
      ? Object.setPrototypeOf(son, father)
      : son.__proto__ = father;
  }
}

另外子类是通过借用构造函数继承(call)来继承父类通过this声明的属性/方法,也跟寄生组合式继承一样。

ES5继承与ES6继承的区别:

本段摘自阮一峰-es6入门文档

  • ES5的继承实质上是先创建子类的实例对象,再将父类的方法添加到this上

  • ES6的继承是先创建父类的实例对象this,再用子类的构造函数修改this

    因为子类没有自己的this对象,所以必须先调用父类的super()方法。

扩展:

为什么要修正constructor指向?

在寄生组合式继承中有一段如下一段修正constructor 指向的代码,很多人对于它的作用以及为什么要修正它不太清楚。

son.prototype.constructor = son; // 修正constructor 指向

constructor的作用

MDN的定义:返回创建实例对象的Object构造函数的引用

即返回实例对象的构造函数的引用,例如:

let instance = new sonFn()
instance.constructor // sonFn函数

constructor的应用场景:

当我们只有实例对象没有构造函数的引用时

某些场景下,我们对实例对象经过多轮导入导出,我们不知道实例是从哪个函数中构造出来或者追踪实例的构造函数,较为艰难。

这个时候就可以通过实例对象的constructor属性来得到构造函数的引用:

let instance = new sonFn() // 实例化子类
export instance;
// 多轮导入+导出,导致sonFn追踪非常麻烦,或者不想在文件中再引入sonFn
let  fn = instance.constructor
// do something: new fn() / fn.prototype / fn.length / fn.arguments等等

保持constructor指向的一致性:

因此每次重写函数的prototype都应该修正一下constructor的指向,以保持读取constructor行为的一致性。

小结

继承也是前端的高频面试题,了解本文中继承方法的优缺点,有助于更深刻的理解JS继承机制。除了组合继承和寄生式继承都是由其他方法组合而成的,分块理解会对它们理解的更深刻。

建议多看几遍本文,建个html文件试试文中的例子,两相结合更佳!

对prototype还不是很理解的同学,可以再看看:JS基础-函数、对象和原型、原型链的关系

觉得我的博客对你有帮助的话,就给我点个Star吧!

前端进阶积累公众号GitHub、wx:OBkoro1、邮箱:obkoro1@foxmail.com

以上2019/9/22

作者:OBKoro1

参考资料:

JS高级程序设计(红宝书)6.3继承

JavaScript常用八种继承方案

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

推荐阅读更多精彩内容