認識JS中的Class

forceddd發表於2021-09-25

1.JS中沒有真正的類!

JavaScript 和麵向類的語言不同,它並沒有類來作為物件的抽象模式。JavaScript 中只有物件,而並沒有真正的類。JS只是利用了函式的一種特殊特性——所有的函式預設都會擁有一個名為 prototype 的公有並且不可列舉的屬性,它會指向另一個物件,來模擬類的行為。

要注意的是,如果使用內建的 bind函式來生成一個硬繫結函式的話,該函式是沒有 prototype 屬性的,目標函式的 prototype 會代替硬繫結函式的 prototype。在這樣的函式上使用 instanceof 或者new的話,相當於直接對目標函式使用 。
function Animal() {};
console.log(Animal.prototype);// {}

在JS中,new Animal()看起來像是例項化了Animal類,但是事實上並非如此。

const a = new Animal();
console.log(Object.getPrototypeOf(a) === Animal.prototype);// true

在面向類的語言中,類可以被複制(或者說例項化)多次。例項化一個類就意味著“把類的行為複製到物理物件中”,對於每一個新例項來說都會重複這個過程。

但是在 JS 中,並沒有類似的複製機制。你不能建立一個類的多個例項,只能建立多個物件,它們的prototype 關聯的是同一個物件。但是在預設情況下並不會進行復制,因此這些物件之間並不會完全失去聯絡,它們是互相關聯的。new Animal() 會生成一個新物件(我們稱之為 a ),這個新物件的內部連結 prototype 關聯的是 Animal.prototype 物件。我們並沒有初始化一個類,實際上我們並沒有從“類”中複製任何行為到一個物件中,只是讓兩個物件互相關聯。

2.JS中的建構函式是什麼?

function Animal() {};
const a = new Animal();

在JS中並沒有真正的類,但是在看到這兩行程式碼時,我依然會覺得Animal是一個類。這是為什麼呢?在我看來,一個原因在於出現了new操作符,而在面向類的語言中,需要使用new操作符。另一個原因是,在new Animal()中,Animal呼叫方式特別像是呼叫方式很像例項化類時類建構函式的呼叫方式

但是實際上,Animal和你程式中的其他函式沒有任何區別。函式本身並不是建構函式,只是當我們在普通的函式呼叫前面加上new 關鍵字之後,就會把這個函式呼叫變成一個“建構函式呼叫”(new會劫持所有普通函式並用構造物件的形式來呼叫它) 。

簡單地說,在JS中,“建構函式”可以解釋為使用new操作符呼叫的函式。但是,我們需要知道的是,JS中的函式並不是建構函式,只有使用new時,函式呼叫會變成建構函式呼叫

3.JS中的“面向類”

function Animal(name) {
    this.name = name;
}
Animal.prototype.sayName = function () {
    console.log(this.name);
};

const dog = new Animal('dog');
const cat = new Animal('cat');

dog.sayName(); // dog
cat.sayName(); // cat
console.log(dog.constructor); //  [Function: Animal]
  1. this.name = name 通過this的隱式繫結給每個物件都新增了 name 屬性,有點像類例項封裝的資料值。
  2. Animal.prototype.sayName = ... 會給 Animal.prototype 物件新增一個屬性(函式)。在建立的過程中, dogcat 的內 ),這個新物件的內部連結prototype 都會關聯到 Animal.prototype 上。當 dogcat 中無法找到 sayName 時,它會在 Animal.prototype 上找到。

    需要注意的是,dog.constructor指向了Animal函式,所以dogconstructor屬性,似乎在代表著dog是由誰構造的。但是事實上,這僅僅是看起來如此,因為dog本身並沒有constructor屬性,constructor屬性和sayName一樣,同樣是Animal.prototype 的屬性,這個屬性和dog(或者cat)之間沒有什麼聯絡。

    對於Animal.prototype而言,constructor也僅僅是Animal函式在宣告時所產生的一個預設屬性而已(它是不可列舉的,但它是可以更改的),

    Animal.prototype.constructor = 'animal';
    console.log(dog.constructor); // 'animal'

    當改變Animal.prototype的指向時,constructor屬性的指向同樣變得令人迷惑。這是因為fish上不存在constructor屬性,所以查詢的是Animal.prototype(即{})上的constructor屬性,但是{}也沒有constructor屬性,所以會繼續查詢到Object.prototype 。這個物件有 constructor 屬性,指向內建的 Object 函式。

    Animal.prototype = {};
    const fish = new Animal();
    console.log(fish.constructor); // [Function: Object]

    當然,我們可以手動指定constructor屬性。

    Animal.prototype = {};
    Object.defineProperty(Animal.prototype, 'constructor', {
     enumerable: false, // 不可列舉
     writable: true,
     configurable: true,
     value: Animal, // 讓 constructor 指向 Animal
    });
    const fish = new Animal();
    console.log(fish.constructor); // [Function: Animal]
    

    總而言之,constructor屬性僅僅是一個普通的,可能會被更改的屬性,dog.constructor這種引用是不可靠的。

4.繼承

JS中原型風格的繼承

function Animal(name) {
    this.name = name;
}
Animal.prototype.sayName = function () {
    console.log(this.name);
};

function Dog(name, color) {
    Animal.call(this, name);
    this.color = color;
}
// 建立了一個新的 Dog.prototype 物件並關聯到 Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
//Object.setPrototypeOf( Dog.prototype, Animal.prototype )
// 注意!現在 Dog.prototype.constructor 的指向已經變為了Animal
Dog.prototype.sayName = function () {
    console.log('重寫sayName');
    //顯式多型,呼叫Animal.prototype.sayName
    Animal.prototype.sayName.call(this);
};

const teddy = new Dog('泰迪', '棕色');
teddy.sayName(); // 重寫sayName 泰迪

在宣告Dog時,和所有的函式一樣,Dog會有一個prototype屬性指向預設物件(假設該物件名為originObj),但是originObj並不是我們想要的Foo.prototype。因此我們建立了一個新物件並把這個新物件關聯到Foo.prototype,拋棄預設物件originObj。上面程式碼是通過Object.create實現的。當然也可以通過ES6的Object.setPrototypeOf實現,Object.setPrototypeOf( Dog.prototype, Animal.prototype ),這個函式是修改originObj,而不是放棄originObj,也因此通過Object.setPrototypeOf的話,Dog.prototype.constructor指向是沒有發生變化的。

我們可以使用instanceof來檢查teddyDog或者Animal等的關係。

console.log(teddy instanceof Animal);

instanceof左側是一個普通的物件a,右側是一個函式B,該操作符會檢查B.prototype是否存在於aprototype 鏈上。

如果想要檢查兩個普通物件之間的關係的話,可以使用isPrototypeOf

console.log(Animal.prototype.isPrototypeOf(teddy));

5.ES6中的class語法

你可能會認為 ES6 的 class 語法是向 JS 中引入了一種新的“類”機制,其實不是這樣。 class 基本上只是 prototype 機制的一種語法糖。

class Animal {
    constructor(name) {
        this.name = name;
    }
    sayName() {
        console.log(this.name);
    }
}

class Dog extends Animal {
    constructor(name, color) {
        super(name);
        this.color = color;
    }
    sayName() {
        console.log('重寫sayName');
        //相對多型
        super.sayName();
    }
}

const teddy = new Dog('泰迪', 'brown');
teddy.sayName();

除了語法更好看之外,ES6 還解決了什麼問題呢?

  1. 不再引用雜亂的 prototype 了。
  2. Dog聲 明 時 直 接“ 繼 承 ” 了 Animal, 不 再 需 要 通 過 Object.create來 替
    prototype 物件,也不需要設定 __proto__ 或者 Object.setPrototypeOf
  3. 可以通過 super來實現相對多型,這樣任何方法都可以引用原型鏈上層的同名方
    法。不必使用顯使多型的寫法了,Animal.prototype.sayName.call(this);
  4. class 字面語法不能宣告屬性(只能宣告方法)。看起來這是一種限制,但是它會排除
    掉許多不好的情況,否則,原型鏈末端的“例項”可能會意外地獲取
    其他地方的屬性(這些屬性隱式被所有“例項”所“共享”)。所以, class 語法實際上
    可以幫助你避免犯錯
  5. 可以通過 extends 很自然地擴充套件物件(子)型別,甚至是內建的物件(子)型別,比如
    Array 或 RegExp 。沒有 class ..extends 語法時,想實現這一點是非常困難的。

需要注意的是,superthis不同,super不是動態繫結的。下面的testObj.sayName中的super並非指向了它當前的prototype 物件testParen,而是指向了Animal

const testObj = {
 name: 'test',
 sayName: Dog.prototype.sayName,
};
const testParen = {
 sayName() {
     console.log('testParen');
 },
};
Object.setPrototypeOf(testObj, testParen);
testObj.sayName();//重寫sayName test

相關文章