1
/
5

ライブラリ作者におすすめしたいBabelの新機能 babel-plugin-polyfill-corejs3

Babelの新しいpolyfill用プラグインであるbabel-polyfillsが2022年4月に脱experimentalを果たしていました。そこで本稿では、Babelにおいてpolyfillがどのように扱われていたかを振り返りながら新しいpolyfill pluginを紹介します。

注意: Babel 7.4で非推奨化された@babel/polyfillとは別物です。

何が問題だったのか?

babel-polyfillsのREADME元issueにも説明がありますが、本記事ではあらためて日本語で説明したいと思います。端的に言うと今までは以下の要件を両立できませんでした。

  • ソースコード中で使われている機能のpolyfillだけを注入する。
  • ターゲットブラウザを指定して、必要なpolyfillだけを注入する。
  • 副作用のない形でpolyfillを注入する。

babel-polyfillsはこれらを同時に満たす新しいpolyfill pluginを提供します。さらに、core-jsだけではなくes-shimsなど他のpolyfillを選択することもできるようになっています。

詳しいことは後回しにして、まずは導入方法を紹介します。

導入方法

(1) プラグインのインストール

まずはプラグインを入れます。 @babel スコープの外にありますが、歴とした公式のプラグインです。

npm install -D babel-plugin-polyfill-corejs3
# または:
yarn add -D babel-plugin-polyfill-corejs3

現時点でのバージョンは 0.5.2 です。experimentalではないもののバージョン1には達していないので、より厳密な安定性が必要であればまだ使わないほうがいいかもしれませnん。

これらの他にcore-js@2用、es-shims用、regenerator-runtime用のプラグインがあります。以降、本稿ではcore-js@3を例に説明していきます。

(2) 既存のpolyfill設定を削除

すでにBabelでpolyfillを使うような設定をしている場合は削除します。 @babel/preset-env の場合は useBuiltIns オプションと corejs オプションを削除します。

// babel.config.js
module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        // 削除
        useBuiltIns: "entry",
        // 削除
        corejs: "3",

        // それ以外の設定はpolyfillとは関係ないので残す
        targets: "defaults, not ie 11, not ie_mob 11",
        modules: "commonjs",
      },
    ],
  ],
};

また、 @babel/plugin-transform-runtimeにもpolyfill関連設定があります。 codejs オプションがあれば削除しておきます。

// babel.config.js
module.exports = {
  plugins: [
    [
      "@babel/plugin-transform-runtime",
      {
        // 削除
        corejs: "3",

        // それ以外の設定はpolyfillとは関係ないので残す
        absoluteRuntime: false,
        version: "^7.7.4",
      },
    ],
  ],
};

(3) polyfill pluginの設定を追加

先ほどインストールしたプラグインの設定を追加します。

// babel.config.js
module.exports = {
  plugins: [
    [
      "@babel/plugin-polyfill-corejs3",
      {
        // entry-global, usage-global, usage-pure のいずれかを指定
        method: "usage-pure",
        // core-jsのバージョン情報を与えると、より最適な結果を出してくれる
        version: "^3.23.2",
      },
    ],
  ],
};

methodの対応関係は以下のようになっています。

  • @babel/preset-env を useBuiltIns = "entry" で使っていた場合 → method = "entry-global"
  • @babel/preset-env を useBuiltIns = "usage" で使っていた場合 → method = "usage-global"
  • @babel/plugin-transform-runtime に corejs オプションを指定して使っていた場合 → method = "usage-pure"

(4) targets設定を巻き上げる

@babel/preset-env にtargets設定を書いている場合は、この設定が babel-plugin-polyfill-corejs3 からも見えるように共通設定として巻き上げます。

もし他のbrowserslist関連オプション (configPath, browserslistEnv, ignoreBrowserslistConfig) も指定している場合は、これらも一緒に巻き上げる必要があります。

(これらのトップレベル設定は @babel/core および @babel/preset-env 7.13.0 以降で使えます)

// babel.config.js
module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        // これをトップレベルに移す
        targets: "defaults, not ie 11, not ie_mob 11",
        modules: "commonjs",
      },
    ],
  ],
  plugins: [...],
};

// ↓

module.exports = {
  targets: "defaults, not ie 11, not ie_mob 11",
  presets: [
    [
      "@babel/preset-env",
      {
        modules: "commonjs",
      },
    ],
  ],
  plugins: [...],
};

(5) polyfill/runtimeパッケージを導入する (入れ替える)

まず、core-jsへの依存を足します。これはランタイムパッケージのため、devDependenciesではなくdependenciesに入れます。

npm install core-js
# または:
npm install core-js-pure
# または:
yarn add core-js
# または:
yarn add core-js-pure

入れるべきパッケージはcore-jsまたはcore-js-pureのうちのどちらかです。これはmethodオプションに依存します。

  • method = "entry-global" または method = "usage-global" の場合→ core-js をインストール
  • method = "usage-pure" の場合→ core-js-pure をインストール

また、元々 @babel/preset-env や @babel/plugin-transform-runtime でpolyfill設定を有効にしていた場合は、関連するパッケージを削除し、適切なパッケージで置き換えます。

  • @babel/preset-env のpolyfill設定を有効にしていた場合→ core-jsが依存に入っているはず。
    • core-js-pureに切り替えた場合 (method が "usage-pure" の場合) は削除する。
    • 引き続きcore-jsを使う場合 (methodが "entry-global" または "usage-global" の場合) はそのままでよい。
  • @babel/plugin-transform-runtime のpolyfill設定を有効にしていた場合→ @babel/runtime-corejs3が依存に入っているはずなので、削除してかわりに@babel/runtimeをインストールする。

以上で導入は完了です。

注意事項

旧来の @babel/plugin-transform-runtime で対応できていたもののなかには、babel-plugin-polyfill-corejs3 ではカバーできないケースが存在します。特に対応ブラウザの要件を厳密に考える必要がある場合は、以降の説明も読んでいたほうがいいかもしれません。

詳しい説明

ここからはbabel-polyfillsが解決しようとしている問題についてより詳しく説明していきます。まずは前提知識として、transpiler, polyfill, helperの関係を整理します。

前提知識: transpilerとpolyfill

Babelはトランスパイラ、つまりJavaScriptコードを別のJavaScriptコードに変形するツールです。そのためBabelの主な役割はJavaScriptの新しい構文への対応です。

// 比較的新しい構文を使っている
foo?.bar();

// ↓ 変形

var _foo;

(_foo = foo) === null || _foo === void 0 ? void 0 : _foo.bar();

いっぽう、JavaScriptでは構文だけではなく新しい標準ライブラリも追加されます。これはコードの変形ではなく、同等機能をJavaScriptで実装して使用することで対応されます。このような互換実装のことをpolyfillと呼びます。

// replaceAllメソッドが存在しなければ、互換実装 (polyfill) で置き換える
if (!String.prototype.replaceAll) {
  String.prototype.replaceAll = function(needle, replacement) {
    // ...
  };
}

// ブラウザがreplaceAllを提供していなくても、↑で定義したメソッドが呼ばれるため問題ない
"foo".replaceAll("o", "a");

このようにトランスパイラとpolyfillでは適用領域が異なります。ただし、後述するように、polyfillを利用するにあたってトランスパイラのサポートがあると便利なことがあります。

polyfillは機能ごとに独立したプロジェクトで提供されることもありますが、特にECMAScript本体で定義されているものを中心にまとまったpolyfillを提供しているプロジェクトとしてはcore-jses-shimsが有名です。

なお、かつてはBabel自身が @babel/polyfill を提供していましたが、これはcore-jsとregenerator-runtimeのうちBabelが使うものをセットにしたパッケージでした。 (現在の @babel/runtime-corejs2 に近い立ち位置)

前提知識: polyfillとtranspiler helper

Babelが新しい構文を古い構文にトランスパイルするにあたり、同じようなコードを何回も生成する必要があります。たとえばimportをrequireにするには以下のような変換が必要です。

import foo from "some-module";
console.log(foo);

// ↓

const _someModule = require("some-module");
const _someModule2 = _someModule && _someModule.__esModule ? _someModule : { default: _someModule };
console.log(_someModule2.default);

ここでは __esModule というマーカーに応じて結果を変えています。このような頻出なパターンを共通化するために、Babelは上記のように毎回処理を書かずに、専用のヘルパ関数を生成します。

const _someModule = _interopRequireDefault(require("some-module"));

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

console.log(_someModule.default);

これで多少すっきりしますが、それでも各ファイルごとに重複コードが大量に生成されることになります。そこでこの手の共通処理をさらに別パッケージ (@babel/runtime) に集約して、それを呼び出すようなコードとして生成することができます。

const _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default;

const _someModule = _interopRequireDefault(require("some-module"));

console.log(_someModule.default);

ただしこの場合、 @babel/runtime というパッケージを依存関係に追加しておく必要があります。このような追加の要件があるため、Babelはデフォルトではヘルパ関数用のパッケージを使わず、ファイルごとにヘルパ関数を生成します。@babel/plugin-transform-runtime を使うことで、Babelに@babel/runtimeを使うように指示することができます。

これはJavaScriptの新しい構文をサポートするためにトランスパイラが独自に用意するヘルパ関数です。実際、Babel以外のトランスパイラはそれぞれに別のヘルパライブラリを持っています。(tscは tslib, SWCは @swc/helpers など)

このことはJavaScriptの新しい標準ライブラリ機能を実現するための汎用的なライブラリであるpolyfillとは対照的です。一方で、その扱いについては共通する部分もあり、しばしばpolyfillとhelperは関連する形で出てきます。Babelではpolyfillとhelperを合わせてruntimeと呼んでいるようです。

なお、トランスパイラ向けのヘルパの中でもgeneratorとasync/awaitのトランスパイルで必要になるregenerator-runtimeは別格です。これは元々regeneratorというgeneratorとasync/await専用トランスパイラのhelperでしたが、他のhelperとは規模感が違うこともあってか、Babelなど他のトランスパイラからも利用され、特別な地位にあります。

ターゲットブラウザを指定して、必要なpolyfillだけを注入する

polyfillの話に戻ります。polyfill自身はライブラリにすぎず、polyfillを呼び出すにはいくつかの方法があります。わかりやすいのは自分で明示的にpolyfillをインポートする方法です。

// Object.entries と Array.prototype.includes がなければ互換実装を使う
import "core-js/actual/object/entries";
import "core-js/actual/array/includes";

// 以下ユーザーコード

また、 polyfill.io などを使ってアプリケーションコードとは別にpolyfillを提供する方法もあります。これにはUser-Agentに基づいて配信量を動的に変えられるなどの利点がありますが、今回はライブラリが自らpolyfillを利用する場合を想定しているので扱いません。

上記のように自力でpolyfillを取り込まずに、ある程度自動化することもできます。これによりいくつかの恩恵を受けることができます。その自動化の手段のひとつがBabelの @babel/preset-env です。

@babel/preset-env は、ターゲットブラウザの設定に基づいてトランスパイル対象の構文を自動的に選択する仕組みです。たとえば、

targets: "defaults, not ie 11, not ie_mob 11"

と指定すると、2022年6月現在では以下のような結果になります。 (browserslist等のデータは随時更新されるため、これらのバージョン次第で結果が変わる)

// ES6 classes: トランスパイルされない (全てのターゲットブラウザで動く)
class C {}

// async/await: トランスパイルされない
async function f() {}

// Optional chaining: トランスパイルされる
foo?.bar();

このようにトランスパイルするべき構文を選択するのが @babel/preset-env の主要な目的なのですが、実は注入するべきpolyfillの選択も一緒に行ってくれます。@babel/preset-envにpolyfillの注入をさせるには useBuiltIns を使います。

// babel.config.js
module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        corejs: "3.23.2",
        useBuiltIns: "entry",
      },
    ],
  ],
};

上のようにuseBuiltInsに "entry" を指定しておくと、ソースコード中の core-js/stable に対するインポートが自動的に書き換えられ、ターゲットに指定したブラウザで実装されていない可能性があるpolyfillが選択されます。

import "core-js/stable";

// ↓

import "core-js/modules/es.symbol.match-all.js";
import "core-js/modules/es.error.cause.js";
import "core-js/modules/es.aggregate-error.js";
// ...

この方法の場合は、アプリケーションの入口 (エントリポイント) となるモジュールで一度だけ core-js/stable をインポートすれば、あとは必要な機能が全部揃った状態で始められることになります。

ソースコード中で使われている機能のpolyfillだけを注入する

@babel/preset-env の useBuiltIns: "entry" を使った場合、ターゲットブラウザに基づいてpolyfillを節約することはできますが、ソースコード内での利用状況に基づいてpolyfillを節約することはできません。Babel 7.0で導入された useBuiltIns: "usage" を使うと、このような問題を解消できます。

useBuiltIns: "usage" では、各ソースファイルごとにpolyfillが必要かどうかを解析して挿入します。

Object.hasOwn(obj, "foo");

// ↓

import "core-js/modules/es.object.has-own.js";
Object.hasOwn(obj, "foo");

機能の利用判定はかなり賢く行われているようなのでほとんどのケースでは誤りは起きないと考えられますが、絶対にうまくいくわけではないので注意が必要です。

// hasOwnが使われることは検出されない
const hasOwn = location.href[0] + "asOwn";
Object[hasOwn](obj, "foo");

useBuiltIns: "usage" では利用状況に応じて自動的にcore-jsのimportが挿入されるため、エントリポイントに import "core-js/stable"; を書く必要はありません。

副作用のない形でpolyfillを注入する

ここまで紹介した方法では、不足している標準ライブラリ関数が実際に補充されていました。

標準ライブラリ関数は互換性を壊さないように追加されるので、互換実装を足すことで問題が起きる可能性はそこまで大きくはないです。しかし、場合によってはそれでも勝手にグローバル環境をいじるのは好ましくないという判断もありえます。特にライブラリを作る上では他の箇所に影響するリスクを可能な限り削減するのがよいでしょう。

そこで、標準ライブラリ関数を上書きせずに、「標準ライブラリまたは代替実装が入った変数」を提供してそれを使うというpolyfillの亜種があります。 (このような亜種をpolyfillとは区別して "ponyfill" と呼ぶ場合もありますが、本稿ではこのような亜種も含めてpolyfillと呼びます。)

// Object.hasOwnのpolyfillを、標準ライブラリとは独立した変数として用意する
const hasOwn = Object.hasOwn || function hasOwn(obj, key) {
  return Object.prototype.hasOwnProperty.call(obj, key);
};

// Object.hasOwnのかわりにhasOwn変数を使う
hasOwn(obj, key);

core-jsの場合はcore-js-pureパッケージがこのようなpolyfillを提供しています。そして、Babelではこのような副作用のないpolyfillを使う仕組みも提供しています。それが @babel/plugin-transform-runtime です。

@babel/plugin-transform-runtime は本来、構文のトランスパイルに必要なランタイムコードを共通モジュールから呼び出すためのプラグインです。しかし、後述するような理由から、副作用のないpolyfillの注入も @babel/preset-env ではなくこのプラグインが担ってきました。

次のように @babel/plugin-transform-runtime にcorejs: 3を指定することでpolyfillの注入が行われます。

// babel.config.js
module.exports = {
  plugins: [
    [
      "@babel/plugin-transform-runtime",
      {
        corejs: 3,
      },
    ],
  ],
};

正確には以下の2つの変化が起きます。

  • ランタイムコードが @babel/runtime ではなく @babel/runtime-corejs3 からインポートされるようになる。
  • corejsでpolyfill可能な部分に関しては @babel/runtime-corejs3 経由でcore-js-pureのpolyfillを参照するようになる。
// オリジナル
class C {
  foo = 42;
}
Object.hasOwn(new C(), "foo");

// corejs: falseの場合
import _defineProperty from "@babel/runtime/helpers/defineProperty";

class C {
  constructor() {
    _defineProperty(this, "foo", 42);
  }
}
// これは @babel/preset-env 等を用いて別途polyfillを注入する必要がある
Object.hasOwn(new C(), "foo");

// corejs: 3の場合
import _defineProperty from "@babel/runtime-corejs3/helpers/defineProperty";
import _hasOwn from "@babel/runtime-corejs3/core-js-stable/object/has-own.js";

class C {
  constructor() {
    _defineProperty(this, "foo", 42);
  }
}
// Object.hasOwnはcore-js-pureのpolyfillを使うようになる
_hasOwn(new C(), "foo");

このようにcorejs: 3を指定した場合は構文のトランスパイルだけでなく副作用のないpolyfillの注入も同時に行われます。この機能を使う場合は、 @babel/preset-env でcore-jsの設定をする必要は基本的にありません。

なぜ @babel/runtime と @babel/runtime-corejs3 は分かれているのか

@babel/runtime と @babel/runtime-corejs3 には非常に大きな違いがあります。それは、ヘルパ関数のコード自体にpolyfillが注入されるかどうかの違いです。

たとえば、@babel/runtime 7.18.3 の _createClass は以下のように定義されています。

function _createClass(Constructor, protoProps, staticProps) {
  if (protoProps) _defineProperties(Constructor.prototype, protoProps);
  if (staticProps) _defineProperties(Constructor, staticProps);
  Object.defineProperty(Constructor, "prototype", {
    writable: false
  });
  return Constructor;
}

いっぽう、 @babel/runtime-corejs3 7.18.3 の _createClass は以下のようになっています。

var _Object$defineProperty = require("@babel/runtime-corejs3/core-js/object/define-property");

function _createClass(Constructor, protoProps, staticProps) {
  if (protoProps) _defineProperties(Constructor.prototype, protoProps);
  if (staticProps) _defineProperties(Constructor, staticProps);

  _Object$defineProperty(Constructor, "prototype", {
    writable: false
  });

  return Constructor;
}

このようにヘルパ関数の中にはpolyfillを必要としているものがあるため、Babelは3.0でランタイムパッケージを導入して以来、ヘルパ関数にはpolyfillを注入した状態で提供してきました。

この状況が変わったのがBabel 7.0です。Babel 7.0では旧来の babel-runtime と同等の機能を @babel/runtime-corejs2 に移し、 @babel/runtime はpolyfillが注入されていない状態のものとすることでランタイムパッケージをcorejs非依存にしました。これには以下のような背景があると考えられます。

  • ヘルパ関数で必要になりがちなPromise, Symbol, WeakMap, Object.definePropertyなどの基本的なライブラリの普及度が高くなり、これらのpolyfillを注入する必要のない環境が増えてきた。
  • core-jsを2から3に移行するにあたって、core-jsのバージョンに対して中立な振舞いを実現する必要があった。

しかし、元々の問題が解決しているわけではないことには注意が必要です。ヘルパ関数内で必要な標準ライブラリ定義の欠落を防ぐには、以下のような対応が必要な可能性があります。

  • アプリケーション側で @babel/preset-env の useBuiltIns: "entry" を使う。
  • @babel/preset-env の useBuiltIns: "usage" を使い、 @babel/plugin-transform-runtime は使わない。
  • アプリケーション側のモジュールバンドラの設定で @babel/runtime をトランスパイル対象に含める。

結局のところ、ライブラリは依存先コードのpolyfillの適用度を細粒度で制御することはできず、polyfillする/しないの2択くらいしか選択肢がないというのが本質的な問題であると考えられます。

本稿で紹介した babel-polyfills (babel-plugin-polyfill-corejs3 など) は、runtimeのpolyfillは (ライブラリ側の判断としては) 行わないという選択であることに注意が必要です。

@babel/plugin-transform-runtime の問題

@babel/plugin-transform-runtime を使ってpolyfillを注入する場合の問題は、 @babel/preset-env の useBuiltIns: "entry" / "usage" でできていたようなtargetsによる絞り込みが効かないことです。これがBabelのpolyfill設定における重大なジレンマになっていました。

それを解決するのが本稿で紹介しているbabel-polyfillsです。

babel-polyfills の登場

Babel v7でヘルパ関数にpolyfillを注入しないという選択肢があらわれたことで、Babelにおけるpolyfillの立場が変化しました。

  • @babel/plugin-transform-runtime がpolyfillの注入を担う合理的な理由がなくなりました。特に、 @babel/plugin-transform-runtimeではpolyfillを「注入する」「注入しない」の二択しかなかったのですが、targetsにもとづいてこれよりも細粒度の制御を可能にする野望が生まれました。
  • 特定のpolyfillシステム (core-js) を特別扱いする合理的な理由がなくなりました。

そこで、「polyfillを注入する」という処理をBabelの他の部分から切り離し、独立した機能として設計しなおすというアイデアが RFC: Rethink polyfilling story として提案されました。

  • 「polyfillを注入する」ためのプラグインを専用で作る。
  • polyfillライブラリごとに新しくプラグインを作る。Babel公式のpolyfillプラグイン以外のpolyfillプラグインも作れるようにする。
  • 旧来 @babel/preset-env が握っていたいくつかのオプションは他のプラグイン (特にpolyfillプラグイン) でも必要になるので、共通設定に格上げする。

この提案から3年ほど経過し、2022年4月に babel/babel-polyfills のREADMEから "experimental" の語が取り除かれました。

実は、すでに @babel/preset-env や @babel/plugin-transform-runtime も内部ではこの新しいpolyfillプラグイン (babel-plugin-polyfill-corejs3) を呼び出すように書き換えられています。ある意味ではすでにproduction-readyと言えるかもしれません。

もう1つの問題: polyfillからpolyfillへの依存

ヘルパ関数がpolyfillに依存するのと同様に、polyfill自身もまた別のライブラリ定義のpolyfillに依存することがあります。

これも間接依存であり直接の制御下にないため、core-jsを利用していると、実際のtargetで必要になる以上に多くのモジュールが取り込まれているようです。

この問題も、綺麗に解決できるものではなさそうです。

歴史

まとめ

  • Babelではpolyfillを注入する方法が2種類提供されていたが、 @babel/preset-env の機能を使うとグローバルを汚染してしまい、 @babel/plugin-transform-runtime の機能を使うと必要ないpolyfillまで取り込まれてしまうという問題があった。
    • 「グローバル汚染を避けたい」という要求は主にライブラリで生じるため、特にライブラリ作者にとっては悩ましい問題だった。
  • babel/babel-polyfills リポジトリで管理されている新しいpolyfill pluginを使うと、これらの問題を同時に解決することができる。
  • しかし、ヘルパ関数にpolyfillが適用されなくなってしまうという懸念もある。
Wantedly, Inc.'s job postings
14 Likes
14 Likes

Weekly ranking

Show other rankings
Invitation from Wantedly, Inc.
If this story triggered your interest, have a chat with the team?