JavaScript基礎: 類與繼承

請叫我王磊同學發表於2018-03-25

前言

  首先歡迎大家關注我的Github部落格,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現,能堅持下去也是靠的是自己的熱情和大家的鼓勵。

  許久已經沒有寫東西了,因為雜七雜八的原因最近一直沒有抽出時間來把寫作堅持下來,感覺和跑步一樣,一旦鬆懈下來就很難再次撿起來。最近一直想重新靜下心來寫點什麼,選題又成為一個讓我頭疼的問題,最近工作中偶爾會對JavaScript繼承的問題有時候會感覺恍惚,意識到很多知識即使是很基礎,也需要經常的回顧和練習,否則即使再熟悉的東西也會經常讓你感到陌生,所以就選擇這麼一篇非常基礎的文章作為今年的開始吧。   

  JavaScript不像Java語言本身就具有類的概念,JavaScript作為一門基於原型(ProtoType)的語言,(推薦我之前寫的我所認識的JavaScript作用域鏈和原型鏈),時至今日,仍然有很多人不建議在JavaScript中大量使用面對物件的特性。但就目前而言,很多前端框架,例如React都有基於類的概念。首先明確一點,類存在的目的就是為了生成物件,而在JavaScript生成物件的過程並不不像其他語言那麼繁瑣,我們可以通過物件字面量語法輕鬆的建立一個物件:

var person = {
    name: "MrErHu", 
    sayName: function(){
        alert(this.name);
    }
};
複製程式碼

  一切看起來是這樣的完美,但是當我們希望建立無數個相似的物件時,我們就會發現物件字面量的方法就不能滿足了,當然聰明的你肯定會想到採用工廠模式去建立一系列的物件:   

function createObject(name){
    return {
        "name": name,
        "sayName": function(){
            alert(this.name);
        }
    }
}
複製程式碼

  但是這樣方式有一個顯著的問題,我們通過工廠模式生成的各個物件之間並沒有聯絡,沒法識別物件的型別,這時候就出現了建構函式。在JavaScript中建構函式和普通的函式沒有任何的區別,僅僅是建構函式是通過new操作符呼叫的。   

function Person(name, age, job){
    this.name = name;
    this.sayName = function(){
        alert(this.name);
    };    
}

var obj = new Person();
obj.sayName();
複製程式碼

  我們知道new操作符會做以下四個步驟的操作:   

  1. 建立一個全新的物件
  2. 新物件內部屬性[[Prototype]](非正式屬性__proto__)連線到建構函式的原型
  3. 建構函式的this會繫結新的物件
  4. 如果函式沒有返回其他物件,那麼new表示式中的函式呼叫會自動返回這個新物件

  這樣我們通過建構函式的方式生成的物件就可以進行型別判斷。但是單純的建構函式模式會存在一個問題,就是每個物件的方法都是相互獨立的,而函式本質上就是一種物件,因此就會造成大量的記憶體浪費。回顧new操作符的第三個步驟,我們新生成物件的內部屬性[[Prototype]]會連線到建構函式的原型上,因此利用這個特性,我們可以混合建構函式模式原型模式,解決上面的問題。

function Person(name, age, job){
    this.name = name;
}

Person.prototype = {
    constructor : Person,
    sayName : function(){
        alert(this.name);
    }
}

var obj = new Person();
obj.sayName();
複製程式碼

  我們通過將sayName函式放到建構函式的原型中,這樣生成的物件在使用sayName函式通過查詢原型鏈就可以找到對應的方法,所有物件共用一個方法就解決了上述問題,即使你可能認為原型鏈查詢可能會耽誤一點時間,實際上對於現在的JavaScript引擎這種問題可以忽略。對於建構函式的原型修改,處理上述的方式,可能還存在:   

Person.prototype.sayName = function(){
    alert(this.name);
}
複製程式碼

  我們知道函式的原型中的constructor屬性是執行函式本身,如果你是將原來的原型替換成新的物件並且constructor對你又比較重要記得手動新增,因此第一種並不準確,因為constructor是不可列舉的,因此更準確的寫法應該是:

Object.defineProperty(Person, "constructor", {
    configurable: false,
    enumerable: false,
    writable: true,
    value: Person
});
複製程式碼

  到現在為止,我們會覺得在JavaScript中建立個類也太麻煩了,其實遠遠不止如此,比如我們建立的類可能會被直接呼叫,造成全域性環境的汙染,比如:   

Person('MrErHu');
console.log(window.name); //MrErHu
複製程式碼

  不過我們迎來了ES6的時代,事情正在其變化,ES6為我們在JavaScript中實現了類的概念,上面的的程式碼都可以用簡介的類(class)實現。   

class Person {
    constructor(name){
        this.name = name;
    }
    
    sayName(){
        alert(this.name);
    }
}
複製程式碼

  通過上面我們就定義了一個類,使用的時候同之前一樣:   

let person = new Person('MrErHu');
person.sayName(); //MrErHu
複製程式碼

  我們可以看到,類中的constructor函式負擔起了之前的建構函式的功能,類中的例項屬性都可以在這裡初始化。類的方法sayName相當於之前我們定義在建構函式的原型上。其實在ES6中類僅僅只是函式的語法糖:   

typeof Person  //"function"
複製程式碼

  相比於上面自己建立的類方式,ES6中的類有幾個方面是與我們自定義的類不相同的。首先類是不存在變數提升的,因此不能先使用後定義:   

let person = new Person('MrErHu')
class Person { //...... } 
複製程式碼

  上面的使用方式是錯誤的。因此類更像一個函式表示式。

  其次,類宣告中的所有程式碼都是自動執行在嚴格模式下,並且不能讓類脫離嚴格模式。相當於類宣告中的所有程式碼都執行在"use strict"中。

  再者,類中的所有方法都是都是不可列舉的。

  最後,類是不能直接呼叫的,必須通過new操作符呼叫。其實對於函式有內部屬性[[Constructor]][[Call]],當然這兩個方法我們在外部是沒法訪問到的,僅存在於JavaScript引擎。當我們直接呼叫函式時,其實就是呼叫了內部屬性[[Call]],所做的就是直接執行了函式體。當我們通過new操作符呼叫時,其實就是呼叫了內部屬性[[Constructor]],所做的就是建立新的例項物件,並在例項物件上執行函式(繫結this),最後返回新的例項物件。因為類中不含有內部屬性[[Call]],因此是沒法直接呼叫的。順便可以提一句ES6中的元屬性 new.target     

  所謂的元屬性指的就是非物件的屬性,可以提供給我們一些補充資訊。new.target就是其中一個元屬性,當呼叫的是[[Constructor]]屬性時,new.target就是new操作符的目標,如果呼叫的是[[Call]]屬性,new.target就是undefined。其實這個屬性是非常有用的,比如我們可以定義一個僅可以通過new操作符呼叫的函式:

function Person(){
    if(new.target === undefined){
        throw('該函式必須通過new操作符呼叫');
    }
}
複製程式碼

  或者我們可以用JavaScript建立一個類似於C++中的虛擬函式的函式:

class Person {
  constructor() {
    if (new.target === Person) {
      throw new Error('本類不能例項化');
    }
  }
}
複製程式碼

  

繼承

  在沒有ES6的時代,想要實現繼承是一個不小的工作。一方面我們要在派生類中建立父類的屬性,另一方面我們需要繼承父類的方法,例如下面的實現方法:   

function Rectangle(width, height){
  this.width = width;
  this.height = height;
}

Rectangle.prototype.getArea = function(){
  return this.width * this.height;
}

function Square(length){
  Rectangle.call(this, length, length);
}

Square.prototype = Object.create(Rectangle.prototype, {
  constructor: {
    value: Square,
    enumerable: false,
    writable: false,
    configurable: false
  }
});

var square = new Square(3);

console.log(square.getArea());
console.log(square instanceof Square);
console.log(square instanceof Rectangle);
複製程式碼

  首先子類Square為了建立父類Rectangle的屬性,我們在Square函式中以Rectangle.call(this, length, length)的方式進行了呼叫,其目的就是在子類中建立父類的屬性,為了繼承父類的方法,我們給Square賦值了新的原型。除了通過Object.create方式,你應該也見過以下方式:   

Square.prototype = new Rectangle();
Object.defineProperty(Square.prototype, "constructor", {
    value: Square,
    enumerable: false,
    writable: false,
    configurable: false
});
複製程式碼

  Object.create是ES5新增的方法,用於建立一個新物件。被建立的物件會繼承另一個物件的原型,在建立新物件時還可以指定一些屬性。Object.create指定屬性的方式與Object.defineProperty相同,都是採用屬性描述符的方式。因此可以看出,通過Object.createnew方式實現的繼承其本質上並沒有什麼區別。      但是ES6可以大大簡化繼承的步驟:

class Rectangle{
    constructor(width, height){
        this.width = width;
        this.height = height;
    }
    
    getArea(){
        return this.width * this.height;
    }
}

class Square extends Rectangle{
    construct(length){
        super(length, length);
    }
}
複製程式碼

  我們可以看到通過ES6的方式實現類的繼承是非常容易的。Square的建構函式中呼叫super其目的就是呼叫父類的建構函式。當然呼叫super函式並不是必須的,如果你預設預設了建構函式,則會自動呼叫super函式,並傳入所有的引數。      不僅如此,ES6的類繼承賦予了更多新的特性,首先extends可以繼承任何型別的表示式,只要該表示式最終返回的是一個可繼承的函式(也就是講extends可以繼承具有[[Constructor]]的內部屬性的函式,比如null和生成器函式、箭頭函式都不具有該屬性,因此不可以被繼承)。比如:

class A{}
class B{}

function getParentClass(type){
    if(//...){
        return A;
    }
    if(//...){
        return B;
    }
}

class C extends getParentClass(//...){
}
複製程式碼

  可以看到我們通過上面的程式碼實現了動態繼承,可以根據不同的判斷條件繼承不同的類。      ES6的繼承與ES5實現的類繼承,還有一點不同。ES5是先建立子類的例項,然後在子類例項的基礎上建立父類的屬性。而ES6正好是相反的,是先建立父類的例項,然後在父類例項的基礎上擴充套件子類屬性。利用這個屬性我們可以做到一些ES5無法實現的功能:繼承原生物件。

function MyArray() {
  Array.apply(this, arguments);
}

MyArray.prototype = Object.create(Array.prototype, {
  constructor: {
    value: MyArray,
    writable: true,
    configurable: true,
    enumerable: true
  }
});

var colors = new MyArray();
colors[0] = "red";
colors.length  // 0

colors.length = 0;
colors[0]  // "red"
複製程式碼

  可以看到,繼承自原生物件ArrayMyArray的例項中的length並不能如同原生Array類的例項 一樣可以動態反應陣列中元素數量或者通過改變length屬性從而改變陣列中的資料。究其原因就是因為傳統方式實現的陣列繼承是先建立子類,然後在子類基礎上擴充套件父類的屬性和方法,所以並沒有繼承的相關方法,但ES6卻可以輕鬆實現這一點:

class MyArray extends Array {
  constructor(...args) {
    super(...args);
  }
}

var arr = new MyArray();
arr[0] = 12;
arr.length // 1

arr.length = 0;
arr[0] // undefined
複製程式碼

  我們可以看見通過extends實現的MyArray類建立的陣列就可以同原生陣列一樣,使用length屬性反應陣列變化和改變陣列元素。不僅如此,在ES6中,我們可以使用Symbol.species屬性使得當我們繼承原生物件時,改變繼承自原生物件的方法的返回例項型別。例如,Array.prototype.slice本來返回的是Array型別的例項,通過設定Symbol.species屬性,我們可以讓其返回自定義的物件型別:   

class MyArray extends Array {
  static get [Symbol.species](){
    return MyArray;
  }
    
  constructor(...args) {
    super(...args);
  }
}

let items = new MyArray(1,2,3,4);
subitems = items.slice(1,3);

subitems instanceof MyArray; // true
複製程式碼

  最後需要注意的一點,extends實現的繼承方式可以繼承父類的靜態成員函式,例如:   

class Rectangle{
    // ......
    static create(width, height){
        return new Rectangle(width, height);
    }
}

class Square extends Rectangle{
    //......
}

let rect = Square.create(3,4);
rect instanceof Square; // true
複製程式碼

相關文章