JavaScript 基礎:Babel 轉譯 class 過程窺探

JavaScript 基礎:Babel 轉譯 class 過程窺探

零、前言

雖然在 JavaScript 中對象無處不在,但這門語言並不使用經典的基於類的繼承方式,而是依賴原型,至少在 ES6 之前是這樣的。當時,假設我們要定義一個可以設置 id 與座標的類,我們會這樣寫:

// Shape 類
function Shape(id, x, y) {
this.id = id;
this.setLocation(x, y);
}
// 設置座標的原型方法
Shape.prototype.setLocation = function(x, y) {
this.x = x;
this.y = y;
};

上面是類定義,下面是用於設置座標的原型方法。從 ECMAScript 2015 開始,語法糖 class被引入,開發者可以通過 class 關鍵字來定義類。我們可以直接定義類、在類中寫靜態方法或繼承類等。上例便可改寫為:

class Shape {
constructor(id, x, y) { // 構造函數語法糖
this.id = id;
this.setLocation(x, y);
}
setLocation(x, y) { // 原型方法
this.x = x;
this.y = y;
}
}

一個更符合“傳統語言”的寫法。語法糖寫法的優勢在於當類中充滿各類靜態方法與繼承關係時,class 這種對象模版寫法的簡潔性會更加突出,且不易出錯。但不可否認時至今日,我們還需要為某些用戶兼容我們的 ES6+ 代碼,class 就是 TodoList 上的一項:


JavaScript 基礎:Babel 轉譯 class 過程窺探


作為當下最流行的 JavaScript 編譯器,Babel 替我們轉譯 ECMAScript 語法,而我們不用再擔心如何進行向後兼容。

本地安裝 Babel 或者利用 Babel CLI 工具,看看我們的 Shape 類會有哪些變化。可惜的是,你會發現代碼體積由現在的219字節激增到2.1KB,即便算上代碼壓縮(未混淆)代碼也有1.1KB。轉譯後輸出的代碼長這樣:

"use strict";var _createClass=function(){function a(a,b){for(var c,d=0;d

Babel 僅僅是把我們定義的 Shape 還原成一個 ES5 函數與對應的原型方法麼?

一、揭秘

好像沒那麼簡單,為了摸清實際轉譯流程,我們先將上述類定義代碼簡化為一個只有14字節的空類:

class Shape {}

首先,當訪問器走到類聲明階段,需要補充嚴格模式:

"use strict";
class Shape {}

而進入變量聲明與標識符階段時則需補充 let 關鍵字並轉為 var:

"use strict";
var Shape = class Shape {};

到這個時候代碼的變化都不太大。接下來是進入函數表達式階段,多出來幾行函數:

"use strict";
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
var Shape = function Shape() {
_classCallCheck(this, Shape);
};

該階段不僅替換了 class,還在類中調用了叫做 _classCallCheck 的方法。這是什麼呢?

這個函數的作用在於確保構造方法永遠不會作為函數被調用,它會評估函數的上下文是否為 Shape 對象的實例,以此確定是否需要拋出異常。接下來,則輪到 babel-plugin-minify-simplify上場,這個插件做的事情在於通過簡化語句為表達式、並使表達式儘可能統一來精簡代碼。運行後的輸出是這樣的:

"use strict";
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) throw new TypeError("Cannot call a class as a function"); }
var Shape = function Shape() {
_classCallCheck(this, Shape);
};

可以看到 if 語句中由於只有一行代碼,於是花括號被去掉。接下來上場的便是內置的 Block Hoist ,該插件通過遍歷參數排序然後替換,Babel 輸出結果為:

"use strict";
function _classCallCheck(a, b) { if (!(a instanceof b)) throw new TypeError("Cannot call a class as a function"); }
var Shape = function a() {
_classCallCheck(this, a);
};

最後一步,minify 一下,代碼體積由最初的14字節增為338字節:

"use strict";function _classCallCheck(a,b){if(!(a instanceof b))throw new TypeError("Cannot call a class as a function")}var Shape=function a(){_classCallCheck(this,a)};

二、再說一些

這是一個什麼都沒幹的類聲明,但現實中任何類都會有自己的方法,而此時 Babel 必定會引入更多的插件來幫助它完成代碼的轉譯工作。直接在剛剛的空類中定義一個方法吧。

class Shape {
render() {
console.log("Hi");
}
}

我們用 Babel 轉譯一下,會發現代碼中包含如下這段:

var _createClass = function () { function a(a, b) { for (var c, d = 0; d < b.length; d++) c = b[d], c.enumerable = c.enumerable || !1, c.configurable = !0, "value" in c && (c.writable = !0), Object.defineProperty(a, c.key, c); } return function (b, c, d) { return c && a(b.prototype, c), d && a(b, d), b; }; }();

類似前面我們遇到的 _classCallCheck ,這裡又多出一個 _createClass ,這是做什麼的呢?我們稍微把代碼狀態往前挪一挪,來到 babel-plugin-minify-builtins 處理階段(該插件的作用在於縮減內置對象代碼體積,但我們主要關注點在於這個階段的 _createClass函數是基本可讀的),此時 _classCallCheck 長成這樣:

var _createClass = function() {
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
return function(Constructor, protoProps, staticProps) {
if (protoProps) defineProperties(Constructor.prototype, protoProps);
if (staticProps) defineProperties(Constructor, staticProps);
return Constructor;
};
} ();

可以看出 _createClass 用於處理創建對象屬性,函數支持傳入構造函數與需定義的鍵值對屬性數組。函數判斷傳入的參數(普通方法/靜態方法)是否為空對應到不同的處理流程上。而 defineProperties 方法做的事情便是遍歷傳入的屬性數組,然後分別調用 Object.defineProperty 以更新構造函數。而在 Shape 中,由於我們定義的不是靜態方法,我們便這樣調用:

_createClass(Shape, [{
key: "render",
value: function render() {
console.log("Hi");
}
}]);

T.J. Crowder 在 How does Babel.js create compile a class declaration into ES2015? 中談到 Babel 是如何將 class 轉化為 ES5 兼容代碼時談到了幾點,大意為:

  • constructor 會成為構造方法數;
  • 所有非構造方法、非靜態方法會成為原型方法;
  • 靜態方法會被賦值到構造函數的屬性上,其他屬性保持不變;
  • 派生構造函數上的原型屬性是通過 Object.create(Base.prototype) 構造的對象,而不是 new Base() ;
  • constructor 調用構造器基類是第一步操作;
  • ES5 中對應 super 方法的寫法是 Base.prototype.baseMethod.call(this); ,這種操作不僅繁瑣而且容易出錯;

這些概述大致總結了類定義在兩個 ES 版本中的一些差異,其他很多方面比如 extends——繼承關鍵字,它的使用則會使 Babel 在轉譯結果加上 _inherits 與 _possibleConstructorReturn兩個函數。篇幅所限,此處不再展開詳述。

三、最後

語法糖 class 給我們帶來了很多寫法上的便利,但可能會使我們在代碼體積上的優化努力“付諸東流”。

另一方面,如果你是一名 React 應用開發者,你是否已經在想將代碼中的所有 class 寫法換為 function 呢?那樣做的話,代碼體積無疑會減少很多,但你一定也知道 PureComponent 相比 Component 的好處。所以雖然 function 給你的代碼體積減負了,但他在哪裡又給你無形增加負擔了呢?

因此,真的不推薦開發者用 class 這種寫法麼,你覺得呢?


分享到:


相關文章: