专栏名称: SegmentFault思否
SegmentFault (www.sf.gg)开发者社区,是中国年轻开发者喜爱的极客社区,我们为开发者提供最纯粹的技术交流和分享平台。
目录
相关文章推荐
程序员的那些事  ·  快!快!快!DeepSeek 满血版真是快 ·  昨天  
程序员的那些事  ·  OpenAI ... ·  2 天前  
OSC开源社区  ·  宇树王兴兴早年创业分享引围观 ·  4 天前  
程序猿  ·  “我真的受够了Ubuntu!” ·  4 天前  
程序猿  ·  “未来 3 年内,Python 在 AI ... ·  5 天前  
51好读  ›  专栏  ›  SegmentFault思否

如何继承 Date 对象?由一道题彻底弄懂 JS 继承

SegmentFault思否  · 公众号  · 程序员  · 2018-01-24 08:00

正文

前言

故事是从一次实际需求中开始的。。。

某天,某人向我寻求了一次帮助,要协助写一个日期工具类,要求:

  • 此类继承自 Date ,拥有Date的所有属性和对象

  • 此类可以自由拓展方法

形象点描述,就是要求可以这样:

  1. // 假设最终的类是 MyDate,有一个getTest拓展方法

  2. let date = new MyDate();

  3. // 调用Date的方法,输出GMT绝对毫秒数

  4. console.log(date.getTime());

  5. // 调用拓展的方法,随便输出什么,譬如helloworld!

  6. console.log(date.getTest());

于是,随手用JS中经典的 组合寄生法 写了一个继承,然后,刚准备完美收工,一运行,却出现了以下的情景:

但是的心情是这样的: 😳囧

以前也没有遇到过类似的问题,然后自己尝试着用其它方法,多次尝试,均无果(不算暴力混合法的情况),其实回过头来看,是因为思路新奇,凭空想不到,并不是原理上有多难。。。

于是,借助强大的搜素引擎,搜集资料,最后,再自己总结了一番,才有了本文。

正文开始前,各位看官可以先暂停往下读,尝试下,在不借助任何网络资料的情况下,是否能实现上面的需求?(就以 10分钟 为限吧)

分析问题的关键

借助stackoverflow上的回答。

经典的继承法有何问题

先看看本文最开始时提到的经典继承法实现,如下:

  1. /**

  2. * 经典的js组合寄生继承

  3. */

  4. function MyDate() {

  5.    Date.apply(this, arguments);

  6.    this.abc = 1;

  7. }

  8. function inherits(subClass, superClass) {

  9.     function Inner() {}

  10.    Inner.prototype = superClass.prototype;

  11.    subClass.prototype = new Inner();

  12.    subClass.prototype.constructor = subClass;

  13. }

  14. inherits(MyDate, Date);

  15. MyDate.prototype.getTest = function() {

  16.    return this.getTime();

  17. };

  18. let date = new MyDate();

  19. console.log(date.getTest());

就是这段代码⬆,这也是JavaScript高程(红宝书)中推荐的一种,一直用,从未失手,结果现在马失前蹄。。。

我们再回顾下它的报错:

再打印它的原型看看:

怎么看都没问题,因为按照原型链回溯规则, Date 的所有原型方法都可以通过 MyDate 对象的原型链往上回溯到。再仔细看看,发现它的关键并不是找不到方法,而是 thisisnotaDateobject.

嗯哼,也就是说,关键是: 由于调用的对象不是Date的实例,所以不允许调用,就算是自己通过原型继承的也不行。

为什么无法被继承?

首先,看看 MDN 上的解释,上面有提到,JavaScript的日期对象只能通过 JavaScriptDate 作为构造函数来实例化。

然后再看看stackoverflow上的回答:

有提到, v8 引擎底层代码中有限制,如果调用对象的 [[Class]] 不是 Date ,则抛出错误。

总的来说,结合这两点,可以得出一个结论: 要调用Date上方法的实例对象必须通过Date构造出来,否则不允许调用Date的方法。

该如何实现继承?

虽然原因找到了,但是问题仍然要解决啊,真的就没办法了么?当然不是,事实上还是有不少实现的方法的。

暴力混合法

首先,说说说下暴力的混合法,它是下面这样子的:

说到底就是:内部生成一个 Date 对象,然后此类暴露的方法中,把原有 Date 中所有的方法都代理一遍,而且严格来说,这根本算不上继承(都没有原型链回溯)。

ES5黑魔法

然后,再看看ES5中如何实现?

  1. // 需要考虑polyfill情况

  2. Object.setPrototypeOf = Object.setPrototypeOf ||

  3. function(obj, proto) {

  4.    obj.__proto__ = proto;

  5.    return obj;

  6. };

  7. /**

  8. * 用了点技巧的继承,实际上返回的是Date对象

  9. */

  10. function MyDate() {

  11.    // bind属于Function.prototype,接收的参数是:object, param1, params2...

  12.    var dateInst = new(Function.prototype.bind.apply( Date, [Date].concat(Array.prototype.slice.call(arguments))))();

  13.    // 更改原型指向,否则无法调用MyDate原型上的方法

  14.    // ES6方案中,这里就是[[prototype]]这个隐式原型对象,在没有标准以前就是__proto__

  15.    Object.setPrototypeOf(dateInst, MyDate.prototype);

  16.    dateInst.abc = 1;

  17.    return dateInst;

  18. }

  19. // 原型重新指回Date,否则根本无法算是继承

  20. Object.setPrototypeOf(MyDate.prototype, Date.prototype);

  21. MyDate.prototype.getTest = function getTest() {

  22.    return this.getTime();

  23. };

  24. let date = new MyDate();

  25. // 正常输出,譬如1515638988725

  26. console.log(date.getTest());

一眼看上去不知所措?没关系,先看下图来理解:(原型链关系一目了然)

可以看到,用的是非常巧妙的一种做法:

正常继承的情况如下:

  • newMyDate() 返回实例对象 date 是由 MyDate 构造的

  • 原型链回溯是: date(MyDate对象)->date.__proto__->MyDate.prototype->MyDate.prototype.__proto__->Date.prototype

这种做法的继承的情况如下:

  • newMyDate() 返回实例对象 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]]

ES6大法

当然,除了上述的ES5实现,ES6中也可以直接继承(自带支持继承 Date ),而且更为简单:

  1. class MyDate extends Date {

  2.    constructor() {

  3.        super();

  4.         this.abc = 1;

  5.    }

  6.    getTest() {

  7.        return this.getTime();

  8.    }

  9. }

  10. let date = new MyDate();

  11. // 正常输出,譬如1515638988725

  12. console.log(date.getTest());

对比下ES5中的实现,这个真的是简单的不行,直接使用ES6的Class语法就行了。而且,也可以正常输出。

注意: 这里的正常输出环境是直接用ES6运行,不经过babel打包,打包后实质上是转化成ES5的,所以效果完全不一样。

ES6写法,然后Babel打包

虽然说上述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打包后的实现(无法正常调用的)

  1. ~~~~以下是MyDate们的prototype~~~~~~~~~

  2. Date {constructor: ƒ, getTest: ƒ}

  3. Date {constructor: ƒ, getTest: ƒ}

  4. Date {getTest: ƒ, constructor: ƒ}

  5. Date {constructor: ƒ, getTest: ƒ}

  6. ~~~~以下是new出的对象~~~~~~~~~

  7. Sat Jan 13 2018 21:58:55 GMT+0800 (CST)

  8. MyDate2 {abc: 1}

  9. Sat Jan 13 2018 21:58:55 GMT+0800 (CST)

  10. MyDate {abc: 1}

  11. ~~~~以下是new出的对象的Object.prototype.toString.call~~~~~~~~~

  12. [object Date]

  13. [object Object]

  14. [object Date]

  15. [object Object]

  16. ~~~~以下是MyDate们的__proto__~~~~~~~~~

  17. ƒ Date() { [native code] }

  18. ƒ () { [native code] }

  19. ƒ () { [native code] }

  20. ƒ Date() { [native code] }

  21. ~~~~以下是new出的对象的__proto__~~~~~~~~~

  22. Date {constructor: ƒ, getTest: ƒ}

  23. Date {constructor: ƒ, getTest: ƒ}

  24. Date {getTest: ƒ, constructor: ƒ}

  25. Date {constructor: ƒ, getTest: ƒ}

  26. ~~~~以下是对象的__proto__与MyDate们的prototype比较~~~~~~~~~

  27. true

  28. true

  29. true

  30. true

看出,主要差别有几点:

  1. MyDate们的 proto 指向不一样

  2. Object.prototype.toString.call的输出不一样

  3. 对象本质不一样,可以正常调用的 1,3 都是 Date 构造出的,而其它的则是 MyDate 构造出的

我们上文中得出的一个结论是: 由于调用的对象不是由Date构造出的实例,所以不允许调用,就算是自己的原型链上有Date.prototype也不行

但是这里有两个变量: 分别是底层构造实例的方法不一样,以及对象的 Object.prototype.toString.call 的输出不一样 (另一个 MyDate.__proto__ 可以排除,因为原型链回溯肯定与它无关)。

万一它的判断是根据 Object.prototype.toString.call 来的呢?那这样结论不就有误差了?

于是,根据ES6中的, Symbol.toStringTag ,使用黑魔法,动态的修改下它,排除下干扰:

  1. // 分别可以给date2,date3设置

  2. Object.defineProperty(date2, Symbol.toStringTag, {

  3.    get: function() {

  4.        return "Date";

  5.    }

  6. });

然后在打印下看看,变成这样了:

  1. [object Date]

  2. [object Date]

  3. [object Date]

  4. [object Object]

可以看到,第二个的 MyDate2 构造出的实例,虽然打印出来是 [objectDate] ,但是调用Date方法仍然是有错误。

此时我们可以更加准确一点的确认: 由于调用的对象不是由Date构造出的实例,所以不允许调用

而且我们可以看到,就算通过黑魔法修改 Object.prototype.toString.call ,内部的 [[Class]] 标识位也是无法修改的。(这块知识点大概是Object.prototype.toString.call可以输出内部的[[Class]],但无法改变它,由于不是重点,这里不赘述)。

ES6继承与ES5继承的区别

从上午中的分析可以看到一点: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 一个对象到底发生了什么?先形象点说:

new MyClass()中,都做了些什么工作
  1. function MyClass() {

  2.    this.abc = 1;

  3. }

  4. MyClass.prototype.print = function() {

  5.    console.log('this.abc:' + this.abc);

  6. };

  7. let instance = new MyClass();

譬如,上述就是一个标准的实例对象生成,都发生了什么呢?

步骤简述如下:( 参考MDN ,还有部分关于底层的描述略去-如[[Class]]标识位等)

  1. 构造函数内部,创建一个新的对象,它继承自 MyClass.prototype letinstance=Object.create(MyClass.prototype);

  2. 使用指定的参数调用构造函数 MyClass ,并将 this绑定到新创建的对象, MyClass.call(instance); ,执行后拥有所有实例属性

  3. 如果构造函数返回了一个“对象”,那么这个对象会取代整个 new 出来的结果。如果构造函数没有返回对象,那么new出来的结果为步骤1创建的对象。 (一般情况下构造函数不返回任何值,不过用户如果想覆盖这个返回值,可以自己选择返回一个普通对象来覆盖。当然,返回数组也会覆盖,因为数组也是对象。)

结合上述的描述,大概可以还原成以下代码(简单还原,不考虑各种其它逻辑)

  1. let instance = Object.create(MyClass.prototype);

  2. let innerConstructReturn = MyClass.call(instance);

  3. let innerConstructReturnIsObj = typeof innerConstructReturn === 'object' || typeof innerConstructReturn === 'function';

  4. return innerConstructReturnIsObj ? innerConstructReturn : instance;

注意⚠️:普通的函数构建,可以简单的认为就是上述步骤。实际上对于一些内置类(如Date等),并没有这么简单,还有一些自己的隐藏逻辑,譬如 [[Class]] 标识位等一些重要私有属性。譬如可以在MDN上看到,以常规函数调用Date(即不加 new 操作符)将会返回一个字符串,而不是一个日期对象,如果这样模拟的话会无效。

觉得看起来比较繁琐?可以看下图梳理:

那现在再回头看看。

什么是构造函数?

如上述中的 MyClass 就是一个构造函数,在内部它构造出了 instance 对象。

什么是实例对象?

instance 就是一个实例对象,它是通过 new 出来的?

实例与构造的关系

有时候浅显点,可以认为构造函数是xxx就是xxx的实例。即:

  1. let instance = new







请到「今天看啥」查看全文