征服 JavaScript 面试:类继承和原型继承的区别

2017/01/30 · JavaScript
· 继承

原文出处: Eric
Elliott   译文出处:众成翻译   

金沙国际官网 1

图-电子吉他-Feliciano Guimarães(CC BY 2.0)

“征服JavaScript面试”是我所写的一个系列文章,旨在帮助那些应聘中、高级JavaScript开发职位的读者们准备一些常见的面试问题。我自己在实际面试当中也经常会问到这类问题。系列的第一篇文章请参见“什么是闭包”

注:本文均以ES6标准做代码举例。如果想了解ES6,可以参阅“ES6学习指南”

原文链接:https://medium.com/javascript-scene/master-the-javascript-interview-what-s-the-difference-between-class-prototypal-inheritance-e4cd0a7562e9\#.d84c324od

对象在JavaScript语言中使用十分广泛,学会如何有效地运用对象,有助于工作效率的提升。而不良的面向对象设计,可能会导致代码工程的失败,更严重的话还会引发整个公司悲剧

不同于其它大部分语言,JavaScript是基于原型的对象系统,而不是基于。遗憾的是,大多数JavaScript开发者对其对象系统理解不到位,或者难以良好地应用,总想按照类的方式使用,其结果将导致代码里的对象使用混乱不堪。所以JavaScript开发者最好对原型和类都能有所了解。

我曾尝试理解关于prototype的相关概念,最初理解起来晦涩难懂,加上当时用的地方又少。后面渐渐明白,当你需要了解一个东西的时候,刻意的去理解是没有本质的作用的,但是能在你的脑海里留下一丝印象,当你真正遇到的时候,会想起曾经看到过,时机成熟的时候再去理解,会有不少收获,轮番看个几遍,拿上实例解析,会发现豁然开朗。

本文所述内容:

一、来源

JavaScript不是真正意义上的面向对象语言,没有提供传统的继承方式,它提供的是一种叫做原型继承的方式

前言

写这篇笔记的初衷,是想进一步了解ES6的特性extends是如何实现了继承,查看源码后发现核心的一段不能理解

// 赋值原型 
subClass.prototype = Object.create(superClass && superClass.prototype)

// 这一步是作甚?根据组合继承的逻辑,完全没有必要这一步?
if (superClass) 
    Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;

由于不太清楚__proto__与原型prototype的含义,以及ObjectFunction的关系,便做进一步深究。

题外话:我并不觉得深究一件事有何不值得,就像是“走火入魔”般,何况这件事是很多人都曾做过的。我相信我会喜欢上深究一个问题这样一个过程而不是结果。

类继承和原型继承有何区别?

这个问题比较复杂,大家有可能会在评论区各抒己见、莫衷一是。因此,列位看官需要打起十二分的精神学习个中差异,并将所学良好地运用到实践当中去。

类继承:可以把类比作一张蓝图,它描绘了被创建对象的属性及特征。

众所周知,使用new关键字调用构造函数可以创建类的实例。在ES6中,不用class关键字也可以实现类继承。像Java语言中类的概念,从技术上来说在JavaScript中并不存在。不过JavaScript借鉴了构造函数的思想。ES6中的class关键字,相当于是建立在构造函数之上的一种封装,其本质依旧是函数。

JavaScript

class Foo {} typeof Foo // ‘function’

1
2
class Foo {}
typeof Foo // ‘function’

虽然JavaScript中的类继承的实现建立在原型继承之上,但是并不意味二者具有相同的功能:

JavaScript的类继承使用原型链来连接子类和父类的
[[Prototype]],从而形成代理模式。通常情况下,super()_构造函数也会被调用。这种机制,形成了单一继承结构,以及面向对象设计中最紧密的耦合行为

“类之间的继承关系,导致了子类间的相互关联,从而形成了——基于层级的分类。”

原型继承: 原型是工作对象的实例。对象直接从其他对象继承属性。

原型继承模式下,对象实例可以由多个对象源所组成。这样就使得继承变得更加灵活且[[Prototype]]代理层级较浅。换言之,对于基于原型继承的面向对象设计,不会产生层级分类这样的副作用——这是区别于类继承的关键所在。

对象实例通常由工厂函数或者Object.create()来创建,也可以直接使用Object字面定义。

原型是工作对象的实例。对象直接从其他对象继承属性。”

本文阐述的相关内容:

  • 由组合构造模式详解组合继承模式,及其问题所在,问题所产生的原因,解决问题的办法
  • 合理的继承模式原理及瑕疵

二、原型的作用

最主要的一点是数据共享,创建对象的时候,我们会把公共的方法和属性挂载到原型上,实例可以访问原型对象上定义的属性和方法

概述

这里我想要理清楚的问题是:
1、ObjectFunctionprototype__proto__究竟是怎样的关系,怎么得来?
2、ES5合理继承的方式(即前篇文章所提到的组合继承)与ES6extends有何异同?ES6源码剖析


为什么搞清楚类继承和原型继承很重要?

继承,本质上讲是一种代码重用机制——各种对象可以借此来共享代码。如果代码共享的方式选择不当,将会引发很多问题,如:

使用类继承,会产生父-子对象分类的副作用

这种类继承的层次划分体系,对于新用例将不可避免地出现问题。而且基类的过度派生,也会导致脆弱基类问题,其错误将难以修复。事实上,类继承会引发面向对象程序设计领域的诸多问题:

  • 紧耦合问题(在面向对象设计中,类继承是耦合最严重的一种设计),紧耦合还会引发另一个问题:
  • 脆弱基类问题
  • 层级僵化问题(新用例的出现,最终会使所有涉及到的继承层次上都出现问题)
  • 必然重复性问题(因为层级僵化,为了适应新用例,往往只能复制,而不能修改已有代码)
  • 大猩猩-香蕉问题(你想要的是一个香蕉,但是最终到的却是一个拿着香蕉的大猩猩,还有整个丛林)

对于这些问题我曾做过深入探讨:“类继承已是明日黄花——探究基于原型的面向对象编程思想”

“优先选择对象组合而不是类继承。”
~先驱四人,《设计模式:可复用面向对象软件之道》

里面很好地总结了:

  • 创建对象的几种模式以及创建的过程
  • 原型链prototype的理解,以及prototype
    __proto__[[Prototype]])的关系
  • 继承的几种实现

三、原型继承

当我们通过new Person();初始化一个实例的时候,实例一创造出来就具有constructor属性(指向构造函数)和_proto_属性(指向原型对象)

function Person(name){                            //构造函数
    this.name=name;
    }
let person1=new Person('name');                   //实例化对象
console.log(person1._proto_== Person.prototype);  // true           
console.log(person1.constructor);//function Person(name){ this.name=name }

子对象可以继承父对象的prototype,往上继承直到object,也就是null

详解

  • prototype:是每个函数都有的一个属性,包含给后代用的属性方法
  • _proto_:是实例对象的属性,保存父类的 prototype

实例的属性:

  • constructor(指向构造函数)
  • _proto_(指向原型对象)

原型对象(构造函数.prototype) 的属性:

  • 构造函数.prototype.constructor == 实例对象.constructor,都是构造函数
  • _proto_指向父对象的原型对象
    当实例访问原型链上的方法时,他们的地址是共享的,所以输出两个实例是相等的,如果是实例方法,不同的实例化,他们的方法地址是不一样的

一、Object,Function,prototype,__proto__之间的关系

是否所有的继承方式都有问题?

人们说“优先选择对象组合而不是继承”的时候,其实是要表达“优先选择对象组合而不是类继承”(引用自《设计模式》的原文)。该思想在面向对象设计领域属于普遍共识,因为类继承方式的先天缺陷,会导致很多问题。人们在谈到继承的时候,总是习惯性地省略这个字,给人的感觉像是在针对所有的继承方式,而事实上并非如此。

因为大部分的继承方式还是很棒的。


1.组合继承模式

我们常说对于引用类型的数据,不能直接赋值修改,因为即使赋值给另外一个变量后,这个变量的值实际保存的是这个引用类型的指针,该指针依然指向的是这个引用类型所在堆中的值,换句话说,该指针指向引用类型原型。

我觉得这也是由于js原型链本身特性所造成的一种简单的继承。

前一篇文章我们有看到组合继承,就如创建对象的组合构造模式一样。

温习组合模式:

// 组合构造模式,即合并构造函数模式和原型模式

// 1. 这一步是构造函数模式
function Test(name){
    this.name = name
}

// 2. 这一步是原型模式
Test.prototype = {
    // 此处最好将原型指向构造函数本身,虽然影响不大,具体解释前一章节有说到
    constructor: Test,
    getName() {
        console.log(this.name)
    }
}

同理,组合继承也是类似(我们依旧假设继承与被继承的2个对象为ChildParent),将Child的原型重写并指向给Parent实例的原型

// 组合继承,即借用构造函数和重写原型的方式
function Parent(name) {
    this.name = name
    this.colors = ['red', 'green']
}

Parent.prototype.getName = function() {
    console.log(this.name)
}

function Child(name) {
    // 1.借用了 Parent构造函数,将name和colors属性“引用到”Child构造函数中
    // 这么做的目的是 使每个Child实例都拥有自己的name 和 colors
    Parent.call(this, name)
}

// 2.将`Child`的原型重写并指向给`Parent`实例的原型
Child.prototype = new Parent()

这样便达到了Child的所有实例,都拥有Parent实例的属性和方法,且这些属性和方法不是共享的,是实例本身所拥有的。

但这样同样会带来一个问题,Parent的构造函数会运行2次

// 第一次,将`Child`的原型重写并指向给`Parent`实例的原型
Child.prototype = new Parent()

// 第二次,实例化Child的时候,会调用Child构造函数,
// 此时,会再次调用Parent构造函数,克隆一份name和colors到Child中
let ym = new Child('ym')

注意:这个问题不仅仅是Parent的构造函数会运行2次的问题,还有一个问题是,Parent构造函数中的name和colors会存在2份,因为每一次调用Parent构造函数都会创建Parent的实例。

第一份存在于ym实例中,我们可以打印

let ym = new Child('ym')
ym.name // ym
ym.colors = // ['red', 'green']

如图所示
![6[@]0Z3$3U0WLG]JAJ`ZZLT.png](http://upload-images.jianshu.io/upload\_images/3637499-b475a621659d6695.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

第二份存在于Child.prototype

![1E5]O@H$I0P36K)TMJ5O`{0.png](http://upload-images.jianshu.io/upload\_images/3637499-c2eabef2ea19f882.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

这里我们可以看到Child.prototype中的name为undefined,因为我们第一次初始化Parent时,并没有传参。

为了印证这一点,我们前一章说过,delete操作符可以使得查找属性继续顺着原型链往上,(delete是删除当前对象上的属性)

这时,我们在第一次初始化的时候传一个参数name 为 test

Child.prototype = new Parent('test')

然后再删除实例ym上的name,调用getName打印当前name值

delete ym.name
ym.getName()  // test 符合预期

这个问题造成的原因前面也说过了,就是因为初始化了2次Parent构造函数。那究竟哪一次是多余的呢?答案是第一次。

四、自有属性和继承属性

Javascript对象拥有自有属性和继承属性

  • 自有属性:如通过构造函数this.name=name设置的属性
  • 继承属性:如对象.prototype的属性
    可以使用object.hasOwnProperty(proName)来判断对象的属性是否是自有属性,如果是则返回
    true,否则返回
    false,此方法不会检查对象原型链中的属性,因此可用来过滤继承属性
1.理解对象

我们常说,“js是面向对象的,因为它也可以拥有自己的属性和方法…”,其实说了跟没说没啥区别,反正我还是没理解。

我觉得最能解释它的是:Object.prototype是一切对象和函数的根源,一张图来证明我的观点:

金沙国际官网 2

Object.prototype

为什么这么说?我们会发现,每当我们在控制台打印一个对象的时候,都能顺着原型链找到图中所有的内容。并且:

// 说明Object.prototype不是任何一个构造函数的实例
Object.prototype.__proto__ === null

三种不同的原型继承方式

在深入探讨其他继承类型之前,还需要先仔细分析下我所说的类继承

你可以在Codepen上找到并测试下这段示例程序

BassAmp 继承自 GuitarAmp, ChannelStrip 继承自 BassAmp
GuitarAmp。从这个例子我们可以看到面向对象设计发生问题的过程。ChannelStrip实际上并不是GuitarAmp的一种,而且它根本不需要一个cabinet的属性。一个比较好的解决办法是创建一个新的基类,供amps和strip来继承,但是这种方法依然有所局限。

到最后,采用新建基类的策略也会失效。

更好的办法就是通过类组合的方式,来继承那些真正需要的属性:

修改后的代码

认真看这段代码,你就会发现:通过对象组合,我们可以确切地保证对象可以按需继承。这一点是类继承模式不可能做到的。因为使用类继承的时候,子类会把需要的和不需要的属性统统继承过来。

这时候你可能会问:“唔,是那么回事。可是这里头怎么没提到原型啊?”

客官莫急,且听我一步步道来~首先你要知道,基于原型的面向对象设计方法总共有三种。

  1. 拼接继承:
    是直接从一个对象拷贝属性到另一个对象的模式。被拷贝的原型通常被称为mixins。ES6为这个模式提供了一个方便的工具Object.assign()。在ES6之前,一般使用Underscore/Lodash提供的.extend(),或者
    jQuery 中的$.extend(),
    来实现。上面那个对象组合的例子,采用的就是拼接继承的方式。
  2. 原型代理:JavaScript中,一个对象可能包含一个指向原型的引用,该原型被称为代理。如果某个属性不存在于当前对象中,就会查找其代理原型。代理原型本身也会有自己的代理原型。这样就形成了一条原型链,沿着代理链向上查找,直到找到该属性,或者找到根代理Object.prototype为止。原型就是这样,通过使用new关键字来创建实例以及Constructor.prototype前后勾连成一条继承链。当然,也可以使用Object.create()来达到同样的目的,或者把它和拼接继承混用,从而可以把多个原型精简为单一代理,也可以做到在对象实例创建后继续扩展。
  3. 函数继承:在JavaScript中,任何函数都可以用来创建对象。如果一个函数既不是构造函数,也不是
    class,它就被称为工厂函数。函数继承的工作原理是:由工厂函数创建对象,并向该对象直接添加属性,借此来扩展对象(使用拼接继承)。函数继承的概念最先由道格拉斯·克罗克福德提出,不过这种继承方式在JavaScript中却早已有之。

这时候你会发现,拼接继承是JavaScript能够实现对象组合的秘诀,也使得原型代理和函数继承更加丰富多彩。

多数人谈起JavaScript面向对象设计时,首先想到的都是原型代理。不过你看,可不仅仅只有原型代理。要取代类继承,原型代理还是得靠边站,对象组合才是主角

1.常见模式与原型链的理解

a.构造函数创建

function Test() {
    // 
}

流程

  • 创建函数的时候会默认为Test创建一个prototype属性,Test.prototype包含一个指针指向的是Object.prototype
  • prototype默认会有一个constructor,且Test.prototype.constructor = Test
  • prototype里的其它方法都是从Object继承而来

金沙国际官网 3

示例

// 调用构造函数创建实例
var instance = new Test()

此处的instance包含了一个指针指向构造函数的原型,(此处的指针在chrome里叫__proto__,也等于[[Prototype]]

金沙国际官网 4

示例

b.原型模式
由上我们可以知道,默认创建的prototype属性只拥有constructor和继承至Object的属性,原型模式就是为prototype添加属性和方法

Test.prototype.getName = ()=> {
    alert('name')
}

此时的instance实例就拥有了getName方法,因为实例的指针是指向Test.prototype的

instance.__proto__ === Test.prototype

如下图所示
![897RVF]E5@IX$)`IVJ3BOSY.png](http://upload-images.jianshu.io/upload\_images/3637499-2c25e10269d8bbbd.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

这里我们可得知:实例instance与构造函数之间是通过原型prototype来相关联的。

c.组合模式
这种模式我们用的最多,其实也是原型模式的另一种写法,只不过有一点小区别而已

function Test() {}

Test.prototype = {
    getName() {
        alert('name')
    }
}

我们经常会这么直接重写prototype方法,由上我们可知,prototype会默认自带constructor属性指向构造函数本身,那么重写以后呢?

Test.prototype.constructor === Object 
// 而并不等于Test了
// 因为重写以后相当于利用字面量方式创建一个实例对象,这个实例的构造函数是指向Object本身的

当然我们也可以手动赋值constructor

Test.prototype = {
    constructor: Test,
    getName() {
        alert('name')
    }
}

那么又会有疑问了constructor要不要有何意义?我觉得constructor意义仅仅是为了来鉴别原型所属的构造函数吧。

当需要获取某个属性的时候,会先从实例中查找,没有就根据指针所指向的原型去查找,依次向上,直到实例的指针__proto__指向为null时停止查找,例如:

// 1 读取name
instance.name 

// 2 instance.__proto__ === Test.prototype
Test.prototype.name

// 3 Test.prototype.__proto__ === Object.prototype
Object.prototype.name

// 4
Object.prototype.__proto__ === null

当找到了这个属性就会直接返回,而不会继续查找,即使这个属性值为null,想要继续查找,我们可以通过delete操作符来实现。

由这里我们自然可以想到Array, Date, Function, String,都是一个构造函数,他们的原型的指针都是指向Object.prototype,它们就像我这里定义的Test一样,只不过是原生自带而已

d.几个有用的方法

  • Object.getPrototypeOf() 获取某个实例的指针所指向的原型

Object.getPrototypeOf(instance) === Test.prototype
  • hasOwnProperty
    判断一个属性是存在于实例中还是存在于原型中,如图所示:

    金沙国际官网 5

    NY~N}CNR`}8W%4QA$M8LFE4.png

  • in操作符,无论该属性是否可枚举

'name' in instance  // true
'getName' in instance // true

无论属性是在实例中,还是在原型中都返回true,所以当我们需要判断一个属性存在与实例中,还是原型中有2种办法

// 一种就是使用hasOwnProperty判断在实例中
// 另一种判断在原型中
instance.hasOwnProperty('getName') === false && 'getName' in instance === true
  • for ... in操作符也是一样的,但只会列出可枚举的属性,ie8版本的bug是无论该属性是否可枚举,都会列出

    金沙国际官网 6

    D(%S__GN8404{H9X6PW$DVK.png

name是在实例中定义的,getName是在原型中定义的
  • Object.keys()则不一样,它返回一个对象上所有可枚举的属性,仅仅是该实例中的

Object.keys(instance)
// ["name"]

e.总结
以上讨论了构造函数,原型和实例的关系:

  • 每个构造函数都有原型对象
  • 每个原型对象都有一个constructor指针指向构造函数
  • 每个实例都有一个__proto__指针指向原型

2.合理的继承模式

想想继承是为了什么?就是为了拥有父类的所有属性和方法而又不造成原型链的“污染和浪费”,同时父类原型和子类原型又可以很好的扩展,那我们何不简单粗暴的把父类原型克隆一份,不需要使用prototype指来指去?,6月8日更新:克隆一份的说法是错误的理解,继承的核心是原型链,父类扩展后,子类也相应得到扩展,而克隆做不到。

也就是在第一次初始化的时候不使用new Parent(),而是直接克隆一份Parent.prototype赋值给Child.prototype

那么这里会有疑问,Parent的实例上的属性不就没有被Child继承了吗?答案是依旧被继承了,在第二次初始化的时候。

依旧是上面的例子改造,完整的示例:

function Parent(name) {
    this.name = name
    this.colors = ['red', 'green']
}

Parent.prototype.getName = function() {
    console.log(this.name)
}

function Child(name) {
    Parent.call(this, name)
}

Child.prototype = Object.create(Parent.prototype)

// 这里我们依旧手动指定构造函数,为了便于区分实例与构造函数的关系
Child.prototype.constructor = Child

let ym = new Child('ym')
console.log(ym)

Object.create()
方法使用指定的原型对象和其属性创建了一个新的对象。

另外,jquery的`$.extend(true, {}, {})`深拷贝我觉得也是可以的。,更新:$.extend做深拷贝是实现不了继承的,Object.create第一个参数如果是原型,那么返回的新对象的原型也是指向这个参数原型的,所以原型链并没有断掉。

查看下结果:

金沙国际官网 7

T)0VVG[P4W_LG_R70O4]EXR.png

发现这个方法也是有瑕疵的,查找的时候多了一层object,原因是Object.create本身会返回一个新的对象实例,该实例的指针指向克隆的Parent.prototype

五、属性查找赋值

  • 当查找一个对象的属性时,JS会向上遍历原型链,直到找到该属性,如果没有找到,返回undefined
  • 当给一个对象的属性赋值时,如果自有属性中包含这个属性则改变它的值,若不存在或继承属性中有这个属性,都为对象创建这个属性并赋值
    也就是说,只有在查询时原型链才会起作用,赋值只针对自有属性
2.谁构造了谁

js原生内置了部分构造函数,其中就包含了Object和Function

Object、Function、Boolean、Number、String、Array、Date、RegExp、Error

我们知道,当我们想要一个子类拥有父类的属性和方法时,会先创建一个父类Parent,其实这个构造函数Parent形式等价于内置的构造函数

Parent和这些内置构造函数继承的谁呢?答案是Function本身,换句话说所有的构造函数都是Function的实例,如图所示:

![HWC41$DF0P)]X66P7Y{M%2X.png](http://upload-images.jianshu.io/upload\_images/3637499-448654f044fe6fc6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

依照我们前面的说法,实例的__proto__始终指向构造函数的prototype没错。

Function又是谁构造而来?我发现我真是蠢到了极点,图中不是答案么,Function是自己的实例,也就是Function由自己构造的,听起来像是科幻小说。

既然这样,那构造Function的时候,它的原型从哪继承而来?这里我不得不引用别人的原文:

Function.prototype是Object的实例对象

虽然我们从控制台印证了这个观点
![G\]HSRKI{}M8I~8E5JK0JF.png](http://upload-images.jianshu.io/upload\_images/3637499-fc8dfd11d19fd018.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

但是这里我还是得先弄清楚prototype__proto__。最终,在别人的文章中找到了似乎可以印证的观点

prototype是函数的一个属性(每个函数都有一个prototype属性),指向一个对象

我们先接受这个观点,那么这个对象指向的谁呢?创建函数的方式有3种

  • 通过Function构造函数
  • 字面量创建
  • 直接声明

![{A3}PS6C3HTGRJ_0YJ5]4W.png](http://upload-images.jianshu.io/upload_images/3637499-8d44d6ab3065fb68.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 从图中我们可以看到,所有function实例的prototype都是指向Object.prototype`

// 这里的a其实形式上等价于Function
a.prototype.__proto__ === Function.prototype.__proto__ === Object.prototype

__proto__是一个对象拥有的内置属性,指向于它所对应的原型对象,原型链正是基于__proto__才得以形成(note:不是基于函数对象的属性prototype)

*为什么说对象组合能够避免脆弱基类问题

要搞清楚这个问题,首先要知道脆弱基类是如何形成的:

  1. 假设有基类A
  2. B继承自基类A
  3. C继承自B
  4. D也继承自B

C中调用super方法,该方法将执行类B中的代码。同样,B也调用super方法,该方法会执行A中的代码。

CD需要从AB中继承一些无关联的特性。此时,D作为一个新用例,需要从A的初始化代码继承一些特性,这些特性与C的略有不同。为了应对以上需求,菜鸟开发人员会去调整A的初始化代码。于是乎,尽管D可以正常工作,但是C原本的特性被破坏了。

上面这个例子中,ABCD提供各种特性。可是,CD不需要来自AB的所有特性,它们只是需要继承某些属性。但是,通过继承和调用super方法,你无法选择性地继承,只能全部继承:

“面向对象语言的问题在于,子类会携带有父类所隐含的环境信息。你想要的是一个香蕉,但是最终到的却是一个拿着香蕉的大猩猩,以及整个丛林”——乔·阿姆斯特朗《编程人生》

如果是使用对象组合的方式 设想有如下几个特性:

JavaScript

feat1, feat2, feat3, feat4

1
feat1, feat2, feat3, feat4

C需要特性feat1feat3,而D 需要特性feat1, feat2,
feat4

JavaScript

const C = compose(feat1, feat3); const D = compose(feat1, feat2, feat4);

1
2
const C = compose(feat1, feat3);
const D = compose(feat1, feat2, feat4);

假如你发现D需要的特性与feat1**略有出入。这时候无需改动feat1只要创建一个feat1的定制化版本*,就可以做到保持feat2feat4特性的同时,也不会影响到C*,如下:

JavaScript

const D = compose(custom1, feat2, feat4);

1
const D = compose(custom1, feat2, feat4);

像这样灵活的优点,是类继承方式所不具备的。因为子类在继承的时候,会连带着整个类继承结构

这种情况下,要适应新的用例,要么复制现有类层划分(必然重复性问题),要么在现有类层结构的基础上进行重构,就又会导致脆弱基类问题

而采用对象组合的话,这两个问题都将迎刃而解。

2.继承

继承的实质是利用构造函数的原型 =
某个构造函数的实例,以此来形成原型链。例如

// 定义父类
function Parent() {}
Parent.prototype.getName = ()=> {
    console.log('parent')
}
// 实例化父类
let parent = new Parent()

// 定义子类
function Child() {}
Child.prototype = parent 
// 实例化子类
let child = new Child()

child.getName() // parent
// 此时
child.constructor === parent.constructor === Parent

a.最经典的继承模式

function Parent(name) {
    this.name = name
    this.colors = ['red']
}
Parent.prototype.getName = function() {
    console.log(this.name)
}
// 实例化父类
let parent = new Parent()

function Child(age, name) {
    Parent.call(this, name)
    this.age = age
}
Child.prototype = parent 
// 实例化子类
let child = new Child(1, 'aaa')
child.getName() // parent

这里会让我想到ES6中的class继承

class Parent {
    constructor(name) {
        this.name = name
        this.colors = ['red']
    }
    getName() {
        console.log(this.name)
    }
}

class Child extends Parent {
    constructor(age, name) {
        super(name)
    }
}

let child = new Child(1, 'aaa')
child.getName() // parent

其实是一个道理,这里我们不难想到,将Child.prototype指向parent实例,就是利用原型实现的继承,而为了每个实例都拥有各自的colors和name,也就是基础属性,在Child的构造函数中call调用了Parent的构造函数,相当于每次实例化的时候都初始化一遍colors和name,而不是所有实例共享原型链中的colors和name


以上也是自己一边学习一边整理的,逻辑有点混乱,见谅,还望有误之处指出,不胜感激!

参考:
红宝书第六章
MDN
继承与原型链
理解JavaScript的原型链和继承

相关

  • JavaScript原型与继承(一)
  • JavaScript原型与继承(二)
  • JavaScript原型与继承(三)

3.总结

继承的更合理的方式,是基于组合模式,去掉组合模式中多余的成分,即:

去掉第一次初始化Parent构造函数,使Parent的实例属性只存在于Child实例中

由于现在一般用ES6的特性写js,其中extends继承是经常用的,但是原理还有待深究(期待下一篇吧)……或许,这才是最合理的继承方法。

六、设置原型的方式

let Calculator = function () { };
  • 方式一:分别设置原型对象

Calculator.prototype.add = function (x, y) { 
    return x + y;
};
Calculator.prototype.subtract = function (x, y) { 
    return x - y;
};
  • 方式二:通过给对象的prototype属性赋值来设定对象的原型

Calculator.prototype = {
    add: function (x, y) { 
      return x + y;
    },
    subtract: function (x, y) { 
      return x - y;
    }
};          
alert((new Calculator()).add(1, 3));

JavaScript的原型和原型链的前世今生(一)
图解Javascript原型链

3.小总结

我们知道了__proto__prototype的区别,那我们自然而然的得出:

// 所有构造函数都是Function的实例
Object.__proto__ === Function.prototype
Function.__proto__  === Function.prototype

// 所有Function都有一个prototype指向Object.prototype
// 也就是说,Function.prototype都是Object.prototype的实例
// 这一点我不是很确定,但从控制台看到确实如此
Function.prototype.__proto__ === Object.prototype

// Object.prototype到达了根源,不指向任何谁,原型链到此就结束了
Object.prototype.__proto__ === null

因此,关于谁构造了谁这个问题,答案是:

所以,是先有的Object.prototype,再有的Function.prototype,再有的Function和Object函数对象

最后再带上一张图来加深理解,此图来源Javascript中Function,Object,Prototypes,proto等概念详解

金沙国际官网 8

71M99JF8BL0ZKYZ0LXIB(O.png

此小节参考
js 原型的问题 Object 和 Function
到底是什么关系?
Js中Prototype、proto、Constructor、Object、Function关系介绍

你真的了解原型了吗?

采用先创建类和构造函数,然后再继承的方式,并不是正宗的原型继承,不过是使用原型来模拟类继承的方法罢了。这里有一些关于JavaScript中关于继承的常见误解,供君参考。

JavaScript中,类继承模式历史悠久,而且建立在灵活丰富的原型继承特性之上(ES6以上的版本亦然)。可是一旦使用了类继承,就再也享受不到原型灵活强大的特性了。类继承的所有问题都将始终如影随形无法摆脱

在JavaScript中使用类继承,是一种舍本逐末的行为。

4.相关

  • JavaScript原型与继承(一)
  • JavaScript原型与继承(二)
  • JavaScript原型与继承(三)

4.疑问

当我们通过3种方式创建函数的时候,它们的构造函数是一样的吗?

![LR3J6$(]NL7@T~M7@5A8Q@C.png](http://upload-images.jianshu.io/upload\_images/3637499-4b45ba659b03c618.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

由上图可以发现,通过3种方式创建函数,它们实质是一样的,都是调用Function构造函数来创建,只不过创建的时候匿名不匿名的问题

a.constructor === b.constructor === c.constructor === Function

包括通过字面量创建的对象和通过构造函数创建对象他们的区别,只不过是多了一层中间构造函数而已

// 1.通过构造函数创建对象
function Extra(name) {
    this.name = name
}

var instance = new Extra('hehe')

// 2.通过字面量创建
var instance2 = {
    name: 'hehe2'
}

// instance与instance2的区别
instance.__proto__ === Extra.prototype
Extra.prototype.__proto__ === Object.prototype

// 而
instance2.__proto__ === Object.prototype

// 可以发现instance就多了一层构造函数Extra的原型,我们还可以知道,原型链就是基于__proto__才得以依次往上查找

区别我想肯定不止这些,因为我还不知道创建函数是怎样的一个过程,包括new的时候,具体发生了哪些细节,大致我只知道,new一个构造函数的时候,在其内部完成了原型链的连接(即继承至Object.prototype,可能是通过this = {}实现的),并且赋值了this的指向。而通过字面量创建的时候是没有这些过程的。

Stamps:可组合式工厂函数

多数情况下,对象组合是通过使用工厂函数来实现:工厂函数负责创建对象实例。如果工厂函数也可以组合呢?快查看Stamp文档找出答案吧。

(译者注:感觉原文表达有些不尽兴。于是我自作主张地画了2个图便于读者理解。不足之处还请谅解和指正)
金沙国际官网 9图:类继承

说明:从图上可以直接看出单一继承关系、紧耦合以及层级分类的问题;其中,类8,只想继承五边形的属性,却得到了继承链上其它并不需要的属性——大猩猩/香蕉问题;类9只需要把五角星属性修改成四角形,导致需要修改基类1,从而影响整个继承树——脆弱基类/层级僵化问题;否则就需要为9新建基类——必然重复性问题。
金沙国际官网 10图:原型继承/对象组合

说明:采用原型继承/对象组合,可以避免复杂纵深的层级关系。当1需要四角星特性的时候,只需要组合新的特性即可,不会影响到其他实例。

1 赞 8 收藏
评论

金沙国际官网 11

二、ES5合理继承的方式(即组合继承)与ES6 extends的异同

阮一峰老师的教程里有说到

ES5
的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。
ES6
的继承机制完全不同,实质是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this。

也就是说ES6中子类是没有this的,必须通过super得到父类的实例对象this,这是结果,那么实现呢?

// 简单的extends继承
 class Parent{
   // static属性和方法 
   static sex = 'man'
   static getSex() {
     console.log(Parent.sex)
   }

   // 构造函数 
   constructor(name) {
     this.name = name
   }

   // 非静态方法
   say() {
     console.log(this.name)
   }
 }

 class Child extends Parent {
   static sex = 'women'

   constructor(name, age) {
      super(name)

      this.age = age
   }

   say() {
     console.log(this.name, this.age)
   }
 }

编译后查看源码

金沙国际官网 12

RE6OQ6VQ@CDU}SK4@8UWU9.png

会发现es6 class
继承实现通过这4个方法:_createClass_possibleConstructorReturn
_inherits,
_classCallCheck,那么extends是如何继承,包括super是如何获取this的,我们来一步步解析。

1.编译后的Parent

var Parent = function () {

  // 函数内部声明一个同名的构造函数
  function Parent(name) {

    // 检查当前的this是否是构造函数的实例,也就是说必须通过new的方式调用构造函数
    // 而不能是像调用函数一样直接调用,因为这样是不会生成实例的this
    _classCallCheck(this, Parent);

    // 将构造函数的属性赋值给当前实例的this
    this.name = name;
  }

  // 将Parent中的静态方法直接赋值Parent构造函数
  _createClass(Parent, null, [{
    key: 'getSex',
    value: function getSex() {
      console.log(Parent.sex);
    }
  }]);

  // 将Parent中的非静态方法赋值Parent的原型,也就是Parent.prototype
  _createClass(Parent, [{
    key: 'say',
    value: function say() {
      console.log(this.name);
    }
  }]);

  return Parent;
}();

Parent做了2件事情,也可以说所有的通过Class关键字声明的函数做了2件事

  • 在函数里面声明创建了一个同名的构造函数,将constructor以外的static声明的方法和非static声明的方法分别挂载到构造函数本身和构造函数的原型上
  • 该函数会返回这个同名的构造函数,这里是采用寄生模式创建的构造函数。这里还做了校验,必须通过new来调用,否则抛出异常

2.constructor以外的方法是如何挂载的

// 用该方法实现
var _createClass = function () { 

    // 重写了es5的defineProperties方法,至于为什么会重写,后面解释
    // 该方法就是遍历props,然后利用defineProperty,依次给target定义属性
    function defineProperties(target, props) { 
        for (var i = 0; i < props.length; i++) { 
            var descriptor = props[i]; 

            // 是否可枚举,默认为false ?也就是说把所有的方法都置为不可枚举
            descriptor.enumerable = descriptor.enumerable || false; 
            descriptor.configurable = true; 

            if ("value" in descriptor) 
                descriptor.writable = true; 

            Object.defineProperty(target, descriptor.key, descriptor); 
        } 
    } 

    // 返回一个函数,如果是static方法就定义在Constructor上,否则就定义在Constructor.prototype上
    // 我们可以对应到Parent里调用_createClass 时,对于静态和非静态方法的传参
    return function (Constructor, protoProps, staticProps) { 
        if (protoProps) 
            defineProperties(Constructor.prototype, protoProps); 

        if (staticProps) 
            defineProperties(Constructor, staticProps); 

        return Constructor; 
    }; 
}();

我们可以看到,ES6把所有定义在构造函数或原型中的方法都定义为不可枚举,而属性是通过默认赋值可枚举的。为什么?谁能解释下。。。。

3.插播一条小广告,弄清楚数据属性

数据属性有4个(这里只是简要的带过)

  • value 属性的值
  • enumerable 是否可枚举
  • configurable 是否可修改
  • writable 是否可写

我们在定义对象或者赋值对象属性的时候,通常是不知道这些数据属性的,因为默认情况下,都是true

金沙国际官网 13

YUZ(5J3(Y_1UG@A%7@02TG9.png

但是在有些时候,我们是不希望属性是可枚举的,就像前一章说过的组合继承,手动赋值constructor

// 此时constructor也是可枚举的
Child.prototype.constructor = Child

另外,通过Object.defineProperty定义属性时,如果不指定数据属性,默认情况下都为false

金沙国际官网 14

)I35E_)UJ@Y412DL(VBJP$5.png

根据这些解释,就可以理解前文中_createClass为什么要重写defineProperties方法了。

4.编译后的Child

同理,Child与Parent一样,都会有相同的2个步骤(前面说的2件事情),不同的是

var Child = function (_Parent) {
  function Child(name, age) {
    // ...
    // 2.这里我想就是阮一峰老师文章里有说的
    // 拿到Parent实例的this,封装成Child子类的this
    var _this = _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).call(this, name));
    // ...
  }

  // 1.这一步是关键的继承
  _inherits(Child, _Parent);
  // ...
}(Parent);

我们看到Child多做了2件事情,先说第1件事:

_inherits继承

_inherits也做了2件事

  1. 就是我们组合继承中提到的,利用Object.create将子类的原型指向父类的原型
  2. 将子类的__proto__指向父类构造函数,也就是说,认为子类是父类构造而来。
    这里有点绕,其实就是这么个意思:你还记得组合继承中,在Child的构造函数里callParent一下么,将Parent上的属性都复制一份到Child的this中。那么在这里,ES6并不知道要call谁啊,所以只好将父类的构造函数指给子类的__proto__,这样后面就只需要Child.__proto__.call(this)了。

function _inherits(subClass, superClass) { 
    // 校验代码...

    // 利用 Object.create创建实例对象,并将实例赋值给subClass.prototype
    // 并手动赋值constructor
    subClass.prototype = Object.create(superClass && superClass.prototype, { 
        constructor: { 
            value: subClass, 
            enumerable: false, 
            writable: true, 
            configurable: true 
        } 
    }); 

    // 这一步就是上面说的第2件事,将superClass构造函数赋值给subClass.__proto__,方便后面调用
    // 因为我们知道,所有的函数都是由Function构造而来
    // 也就是说如果这里不赋值,subClass.__proto__ === Function.prototype
    if (superClass) 
        Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; 
}

Child做的第2件事情

_possibleConstructorReturn调用父类构造函数

其实这里的表述有误,调用父类构造函数并不是_possibleConstructorReturn做的,_possibleConstructorReturn只是做了一个简单的校验。

// 调用父类构造函数
(Child.__proto__ || Object.getPrototypeOf(Child)).call(this, name)

看到这一句就证明我前面的理解是对的,这一句在es5组合继承中就是

Parent.call(this, name)

只不过它不知道Parent是谁,所以就先把Parent赋值给了Child.__proto__。由于ES6中都是通过寄生模式来创建构造函数,这里call之后,返回的是Parent的实例,与组合继承的call并不一致。

5.总结

我们再回过头来看ES5与ES6继承的区别,其实ES5与ES6的继承没有太大的区别,其原理都是采用了组合继承,核心唯一不同的就是这个this值的问题,另外就是对定义静态方法做了封装(staitc)。

es5的继承,是我们手动写父类,子类手动call父类。

但是es6中的继承是抽象出来的语法糖,并不知道你这里哪个是父类哪个是子类,所以它得通过一个巧妙的方法来知道这个是父类这个是子类。Child.__proto__ === Parent或者调用Object.setPrototypeOf()

之所以this不一样,是因为它们创建构造函数的机制不一样

  • es5是直接声明,那样this就直接被初始化了,所以只能在子类里通过call来“丰富”它的this
  • es6则是通过寄生模式(非工厂模式),返回的一个新的构造函数,当你call的时候,相当于new了这个新的构造函数,此时父类的构造函数看起来就是个闭包,因为他还要返回new后的实例对象。所以在子类中是直接就拿到了父类的实例对象,那么就将this指向了他,再赋值自己的属性。

后话

这是自己第一次系统的去理清其中的关系,并不是很熟练的掌握了个中的原理,有误之处还望指出!

相关

  • JavaScript原型与继承(一)
  • JavaScript原型与继承(二)
  • JavaScript原型与继承(三)

发表评论

电子邮件地址不会被公开。 必填项已用*标注

网站地图xml地图