看法有限,若有描述不当之处,请帮忙及时指出,若有错误,会及时修正。javascript
20180201更新:html
修改用词描述,如组合寄生式改为寄生组合式,修改多处笔误(感谢@Yao Ding的反馈)前端
----------长文+多图预警,须要花费必定时间----------java
故事是从一次实际需求中开始的。。。git
某天,某人向我寻求了一次帮助,要协助写一个日期工具类,要求:es6
此类继承自Date
,拥有Date的全部属性和对象github
此类能够自由拓展方法算法
形象点描述,就是要求能够这样:segmentfault
// 假设最终的类是 MyDate,有一个getTest拓展方法 let date = new MyDate(); // 调用Date的方法,输出GMT绝对毫秒数 console.log(date.getTime()); // 调用拓展的方法,随便输出什么,譬如helloworld! console.log(date.getTest());
因而,随手用JS中经典的寄生组合式写了一个继承,而后,刚准备完美收工,一运行,却出现了如下的情景:数组
可是的心情是这样的: 😳囧
之前也没有遇到过相似的问题,而后本身尝试着用其它方法,屡次尝试,均无果(不算暴力混合法的状况),其实回过头来看,是由于思路新奇,凭空想不到,并非原理上有多难。。。
因而,借助强大的搜素引擎,搜集资料,最后,再本身总结了一番,才有了本文。
----------正文开始前----------
正文开始前,各位看官能够先暂停往下读,尝试下,在不借助任何网络资料的状况下,是否能实现上面的需求?(就以10分钟
为限吧)
先说说如何快速快速寻求解答
stackoverflow上早就有答案了!
假若用的是中文搜索。
分析问题的关键
经典的继承法有何问题
为何没法被继承?
该如何实现继承?
暴力混合法
ES5黑魔法
ES6大法
ES6写法,而后babel打包
几种继承的细微区别
ES6继承与ES5继承的区别
构造函数与实例对象
[[Class]]与Internal slot
如何快速判断是否继承?
写在最后的话
遇到不会的问题,确定第一目标就是如何快速寻求解决方案,答案是:
因而,借助搜索引擎搜索了下,第一条就符合条件,点开进去看描述
先说说结果,再浏览一番后,确实找到了解决方案,而后回过头来一看,惊到了,由于这个问题的提问时间是6 years, 7 months ago
。
也就是说,2011
年的时候就已经有人提出了。。。
感受本身落后了一个时代**>_<**。。。
并且还发现了一个细节,那就是viewed:10,606 times
,也就是说至今一共也才一万屡次阅读而已,考虑到前端行业的从业人数,这个比例惊人的低。
以点见面,看来,遇到这个问题的人并非不少。
用中文搜索并不丢人(我遇到问题时的本能反应也是去百度)。结果是这样的:
嗯,看来英文关键字搜索效果不错,第一条就是符合要求的。而后又试了试中文搜索。
效果不如人意,搜索前几页,惟一有一条看起来比较相近的(segmentfault
上的那条),点进去看
怎么说呢。。。这个问题关注度不高,浏览器数较少,并且上面的问题描述和预期的有点区别,仍然是有人回答的。
不过,虽说问题在必定程度上获得了解决,可是回答者绕过了没法继承这个问题,有点未竟全功的意思。。。
借助stackoverflow上的回答
先看看本文最开始时提到的经典继承法实现,以下:
/** * 经典的js寄生组合式继承 */ function MyDate() { Date.apply(this, arguments); this.abc = 1; } function inherits(subClass, superClass) { function Inner() {} Inner.prototype = superClass.prototype; subClass.prototype = new Inner(); subClass.prototype.constructor = subClass; } inherits(MyDate, Date); MyDate.prototype.getTest = function() { return this.getTime(); }; let date = new MyDate(); console.log(date.getTest());
就是这段代码⬆,这也是JavaScript高程(红宝书)中推荐的一种,一直用,从未失手,结果如今马失前蹄。。。
咱们再回顾下它的报错:
再打印它的原型看看:
怎么看都没问题,由于按照原型链回溯规则,Date
的全部原型方法均可以经过MyDate
对象的原型链往上回溯到。
再仔细看看,发现它的关键并非找不到方法,而是this is not a Date object.
嗯哼,也就是说,关键是:因为调用的对象不是Date的实例,因此不容许调用,就算是本身经过原型继承的也不行
首先,看看MDN
上的解释,上面有提到,JavaScript的日期对象只能经过JavaScript Date
做为构造函数来实例化。
而后再看看stackoverflow上的回答:
有提到,v8
引擎底层代码中有限制,若是调用对象的[[Class]]
不是Date
,则抛出错误。
总的来讲,结合这两点,能够得出一个结论:
要调用Date上方法的实例对象必须经过Date构造出来,不然不容许调用Date的方法
虽然缘由找到了,可是问题仍然要解决啊,真的就没办法了么?固然不是,事实上仍是有很多实现的方法的。
首先,说说说下暴力的混合法,它是下面这样子的:
说到底就是:内部生成一个Date
对象,而后此类暴露的方法中,把原有Date
中全部的方法都代理一遍,并且严格来讲,这根本算不上继承(都没有原型链回溯)。
而后,再看看ES5中如何实现?
// 须要考虑polyfill状况 Object.setPrototypeOf = Object.setPrototypeOf || function(obj, proto) { obj.__proto__ = proto; return obj; }; /** * 用了点技巧的继承,实际上返回的是Date对象 */ function MyDate() { // bind属于Function.prototype,接收的参数是:object, param1, params2... var dateInst = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))(); // 更改原型指向,不然没法调用MyDate原型上的方法 // ES6方案中,这里就是[[prototype]]这个隐式原型对象,在没有标准之前就是__proto__ Object.setPrototypeOf(dateInst, MyDate.prototype); dateInst.abc = 1; return dateInst; } // 原型从新指回Date,不然根本没法算是继承 Object.setPrototypeOf(MyDate.prototype, Date.prototype); MyDate.prototype.getTest = function getTest() { return this.getTime(); }; let date = new MyDate(); // 正常输出,譬如1515638988725 console.log(date.getTest());
一眼看上去不知所措?不要紧,先看下图来理解:(原型链关系一目了然)
能够看到,用的是很是巧妙的一种作法:
正常继承的状况以下:
new MyDate()
返回实例对象date
是由MyDate
构造的
原型链回溯是: date(MyDate对象)->date.__proto__->MyDate.prototype->MyDate.prototype.__proto__->Date.prototype
这种作法的继承的状况以下:
new MyDate()
返回实例对象date
是由Date
构造的
原型链回溯是: date(Date对象)->date.__proto__->MyDate.prototype->MyDate.prototype.__proto__->Date.prototype
能够看出,关键点在于:
构造函数里返回了一个真正的Date
对象(由Date
构造,因此有这些内部类中的关键[[Class]]
标志),因此它有调用Date
原型上方法的权利
构造函数里的Date对象的[[ptototype]]
(对外,浏览器中可经过__proto__
访问)指向MyDate.prototype
,而后MyDate.prototype
再指向Date.prototype
。
因此最终的实例对象仍然能进行正常的原型链回溯,回溯到本来Date的全部原型方法
这样经过一个巧妙的欺骗技巧,就实现了完美的Date继承。不过补充一点,MDN
上有提到尽可能不要修改对象的[[Prototype]]
,由于这样可能会干涉到浏览器自己的优化。
若是你关心性能,你就不该该在一个对象中修改它的 [[Prototype]]
固然,除了上述的ES5实现,ES6中也能够直接继承(自带支持继承Date
),并且更为简单:
class MyDate extends Date { constructor() { super(); this.abc = 1; } getTest() { return this.getTime(); } } let date = new MyDate(); // 正常输出,譬如1515638988725 console.log(date.getTest());
对比下ES5中的实现,这个真的是简单的不行,直接使用ES6的Class语法就好了。
并且,也能够正常输出。
注意:这里的正常输出环境是直接用ES6运行,不通过babel打包,打包后实质上是转化成ES5的,因此效果彻底不同
虽说上述ES6大法是能够直接继承Date的,可是,考虑到实质上大部分的生产环境是:ES6 + Babel
直接这样用ES6 + Babel是会出问题的
不信的话,能够自行尝试下,Babel打包成ES5后代码大体是这样的:
而后当信心满满的开始用时,会发现:
对,又出现了这个问题,也许这时候是这样的⊙?⊙
由于转译后的ES5源码中,仍然是经过MyDate
来构造,
而MyDate
的构造中又没法修改属于Date
内部的[[Class]]
之类的私有标志,
所以构造出的对象仍然不容许调用Date
方法(调用时,被引擎底层代码识别为[[Class]]
标志不符合,不容许调用,抛出错误)
因而可知,ES6继承的内部实现和Babel打包编译出来的实现是有区别的。
(虽然说Babel的polyfill通常会按照定义的规范去实现的,但也不要过分迷信)。
虽然上述提到的三种方法均可以达到继承Date
的目的-混合法严格说不能算继承,只不过是另类实现。
因而,将全部能打印的主要信息都打印出来,分析几种继承的区别,大体场景是这样的:
能够参考:( 请进入调试模式)https://dailc.github.io/fe-interview/demo/extends_date.html
从上往下,1, 2, 3, 4
四种继承实现分别是:(排出了混合法)
ES6的Class大法
经典寄生组合式继承法
本文中的取巧作法,Date构造实例,而后更改__proto__
的那种
ES6的Class大法,Babel打包后的实现(没法正常调用的)
~~~~如下是MyDate们的prototype~~~~~~~~~ Date {constructor: ƒ, getTest: ƒ} Date {constructor: ƒ, getTest: ƒ} Date {getTest: ƒ, constructor: ƒ} Date {constructor: ƒ, getTest: ƒ} ~~~~如下是new出的对象~~~~~~~~~ Sat Jan 13 2018 21:58:55 GMT+0800 (CST) MyDate2 {abc: 1} Sat Jan 13 2018 21:58:55 GMT+0800 (CST) MyDate {abc: 1} ~~~~如下是new出的对象的Object.prototype.toString.call~~~~~~~~~ [object Date] [object Object] [object Date] [object Object] ~~~~如下是MyDate们的__proto__~~~~~~~~~ ƒ Date() { [native code] } ƒ () { [native code] } ƒ () { [native code] } ƒ Date() { [native code] } ~~~~如下是new出的对象的__proto__~~~~~~~~~ Date {constructor: ƒ, getTest: ƒ} Date {constructor: ƒ, getTest: ƒ} Date {getTest: ƒ, constructor: ƒ} Date {constructor: ƒ, getTest: ƒ} ~~~~如下是对象的__proto__与MyDate们的prototype比较~~~~~~~~~ true true true true
看出,主要差异有几点:
MyDate们的__proto__指向不同
Object.prototype.toString.call的输出不同
对象本质不同,能够正常调用的1, 3
都是Date
构造出的,而其它的则是MyDate
构造出的
咱们上文中得出的一个结论是:因为调用的对象不是由Date构造出的实例,因此不容许调用,就算是本身的原型链上有Date.prototype也不行
可是这里有两个变量:分别是底层构造实例的方法不同,以及对象的Object.prototype.toString.call
的输出不同。
(另外一个MyDate.__proto__
能够排除,由于原型链回溯确定与它无关)
万一它的判断是根据Object.prototype.toString.call
来的呢?那这样结论不就有偏差了?
因而,根据ES6中的,Symbol.toStringTag
,使用黑魔法,动态的修改下它,排除下干扰:
// 分别能够给date2,date3设置 Object.defineProperty(date2, Symbol.toStringTag, { get: function() { return "Date"; } });
而后在打印下看看,变成这样了:
[object Date] [object Date] [object Date] [object Object]
能够看到,第二个的MyDate2
构造出的实例,虽然打印出来是[object Date]
,可是调用Date方法仍然是有错误
此时咱们能够更加准确一点的确认:因为调用的对象不是由Date构造出的实例,因此不容许调用
并且咱们能够看到,就算经过黑魔法修改Object.prototype.toString.call
,内部的[[Class]]
标识位也是没法修改的。
(这块知识点大概是Object.prototype.toString.call能够输出内部的[[Class]],但没法改变它,因为不是重点,这里不赘述)。
从上文中的分析能够看到一点:ES6的Class写法继承是没问题的。可是换成ES5写法就不行了。
因此ES6的继承大法和ES5确定是有区别的,那么到底是哪里不一样呢?(主要是结合的本文继承Date来讲)
区别:(以SubClass
,SuperClass
,instance
为例)
ES5中继承的实质是:(那种经典寄生组合式继承法)
先由子类(SubClass
)构造出实例对象this
而后在子类的构造函数中,将父类(SuperClass
)的属性添加到this
上,SuperClass.apply(this, arguments)
子类原型(SubClass.prototype
)指向父类原型(SuperClass.prototype
)
因此instance
是子类(SubClass
)构造出的(因此没有父类的[[Class]]
关键标志)
因此,instance
有SubClass
和SuperClass
的全部实例属性,以及能够经过原型链回溯,获取SubClass
和SuperClass
原型上的方法
ES6中继承的实质是:
先由父类(SuperClass
)构造出实例对象this,这也是为何必须先调用父类的super()
方法(子类没有本身的this对象,需先由父类构造)
而后在子类的构造函数中,修改this(进行加工),譬如让它指向子类原型(SubClass.prototype
),这一步很关键,不然没法找到子类原型(注,子类构造中加工这一步的实际作法是推测出的,从最终效果来推测)
而后一样,子类原型(SubClass.prototype
)指向父类原型(SuperClass.prototype
)
因此instance
是父类(SuperClass
)构造出的(因此有着父类的[[Class]]
关键标志)
因此,instance
有SubClass
和SuperClass
的全部实例属性,以及能够经过原型链回溯,获取SubClass
和SuperClass
原型上的方法
以上⬆就列举了些重要信息,其它的如静态方法的继承没有赘述。(静态方法继承实质上只须要更改下SubClass.__proto__
到SuperClass
便可)
能够看着这张图快速理解:
有没有发现呢:**ES6中的步骤和本文中取巧继承Date的方法如出一辙,不一样的是ES6是语言底层的作法,有它的底层优化之处,而本文中的直接修改__proto__容易影响性能**
ES6中在super中构建this的好处?
由于ES6中容许咱们继承内置的类,如Date,Array,Error等。若是this先被建立出来,在传给Array等系统内置类的构造函数,这些内置类的构造函数是不认这个this的。
因此须要如今super中构建出来,这样才能有着super中关键的[[Class]]
标志,才能被容许调用。(不然就算继承了,也没法调用这些内置类的方法)
看到这里,不知道是否对上文中频繁提到的构造函数,实例对象有所混淆与困惑呢?这里稍微描述下:
要弄懂这一点,须要先知道new
一个对象到底发生了什么?先形象点说:
function MyClass() { this.abc = 1; } MyClass.prototype.print = function() { console.log('this.abc:' + this.abc); }; let instance = new MyClass();
譬如,上述就是一个标准的实例对象生成,都发生了什么呢?
步骤简述以下:(参考MDN,还有部分关于底层的描述略去-如[[Class]]标识位等)
构造函数内部,建立一个新的对象,它继承自MyClass.prototype
,let instance = Object.create(MyClass.prototype);
使用指定的参数调用构造函数MyClass
,并将 this绑定到新建立的对象,MyClass.call(instance);
,执行后拥有全部实例属性
若是构造函数返回了一个“对象”,那么这个对象会取代整个new
出来的结果。若是构造函数没有返回对象,那么new出来的结果为步骤1建立的对象。
(通常状况下构造函数不返回任何值,不过用户若是想覆盖这个返回值,能够本身选择返回一个普通对象来覆盖。固然,返回数组也会覆盖,由于数组也是对象。)
结合上述的描述,大概能够还原成如下代码:(简单还原,不考虑各类其它逻辑)
let instance = Object.create(MyClass.prototype); let innerConstructReturn = MyClass.call(instance); let innerConstructReturnIsObj = typeof innerConstructReturn === 'object' || typeof innerConstructReturn === 'function'; return innerConstructReturnIsObj ? innerConstructReturn : instance;
注意⚠️:
普通的函数构建,能够简单的认为就是上述步骤
实际上对于一些内置类(如Date等),并无这么简单,还有一些本身的隐藏逻辑,譬如[[Class]]
标识位等一些重要私有属性。
以为看起来比较繁琐?能够看下图梳理:
那如今再回头看看。
什么是构造函数?
如上述中的MyClass
就是一个构造函数,在内部它构造出了instance
对象
什么是实例对象?
instance
就是一个实例对象,它是经过new
出来的?
实例与构造的关系
有时候浅显点,能够认为构造函数是xxx就是xxx的实例。即:
let instance = new MyClass();
此时咱们就能够认为instance
是MyClass
的实例,由于它的构造函数就是它
不必定,咱们那ES5黑魔法来作示例
function MyDate() { // bind属于Function.prototype,接收的参数是:object, param1, params2... var dateInst = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))(); // 更改原型指向,不然没法调用MyDate原型上的方法 // ES6方案中,这里就是[[prototype]]这个隐式原型对象,在没有标准之前就是__proto__ Object.setPrototypeOf(dateInst, MyDate.prototype); dateInst.abc = 1; return dateInst; }
咱们能够看到instance
的最终指向的原型是MyDate.prototype
,而MyDate.prototype
的构造函数是MyDate
,
所以能够认为instance
是MyDate
的实例。
可是,实际上,instance
倒是由Date
构造的
咱们能够继续用ES6
中的new.target
来验证。
注意⚠️
关于new.target
,MDN
中的定义是:new.target返回一个指向构造方法或函数的引用。
嗯哼,也就是说,返回的是构造函数。
咱们能够在相应的构造中测试打印:
class MyDate extends Date { constructor() { super(); this.abc = 1; console.log('~~~new.target.name:MyDate~~~~'); console.log(new.target.name); } } // new操做时的打印结果是: // ~~~new.target.name:MyDate~~~~ // MyDate
而后,能够在上面的示例中看到,就算是ES6的Class继承,MyDate
构造中打印new.target
也显示MyDate
,
但实际上它是由Date
来构造(有着Date
关键的[[Class]]
标志,由于若是不是Date构造(如没有标志)是没法调用Date的方法的)。
因此,实际上用new.target
是没法判断实例对象究竟是由哪个构造构造的(这里指的是判断底层真正的[[Class]]
标志来源的构造)
在MDN上的定义也能够看到,new.target
返回的是直接构造函数(new做用的那个),因此请不要将直接构造函数与实际上的构造搞混
再回到结论:实例对象不必定就是由它的原型上的构造函数构造的,有可能构造函数内部有着寄生等逻辑,偷偷的用另外一个函数来构造了下,
固然,简单状况下,咱们直接说实例对象由对应构造函数构造也没错(不过,在涉及到这种Date之类的分析时,咱们仍是得明白)。
这一部分为补充内容。
前文中一直提到一个概念:Date内部的[[Class]]
标识
其实,严格来讲,不能这样泛而称之(前文中只是用这个概念是为了下降复杂度,便于理解),它能够分为如下两部分:
在ES5中,每种内置对象都定义了 [[Class]] 内部属性的值,[[Class]] 内部属性的值用于内部区分对象的种类
Object.prototype.toString
访问的就是这个[[Class]]
规范中除了经过Object.prototype.toString
,没有提供任何手段使程序访问此值。
并且Object.prototype.toString输出没法被修改
而在ES6中,以前的 [[Class]] 再也不使用,取而代之的是一系列的internal slot
Internal slot 对应于与对象相关联并由各类ECMAScript规范算法使用的内部状态,它们没有对象属性,也不能被继承
根据具体的 Internal slot 规范,这种状态能够由任何ECMAScript语言类型或特定ECMAScript规范类型值的值组成
经过Object.prototype.toString
,仍然能够输出Internal slot值
简单点理解(简化理解),Object.prototype.toString的流程是:若是是基本数据类型(除去Object之外的几大类型),则返回本来的slot,
若是是Object类型(包括内置对象以及本身写的对象),则调用Symbol.toStringTag
Symbol.toStringTag
方法的默认实现就是返回对象的Internal slot,这个方法能够被重写
这两点是有所差别的,须要区分(不过简单点能够统一理解为内置对象内部都有一个特殊标识,用来区分对应类型-不符合类型就不给调用)。
JS内置对象是这些:
"Arguments", "Array", "Boolean", "Date", "Error", "Function", "JSON", "Math", "Number", "Object", "RegExp", "String"
ES6新增的一些,这里未提到:(如Promise对象能够输出[object Promise]
)
而前文中提到的:
Object.defineProperty(date, Symbol.toStringTag, { get: function() { return "Date"; } });
它的做用是重写Symbol.toStringTag,截取date(虽然是内置对象,可是仍然属于Object)的Object.prototype.toString
的输出,让这个对象输出本身修改后的[object Date]
。
可是,仅仅是作到输出的时候变成了Date,实际上内部的internal slot
值并无被改变,所以仍然不被认为是Date
其实,在判断继承时,没有那么多的技巧,就只有关键的一点:[[prototype]]
(__ptoto__
)的指向关系
譬如:
console.log(instance instanceof SubClass); console.log(instance instanceof SuperClass);
实质上就是:
SubClass.prototype
是否出如今instance
的原型链上
SuperClass.prototype
是否出如今instance
的原型链上
而后,对照本文中列举的一些图,一目了然就能够看清关系。有时候,彻底没有必要弄的太复杂。
因为继承的介绍在网上已经多不胜数,所以本文没有再重复描述,而是由一道Date继承题引起,展开。(关键就是原型链)
不知道看到这里,各位看官是否都已经弄懂了JS中的继承呢?
另外,遇到问题时,多想想,有时候你会发现,其实你知道的并非那么多,而后再想想,又会发现其实并无这么复杂。。。
初次发布2018.01.15
于我我的博客上面
http://www.dailichun.com/2018/01/15/howtoextenddate.html