詳解JS的繼承(三)-- 圖解Es6的Extend

安歌發表於2022-01-15

前言

距離上一篇js的繼承系列已經過去了四年,時不時還有新的讀者評論和回覆,開心之餘也想著更新一下內容,因為當時的內容裡沒有涉及到es6的 extend 實現,所以現在抽空補上。 當然,如果是0基礎的同學或者對於基本的繼承有些遺忘的同學,可以先回顧一下前兩篇:

詳解js中的繼承(一)

詳解js中的繼承(二)

正文

基礎回顧 & 預備知識

為了使後面的學習過程更絲滑,在開始之前,一起再回顧一下這個建構函式-原型物件-例項模型:

當訪問 a 的屬性時,會先從a本身的屬性(或方法)去找,如果找不到,會沿著 __proto__ 屬性找到原型物件A.prototype,在原型物件上查詢對應的屬性(或方法);如果再找不到,繼續沿著原型物件的__proto__ 繼續找,這也就是最早我們介紹過的原型鏈的內容。

function A (){
  this.type = 'A'
}
const a = new A();

圖片
當然,圖上的原型鏈可以繼續找,我們知道 A 雖然是函式,但是本質也是 Object ,沿著__proto__ 屬性 不斷上溯,最終會返回 null ;

a.__proto__ === A.prototype; // true
a.__proto__.__proto__ === Object.prototype; // true
a.__proto__.__proto__.__proto__ === null; // true

extend實現原始碼解析

進入正題, 學過 es6 的同學都知道,可以通過關鍵字 extend 直接實現繼承,比如:

// 首先建立一個Animal類
class Animal {
    name: string;
    constructor(theName: string) { this.name = theName; };
    move(distanceInMeters: number = 0) {
        console.log(`Animal moved ${distanceInMeters}m.`);
    }
}

// 子類Dog繼承於Animal
class Dog extends Animal {
    age: number;
    constructor(name: string, age: number) { 
        super(name); 
        this.age = age;
    }
    bark() {
        console.log('Woof! Woof!');
    }
}

const dog = new Dog('wangwang', 12);
dog.bark();// 'Woof! Woof!'
dog.move(10);//`Animal moved 10m.`

那麼這個 extend 究竟做了哪些事情呢? 這裡藉助安裝 typescript 這個 npm 包,然後在本地執行 tsc [檔案路徑] ,把ts以及es6的程式碼轉換成原生js的程式碼來進行研究,(當然也有個缺點是轉換的程式碼為了追求程式碼極簡 有時可能會影響可讀性 比如 undefined 寫作 void 0 之類的),上面的程式碼轉換之後長這樣:

// 第一部分
var __extends = (this && this.__extends) || (function () {
    var extendStatics = function (d, b) {
        extendStatics = Object.setPrototypeOf ||
            ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
            function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
        return extendStatics(d, b);
    };
    return function (d, b) {
        if (typeof b !== "function" && b !== null)
            throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
        extendStatics(d, b);
        function __() { this.constructor = d; }
        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
    };
})();

// 第二部分
// 首先建立一個Animal類
var Animal = /** @class */ (function () {
    function Animal(theName) {
        this.name = theName;
    }
    ;
    Animal.prototype.move = function (distanceInMeters) {
        if (distanceInMeters === void 0) { distanceInMeters = 0; }
        console.log("Animal moved ".concat(distanceInMeters, "m."));
    };
    return Animal;
}());

// 第三部分
// 子類Dog繼承於Animal
var Dog = /** @class */ (function (_super) {
    __extends(Dog, _super);
    function Dog(name, age) {
        var _this = _super.call(this, name) || this;
        _this.age = age;
        return _this;
    }
    Dog.prototype.bark = function () {
        console.log('Woof! Woof!');
    };
    Dog.prototype.move = function (distanceInMeters) {
        if (distanceInMeters === void 0) { distanceInMeters = 5; }
        console.log("Dog moved ".concat(distanceInMeters, "m."));
    };
    return Dog;
}(Animal));

// 第四部分 無需解析
var dog = new Dog('wangwang', 12);
dog.bark(); // 'Woof! Woof!'
dog.move(10); // Dog moved 10m.

程式碼看起來有些複雜,我們按照程式碼註釋裡,各部分內容複雜程度從簡單到複雜進行分析:

  • 先看第二部分,首先是用匿名立即執行函式(IIFE)包裹了一層,這一點我們在聊閉包的時候說過,這樣寫的好處是避免汙染到全域性名稱空間;然後在內部,就是之前第一篇說過的建構函式-原型物件的經典模型-- 屬性放在建構函式裡,方法繫結在原型物件上, 所以這一部分其實就是 es6的Class 對應的原生js寫法;
  • 第三部分, Dog 類的寫法和第二部分大體相同,但是還是有幾處區別:

    • _super.call(this, name)_super 代表父類,所以這一步是使用父類的建構函式生成一個物件,之後再根據自身的建構函式,修改該物件;
    • __extends 方法,也是本文的核心內容。

    • 最後來介紹第一部分,也就是__extends 的具體實現。這部分的外層也是一個簡單的避免重複定義以及匿名立即執行函式(IIFE),這一點就不贅述了。 核心內容是 extendStatics 的實現:

      • 首先介紹下 Object.setPrototypeOf 這個方法,這個方法的作用是為某個物件重新指定原型,用法如下:

        Object.setPrototypeOf(d, b) // 等價於d.__proto__ = b;

        後續每個 || 分隔符後面,都可以理解為一種 polyfill 寫法,只是為了相容不同的執行環境;

      • 接下來返回一個新的函式,前面提到,直接轉換過來的可能有點晦澀,所以我在這裡稍微整理成可讀性更強的寫法:

        return function (d, b) {
        // 當b不是建構函式或者null時,丟擲錯誤
          if (typeof b !== "function" && b !== null) {
             throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
          }
        
          // 修改d原型鏈指向
          extendStatics(d, b); 
        
          // 模擬原型鏈的繼承
          function Temp() { this.constructor = d; }
            if(b === null){
                  d.prototype = {}; // Object.create(null) 此時返回一個新的空物件{}
          } else {
                Temp.prototype = b.prototype;
                var temp = new Temp(); 
                d.prototype = temp;
          }
        };
        此處第一個 `if` 比較好理解,不多解釋;
        

接下來的 extendStatics(d, b) 也介紹了效果是 d.__proto__ = b;

再接著就是比較有意思了,為了方便大家看懂,還是畫一下相關的關係圖:

首先, d和b 各自獨立(當然)這裡請注意!!!,我們用大寫字母B和D分表表示b和d的建構函式,而b和d本身也可能還是一個函式,也還有自己對應的原型物件,只是圖上沒有標出。(眼神不太好或者不太仔細的同學務必要認真 否則很容易理解出錯)

舉個例子,前文的 Animal 對應圖上的b, 那麼 B 則對應 Function , 即 Animal.__proto__ = Function.prototype , 但是與此同時,Animal 還有自己的原型物件Animal.protptype

執行extendStatics(d, b) 後,原型關係如下(D的建構函式和原型物件變成不可訪問了,所以用灰色表示):

再接著 執行以下程式碼之後:

function Temp() { this.constructor = d; }
Temp.prototype = b.prototype;
var temp = new Temp(); 
d.prototype = temp;

結構圖如下:

從圖上可以看到,這個臨時變數temp最後變成了d的原型物件, 同時也是一個b的例項。 這一點和我們最早學過的原型鏈繼承其實是類似的,區別在於多了一個 d.__proto__ = b .

那麼,如果執行 var dog = new Dog('wangwang', 12); 其實,這裡的 Dog 就對應上圖的 d , dog 的原型鏈其實就是 dog.__proto__ === temp ,再向上也就是 b.prototype ,自然也就可以呼叫到定義在b.prototype 的方法了。

自測環節

那麼在完成 extend 之後,回答幾個問題,測試下自己的理解程度。

Q1: 首先,屬性是怎麼繼承的,和ES5有何區別?

A1: extend是通過呼叫父類的方法建立初始物件,在此基礎上,再根據子類的建構函式對該物件進行調整; ES5 的繼承(組合繼承),實質是先創造子類的例項物件 this ,再利用 call 或者 apply ,將父類的屬性新增到 this .

Q2: dog 是如何呼叫到 move 方法的?

A2: 這個問題其實就是前面剛剛分析的原型鏈模型,方法的查詢順序是: dog.move(不存在) > dog.__proto__(temp變數).move (不存在) > dog.__proto__.__proto__.move (找到)

Q3: 多出來的d.__proto__ = b 有何作用?

A3: 可以繼承父類的靜態方法,例如新增方法: Animail.sayHello = function() {console.log('hello')}; ,那麼Dog.sayHello() 同樣生效,可以參照上圖進行理解,查詢順序: d.hello(不存在) > d.__proto__.hello (找到)

小結

本文是繼承系列的後續文章,主要針對ES6Extend做個簡單的原始碼分析和原理介紹,最關鍵的還是原型鏈的圖解部分,希望能對讀者有幫助。

歡迎大家關注專欄,也希望大家對於喜愛的文章,能夠不吝點贊和收藏,對於行文風格和內容有任何意見的,都歡迎私信交流。

(想來外企的小夥伴歡迎私信或者新增主頁聯絡方式諮詢詳情~)

相關文章