Babelで見るJavaScriptの世界

Babelでの成果物をもとに、どうしてそのような変換を行っているのか掘り下げて説明・考察をします。

概要

この記事ではBabelを使用し、ES6がどのようにES5へ変換されるのかを 実例を使用して説明する。

またこの記事を読む読者として ECMAScript とは何かをザックリ理解している者を対象とする。

Babelとは

Babelとは ECMAScript 2015よりも新しいコードを、ブラウザーやNodeなどの環境で互換性を持ったコードを生成することができるコンパイラである。

Babelを使うことで、最新のECMAScriptの構文を実行環境を気にせず使用することができ開発効率を向上することが期待できる。

また現在のES5とES6の対応状況を確認してみる。

ES5の対応しているブラウザー ES6の対応しているブラウザー

上記のサイトから見て分かるように ES5 であれば Opera Mini 以外のブラウザーで対応しているが、 ES6ではOpera Mini, Baidu Browser, IE などのブラウザーで対応していないことが分かる。

特に IE は 2020年現在でもシェア率が 7% 以上あり、ユーザーの使用するブラウザーに制限を加えることができないのであれば、IEでも動くバージョンのJS(ES5) を出力する必要性がある。

Webブラウザシェアランキング

Babel変換

このセクションでは、ESに新たに加わった機能をそれぞれファイルとして作成し、Babelで変換を行った結果を見て考察を行う。

便宜上、Babelへ入力として渡すコードを原始プログラム、出力されるコードを目的プログラムと呼ぶこととする。

(modularはCommonJSとする。)

空のファイル

まずは何もコードが書かれていない空のファイルをBabelを使いコンパイルしてみる。

原始プログラム

目的プログラム

"use strict";

という内容で出力される。

ひと目でわかるように、差分は "user strict"; という一行だけである。 この一行がどういう意味かを説明する。

use strinct とは

"user strict" と書くことで strictモードを有効にすることができる。

strictモードの詳細については Mozilla を参照にすること。

strictモードの概要を完結にまとめると、

  • バグを発生しやすいJavaScriptの書き方を禁止する: ex: withでプロパティが既存の変数と競合する
  • コード最適化を阻害するような書き方を禁止する: ex: 動的にコードを生成するevalなど
  • 将来的にEcmaScriptで使用される構文・予約語の使用を禁止する

となる。

strictモードは上記で書いたまとめから分かるように、あえて制限を加えることで 予期せぬエラー防止、パフォーマンスの向上、言語の拡張援助 といったメリットを提供する。

ES6

2015年に策定された仕様。別名 ECMAScript2015。

前バージョンが2009年に策定されたため、6年越しに新しく作成された。

このセクションでは ES6で追加された機能がどのようにしてES5へ対応付けされるのかを見る。

ただしこの記事では都合上一部のES6機能を割愛する。

let キーワード

letキーワードは ES5以前から存在していた var キーワードと異なりブロックスコープである。

ブロックスコープとは {} で囲まれた範囲を指し、例えば下記コードではletとvarで振る舞いが異なる。

{ var test = "hoge"; } console.log(test);
{ let test = "hoge"; } console.log(test);

varの場合は正常に動作するが、letでは宣言した test 変数のスコープがブロックの範囲内のため console.log(test) で test変数が定義されていないためエラーが発生する。

ではES6で導入された letキーワード はどのように変換されるかを確認する。

原始プログラム

let test = ""

目的プログラム

"use strict"; var test = "";

単純にletキーワードは varキーワードへ変換されていることが分かる。

しかし let は var と異なりブロックスコープであり、この機能差がどのように実装されるかを確認するため、 原始プログラムを少し変更する。

原始プログラム

let test = "hoge" { let test = "hoge" { let test = "hoge" { let test = "hoge" } } }

目的プログラム

"use strict"; var test = "hoge"; { var _test = "hoge"; { var _test2 = "hoge"; { var _test3 = "hoge"; } } }

var ではブロックスコープを判別することができないので、回避策としてまず 接頭辞に _ をつけ、 さらにそれ以降同じ名前の変数が現れるのであれば、接尾辞に数字をインクリメントしユニークな名前をつけているのが分かる。

const キーワード

次にES6で導入された const キーワードについて検証する。 constlet と同様にブロックスコープであるため、ブロックスコープの挙動は今回無視する。 constletvar とことなり再代入を禁止する特性を持っており、これがどのように再現されるかを見てみる。

原始プログラム

const test = "hoge"

目的プログラム

"use strict"; var test = "hoge";

const で代入をして以降、再代入を行っていないため var キーワードへ変更されるだけである。 ここで目的プログラムを const の特性が使えるように変更を加える

目的プログラム

const test = "hoge" test = "fuga"

原始プログラム

"use strict"; function _readOnlyError(name) { throw new TypeError("\"" + name + "\" is read-only"); } var test = "hoge"; test = (_readOnlyError("test"), "fuga");

新しく _readOnlyError という関数が作成された。 この関数では引数で渡した name をもとにメッセージを作成し TypeError を投げる。 この関数を testを再代入する箇所で呼び出すことで、再代入を禁止していることが分かる。

また気になった点として

test = (_readOnlyError("test"), "fuga");

という書き方だ。

JavaScriptのカンマ演算子は左から順に評価される。

つまりこの場合だと _readOnlyError("test") を実行したあとに "fuga" が呼び出されるということだ。

しかし _readOnlyError では TypeError を投げるため、 "fuga"まで評価が届く前に実行が終わるはずなので、

test = (_readOnlyError("test"), "fuga");

と書かずに

_readOnlyError("test")

で良いのではないか?

アロー関数

原始プログラム

const hoge = () => {}

目的プログラム

"use strict"; var hoge = function hoge() {};

アロー関数がfunctionキーワードでの関数生成に代わっている。 アロー関数の特徴として this の扱い方が function と異なるが、これがどのように変換されるかを確かめる。

原始プログラム

const hoge = () => { this } function hoge2() { this }

目的プログラム

"use strict"; var _this = void 0; var hoge = function hoge() { _this; }; function hoge2() { this; }

functionキーワードでは this の扱いが原始プログラムと目的プログラムで変わっていないが、 アロー関数では _this という変数に void 0 を格納し、それを this の代わりに呼び出していることが分かる。

この void 0 となっているのは strct モードでトップレベルの thisundefined になるためである。( strict モードではないとき global オブジェクトになる。)

また undefined ではなく void 0 を使用している理由として、void 0 のほうが undefined よりも文字数が少ないため、コード量を削減できるというメリットが考えられる。

参考

ではトップレベルではない書き方をしたときに、どのように対応されるのかを見てみる。

原始プログラム

function local() { const hoge = () => { this } function hoge2() { this } }

目的プログラム

"use strict"; function local() { var _this = this; var hoge = function hoge() { _this; }; function hoge2() { this; } }

アロー関数の場合 this は local 関数のthisを 変数 _thisから間接的にアクセスしているが、function hoge2hoge2 自身の this を参照していることが分かる。

クラス

原始プログラム

class Hoge { }

目的プログラム

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

一つ一つ紐解いて解説してみる。

まず Hoge クラスは関数 Hoge に書き換えられ、変数 Hoge に代入されている。

var Hoge = function Hoge() { _classCallCheck(this, Hoge); };

そして Hoge 関数の中で _classCallCheck を呼び出しており、引数に Hoge 関数の thisHoge 関数を代入した Hoge 変数を渡している。

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

_classCallCheck では第一引数で渡された instanceConstructor のインスタンスであるかを確認し、もし違うのであれば TypeError を投げる処理になっており、この Hoge 関数では必ず成功することが分かる。

では次に クラスにメンバ変数を加えてみる。

原始プログラム

class Hoge { huga = 42 }

目的プログラム

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

原始プログラムではメンバ変数をコンストラクタ外で初期化していたが、目的プログラムではコンストラクタ内で初期化されていることが分かる。

では次にコンストラクタを加え、メンバ変数をコンストラクタ内で初期化してみる。

原始プログラム

class Hoge { huga = 42 constructor() { this.uho = 10 } }

目的プログラム

"use strict"; function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } var Hoge = function Hoge() { _classCallCheck(this, Hoge); this.huga = 42; this.uho = 10; };

原始プログラムでコンストラクタの内外で初期化したメンバ変数は、目的プログラムでは全てコンストラクタ内で初期化されていることが分かる。

次に関数を追加してみる。

原始プログラム

class Hoge { huga = 42 constructor() { this.uho = 10 } hello() {} }

目的プログラム

"use strict"; require("core-js/modules/es.object.define-property"); function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a 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); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } var Hoge = /*#__PURE__*/function () { function Hoge() { _classCallCheck(this, Hoge); this.huga = 42; this.uho = 10; } _createClass(Hoge, [{ key: "hello", value: function hello() {} }]); return Hoge; }();

色々と変わったが一つ一つ見ていく。

まず Hoge 関数の定義が 関数内に内包された。

var Hoge = /*#__PURE__*/function () { function Hoge() { _classCallCheck(this, Hoge);

次に _createClass というメソッドが増え、引数には原始プログラムで作成した hello メソッドの内容が渡されていることが分かる。

_createClass では Constructor, protoProps, staticProps という仮引数を定義しており、今回のケースだと ConstructorHoge が、 protoProps には

[{ key: "hello", value: function hello() {} }]

が、staticProps には undefined が入ることが分かる。

そして _createClass 内では protoProps が truthy な値であるので _defineProperties(Constructor.prototype, protoProps) が呼び出される。

_defineProperties では受け取った targetpropsdescriptor をセットしていることが分かる。

この descriptor では enumerable, configurable, writable の属性をそれぞれ格納しており、それぞれの意味は

  • writable プロパティの変更を許可するとき true にする。

  • configurable ディスクリプタの変更またはプロパティの削除を許可するとき true にする。

  • enumerable for of などでイテレーション時に使用を許可するとき true にする。

となる。

これで Hoge 関数の prototype に hello メソッドが加わったので、 Hoge クラスのインスタンスから hello メソッドを呼び出すと、prototype で定義した hello メソッドが呼び出され意図した結果となる。

プロミス

原始プログラム

Promise.resolve(true)

目的プログラム

"use strict"; Promise.resolve(true);

上記でわかるように、原始プログラムと目的プログラムではstrictモード以外差分はない。 しかし、PromiseはES6で追加された機能であり、ES5でしか実行できない環境ではPromiseの実態がないため上記の目的プログラムを実行するとエラーが発生する。

そこでこのような場合、予め Promise を ES5 で疑似的に実装したコードを読み込む必要がある。

これを実現するため Babel の preset-env"useBuiltIns": "usage" オプションを設定した。

この設定を使用することで、原始プログラム上にてES5に提供されていないモジュールを使用すると、 適宜 pollifillをインポートしてくれる。

設定後に再度ビルドすると

目的プログラム

"use strict"; require("core-js/modules/es.object.to-string"); require("core-js/modules/es.promise"); Promise.resolve(true);

が出力された。

promiseの実態は core-js/modules/es.promise に書かれているが中身が複雑かつコード量が多いため適宜見てもらいたい。

ソースコード

デフォルトパラメーター値

デフォルトパラメーターでは引数を省略したときのデフォルト値を指定することができる。

原始プログラム

function hoge(value = "test") {}

目的プログラム

"use strict"; function hoge() { var value = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "test"; }

まず 引数配列 arguments の長さが0以上かつ第一引数に値が入力されているかを確認し、真のときその値を偽のときデフォルト値を返していることがわかる。

レストパラメーター

レストパラメーターとは残りの引数の値を配列として受け取る構文である。

原始プログラム

function hoge(...value) { value }

このように書くと

hoge(1, 2, 3, 4);

と呼び出したとき value の値は [1, 2, 3, 4] と配列になる。

これを ES5 へ変更すると、

目的プログラム

"use strict"; function hoge() { for (var _len = arguments.length, value = new Array(_len), _key = 0; _key < _len; _key++) { value[_key] = arguments[_key]; } value; }

引数の長さと同じ長さを持つ配列を作成し、引数配列 arguments の値を一つ一つ代入していことが分かる。

CommonJS require

hoge.js

module.exports = function hoge() { return "hoge"; }

index.js

const hoge = require("./hoge.js"); hoge();

Babelの成果物は

hoge.js

const hoge = require("./hoge.js"); hoge();

index.js

"use strict"; var hoge = require("./hoge.js"); hoge();

moduleをCommonJSに設定しているため大きく変わったところはない。 しいて言うならば、 const が var へ変更されたことである。 これは constlet などスコープがブロックの範囲内のキーワードが ES5 では対応していないためである。

ES import

hoge.js

export default function hoge() { return "hoge"; }

index.js

import hoge from "hoge"; hoge();

Babelでコンパイルすると

hoge.js

"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports["default"] = hoge; function hoge() { return "hoge"; }

index.js

"use strict"; var _hoge = _interopRequireDefault(require("hoge")); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; } (0, _hoge["default"])();

となる。

今回 module は CommonJS を指定しているため、 ES6 export import はそれぞれ CommonJS に変換されている。

hoge.jsではまず exports オブジェクトに __esModule というプロパティを作成し、値を true にしている。

これは、このmoduleをインポートする index.js で使用されており、 インポートしたオブジェクトが ES Module で書かれたものか、それ以外かを確認している。

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

ES Module で書かれていた場合、 default プロパティに指定されたオブジェクトを返し、それ以外 (CommonJS)の場合は obj を返すようにしている。

ここで一つ疑問が思い浮かぶ。

このように __esModule というフラグを付けなくても CommonJS で module.exports = hoge と書いてしまえば、わざわざ _interopRequireDefault という関数を作らなくてもよいのではないかと。

わざわざこのようにしている理由として。。。

まとめ

この記事ではES2015+のコードがどのようにしてES5へ変換されるのかを見た。

ES5への変換は最適化されコード量を減らそうとする努力を感じられるものの、元となる原始プログラムよりコード量が増えている。

コード量の増加はクライアントサイドでパフォーマンスに影響が出るため、極力抑えたい。

これを解決するために、例えば 対象ブラウザをChrome のみに絞ると、ES5 ではなく ESX まで使用することができるので、目的プログラムはESXまで原始プログラムと同じコード量になり、パフォーマンス面で大きなメリットが得られると考えられる。

©Tsurutan. All Rights Reserved.