Open7

JS のビルドサイズを極限まで絞るための TIPS 集

mizchimizchi

ビルドサイズ限界まで絞りたい人向け。
あらゆる環境で実践するものではないが、知ってたら簡単に避けることができるのもあるので知っておくと便利なTIPS書いていく。

基本ポリシー

未使用コードはビルド時に全部落とす。
何が未使用コードで、何が定数かわかるようなインターフェースを人間が心がける。

用語

Dead Code Ellimination(DCE)

Rollup や Terser で、未使用コードを削除すること

mizchimizchi

可能な限り ES2017~ を使う

async/await の変換で、相当量のコードが生成される。

TODO: 実例

mizchimizchi

定数 object を避ける (export default {} を避ける)

オブジェクトに代入すると getter/setter で潜在的に副作用が起きうる関係で、 terser はオブジェクトの実質的な定数が定数だと判断することができない

export default {
  A: 1,
  B: 2
};

このとき、 A だけ参照する方法がないので、 B を削ることができない。

書き換え例

export const A = 1;
export const B = 2;
mizchimizchi

sideEffect を避ける(ファイルスコープのトップレベルの副作用)

const __cache = {};
__cache[1] = new Object();
export function getCache(key) {
  return __cache[key];
}

これは rollup/webpack 的にはモジュールで sideEffect が起きているという判定になり、 import {init} from '...' したコードで init が未使用でも削除されない。

TODO: 副作用がスコープが閉じてる判定までやってくれていたら、上の例は minify される。

TODO: rollup + terser の挙動を確認

書き換え例

let __cache;
export function getCache(key) {
  if (!__cache) {
    __cache[1] = new Object();
  }
  return __cache[key];
}
mizchimizchi

import * as を避ける

import as で生成される名前空間は実質的な Object なので、最適化がしづらい。

foo/index.ts
import * as api from "...";

api.foo();
// api.bar();

これは terser 的には * as でキャプチャした時点で mutable な object の扱いになり、 api.bar() が未使用でも DCEの 対象にならない。

TODO: 実例を書く。もしちゃんとやってる場合は削除

mizchimizchi

過剰に再 export しない (とくに */index.ts において )

foo ディレクトリで色々と実装があり、index ファイルでまとめて export するパターン

foo/index.ts
export * from "./a";
export * from "./b";

モジュール名を整理する .ts 以前からあるハックだが、静的解析ツールが普及した今は邪魔になることが多い。
a の提供する機能が foo と foo/a の 2 パターンの import 先があり、また同一性を担保するのに一応確認するのが必要になっている。

そのファイルを Single Source of Truth の原則的にも、必ず同じ場所から同じソースを参照出来たほうがよい。

信頼できる唯一の情報源 - Wikipedia

また、再 export は typescript の compilerOption で "importsNotUsedAsValue": "error" にした際にも問題になる。

export type { Foo } from "./foo";

これは静的解析は通るものの、 Foo がビルド時に型かどうかをソースコードレベルでは決定できず、 rollup で未解決 import の warning となる。

過剰にやるなという話で、 例えば Deno だと公開API を /mod.ts にまとめる文化があるが、その場合も * は使わずに絞って import したほうがよい。

mizchimizchi

非公開変数/メソッド名を圧縮する

JS のObject({...})はそれ自体が状態を持つものとして扱われるので、オブジェクトの名前空間を生成した時点で内部のメンバの長さだけビルドサイズを消費してしまう。これ自体は軽微でも、アクセス頻度が高いと積もっていく。

なので、できる限り、関数や const のまま扱う。この関係で、残念ながら実質的に object への糖衣構文である class による実装が推奨されない。

terser の --mangle-props でメソッド名を minify することができることもあるが、これも結局どの規約を守ると minify できるかを知ってないといけない。

この原則を実践するために、正しく外に向けた公開APIを定義して、それ以外の名前を minify することを試みる。

Object

x.js
const C = {
  HELLO: 1,
  WORLD: 2,
};
console.log(C.HELLO);
$ terser x.js -m --toplevel
const L={HELLO:1,WORLD:2};console.log(L.HELLO);

C.WORLD は未使用だが消えない

Flat

x.js
const C_HELLO = 1;
const C_WORLD = 2;
export function run () {
  console.log(C_HELLO);
}
$ terser x.js -m --toplevel
const o=1;export function run(){console.log(o)}

--mangle-props

---mangle-props オプションはオブジェクトメンバまで圧縮する

const C = {
  HELLO: 1,
  WORLD: 2,
};
export const out = C.HELLO;

module を付けた場合、 export する名前をキープしてくれている。

$ terser x.js -m --toplevel --mangle-props
const o={o:1,t:2};export const out=o.o;

class と一緒に使う場合、内部メンバも変化されるが、 quoted な文字列を代入する場合展開されない。

export class X {
  hello() {
    return 'hello';
  }
  internal() {
  }

  ["quoted"]() {
    return "quoted";
  }
}
$ terser x.js -m --toplevel --mangle-props
export class X{l(){return"hello"}t(){}["quoted"](){return"quoted"}}

reserved で予約名を与えるとそれはキープされる。

export class X {
  hello() {
    this.internal();
    return 'hello';
  }
  internal() {
    console.log('1');
  }

  ["quoted"]() {
    return "quoted";
  }
}
$ terser x.js -m --toplevel --mangle-props reserved=['hello']
export class X{hello(){this.l();return"hello"}l(){console.log("1")}["quoted"](){return"quoted"}}