如何繼承Date對象?由一道題完全弄懂JS繼承。

前言

看法有限,若有描述不當之處,請幫忙及時指出,若有錯誤,會及時修正。javascript

20180201更新:html

修改用詞描述,如組合寄生式改爲寄生組合式,修改多處筆誤(感謝@Yao Ding的反饋)前端

----------長文+多圖預警,須要花費必定時間----------java

故事是從一次實際需求中開始的。。。git

某天,某人向我尋求了一次幫助,要協助寫一個日期工具類,要求:es6

  • 此類繼承自Date,擁有Date的全部屬性和對象
  • 此類能夠自由拓展方法

形象點描述,就是要求能夠這樣:github

// 假設最終的類是 MyDate,有一個getTest拓展方法
let date = new MyDate();

// 調用Date的方法,輸出GMT絕對毫秒數
console.log(date.getTime());
// 調用拓展的方法,隨便輸出什麼,譬如helloworld!
console.log(date.getTest());

因而,隨手用JS中經典的寄生組合式寫了一個繼承,而後,剛準備完美收工,一運行,卻出現瞭如下的情景:算法

可是的心情是這樣的: ?囧segmentfault

之前也沒有遇到過相似的問題,而後本身嘗試着用其它方法,屢次嘗試,均無果(不算暴力混合法的狀況),其實回過頭來看,是由於思路新奇,憑空想不到,並非原理上有多難。。。數組

因而,藉助強大的搜素引擎,蒐集資料,最後,再本身總結了一番,纔有了本文。

----------正文開始前----------

正文開始前,各位看官能夠先暫停往下讀,嘗試下,在不借助任何網絡資料的狀況下,是否能實現上面的需求?(就以10分鐘爲限吧)

大綱

  • 先說說如何快速快速尋求解答

    • stackoverflow上早就有答案了!
    • 假若用的是中文搜索。
  • 分析問題的關鍵

    • 經典的繼承法有何問題
    • 爲何沒法被繼承?
  • 該如何實現繼承?

    • 暴力混合法
    • ES5黑魔法
    • ES6大法
    • ES6寫法,而後babel打包
  • 幾種繼承的細微區別
  • ES6繼承與ES5繼承的區別
  • 構造函數與實例對象
  • [[Class]]與Internal slot
  • 如何快速判斷是否繼承?
  • 寫在最後的話

先說說如何快速快速尋求解答

遇到不會的問題,確定第一目標就是如何快速尋求解決方案,答案是:

  • 先去stackoverflow上看看有沒有相似的題。。。

因而,藉助搜索引擎搜索了下,第一條就符合條件,點開進去看描述

stackoverflow上早就有答案了!

先說說結果,再瀏覽一番後,確實找到了解決方案,而後回過頭來一看,驚到了,由於這個問題的提問時間是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黑魔法

而後,再看看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]]

ES6大法

固然,除了上述的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寫法,而後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打包後的實現(沒法正常調用的)
~~~~如下是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

看出,主要差異有幾點:

  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,使用黑魔法,動態的修改下它,排除下干擾:

// 分別能夠給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繼承與ES5繼承的區別

從上文中的分析能夠看到一點:ES6的Class寫法繼承是沒問題的。可是換成ES5寫法就不行了。

因此ES6的繼承大法和ES5確定是有區別的,那麼到底是哪裏不一樣呢?(主要是結合的本文繼承Date來講)

區別:(以SubClassSuperClassinstance爲例)

  • ES5中繼承的實質是:(那種經典寄生組合式繼承法)

    • 先由子類(SubClass)構造出實例對象this
    • 而後在子類的構造函數中,將父類(SuperClass)的屬性添加到this上,SuperClass.apply(this, arguments)
    • 子類原型(SubClass.prototype)指向父類原型(SuperClass.prototype
    • 因此instance是子類(SubClass)構造出的(因此沒有父類的[[Class]]關鍵標誌)
    • 因此,instanceSubClassSuperClass的全部實例屬性,以及能夠經過原型鏈回溯,獲取SubClassSuperClass原型上的方法
  • ES6中繼承的實質是:

    • 先由父類(SuperClass)構造出實例對象this,這也是爲何必須先調用父類的super()方法(子類沒有本身的this對象,需先由父類構造)
    • 而後在子類的構造函數中,修改this(進行加工),譬如讓它指向子類原型(SubClass.prototype),這一步很關鍵,不然沒法找到子類原型(注,子類構造中加工這一步的實際作法是推測出的,從最終效果來推測
    • 而後一樣,子類原型(SubClass.prototype)指向父類原型(SuperClass.prototype
    • 因此instance是父類(SuperClass)構造出的(因此有着父類的[[Class]]關鍵標誌)
    • 因此,instanceSubClassSuperClass的全部實例屬性,以及能夠經過原型鏈回溯,獲取SubClassSuperClass原型上的方法

以上⬆就列舉了些重要信息,其它的如靜態方法的繼承沒有贅述。(靜態方法繼承實質上只須要更改下SubClass.__proto__SuperClass便可)

能夠看着這張圖快速理解:

有沒有發現呢:ES6中的步驟和本文中取巧繼承Date的方法如出一轍,不一樣的是ES6是語言底層的作法,有它的底層優化之處,而本文中的直接修改__proto__容易影響性能

ES6中在super中構建this的好處?

由於ES6中容許咱們繼承內置的類,如Date,Array,Error等。若是this先被建立出來,在傳給Array等系統內置類的構造函數,這些內置類的構造函數是不認這個this的。
因此須要如今super中構建出來,這樣纔能有着super中關鍵的[[Class]]標誌,才能被容許調用。(不然就算繼承了,也沒法調用這些內置類的方法)

構造函數與實例對象

看到這裏,不知道是否對上文中頻繁提到的構造函數實例對象有所混淆與困惑呢?這裏稍微描述下:

要弄懂這一點,須要先知道new一個對象到底發生了什麼?先形象點說:

new MyClass()中,都作了些什麼工做

function MyClass() {
    this.abc = 1;
}

MyClass.prototype.print = function() {
    console.log('this.abc:' + this.abc);
};

let instance = new MyClass();

譬如,上述就是一個標準的實例對象生成,都發生了什麼呢?

步驟簡述以下:(參考MDN,還有部分關於底層的描述略去-如[[Class]]標識位等)

  1. 構造函數內部,建立一個新的對象,它繼承自MyClass.prototypelet instance = Object.create(MyClass.prototype);
  2. 使用指定的參數調用構造函數MyClass,並將 this綁定到新建立的對象,MyClass.call(instance);,執行後擁有全部實例屬性
  3. 若是構造函數返回了一個「對象」,那麼這個對象會取代整個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]]標識位等一些重要私有屬性。

      • 譬如能夠在MDN上看到,以常規函數調用Date(即不加 new 操做符)將會返回一個字符串,而不是一個日期對象,若是這樣模擬的話會無效

以爲看起來比較繁瑣?能夠看下圖梳理:

那如今再回頭看看。

什麼是構造函數?

如上述中的MyClass就是一個構造函數,在內部它構造出了instance對象

什麼是實例對象?

instance就是一個實例對象,它是經過new出來的?

實例與構造的關係

有時候淺顯點,能夠認爲構造函數是xxx就是xxx的實例。即:

let instance = new MyClass();

此時咱們就能夠認爲instanceMyClass的實例,由於它的構造函數就是它

實例就必定是由對應的構造函數構造出的麼?

不必定,咱們那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
所以能夠認爲instanceMyDate的實例。

可是,實際上,instance倒是由Date構造的

咱們能夠繼續用ES6中的new.target來驗證。

注意⚠️

關於new.targetMDN中的定義是: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之類的分析時,咱們仍是得明白)。

[[Class]]與Internal slot

這一部分爲補充內容。

前文中一直提到一個概念: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

參考資料

相關文章
相關標籤/搜索