跳到主要內容
黯羽輕揚每天積累一點點

class 繼承_ES6 筆記 12

免費2016-09-17#JS#js super#js extend#js new.target#js mixin#ES6继承

如果跟著全文一步一步從 ES5 走過來,那麼沒有理由排斥 ES6 的 class,因為它解決了一直以來存在的很多問題,比如「應該在哪裡聲明什麼」和「更優雅的繼承實現」

寫在前面

[class_ES6 筆記 10](/articles/class-es6 筆記 10/) 中提到 classconstructorstatic 等關鍵字簡化了類的定義,包括更簡單的 getter/setter 以及各種函數屬性的簡化定義方式,如果只是這樣,ES6 的類好像還缺點什麼

嗯,繼承。ES5 之前有 [6 種繼承方案](http://www.ayqy.net/blog/%E9%87%8D%E6%96%B0%E7%90%86%E8%A7%A3 JS%E7%9A%846%E7%A8%AE%E7%B9%BC%E6%89%BF%E6%96%B9%E5%BC%8F/),ES5 提供了官方版 beget 方法(Object.create()),表示對寄生組合式繼承的支持,ES6 希望帶來一些更強大更方便的變革,而不只是一個被貼上原生標籤的工具函數

所以簡化類的定義只是簡化對象定義方式時順便完成的,ES6 真正想說的是:現在我們提供了一種更方便的繼承機制

一。如何繼承靜態屬性?

仔細回想下,我們在討論 JS 繼承時,似乎從未提到過靜態屬性,例如:

function Super() {}
Super.staticAttr = 'static';
function Sub() {}
Sub.prototype = new Super();
console.log(Sub.staticAttr);  // undefined

這樣做當然無法繼承靜態屬性,與之類似的其它 5 種方案也都沒有考慮過靜態屬性的感受。靜態屬性在 JS 中的應用場景不多,不能繼承也不會影響什麼,但對於繼承機制來說,無法繼承靜態屬性多少算是個缺憾

如果非要繼承靜態屬性呢,有沒有辦法?我們試著分析:

// 定義一個 Function 實例 Type,所謂的「類」
function Type() {}
// Function 實例的原型自然是所屬類 Function 的 prototype 屬性指向的東西
Type.__proto__ === Function.prototype
// 反過來看,類 Type 的 prototype 屬性指向的東西自然是該類實例的原型
Type.prototype === new Type().__proto__
// 所以,這是兩個不同的東西
Type.prototype !== Type.__proto__

最後一行左邊的 Type 應該叫自定義類,右邊的 Type 應該叫 Function 實例。因為 Type.prototype 指向的東西會影響自定義類 Type 的所有實例,它們都能訪問這個東西;而 Type.__proto__ 指向的東西影響的是 Type 所屬類的所有實例,Type 本身就是其中之一。prototype 只能影響未來,而 __proto__ 會改寫歷史:

function SubType() {}
var p = new Type();
SubType.prototype = p;
Type.__proto__ = {a: 1};
console.log(new SubType().a);   // undefined
// p.__proto__ = {a: 1};
// console.log(new SubType().a);   // 1

每個對象都有內部屬性 __proto__,屬性訪問時的原型鏈查找就是追溯該屬性的過程,之所以存在上面的差異,是因為 SubType 實例的原型鏈與 SubType 本身的原型鏈唯一的交點是 Object.prototype,此外毫不相干:

// SubType 實例的原型鏈
new SubType().__proto__ === SubType.prototype
new SubType().__proto__.__proto__ === Type.prototype
new SubType().__proto__.__proto__.__proto__ === Object.prototype
new SubType().__proto__.__proto__.__proto__.__proto__ === null
// SubType 本身的原型鏈
SubType.__proto__ === Function.prototype
SubType.__proto__.__proto__ === Object.prototype
SubType.__proto__.__proto__.__proto__ === null

new 出來的實例的原型鏈���根本不會有 Function.prototype 這一環,除非它的某個祖先的原型是 function 類型,但這不太可能,因為沒有任何理由這樣做:

// 除非這樣做,但這個函數要如何調用呢?
Type.prototype = function() {};

現在一切都清楚了。那麼想要繼承靜態屬性的話,我們應該修改另一條原型鏈

Sub.__proto__ = Super;  // 繼承靜態屬性

用最開始的例子測試效果:

function Super() {}
Super.staticAttr = 'static';
function Sub() {}
Sub.prototype = new Super();    // 繼承實例屬性和原型屬性
Sub.__proto__ = Super;          // 繼承靜態屬性
console.log(Sub.staticAttr);    // static

這是一個支持靜態屬性的簡單原型鏈繼承,但是很可惜,我們開掛了__proto__ 這個內部屬性不是廣泛兼容的,沮喪地發現白忙活了,這也是為什麼一直以來沒有繼承靜態屬性一說,因為我們需要直接修改一個 Function 實例的原型,可是這真的做不到

ES6 解決了這個問題,因為它想提供一套完整的繼承機制

二。原型操作 API

ES6 提供了 Object.get/setPrototypeOf() 來代替內部屬性 __proto__,例如:

let obj = {};
Object.setPrototypeOf(obj, Array.prototype);
obj.push(1);
console.log(obj.pop());     // 1
console.log(obj.length);    // 0
console.log(obj instanceof Array);  // true

通過 Object.setPrototypeOf() 直接修改一個普通對象的原型,讓它變成數組的實例

非常強大,但要注意

Changing the [[Prototype]] of an object is, by the nature of how modern JavaScript engines optimize property accesses, a very slow operation, in every browser and JavaScript engine. The effects on performance of altering inheritance are subtle and far-flung, and are not limited to simply the time spent in obj.__proto__ = ... statement, but may extend to any code that has access to any object whose [[Prototype]] has been altered. If you care about performance you should avoid setting the [[Prototype]] of an object. Instead, create a new object with the desired [[Prototype]] using Object.create().

(引自 Object.setPrototypeOf() - JavaScript | MDN

大意是這個 API性能很差,手動篡改原型鏈會導致 JS 引擎無法優化屬性訪問,帶來的性能影響要比篡改 obj.__proto__ 大得多,因為所有訪問被篡改過原型的對象的代碼都會受到影響。如果很在意性能的話,建議使用 Object.create() 新建一個原型,再以此為模板創建需要的對象

性能差是因為篡改 __proto__ 是在改寫歷史(穿越到以前,換掉關鍵人物,將改寫該點之後的所有歷史),而修改 Type.prototype 性能不差,因為是在鋪墊未來,不影響已經存在的東西,經典例子:

function Super() {
    this.key = 'value';
}
function Sub() {}
var obj1 = new Sub();
// 鋪墊未來
Sub.prototype = new Super();
var obj2 = new Sub();
console.log(obj1.key);  // undefined
console.log(obj2.key);  // value

因為修改 prototype 不影響已經存在的東西(obj1),性能影響自然就小。而 __prototype__ 明顯不一樣:

function Super() {
    this.key = 'value';
}
function Sub() {}
var obj1 = new Sub();
// 改寫歷史
Sub.prototype.__proto__ = new Super();
var obj2 = new Sub();
console.log(obj1.key);  // value
console.log(obj2.key);  // value

雖然兩個例子不是對照試驗,後者改的是 obj 的下下一環,但我們成功改寫了歷史,而用 prototype 肯定是做不到的(改 Object.prototype?確實可以,但這樣的話,討論繼承還有什麼意義呢?)

所以,繼承要付出代價,繼承原生類代價更大(原生類內部有一些盤根錯節的東西,而且數組對象和普通對象的內存佈局都不同,想想都費勁)

三。最完美的繼承

ES5 最完美的繼承方式是寄生組合式,如下:

function Super(){
    // 只在此處聲明基本屬性和引用屬性
    this.val = 1;
    this.arr = [1];
}
Super.staticProp = 1;   // 靜態屬性
//  在此處聲明函數
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...

function Sub(){
    // 1. 繼承實例屬性 val, arr
    Super.call(this);
    // ...
}
// 2. 繼承原型屬性 fun1, fun2
var proto = Object.create(Super.prototype);
proto.constructor = Sub;
Sub.prototype = proto;

完美繼承實例屬性和原型屬性,避免了原型引用在子類實例間共享的問題且切掉了多餘的那份實例屬性

但它同樣沒有考慮靜態屬性的繼承問題,我們手動添上:

function Super(){
    // 只在此處聲明基本屬性和引用屬性
    this.val = 1;
    this.arr = [1];
}
Super.staticProp = 1;   // 靜態屬性
//  在此處聲明函數
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...

function Sub(){
    // 1. 繼承實例屬性 val, arr
    Super.call(this);
    // ...
}
// 2. 繼承原型屬性 fun1, fun2
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 3. 繼承靜態屬性
Object.setPrototypeOf(Sub, Super);

這裡把繼承原型屬性的 3 句簡化成 1 句了,對比一下:

/* 以前的 3 句 */
// 1. 把 Super.prototype 包進一個匿名對象的原型,返回這個匿名對象
var proto = Object.create(Super.prototype);
// 2. 修正 constructor 屬性
proto.constructor = Sub;
// 3. 讓子類實例獲得匿名對象原型屬性的訪問權
// new Sub().__proto__ === proto
Sub.prototype = proto;

/* 簡化成 1 句 */
// 效果等同於上面 3 句(子類實例獲得了父類原型屬性的訪問權),實現方式類似於「下下一環」
Object.setPrototypeOf(Sub.prototype, Super.prototype);  // 等價於 Sub.prototype.__proto__ = Super.prototype

這樣做除了精簡代碼(3 行變 1 行)外,沒有太大意義,考慮性能的話,就更沒理由做這種精簡了

Object.setPrototypeOf(Sub, Super) 是無法避免的,這是到 ES6 為止,唯一合法的能夠真正意義上「改寫歷史」的手段

添上靜態屬性繼承支持後的完整示例如下:

function Super(){
    // 只在此處聲明基本屬性和引用屬性
    this.val = 1;
    this.arr = [1];
}
Super.staticProp = 1;   // 靜態屬性
//  在此處聲明函數
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...

function Sub(){
    // 1. 繼承實例屬性 val, arr
    Super.call(this);
    // ...
}
// 2. 繼承原型屬性 fun1, fun2
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 3. 繼承靜態屬性
Object.setPrototypeOf(Sub, Super);

// test
var sub = new Sub();
console.log(sub.val);   // 1
console.log(sub.arr);   // [1]
console.log(Super.staticProp);  // 1
console.log(Sub.staticProp);    // 1

這就是最完美的繼承了,支持靜態屬性的寄生組合式繼承。但存在一個問題,「應該在哪裡聲明什麼」只是一個道德約束,這種弱約束不利於車間生產,我們需要一個更強的約束

四。ES6 繼承

我們已經學會了 classstatic(見 [class_ES6 筆記 10](/articles/class-es6 筆記 10/)),也知道了如何繼承靜態屬性,那麼 ES6 的繼承應該是這樣:

class Super {
    constructor(sub) {
        console.log(sub);
        this.greeting = 'hello' + (sub && `, ${sub.name}`);
    }
}
class Sub {
    constructor() {
        // 繼承實例屬性
        this.name = 'sam';
        return new Super(this);
    }
}
// 繼承原型屬性
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 繼承靜態屬性
Object.setPrototypeOf(Sub, Super);

// test
console.log(new Sub().greeting);    // hello, sam

簡潔了不少,看起來也很完美,但是:

console.log(new Sub() instanceof Super);    // true
console.log(new Sub() instanceof Sub);      // false

類型果然凌亂了,再手動修改 constructor?太麻煩了。ES6 也發現了這一點,所以提供了 extends 關鍵字:

class Super {
    constructor() {
        // 實例屬性
        this.val = 1;
        this.arr = [1];
    }
    // 靜態屬性
    static get staticProp() {
        return this._staticProp || 1;
    }
    static set staticProp(val) {
        this._staticProp = val;
    }
    // 原型屬性
    fun1() {}
    fun2() {}
}
class Sub extends Super {
    // ...
}

// test
var obj = new Sub();
console.log(obj.val);    // 1
console.log(obj.arr);    // [1]
console.log(Super.staticProp);    // 1
console.log(Sub.staticProp);      // 1
console.log(obj instanceof Super);  // true
console.log(obj instanceof Sub);    // true

這才看到幾個新增關鍵字的真正作用,如下:

  • 對「應該在哪裡聲明什麼」提供強約束

  • 支持靜態屬性繼承

  • 自動維護類型

class Sub extends Super 語法,Super 可以是其它類、基於原型繼承的函數、一般函數、包含函數或類的變量、對象上的某個屬性、函數調用。此外,如果不想繼承自 Object.prototype 還可以 extends null,這提供了足夠大的靈活性,讓我們可以輕鬆使用新語法擴展現有類以及第三方類

繼承機制的基本要素有了,自然就會有一些更高級的需求,比如,怎麼訪問父類屬性?怎麼給父類構造函數傳參?

訪問祖先類屬性

通過 super 關鍵字可以訪問祖先類屬性,調用父類構造函數,如下:

class A {
    constructor(name) {
        // 實例屬性
        this.name = name;
    }
    // 原型屬性
    fn() {
        console.log('fn at A');
    }
    // 靜態屬性
    static get staticProp() {
        return this._staticProp || 1;
    }
    static set staticProp(val) {
        this._staticProp = val;
    }
}
class B extends A {
    constructor(name) {
        super(name.toUpperCase());
        super.fn();
    }
    fn() {
        super.fn();
    }
}
var b = new B('BextendsfromA'); // fn at A
console.log(b.name);    // BEXTENDSFROMA

super 會跳過子類中定義的屬性,直接從子類原型開始查找

因為內部仍然是原型鏈查找,所以 super 能訪問的祖先類屬性僅限於原型屬性,無法訪問祖先類的靜態屬性和實例屬性,例如:

class B extends A {
    constructor(name) {
        super(name.toUpperCase());
        super.fn();
        // 從子類實例的原型開始做原型鏈查找,當然找不到位於另一條原型鏈上的祖先類靜態屬性
        console.log(super.staticProp);  // undefined
        // 想通過原型鏈找到祖先類的實例屬性就更不可能了,明顯應該通過 `this.key` 來找(實例屬性的繼承方式是值拷貝,而不是持有屬性訪問權)
        console.log(super.key);         // undefined
    }
    ...
}

注意super 看起來和 this 很像,this 像一個內置的變量名,在不同作用域可能指向不同的值,但 super 不一樣,super 是一個關鍵字

typeof super;   // Uncaught SyntaxError: 'super' keyword unexpected here
super instanceof SuperType; // 同上

要麼 super.xxx/super['xxx'] 訪問父類屬性,要麼 super() 調用父類構造函數,其它形式都是非法的

支持擴展原生類型

如果 CharArray extends Array,那麼 Array.isArray() 檢測應該返回 trueinstanceof 檢測應該返回 true,而且 slice 之類的方法也應該返回 CharArray(Chrome47 返回 Array,目前 53 已經能夠正確返回 CharArray 了),示例如下:

class CharArray extends Array {
    constructor(str) {
        console.log(typeof str, str);
        if (str.length > 1) {
            // 先創建實例 [],否則 this 報錯
            // Uncaught ReferenceError: this is not defined
            super();
            // 再 push
            super.push.apply(this, str.split(''));
        }
        else {
            super(str);
        }
    }
    toUpperCase() {
        return this.map(function(c) {
            return c.toUpperCase();
        });
    }
}
var ca1 = new CharArray('abcde');   // string abcde
var ca2 = new CharArray('c');       // string c
console.log(ca1);   // ["a", "b", "c", "d", "e"]
console.log(ca2);   // ["c"]
console.log(ca1.slice(1));  // ["b", "c", "d", "e"] number 4
console.log(ca1.toUpperCase());  // ["A", "B", "C", "D", "E"] number 5
console.log(Array.isArray(ca1));    // true
console.log(ca1 instanceof CharArray);  // true
//! 理論上應該返回 true
console.log(ca1.slice(2) instanceof CharArray);  // true number 3
console.log(ca1 instanceof Array);      // true

原生類的擴展類除了能夠像真貨一樣通過現有的所有類型檢測外,繼承來的所有原生方法都應該有同樣的行為,上面代碼中的一個關鍵點是 console.log(typeof str, str);,我們預期的第一個參數應該是 string,但實際上有時候這個參數是 number(調用 slice() 的時候),這說明原生 slice() 可能是這樣:

Array.prototype.slice = function(start) {
    // 暫不考慮 start 為負數的情況
    let size = this.length - start;
    let res = new Array(size);
    for (let i = 0; i < size; i++) {
        res[i] = this[start + i];
    }
    return res;
}

new Array(size) 就是有時候構造函數收到 number 的原因,這說明自定義的假貨和真貨內部機制完全一致

子類實例的創建過程

子類 constructor 中,this 需要調用 super() 獲得,在 super() 之前使用 this 會報錯 ReferenceError(這個約束類似於在 Java 類的構造函數中,super() 必須位於第一行,同樣的道理),例如:

class C {}
class D extends C {
    constructor() {
        // this.a = 1; // Uncaught ReferenceError: this is not defined
        super();    // 調用 C 預設的空構造函數
        // 也可以直接 return 一個其它對象,不用 this
        // return {a: 1};
    }
}
// test
console.log(new D());

定義子類 constructor 就是告訴 JS「創建子類實例這個事情交給我們吧,不用你管了」

所以在子類構造函數中,我們可以調用父類構造函數來創建合適的實例,然後借用祖先屬性對實例進行初始化,甚至更粗暴的,我們可以放棄 this,手動返回一個毫不相干的其它對象,作為 new 運算的結果

當然,也可以不定義 constructor,表示我們不關心子類實例的創建過程,那麼會創建一個該類的空白實例

在訪問 this 之前必須調用 super() 是理所應當的,否則 this 的結構不確定(是 Array 還是普通對象?)

new.target

祖先類構造函數中可以通過檢測 new.target 的值來得知是誰在調用該構造函數

因為子類構造函數必須先執行 super() 才能獲取 this,而某些兄弟子類存在本質的差異(比如 Array 和一般對象內存佈局不同),所以父類需要知道應該返回哪種對象作為調用者的 thisnew.target 用來解決這個問題。例如:

class E {
    constructor() {
        switch(new.target) {
            case E:
                console.log('call from E');
                break;
            case F:
                console.log('call from F');
                break;
            case G:
                console.log('call from G');
                break;
            case H:
                console.log('call from H');
                break;
            default: break;
        }
    }
}
// 建立繼承樹
//     E
//    / \
//   F   G
//       |
//       H
class F extends E {}    // 預設調用父類構造函數
class G extends E {}
class H extends G {}
// test
new E();    // call from E
new F();    // call from F
new G();    // call from G
new H();    // call from H

P.S.new.target 在任何函數中都是合法的,如果函數不是通過 new 調用,new.target 將被賦值為 undefined

基類需要知道子類信息,這很難理解,感覺是抽象反過來依賴具體了,那麼基類為什麼需要知道想 new 什麼子類?

如上所述,因為

某些兄弟子類存在本質的差異

既然差異這麼大,為什麼分離開作為 2 個基類呢?Java 中這樣做肯定能解決問題,但 JS 不行,因為JS 的繼承樹只有一個根節點——Object,所以 Object 需要知道我們想 new 個普通 Object 還是 Array

說白了就是因為 JS 把所有東西都掛在 Object 這個根節點下,才出現了「兄弟子類差異巨大」的問題,然後有了 new.target 的解決方案。而Java 的繼承樹不止一個根節點,所以不存在這個問題

五。多繼承

有一種方式叫 class extends Mixin

function mix(...mixins) {
    class Mix {}
    function copy(target, source) {
        // 命名函數有 name 屬性
        let filterSet = new Set(['constructor', 'prototype', 'name']);
        // for (let key of Reflect.ownKeys(source)) {
        for (let key in source) {
            // if (!filterSet.has(key)) {
            if (source.hasOwnProperty(key) && !filterSet.has(key)) {
                let desc = Object.getOwnPropertyDescriptor(source, key);
                Object.defineProperty(target, key, desc);
            }
        }
    }

    for (let mixin of mixins) {
        // 如果 mixin 是「類」,「繼承」其靜態屬性和原型屬性
        // 如果 mixin 是普通對象,「繼承」其實例屬性
        copy(Mix, mixin);
        copy(Mix.prototype, mixin.prototype);
    }

    return Mix;
}

以前的 mix/extends 方案是把多個對象揉成一個對象,現在多走了一步,把多個 mixin 揉成一個類,這個類的實例能「繼承」到模版*「類」*的靜態屬性和原型屬性以及模版對象的實例屬性

注意:「類」帶有雙引號很關鍵,因為這裡的類不是指用 class 關鍵字定義的類,而是用 function 關鍵字定義的類型,區別是前者不可枚舉(這也是 class封裝性的體現),copy 方案失效,那就什麼都「繼承」不到了

測試一下效果:

let obj = {key: 'value'};
function Type() {}
Type.staticProp = 'static value from Type';
Type.prototype.fn = function() {
    return 'proto fn from Type';
};
let M = mix(obj, Type);
console.log(M.key);         // value
console.log(M.staticProp);  // static value from Type
console.log(new M().fn());  // proto fn from Type

怎麼說,感覺效果有限,比以前的 mix/extends 方案略強大一點,但效果比較有限,尤其是 mixin 不支持用 class 關鍵字定義的類,這種方案就很難再有發展了

所以,JS 還是沒有多繼承。

六。總結

如果跟著全文一步一步從 ES5 走過來,那麼沒有理由排斥 ES6 的 class,因為它解決了一直以來存在的很多問題,比如「應該在哪裡聲明什麼」和「更優雅的繼承實現」

參考資料

  • 《ES6 in Depth》:InfoQ 中文站提供的免費電子書

評論

暫無評論,快來發表你的看法吧

提交評論