JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

Fundebug發表於2019-01-24

摘要: 深入JS系列15。

Fundebug經授權轉載,版權歸原作者所有。

這是專門探索 JavaScript 及其所構建的元件的系列文章的第 15 篇。

如果你錯過了前面的章節,可以在這裡找到它們:

現在構建任何型別的軟體專案最流行的方法這是使用類。在這篇文章中,探討用 JavaScript 實現類的不同方法,以及如何構建類的結構。首先從深入研究原型工作原理,並分析在流行庫中模擬基於類的繼承的方法。 接下來是講如何將新的語法轉製為瀏覽器識別的語法,以及在 Babel 和 TypeScript 中使用它來引入ECMAScript 2015類的支援。最後,將以一些在 V8 中如何本機實現類的示例來結束本文。

概述

在 JavaScript 中,沒有基本型別,建立的所有東西都是物件。例如,建立一個新字串:

const name = "SessionStack";
複製程式碼

接著在新建立的物件上呼叫不同的方法:

console.log(a.repeat(2)); // SessionStackSessionStack
console.log(a.toLowerCase()); // sessionstack
複製程式碼

與其他語言不同,在 JavaScript 中,字串或數字的宣告會自動建立一個封裝值的物件,並提供不同的方法,甚至可以在基本型別上執行這些方法。

另一個有趣的事實是,陣列等複雜型別也是物件。如果檢查陣列例項的型別,你將看到它是一個物件。列表中每個元素的索引只是物件中的屬性。當通過陣列中的索引訪問一個元素時,實際上是訪問了陣列物件的一個 key 值,並得到 key 對應的值。從資料的儲存方式看時,這兩個定義是相同的:

let names = [“SessionStack”];

let names = {
  “0”: “SessionStack”,
  “length”: 1
}
複製程式碼

因此,訪問陣列中的元素和物件的屬性耗時是相同的。我(本文作者)通過多次的努力才發現這一點的。就是不久,我(本文作者)不得不對專案中的一段關鍵程式碼進行大規模優化。在嘗試了所有簡單的可選項之後,最後用陣列替換了專案中使用的所有物件。理論上,訪問陣列中的元素比訪問雜湊對映中的鍵要快且對效能沒有任何影響。在 JavaScript中,這兩種操作都是作為訪問雜湊對映中的鍵來實現的,並且花費相同的時間。

使用原型模擬類

一般的想到物件時,首先想到的是類。我們大都習慣於根據類及其之間的關係來構建應用程式。儘管 JavaScript 中的物件無處不在,但該語言並不使用傳統的基於類的繼承,相反,它依賴於原型來實現。

JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

在 JavaScript 中,每個物件通過原型連線著另一個物件。當嘗試訪問物件上的屬性或方法時,首先從物件本身開始查詢,如果沒有找到任何內容,則在物件的原型中繼續查詢。

從一個簡單的例子開始:

function Component(content) {
  this.content = content;
}

Component.prototype.render = function() {
    console.log(this.content);
}
複製程式碼

Component 的原型上新增 render 方法,因為希望 Component 的每個例項都能有 render 方法。Component 任何例項呼叫此方法時,首先將在例項本身中執行查詢,如果沒有,接著從它的原型中執行查詢。

JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

接著引入一個新的子類:

function InputField(value) {
    this.content = `<input type="text" value="${value}" />`;
}
複製程式碼

如果想要 InputField 繼承 Component 並能夠呼叫它的 render 方法,就需要更改它的原型。當對子類的例項呼叫 render方法時,不希望在它的空原型中查詢,而應該從從 Component 上的原型查詢:

InputField.prototype = Object.create(new Component());
複製程式碼

通過這種方式,就可以在 Component 的原型中找到 render 方法。為了實現繼承,需要將 InputField 的原型連線到 Component 的例項上,大多數庫都使用 Object.setPrototypeOf 方法來實現這一點。

JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

然而,這不是唯一一件事要做的,每次繼承一個類,需要:

  • 將子類的原型指向父類的例項。
  • 在子類建構函式中呼叫的父建構函式,完成父建構函式中的初始化邏輯。

如上所述,如果希望繼承基類的的所有特性,那麼每次都需要執行這個複雜的邏輯。當建立多個類時,將邏輯封裝在可重用函式中是有意義的。這就是開發人員最初解決基於類繼承的方法——通過使用不同的庫來模擬它。

這些解決方案越來越流行,造成了 JS 中明顯缺少了一些型別的現象。這就是為什麼在 ECMAScript 2015 的第一個主要版本中引入了類,繼承的新語法。

類的轉換

當 ES6 或 ECMAScript 2015 中的新特性被提出時,JavaScript 開發人員不能等待所有引擎和瀏覽器都開始支援它們。為實現瀏覽器能夠支援新的特性一個好方法是通過 轉換 (Transpiling) ,它允許將 ECMAScript 2015 中編寫的程式碼轉換成任何瀏覽器都能理解的 JavaScript 程式碼,當然也包括使用基於類的繼承編寫類的轉換功能。

JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

Babel

最流行的 JavaScript 編譯器之一就是 Babel,巨集觀來說,它分3個階段執行程式碼:解析(parsing),轉譯(transforming),生成(generation),來看看它是如何轉換的:

class Component {
  constructor(content) {
    this.content = content;
  }

  render() {
      console.log(this.content)
  }
}

const component = new Component('SessionStack');
component.render();
複製程式碼

以下是 Babel 轉換後的樣式:

var Component = function () {
  function Component(content) {
    _classCallCheck(this, Component);
    this.content = content;
  }

  _createClass(Component, [{
    key: 'render',
    value: function render() {
      console.log(this.content);
    }
  }]);

  return Component;
}();
複製程式碼

如上所見,轉換後的程式碼就可在任何瀏覽器執行了。 此外,還新增了一些功能, 這些是 Babel 標準庫的一部分。

_classCallCheck_createClass 作為函式包含在編譯檔案中。

  • _classCallCheck 函式的作用在於確保構造方法永遠不會作為函式被呼叫,它會評估函式的上下文是否為 Component物件的例項,以此確定是否需要丟擲異常。
  • _createClass 用於處理建立物件屬性,函式支援傳入建構函式與需定義的鍵值對屬性陣列。函式判斷傳入的引數(普通方法/靜態方法)是否為空對應到不同的處理流程上。

為了探究繼承的實現原理,分析繼承的 ComponentInputField 類。。

class InputField extends Component {
    constructor(value) {
        const content = `<input type="text" value="${value}" />`;
        super(content);
    }
}
複製程式碼

使用 Babel 處理上述程式碼,得到如下程式碼:

 var InputField = function (_Component) {
 _inherits(InputField, _Component);

 function InputField(value) {
    _classCallCheck(this, InputField);

    var content = '<input type="text" value="' + value + '" />';
    return _possibleConstructorReturn(this, (InputField.__proto__ || Object.getPrototypeOf(InputField)).call(this, content));
  }

  return InputField;
}(Component);
複製程式碼

在本例中, Babel 建立了 _inherits 函式幫助實現繼承。

以 ES6 轉 ES5 為例,具體過程:

  • 編寫ES6程式碼
  • babylon 進行解析
  • 解析得到 AST
  • plugin 用 babel-traverse 對 AST 樹進行遍歷轉譯
  • 得到新的 AST樹
  • 用 babel-generator 通過 AST 樹生成 ES5 程式碼

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

Babel 中的抽象語法樹

AST 包含多個節點,且每個節點只有一個父節點。 在 Babel 中,每個形狀樹的節點包含視覺化型別、位置、在樹中的連線等資訊。 有不同型別的節點,如 stringnumbersnull等,還有用於流控制(if)和迴圈(for,while)的語句節點。 並且還有一種特殊型別的節點用於類。它是基節點類的一個子節點,通過新增欄位來擴充套件它,以儲存對基類的引用和作為單獨節點的類的主體。

把下面的程式碼片段轉換成一個抽象語法樹:

class Component {
  constructor(content) {
    this.content = content;
  }

  render() {
    console.log(this.content)
  }
}
複製程式碼

下面是以下程式碼片段的抽象語法樹:

JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

Babel 的三個主要處理步驟分別是: 解析(parse),轉換 (transform),生成 (generate)。

解析

將程式碼解析成抽象語法樹(AST),每個js引擎(比如Chrome瀏覽器中的V8引擎)都有自己的AST解析器,而Babel是通過 Babylon 實現的。在解析過程中有兩個階段: 詞法分析 和 語法分析 ,詞法分析階段把字串形式的程式碼轉換為 令牌 (tokens)流,令牌類似於AST中節點;而語法分析階段則會把一個令牌流轉換成 AST的形式,同時這個階段會把令牌中的資訊轉換成AST的表述結構。

轉換

在這個階段,Babel接受得到AST並通過babel-traverse對其進行 深度優先遍歷,在此過程中對節點進行新增、更新及移除操作。這部分也是Babel外掛介入工作的部分。

生成

將經過轉換的AST通過babel-generator再轉換成js程式碼,過程就是 深度優先遍歷整個AST,然後構建可以表示轉換後程式碼的字串。

在上面的示例中,首先生成兩個 MethodDefinition 節點的程式碼,然後生成類主體節點的程式碼,最後生成類宣告節點的程式碼。

使用 TypeScript 進行轉換

另一個利用轉換的流行框架是 TypeScript。它引入了一種用於編寫 JavaScript 應用程式的新語法,該語法被轉換為任何瀏覽器或引擎都可以執行的 EMCAScript 5。下面是用 Typescript 實現 Component :

class Component {
    content: string;
    constructor(content: string) {
        this.content = content;
    }
    render() {
        console.log(this.content)
    }
}
複製程式碼

轉成抽象語法樹如下:

JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

Typescript 還支援繼承:

class InputField extends Component {
    constructor(value: string) {
        const content = `<input type="text" value="${value}" />`;
        super(content);
    }
}
複製程式碼

以下是轉換結果:

var InputField = /** @class */ (function (_super) {
    __extends(InputField, _super);
    function InputField(value) {
        var _this = this;
        var content = "<input type=\"text\" value=\"" + value + "\" />";
        _this = _super.call(this, content) || this;
        return _this;
    }
    return InputField;
}(Component));
複製程式碼

最終的結果還是 ECMAScript 5 程式碼,其中包含 TypeScript 庫中的一些函式。封 __extends 中的邏輯與在第一節中討論的邏輯相同。

隨著 Babel 和 TypeScript 被廣泛採用,標準類和基於類的繼承成為了構造 JavaScript 應用程式的標準方式,這推動了在瀏覽器中引入對類的原生支援。

類的原生支援

2014年,Chrome 引入了對 類的原生支援,這允許在不需要任何庫或轉換器的情況下執行類宣告語法。

JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

本地實現類的過程就是我們所說的語法糖。這只是一種奇特的語法,它可以編譯成語言中已經支援的相同的原語。可以使用新的易於使用的類定義,但是它仍然會建立建構函式和分配原型。

JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

V8的支援

撯著,看看在 V8 中對 ECMAScript 2015 類的本機支援的工作原理。正如在 前一篇文章 中所討論的,首先必須將新語法解析為有效的 JavaScript 程式碼並新增到 AST 中,因此,作為類定義的結果,一個具有ClassLiteral 型別的新節點被新增到樹中。

這個節點儲存了一些資訊。首先,它將建構函式作為一個單獨的函式儲存,還儲存類屬性的列表,這些屬性包括 方法、getter、setter、公共欄位或私有欄位。該節點還儲存對父類的引用,該類將繼承父類,而父類將再次儲存建構函式、屬性列表和父類。

一旦這個新的類 ClassLiteral轉換成程式碼,它又被轉換成函式和原型。

原文:How JavaScript works: The internals of classes and inheritance + transpiling in Babel and TypeScript

關於Fundebug

Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了9億+錯誤事件,付費客戶有Google、360、金山軟體、百姓網等眾多品牌企業。歡迎大家免費試用

JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換

相關文章