core-jsとbabel/preset-envで理解するトランスパイルとポリフィル
概要
モダンなフロントエンド開発では、最新のJavaScript構文を書いても古いブラウザで動く。これを支えているのがBabelだ。しかし「Babelを使えば全部よしなに変換してくれる」と理解していると、asyncは動くのにArray.prototype.flatがundefined is not a functionで落ちる、といった事故に遭う。
この記事では、トランスパイル(transpile) と ポリフィル(polyfill) という2つの別物を切り分けたうえで、@babel/preset-env と core-js がそれぞれ何を担当しているのかを考察する。両者の境界を理解しておくと、バンドルサイズの肥大や「本番だけ落ちる」系のバグに対して筋のいい判断ができる。
トランスパイルとポリフィルは別の問題を解いている
トランスパイル = 構文の書き換え
トランスパイルは、新しい構文(syntax) を古い構文に書き換える処理だ。構文とは、JavaScriptの文法そのものを指す。
// Before
const add = (a, b) => a + b;
class User {}
const run = async () => { await task(); };
// After(イメージ)
var add = function (a, b) { return a + b; };
function User() {}
// async はステートマシンへ展開
アロー関数・クラス構文・async/await・スプレッド構文・オプショナルチェイニング ?.。これらは「書き方」が新しいだけで、やっていること自体は古いJavaScriptでも表現できる。だからBabelはソースコードを静的に解析し、等価な古い書き方へソースコードレベルで置き換えられる。実行環境に何も足す必要がない。これがBabel本体の仕事だ。
ポリフィル = APIの後付け実装
一方、以下は構文ではなく実行時のオブジェクト・メソッドだ。
[1, 2, 3].flat(); // Array.prototype.flat
[1, 2, 3].at(-1); // Array.prototype.at
Object.fromEntries(entries); // Object.fromEntries
"a,b".replaceAll(",", "-"); // String.prototype.replaceAll
new Map(); new Set(); // Map / Set
flat が存在しないブラウザに対して「flat という書き方を別の書き方に変換する」ことはできない。flat は文法ではなく、実行時に Array.prototype へ生えているべきメソッドだからだ。
だから解決策は「変換」ではなく「その環境に flat の実装を注入する」こと。この実装の塊を提供するのが core-js だ。
> つまり、Babel(トランスパイル)は構文を解決し、core-js(ポリフィル)はAPIを解決する。守備範囲がそもそも違う。
@babel/preset-env が調整役になる
@babel/preset-env は「ターゲット環境に応じて必要な変換とポリフィルを選ぶ」プリセットだ。鍵は targets と useBuiltIns の2つ。
targets:どこまで対応するか
対応したいブラウザ・環境を Browserslist の記法で指定する。
# .browserslistrc > 0.5% last 2 versions not dead
ターゲットが新しいほど変換とポリフィルは減り、出力は軽くなる。甘くするほど太る。
> Babel 8(2026-06 に安定版リリース)での重要な変更:Babel 7 は targets 未指定だと全ブラウザ(実質 ES5)まで落としていたが、Babel 8 の @babel/preset-env はデフォルトで ES5 へコンパイルしなくなり、Browserslist の defaults(おおよそ ES2023 相当)に従うようになった。つまり 古いブラウザや ES5 まで確実に落としたいなら、targets もしくは .browserslistrc を明示指定する必要がある。指定を省くと「古い環境向けのつもりが、実は ES2023 止まりだった」という取り違えが起きやすい。逆に言えば、対応範囲を明示しておくことの重要性が Babel 8 で一段上がった。
useBuiltIns:ポリフィルの入り方
useBuiltIns が、core-js のポリフィルをどう注入するかを決める最重要オプションだ。
false(デフォルト): ポリフィルを一切注入しない。構文変換だけ。flat落ちはこの状態で起きる。entry: エントリーに書いたimport "core-js"を、targets に基づいて必要なモジュール群へ展開する。usage: コード中で実際に使っているAPIを検出し、ファイルごとに必要なポリフィルだけを自動 import する。
// babel.config.js(アプリ向けの基本形)
module.exports = {
presets: [
["@babel/preset-env", {
useBuiltIns: "usage",
corejs: { version: "3.51", proposals: false },
}],
],
};
usage にすると、flat を使っているファイルにだけ対応ポリフィルが自動で差し込まれる。
// src/feature.js const result = [1, 2, 3].flat(); // ↓ ビルド時に自動注入されるイメージ // import "core-js/modules/es.array.flat.js";
corejs の version には執筆時点の最新である 3.51 を置いた。core-js はメジャーが長く 3 系のままで、API・使い方自体は変わっていない。ここはお使いの package.json の core-js バージョンにそのまま合わせて読み替えてよい(useBuiltIns / corejs:3 の基本設計は Babel 8 でもそのまま有効)。
アプリは useBuiltIns、ライブラリは transform-runtime
実務で間違えやすいのがここだ。useBuiltIns のポリフィル注入は Array.prototype などのグローバルを書き換える。アプリの最終成果物ならそれでよいが、npm で配布するライブラリがグローバルを汚染すると、利用側の環境を壊しかねない。
ライブラリ側では @babel/plugin-transform-runtime + @babel/runtime-corejs3 を使い、グローバルを汚さない形でポリフィルを参照するのが定石だ。
// babel.config.js(ライブラリ向け)
module.exports = {
presets: ["@babel/preset-env"],
plugins: [["@babel/plugin-transform-runtime", { corejs: 3 }]],
};
アプリは useBuiltIns、ライブラリは transform-runtime と覚えておくと判断を間違えにくい。
確認方法
意図どおりにポリフィルが入っているかは、ビルド出力を直接見るのが一番速い。
useBuiltIns: "usage"でビルドし、生成物にcore-js/modules/es.array.flat相当の import が含まれているか確認する。targetsを新しいブラウザだけに絞るとポリフィルが消え、古いブラウザを足すと増えることを確認する。- preset-env に
debug: trueを渡すと、ビルド時にどの変換・ポリフィルが有効化されたかをログで確認できる。
["@babel/preset-env", { debug: true }]
注意点
corejsのバージョンは必ず明示する。 省略すると警告が出るうえ、意図しないバージョンのポリフィルが使われうる。package.jsonのcore-jsと揃える。proposalsを安易に有効化しない。 仕様確定前のAPIまでポリフィルすると、将来の仕様変更で挙動が変わるリスクを抱える。- ターゲットを広げすぎない。
targetsを甘くするほど変換とポリフィルが増え、バンドルが太る。実ユーザーの環境データで絞る。 Promiseなどの大物ポリフィルはコストが大きい。 対応ブラウザ要件は技術ではなくビジネス判断として握っておく。- Babel 8 では
loose/specオプションが廃止され、トップレベルのassumptionsに統合された。 Babel 7 の設定から移行する場合は、プラグインごとに散らばっていたloose指定をassumptionsに書き換える。 - Babel 8 本体は ESM-only で配布される(全パッケージに TypeScript 型を同梱)。 プログラムから
@babel/coreなどをrequireして呼び出している箇所があればimportへの移行が必要になる。設定ファイル(babel.config.js)経由で使う通常の構成には影響しない。
まとめ
- トランスパイル = 構文の書き換え。Babel本体の仕事。実行環境に何も足さない。
- ポリフィル = APIの後付け実装。core-js が実体を提供する。
@babel/preset-envはtargetsを見て「必要な変換」と「必要なポリフィル」を選び、useBuiltInsでポリフィルの入り方を制御する。Babel 8 からはデフォルトが ES5 ではなく Browserslist のdefaults(≒ES2023)になったため、古い環境対応ではtargetsの明示がいっそう重要になった。- アプリは
useBuiltIns: "usage"、ライブラリはtransform-runtimeが基本線。 - 「Babelが全部やってくれる」ではなく「Babelは構文、core-jsはAPI」と切り分けると、
flat is not a function系の事故も、バンドル肥大の判断も、見通しよく扱える。
Sources:
