📦

TS環境のmangle最適化ベストプラクティス

2023/11/23に公開

mangle とは外からアクセスされない変数を 1~2 文字に縮める処理のこと。こういう処理。

// in
const longLongVar = 1;
console.log(longLongVar);
// out
const o = 1;console.log(o);

主に terser や esbuild のポストプロセスとして行われる。

この記事では mangle のベストプラクティスについてまとめる。本当は jsconf.jp で話したかったが、時間がなかった。

例えば vscode(本体)では外にexportされないプライベートメンバを mangle することで大幅なコード量の削減に成功している。

Shrinking VS Code with name mangling

ライブラリ作者やサードパーティスクリプト作者に必要な技術だが、一般的なコードにも適用できる話でもある。何度か自分の発表資料に書いてきたが、単体記事になってないのでここでまとめておく。

極限環境で最終ビルドを絞るためのフロントエンド設計 - Speaker Deck

バンドル最適化マニアクス at tfconf - Speaker Deck

mangle は日本語で対応する言葉がないので、この記事ではそのまま mangle と表記する。

mangle に優しい命名規約の導入

クラス内のプライベートメンバには _*、モジュール内プライベートには $* という規約を導入する。

class Internal {
  private _foo = 1;
  private _func(){
    return this._foo;
  }
  public $modulePub(){ this._func() }
}
export default () => new Internal().$modulePub();

モジュール内プライベートとは、最終的なトップレベル export に関わらないメンバとそのアクセスのこと。
最終ビルドの TypeScript の型定義に現れないメンバ、と言ってもいい。

// src/index.ts
export const obj = {
  // これはライブラリ外からアクセスされるので mangle しない
  foo() {
    /*...*/
  },
  // トップレベルだが型定義から省いた上で mangle する
  /**
   * @internal
   */
  $bar() {
    /*...*/
  }
}

トップレベルの明示的なモジュールプライベートには $~の命名にした上で jsdoc で @internal を付ける。

_$ を明示的にわけてるのは、 foo._bar._baz のようなメンバアクセスがあると、クラスのプライベートメンバを直接アクセスしてるように見えて、お行儀が悪くみえるから。

この規約、ベストプラクティスといいつつ実は自分+VSCode流でN=2ではあるのだが、自然と同じものにたどり着いていて、他に書いてる人もいないので許してほしい。

https://github.com/microsoft/vscode/blob/48cd8e0c1b142a46f0956b593d8331145634658e/build/lib/mangle/index.ts#L369-L373

terser: mangle.properties.regex の設定

この環境で rollup で terser mangle.properties を指定してビルドする。

(後述するが、vite のライブラリモードは色々足りない)

$ npm install -D rollup typescript @rollup/plugin-typescript @rollup/plugin-terser @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup-plugin-output-size

設定例。

rollup.config.mjs
import terser from "@rollup/plugin-terser";
import cjs from "@rollup/plugin-commonjs";
import nodeResolve from "@rollup/plugin-node-resolve";
import replace from '@rollup/plugin-replace';
import typescript from "@rollup/plugin-typescript";
import outputSize, { summarize } from 'rollup-plugin-output-size';
import pkg from "./package.json" assert { type: "json" };

export default {
  input: "./src/index.ts",
  output: [
    {
      file: pkg.exports['.'].import,
      format: "es",
      sourcemap: true,
    },
    {
      file: "./dist/index.cjs",
      file: pkg.exports['.'].require,
      format: "cjs",
      sourcemap: true,
    },
  ],
  plugins: [
    cjs(),
    nodeResolve(),
    replace({
      values: {
        'process.env.NODE_ENV': JSON.stringify('production'),
        'import.meta.env.NODE_ENV': JSON.stringify('production'),
        'import.meta.vitest': JSON.stringify(false),
      },
      preventAssignment: true,
    }),
    typescript({
      declaration: true,
      rootDir: "src",
      declarationDir: "types",
      emitDeclarationOnly: true,
    }),
    terser({
      compress: {
        passes: 5
      },
      mangle: {
        properties: {
          regex: /^(_|\$)/,
        }
      }
    }),
    outputSize({
      summary(summary) {
        console.log(summarize(summary));
      }
    })
  ]
}

長々書いているが、今大事なのは terser の mangle.properties.regex/^(_|\$)/ を指定する部分で、これを満たしたプロパティが mangle される。

ついで TS の設定。

tsconfig.json
{
  "compilerOptions": {
    "target": "es2019",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "importHelpers": true,
    "stripInternal": true,
    "sourceMap": true,
    "preserveConstEnums": false,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

target は自分に必要な水準でいいが、特に理由がない限り 2017 以上(async/awaitを変換しない) を推奨。

最適化されるコードの例

src/index.ts
// TypeScript enum を避ける。
// もしくは const enum を必須にする
const enum CCC {
  AAA,
  BBB
}

// モジュール内パブリックメソッドは $ から始める
// ドキュメント制御用にも @internal を付ける
class Internal {
  /**
   * @internal
   */
  public $moduleInternal() {
    console.log(CCC.AAA);
  }
}

// オブジェクトも同様
const local = {
  $foo() {
    console.log(CCC.BBB);
  }
};
local.$foo();

export class Pub {
  /**
   * @internal
   */
  private _internal = new Internal();
  public pub() {
    this._internal.$moduleInternal();
  };
}

出力結果 rollup -c rollup.config.mjs

class o{o(){console.log(0)}}({l(){console.log(1)}}).l();class s{constructor(){this.t=new o}pub(){this.t.o()}}export{s as Pub};
//# sourceMappingURL=index.mjs.map

実現できていること

  • const enum が展開され、定数としてのみ残る
  • $moduleInternal が mangle される
  • local.$foo が mangle される
  • 最終的なライブラリのインターフェースとして外に出す Pubpub は mangle せずに残す

型定義の出力

dist/types/index.d.ts
declare class Pub {
    pub(): void;
}
export { Pub };

stripInternal によって非公開インターフェースの型定義が取り除かれている。

他の設定: package.json

明示的に sideEffects: falsetype: module を指定する。後はたぶん定形。

package.json
{
  "name": "mymod",
  "version": "0.0.1",
  "description": "todo",
  "sideEffects": false,
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/types/index.d.ts",
  "author": "mizchi",
  "license": "MIT",
  "scripts": {
    "build": "rollup -c rollup.config.mjs",
    "watch": "rollup -c rollup.config.mjs -w"
  },
  "devDependencies": {
    "@rollup/plugin-commonjs": "^25.0.7",
    "@rollup/plugin-node-resolve": "^15.2.3",
    "@rollup/plugin-replace": "^5.0.5",
    "@rollup/plugin-terser": "^0.4.4",
    "@rollup/plugin-typescript": "^11.1.5",
    "rollup": "^4.5.1",
    "rollup-plugin-output-size": "^1.3.0",
    "tslib": "^2.6.2",
    "typescript": "^5.2.2"
  },
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/types/index.d.ts"
    },
    "./package.json": "./package.json",
    "./dist/*": "./dist/*"
  },
  "files": [
    "dist",
    "src"
  ]
}

根拠

なぜこうなったか、一つずつ説明する。

命名規約の導入

モジュール内メソッドという概念を導入することで、モジュール外から export されないメンバを mangle することができるようにしている。

const obj = { $foo(){} };
obj.$foo(); // $foo は外からアクセスしない前提

違和感があるかもしれないが、どうせモジュール外に漏れないインターフェースなので、完全にプロジェクト固有のローカルの規約と考えて運用する。

静的解析でトップレベル以外から参照されてないオブジェクトのプライベート判定するのも理論上可能だが、簡単な規約で代替できるならコスパがよいはずで、それで十分。

(Rust の pub(crate) mod 的なやつがほしい)

今回の設定では Python を参考に _* を対象としているが、前にライブラリの生成する _system みたいな変数を巻き込んでしまって壊してしまったことがあったので、それが不安な場合は __* や別のプレフィックスを考えたほうがいいかもしれない。

本当はハンガリアン記法的なプレフィックスは使いたくないのだが、後述する Hard Private の問題で必要に迫られている。

enum を使わない or const enum を使う

TS の enum は mangle 困難なコードを出力する。

// TS
enum XXX {
  AAA,
  BBB,
  CCC
}

// JS
var XXX;
(function (XXX) {
    XXX[XXX["AAA"] = 0] = "AAA";
    XXX[XXX["BBB"] = 1] = "BBB";
    XXX[XXX["CCC"] = 2] = "CCC";
})(XXX || (XXX = {}));

動的なメンバーアクセスでプロパティを生成すると、構文解析で定数折りたたみができない。

なので、ビルドサイズを気にする際は enum を使わないのが定番だが、文字列の union より enum のがコード上の扱いが簡単で使いたいときがある。

このような時、出力から完全に消える const enum が有用。

// TS
const enum XXX {
  AAA,
  BBB,
  CCC
}
console.log(XXX.AAA);

// JS
console.log(0 /* XXX.AAA */);

ただし、const enum では XXX[XXX.AAA] で元キーを取得することができないし、 オブジェクト実体がないのでモジュールの外に export することができない。

型の解析が伴うので、型情報を持たない esbuild では限定的なケースでしかサポートしていない。

vite(build.lib) ではなく rollup

  • vite のライブラリモード (build.lib を指定した時)は、そもそも minify を行わない。
  • vite のデフォルトの esbuild は同ファイル内(isolatedModules)でないと const enum の展開ができない

中間ライブラリを minify するべきかは議論がわかれるところだが、自分はこの時点で最適化できるものはしてしまいたい。というのも、プロジェクトをまたぐ mangle は全プロジェクトで設定をすり合わせないと機能しなくなるため。 const enum も同じ。

vite でライブラリモードでない時に const enum が不要なら次の設定が使える

export default defineConfig() {
  // ...
  esbuild: {
    mangleProps: /^(_|\$)/,
  },
}

傾向として、esbuild --minify は高速でほとんどのケースで十分だが、 terser の方が出力サイズは小さいことが多い。
中間ライブラリやサードパーティスクリプトはそもそも出力が大きくないはずなので、 terser で困ることはほぼない。

ES2022: Hard Private がまだ使いづらい

TypeScript の private は TS の型が落ちた段階で他のパブリックメンバと区別できなくなる。
これによってメンバアクセスのために private な識別子が保持されてしまう。

本来にして Hard Private (#~) で明示的に minify したいところなのだが、 少しでも出力を削りたい環境では、 Hard Private 用の Down Transpile の出力が大きい。 tslib のポリフィルを使ってもまだが大きい。

出力例

var e;"function"==typeof SuppressedError&&SuppressedError;e=new WeakMap,console.log(new class{constructor(){e.set(this,void 0),function(e,r,t,o,s){if("m"===o)throw new TypeError("Private method is not writable");if("a"===o&&!s)throw new TypeError("Private accessor was defined without a setter");if("function"==typeof r?e!==r||!s:!r.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");"a"===o?s.call(e,t):s?s.value=t:r.set(e,t)}(this,e,1,"f")}});class r{constructor(){this.t=new r}o(){console.log(0)}}class t{constructor(){this.t=new r}publicMethod(){this.t.o()}}(new t).publicMethod();export{t as XXX};

セマンティクスを再現するために、 Polyfill + WeakMap の get/set に書き換えられる。
小規模だとポリフィル自体のサイズの方が支配的になる。

とはいえ、 1kb 超えてるような環境なら誤差かもしれない。

本当に最新環境のみターゲットでネイティブの Hard Private が使える環境なら "target": "es2022" に指定する。 terser も Hard Private Symbol の mangle には対応している。

MANGLE_PROP での誤検知

terser に @__MANGLE_PROP__ というオプションがある。これは明示的に次に登場する識別子を mangle 対象にする。
じゃあ、これでいいじゃん、と思うかもだが、これはグローバルなキーとして登録しているため、最終的なパブリックメンバに同名のキーがあるとそれも巻き込んでしまう。

class Internal {
  /**
   * @__MANGLE_PROP__
   */
  public xxxx() {
    console.log(CCC.AAA);
  }
}

export class Pub {
  // これは mangle したくないが同名のキーに引きずられる
  public xxxx() {
    return new Internal().xxxx();
  }
}

__MANGLE_PROP__ を本当に最終エクスポートで使っていないか確認するのが面倒なので、命名規約で解決するほうが確実。

確認: 符号化を前提にしても mangle の効果はあるか

mangle ってそもそも gzip 圧縮時の符号化であんまり生きてないんじゃない?と思ったので一応簡単なケースでテスト。

// a.js
const obj = {
  _aueoatsnidoacrsideoarscudeoacruda() {
  }
}
obj._aueoatsnidoacrsideoarscudeoacruda();
obj._aueoatsnidoacrsideoarscudeoacruda();
obj._aueoatsnidoacrsideoarscudeoacruda();
obj._aueoatsnidoacrsideoarscudeoacruda();
obj._aueoatsnidoacrsideoarscudeoacruda();
obj._aueoatsnidoacrsideoarscudeoacruda();

// mangle した場合:
// const obj={o(){}};obj.o();obj.o();obj.o();obj.o();obj.o();obj.o();
  • mangle off: 89bytes (terser a.js)
  • mangle on: 65bytes (terser --mangle-props regex=/^_/ a.js)

わざとらしいコードだが、一応符号化しても利点は残っている。
自分の経験的にも、ちゃんと設定すると最終出力が1~2割ぐらいは減らせる肌感はある。

tsconfig.json の設定

大事なのは、 target で es2017 以降を指定すること。 とくに async/await の変換をやめるだけでコード量が大幅に減って、スタックトレースも読みやすくなる。

importHelpers: true を指定することで tslib を使ってポリフィル部分を再利用可してコードを減らせるが、 es2017以降では大きく効くケースが少ないように思う。

stripInternal と @internal コメント

あまり有名ではない機能だが、 .d.ts の出力から @internal コメントがついたものを取り除く。

TypeScript: TSConfig Reference - Docs on every TSConfig option

typescript 本体で使われている。

おわり

  • 簡単な規約の導入で段階的に mangle 最適化ができる(はず)
  • 頑張れば 1~2割減らせる
  • マニュアル運用はできるが、このルールのための既存の lint が足りてない
  • そもそも treeshake を意識したコードを書くのが重要で、 class や enum は使わない方がいい
    • const enum は例外

Discussion