2021-09-21

Babelを徐々に理解したい

フロントエンド開発に欠かせないBabelですが、正直「新しい仕様のJSを、古い仕様のJSに変換するもの」という程度の理解しかないので、もう少し挙動を理解したい
なので今一度Babelについて動作を追っていく

いきなりソースコードは読めそうにないので、ここではBabelの表面的な動きからみていき、徐々に理解を深めていくことにする

Babelとは

Babelは公式サイトにもデカデカと書いてある通りJSのコンパイラーである
割と新しいJSの仕様であるECMA2015以上のコードを、ECMA2015をサポートしていないブラウザで動くように、古いJSのコードに変換してくれる

ブラウザ毎にどこまでのJSの機能が利用できるかはブラウザに搭載されているJSエンジン次第で、ブラウザ毎に搭載されているエンジンが異なるのがJS界隈の混沌ポイント

  • chrome, Edge => V8
  • FireFox => SpiderMonkey
  • Safari => JavaScriptCore
  • IE(昔のEdgeも) => ChaKra

https://ja.wikipedia.org/wiki/JavaScript%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%B3

なので、最新のJSの仕様のコードがブラウザによっては動いたり動かなかったりするので、Babelによって幅広くサポートされているJSの仕様のコードに変換して、挙動の差異を小さくしたいというのが狙い

Babelによるコードの変換

Babelによるコードの変換には3つの段階があるようだ

  • Parsing(@babel/parser)
    • コードをAST(abstract syntax tree)へと変換する
  • Transformation(@babel/traverse)
    • プラグインを通しつつ、AST => AST変換を行う
  • Code Generator(@babel/generator)
    • ASTをコードへと変換する

調べてみたところ、2段階目のTransformationの時に、変換されたASTに対して変換操作を行うことができ
その変換操作は関数として定義でき、プラグイン形式で導入できたり、自作関数を挟むこともできる模様
従って、何もプラグインなどの関数を導入しなければ、Babelそのものは何もコードに対して特別な変換は行わないということのようだ

(もしかしたら一部理解が間違っているかもしれないが、とにかくなんらかの関数を導入してコードを変換できる)

Babelを最小構成で使ってみる(@babel/core単体での変換)

npm init で空っぽのpackage.jsonを作成し、それを使って色々試してみる

Babelそのものは @babel/core ( https://babeljs.io/docs/en/babel-core )というライブラリに含まれている
Babelは開発環境でしか使わないので devDependencies に入れる( npm install -D @babel/core )

これでコマンドラインが使えるのかと思いきや npx babel コマンドを実行しても babel-cli を入れろと言われてしまう
本体とCLIは別ライブラリであることがわかった

これは node_modules.bin ディレクトリに babel ファイルが存在しないことからも明白である
(ライブラリの提供者は、package.jsonの.binフィールドに実行内容を登録することでコマンドを提供できるが、babel-coreのpackage.jsonには.binフィールドはないので、明確に提供していないということになる)

CLIとして変換コマンドは提供されていないことは分かったが、Babelそのものは存在するため以下のコードを書くことで変換を行うことはできる

main.js

const babel = require('@babel/core')

babel.transform('const hoge = () => console.log(999)', {}, (err, result) => {
  console.log(result.code)
})

// node main.js
// const hoge = () => console.log(999);

上記結果から以下の2つのことがわかる

  • ちゃんと変換処理は行われているようだが、INとOUTのコードが同じであることから、「プラグインを入れないとBabelは何もしない」ということ
  • Babelの変換は transform 関数の実行から全てが始まっている

@babel/cliの導入と変換

@babel/core だけでも変換できることはわかったが、これだと大変使いづらいのでコマンドラインを使えるようにする
npm install -D @babel/cli

今度はCLIを使って先ほどと同じコードを変換してみる

main2.js

const hoge = () => console.log(999)

npx babel main2.js --out-dir dist

dist/main2.js(babelによって生成されたjsファイル)

const hoge = () => console.log(999);

やはり先ほどと同様に何も変換は行われない(なんとなくfunction形式に変換されると考えるが、そうならない)

設定ファイルとプラグイン

Transformationの時にコードに変更を加えるには、 babel.config.js を用意して、Babelの設定を書いてあげる必要がある
(昔は.babelrcというファイルだったが、lintにかけられたりといった利点があるので、最近はこちらの形が公式に推奨されている)

基本的な形は以下の通り

module.exports = {
  plugins: []
}

ここにアロー関数を変換するプラグインを書く
自分で変換処理を行う関数を作ってもいいが、大体の変換処理はサードパーティ製のプラグインがあるので、そちらを活用する
アロー関数の変換は @babel/plugin-transform-arrow-functions というものが用意されているのでインストールする
npm install -D @babel/plugin-transform-arrow-functions

そして設定に追加し、変換を行う

module.exports = {
  plugins: ['@babel/plugin-transform-arrow-functions']
}

npx babel main2.js --out-dir dist

dist/main2.js

const hoge = function () {
  return console.log(999);
};

正しく意図した通りに変換できた!

プラグインとプリセット

プラグインを導入して設定ファイルに記述すれば、変換時に適用することができることはわかったが、1つ1つ必要なものを入れていくのはあまりにも面倒臭い...
そんな人のために、複数のプラグインがセットになったプリセットというものが用意されている
当然プリセットを利用して変換を行なっていくのが一般的である(Railsでもデフォルトで生成される設定はプリセットが入ってますね)

@babel/preset-env

プリセットにも様々なものがあるが、ここでは設定を書くことで必要なプラグインを判定し、指定したターゲットに向けたJSを生成してくれるようになる @babel/preset-env についてみていく

@babel/preset-env はbrowserslist、compat-table、electron-to-chromiumといったデータソースを活用して、各環境で動作するJSへと変換を行なってくれる
https://babeljs.io/docs/en/babel-preset-env

↑の公式サイト的にすげー推されてるし browserslist を活用した方法が割と一般的なんだろうか?
(Railsでもデフォルトで.browserslist生成されるし)

@babel/preset-env はデフォルトで、babel.config.jsに targets または ignoreBrowsersListConfig オプションが設定されていない限り、 .browserslistrc の設定を使ってくれるらしいのでやってみる
とりあえず公式の説明通り、 > 0.25%not dead を指定してみる
これは「0.25%以上の使用率があり、公式アップデートが24ヶ月以内に行われているブラウザ」という意味になる
具体的には https://browserslist.dev/?q=PiAwLjI1JSwgbm90IGRlYWQ%3D のブラウザが対象

.browserslistrc

> 0.25%
not dead

ではこれらを使って変換してみる
まず @babel/preset-env を入れる

npm install -D @babel/preset-env

そして、それを利用するため babel.config.js に追記する

module.exports = {
  presets: ['@babel/preset-env'],
}

.browserslistrc は↑で用意したので省略

最後に変換対象となる.jsファイルを用意する
アロー関数や、クラス構文など、新しいJSの構文を使ったコードにしてみた

main2.js

const hoge = () => console.log(999)

class Hoge {
  constructor() {
    this.fuga = 'fuga'
  }

  bar() {
    return this.fuga
  }
}

const a = new Hoge()
console.log(a.bar())

この状態で変換を行う
npx babel main2.js --out-dir dist

すると以下のような結果となった

"use strict";

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 = function hoge() {
  return console.log(999);
};

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

    this.fuga = 'fuga';
  }

  _createClass(Hoge, [{
    key: "bar",
    value: function bar() {
      return this.fuga;
    }
  }]);

  return Hoge;
}();

var aaa = new Hoge();
console.log(aaa.bar());

アロー関数は前と同じく普通の関数宣言になっているのと、クラス構文も関数で実現されていることがわかる
対象にIE11も含まれているが、確かに動きそうなコードになっている

では、逆に最新のブラウザだけを対象にした場合はどのように変換されるだろうか
現在chromeの最新版は93なので、それだけを指定して変換してみる

.browserslistrc

chrome 93

npx babel main2.js --out-dir dist

結果は以下の通り

"use strict";

const hoge = () => console.log(999);

class Hoge {
  constructor() {
    this.fuga = 'fuga';
  }

  bar() {
    return this.fuga;
  }

}

const aaa = new Hoge();
console.log(aaa.bar());

chrome93はクラス構文もアロー関数もサポートしているため、ほぼそのまんま出力されていることがわかる
サポートブラウザに合わせてトランスパイルされることが確認できた

構文ではなく、JSの最新の機能をビルドしてみる

では、 babel.config.js の設定はそのままに、0.25%, not deadなブラウザを対象に、以下のコードを変換してみる

const fetch = async() => {
  return await new Promise(resolve => {
    setTimeout(() => {
      resolve('complete!!');
    }, 1000);
  });
}

fetch()

以下が変換結果

"use strict";
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }

var fetch = /*#__PURE__*/function () {
  var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee() {
    return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _context.next = 2;
            return new Promise(function (resolve) {
              setTimeout(function () {
                resolve('complete!!');
              }, 1000);
            });

          case 2:
            return _context.abrupt("return", _context.sent);

          case 3:
          case "end":
            return _context.stop();
        }
      }
    }, _callee);
  }));

  return function fetch() {
    return _ref.apply(this, arguments);
  };
}();

fetch();

変換後のコードを見てみると、 new Promise といういかにも古いブラウザでは動かなさそうなコードが紛れ込んでいる
0.25%, not deadなブラウザにはIE 11が含まれているが、当然Promiseは動かない
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise

@babel/preset-env はデフォルトではポリフィルまでは行なってくれない
Babelの責務は構文に対してのみなので、Promiseのような追加機能を動くようにするポリフィルは別途対応する必要がある

しかし @babel/preset-env にはポリフィルもどうにかしてくれるオプションが用意されている

ポリフィル

@babel/preset-env の公式ページ( https://babeljs.io/docs/en/babel-preset-env )を見ると、先ほど僕が書いた babel.config.js とちょっと書き方が違うのに気づく

// presetsが二重配列になっている
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "entry"
      }
    ]
  ]
}

これは @babel/preset-env にオプションを指定するための書き方で、前述したポリフィルのための指定を行なっている
useBuiltIns には usage entry false のいずれかを指定することができる(デフォルトはfalse)
usage または entry を指定した場合、babel実行時に勝手に必要なポリフィルを入れてくれる
usage だと .browserslistrc の設定に応じた必要なものだけを、 entry だと全てのポリフィルを入れてくれるらしい
(余程の事情がない限り usage で十分そう?)

↑の設定で、先ほどのPromiseを使ったコードをもう一度変換してみる

npx babel main2.js --out-dir dist

すると、以下のように警告が出力される

WARNING (@babel/preset-env): We noticed you're using the `useBuiltIns` option without declaring a core-js version. Currently, we assume version 2.x when no version is passed. Since this default version will likely change in future versions of Babel, we recommend explicitly setting the core-js version you are using via the `corejs` option.

You should also be sure that the version you pass to the `corejs` option matches the version specified in your `package.json`'s `dependencies` section. If it doesn't, you need to run one of the following commands:

  npm install --save core-js@2    npm install --save core-js@3
  yarn add core-js@2              yarn add core-js@3

More info about useBuiltIns: https://babeljs.io/docs/en/babel-preset-env#usebuiltins
More info about core-js: https://babeljs.io/docs/en/babel-preset-env#corejs
Successfully compiled 1 file with Babel (382ms).

またコードにはポリフィルらしきコードがrequireされていることがわかる(ポリフィルのオプションを有効にする前はなかった)

"use strict";

require("regenerator-runtime/runtime.js");
require("core-js/modules/es6.object.to-string.js");
require("core-js/modules/es6.promise.js");

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }

var fetch = /*#__PURE__*/function () {
  // 略
}

試しに警告を無視して、そのままコードを実行してみると

node dist/main2.js

internal/modules/cjs/loader.js:883
  throw err;
  ^

Error: Cannot find module 'core-js/modules/es6.object.to-string.js'

というエラーになった
core-js 内部のモジュールが見つからないと言われている
Babelはあくまでもポリフィルを読み込む関数を差し込むだけで、ポリフィルそのものは自分で入れる必要があるらしい

Babelのポリフィルには一般的に core-js を使う
これはJSの新機能を旧ブラウザでも使えるようにする互換コードのライブラリで、現在は2系 or 3系が主流っぽい
とりあえず新しい3系を指定すれば良いと思うので入れる

npm install core-js@3

babel.config.jsに、core-js3を使うように指示する

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        'useBuiltIns': 'usage',
        corejs: 3
      }
    ]
  ],
}

ビルドしても警告が出なくなった

npx babel main2.js --out-dir dist
Successfully compiled 1 file with Babel (315ms).

また、コードの実行も問題なくできている

node dist/main2.js

大まかにBabelの役割を見て行ったが、開発者がそんなに気にしなくてもいい感じにしてくれるBabelは本当に偉大だ

参考文献