🐕

【JS・TS】具体例で学ぶバンドル・ミニファイ・ツリーシェイキング

に公開

前提

  1. 今回はビルドツールとしてesbuildを使用しています。
    理由としては、今回この記事を書こうと思ったきっかけが AWS Lambda であり、CDK で Lambda を作成する際に使う NodejsFunction がデフォルトで esbuild を使っているためです。
    ただし、フロントエンドでも使える(むしろフロントエンドで大事な)知識なのでフロントエンド側の方も対象読者です。

  2. タイトルに TS と書いていますが、バンドル・ミニファイ・ツリーシェイキングは JS も TS も変わらないと思うので、例は JS で書いています。

  3. 具体例は index.js がエントリーポイントとなり、 module.js をインポートして使うようになっています。

  4. 筆者はビルドツールの内部実装まで知っているわけではありません。
    あくまで自分で実験した範囲で分かったことを書いているので、正確でない情報が含まれる可能性があります。
    初学者の方は全てを鵜呑みにせず、ベテランの方は間違いがあればコメントで正していただければと思います。

それでは説明に入ります。

バンドル (bundle)

バンドルとは、複数のファイルを1つ(もしくは少数)にまとめる処理です。

(バンドル)具体例1

以下のようなファイルがあるとします。

index.js
import { foo } from "./module.js";

console.log(foo);
module.js
export const foo = "foo";

このファイルを1つのファイルにバンドルすると以下のようになります。

バンドル結果
// module.js
var foo = "foo";

// index.js
console.log(foo);

(バンドル)具体例2

今度は es-toolkit を npm でインストールして使ってみます。

https://es-toolkit.slash.page/

index.js
import { foo } from "./module.js";
import { sum } from "es-toolkit";

console.log(foo);
console.log(sum([1, 2, 3]));
module.js
export const foo = "foo";

このファイルを1つのファイルにバンドルすると以下のようになります。

バンドル結果
// module.js
var foo = "foo";

// node_modules/es-toolkit/dist/math/sum.mjs
function sum(nums) {
  let result = 0;
  for (let i = 0; i < nums.length; i++) {
    result += nums[i];
  }
  return result;
}

// index.js
console.log(foo);
console.log(sum([1, 2, 3]));

コメントに書かれている node_modules/es-toolkit/dist/math/sum.mjs は以下のようなファイルになっています。

sum.mjs
function sum(nums) {
    let result = 0;
    for (let i = 0; i < nums.length; i++) {
        result += nums[i];
    }
    return result;
}

export { sum };

これら2つの例から、バンドルでは import 文を実際のコードに置き換えるような挙動が見てとれます。

ミニファイ (minify, minification)

ミニファイとは、不要なコードや冗長なコードを削除する処理です。
具体的にはコードコメント、空白、未使用コードの削除、変数名や関数名の短縮を行います。

※ 以降ミニファイをすると言った場合、バンドル結果に対してミニファイをしていると考えてください。

(ミニファイ)具体例1

バンドルの具体例2のコードをミニファイします。

コード再掲

index.js
import { foo } from "./module.js";
import { sum } from "es-toolkit";

console.log(foo);
console.log(sum([1, 2, 3]));
module.js
export const foo = "foo";
バンドル結果
// module.js
var foo = "foo";

// node_modules/es-toolkit/dist/math/sum.mjs
function sum(nums) {
  let result = 0;
  for (let i = 0; i < nums.length; i++) {
    result += nums[i];
  }
  return result;
}

// index.js
console.log(foo);
console.log(sum([1, 2, 3]));

ミニファイした結果は以下のようになります。

ミニファイ結果
var p="foo";function o(e){let t=0;for(let r=0;r<e.length;r++)t+=e[r];return t}console.log(p);console.log(o([1,2,3]));

コードコメント、空白の削除、変数名、関数名の短縮が行われていることが分かります。

(ミニファイ)具体例2

具体例1では未使用コードの削除が確認できなかったので、未使用コードの例を示します。

index.js
import { foo } from "./module.js";

console.log(foo);

const bar = "bar"; // 未使用

function returnError() {
  return new Error("error");

  // 以降は到達不可
  const a = 1;
  const b = 2;
  const c = a + b;
  console.log(c);
}

console.log(returnError());
module.js
export const foo = "foo";

const baz = "baz"; // 未使用

結果は以下のようになります。

バンドル結果
// module.js
var foo = "foo";

// index.js
console.log(foo);
function returnError() {
  return new Error("error");
  const a = 1;
  const b = 2;
  const c = a + b;
  console.log(c);
}
console.log(returnError());
ミニファイ結果
var o="foo";console.log(o);function t(){return new Error("error")}console.log(t());

module.js の baz と index.js の bar はバンドルの段階で削除されていますが、到達不可のコードはバンドルでは削除されずミニファイで削除されていることが分かります。
ここら辺の挙動(どの段階で何が削除されるか)は私もあまり分かっていません。

ただ、最終的に未使用コードの削除もされることは分かりました。

ツリーシェイキング (tree shaking)

ツリーシェイキングとは、export されたコードが他のファイルで使われているかどうかを判定し、使われていないコードを削除する処理です。

(ツリーシェイキング)具体例1

index.js
import * as module from "./module.js";

console.log(module.bar);
module.js
export const foo = "foo";
export const bar = "bar";
export const baz = "baz";

index.js では module.js を * でインポートしていますが bar のみを利用しています。

これにツリーシェイキングを行うと以下のようになります。
なおツリーシェイキングはバンドルをする過程?で行われるため、バンドル結果を示します。

バンドル結果
// module.js
var bar = "bar";

// index.js
console.log(bar);

module.js を * でインポートしていますが、使われていない foo と baz はツリーシェイキングで削除されています。

ただし、ツリーシェイキングができないケースもあります。
以降でそのケースの一部を紹介します。

(ツリーシェイキング)具体例2

CommonJS 形式のモジュールの場合はツリーシェイキングができません。

index.cjs
const module = require("./module.cjs");

console.log(module.bar);
module.cjs
const foo = "foo";
const bar = "bar";
const baz = "baz";

module.exports = { foo, bar, baz };

index.cjs では bar のみを利用しています。
これにツリーシェイキングを行うと以下のようになります。

バンドル結果
var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod) => function __require() {
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};

// module.cjs
var require_module = __commonJS({
  "module.cjs"(exports, module) {
    var foo = "foo";
    var bar = "bar";
    var baz = "baz";
    module.exports = { foo, bar, baz };
  }
});

// index.cjs
var require_index = __commonJS({
  "index.cjs"() {
    var module = require_module();
    console.log(module.bar);
  }
});
export default require_index();

CommonJS形式のモジュールのための処理が追加され、foo と baz も残っています。
これはミニファイをしても消えません。

ミニファイ結果
var r=(b,o)=>()=>(o||b((o={exports:{}}).exports,o),o.exports);var a=r((u,s)=>{var c="foo",e="bar",n="baz";s.exports={foo:c,bar:e,baz:n}});var l=r(()=>{var t=a();console.log(t.bar)});export default l();

(ツリーシェイキング)具体例3

クラスのメソッドに対してはツリーシェイキングができません。
これは感覚としては当たり前に感じますが、Valibotのような事例があるので紹介します。

index.js
import * as module from "./module.js";

console.log(module.foo());

const sample = new module.Sample();
console.log(sample.foo());
module.js
export function foo() {
  return "function foo";
}

export function bar() {
  return "function bar";
}

export function baz() {
  return "function baz";
}

export class Sample {
  foo() {
    return "class foo";
  }

  bar() {
    return "class bar";
  }

  baz() {
    return "class baz";
  }
}

index.js では function foo() と class Sample.foo() を使っています。
これにツリーシェイキングを行うと以下のようになります。

バンドル結果
// module.js
function foo() {
  return "function foo";
}
var Sample = class {
  foo() {
    return "class foo";
  }
  bar() {
    return "class bar";
  }
  baz() {
    return "class baz";
  }
};

// index.js
console.log(foo());
var sample = new Sample();
console.log(sample.foo());

function は使用されている foo() のみが残っているのに対し、class は全てのメソッドが残っています。
これはミニファイをしても消えません。

ミニファイ結果
function n(){return"function foo"}var o=class{foo(){return"class foo"}bar(){return"class bar"}baz(){return"class baz"}};console.log(n());var t=new o;console.log(t.foo());

ライブラリなど一部の機能しか使わないようなケースでは、クラスで書かれているとツリーシェイキングが出来ず、実現したいことに対して無駄にバンドルサイズが大きくなってしまいます。

以上でバンドル・ミニファイ・ツリーシェイキングの説明は終わりです。

最後に

この記事がバンドル・ミニファイ・ツリーシェイキングのイメージを掴む助けになれば幸いです。

私は今回初めて esbuild を直接使ったのですがかなり簡単に使えたので、もっと細かい挙動が知りたい方は自分で色々試してみてください。

また、この記事では "なぜ" の部分を説明出来ていませんが、そこら辺は JavaScript の歴史を知ると理解が深まるので、しまぶーさんの モダンJavaScript講座 の #2 ~ #5 を見ることをおすすめします。(回し者ではないです)

最後まで読んでいただきありがとうございました。

Spectee Developers Blog

Discussion