Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

CommonJS と ESModules が混在している環境で、lodash を lodash-es に置き換え、バンドルサイズを減らす

こんにちは、21卒エンジニアの id:d-kimuson です。

先日、プロダクトで使用している lodash を lodash-es に置き換えることで、バンドルサイズの削減をしました。 lodash を lodash-es に置き換える話はよくありますが、今回のプロダクトは運用歴が長く CommonJS と ESModules が混在している少し特殊な環境での試みだったので、知見を共有したいと思います。

利用されていないコードを消し、バンドルサイズを減らす

lodash はバンドルサイズの大きなライブラリです。minified な状態で 69.9KB のサイズになります。

参考: lodash v4.17.21 ❘ Bundlephobia

webpack にはデッドコードを削除する TreeShaking という機能があり、ライブラリのデッドコードを削除することができるのでバンドルサイズの削減に効果的です。 しかし、lodash は CommonJS で記述されていて、TreeShaking の効果を得にくいので ESModules で書かれている lodash-es を使用することで、効果的にバンドルサイズの削減をすることができます。

長いので以降 CommonJS は CJS, ESModules は ESM と記します。

lodash から lodash-esに TreeShaking が効くように置き換える

このプロダクトでは、CJS で記述されたモジュールと ESM で記述されているモジュールが混在しているので、個別に置き換えていきます。

ESModules で記述されているモジュールを置き換える

ESM の置き換えはシンプルです。

  • lodash-es を使うようにする
  • 全体ではなく、使う関数のみ個別でインポートする

形に変更します。

-import _ from 'lodash';
+import { defaults } from 'lodash-es';

// ...
-const obj = _.defaults(obj1, obj2);
+const obj = defaults(obj1, obj2);

あとは webpack が自動的にデッドコード(defaults 以外) を削除してくれます。

CommonJSで記述されているモジュールは、CommonJS のまま TreeShaking が効くように置き換える

TreeShaking が効く条件は

  • webpack が TreeShaking に対応していること
    • webpack4: ESM のみ
    • webpack5: ESM, CJS 両対応 (効果は ESM のほうが大きい)
  • 使用する値のみ import していること

です。 このプロダクトでは CJS のコードも多く残っていますが、webpack5 を利用しているので「CJS のままでも TreeShaking の効果を得られるが、ESM にしてから置き換えるとより効果が大きい」という状態でした。

ですので

  1. CommonJS のまま置き換える (楽だけど効果は小さい)
  2. ESM に置き換えつつ、lodash-es に置き換える (大変だけど効果が大きい)

の選択肢があります。

詳しくは触れませんが、webpack 環境で ESM に置き換えるときには、default export の場合、同時にそのモジュールを参照している他のモジュールも ESM に置き換える、あるいは require 文を書き換えなくてはいけないという制約があり、2の内容で修正すると変更範囲が大きくなってしまうという懸念点がありました。

したがって、CJS で記述されている場合には

  • 置き換えファイルを参照しているファイルに CJS で記述されているものが存在するか
  • default export に置き換える必要があるか

を確認して、変更範囲がそのファイルのみに閉じている一部のものは ESM に置き換えますが、それ以外の大部分のソースコードでは CJS のまま置き開ける方針を取ることにしました。

変更範囲がファイル内に閉じているものは以下のように ESModules にしつつ置き換えます。

// 参照元がCJSでかつdefault_export
-const _ = require('lodash');
+const { map } = require('lodash-es');

-const obj = _.defaults(obj1, obj2);
+const obj = defaults(obj1, obj2);

変更範囲がファイル外に及ぶものは、ESModules には置き換えず、CJS のまま TreeShaking だけ効くように個別の値をインポートします。(※後述しますが、この方法ではTreeShakingが効かないので、別の方法を取る必要があります)

// それ以外
-const _ = require('lodash');
+import { map } from 'lodash-es';

-const obj = _.defaults(obj1, obj2);
+const obj = defaults(obj1, obj2);

これで CJS でも置き換えが完了しました。

CJS から lodash-es を読むとむしろバンドルサイズが増える問題に遭遇

上記の指針で置き換えを完了したのですが、いざバンドルサイズを計測してみるとむしろバンドルサイズが増えていることがわかりました。

エントリ 元サイズ 置き換え後のサイズ
entry1 493.95KB 534KB
entry2 369.18KB 408.39KB
entry3 362.57KB 401.97KB

バンドルサイズが増えてしまっては置き換えた意味がないので、原因を調査しました

原因①: ESModules を CommonJS で利用するための変換コードが増えてしまった

バンドルサイズを個別に見てみると、lodash のサイズが増えてしまっていることが分かったので、詳細な原因を探すために、以下のパターンでそれぞれ lodash 関係のバンドルサイズを計測してみました。

  • lodash を CJS で全体/個別 に読む
  • lodash を ESM で全体/個別 に読む
  • lodash-es を ESM で全体/個別を読む
  • lodash-es を ESM で全体/個別に読む

以下が調査結果です。

ライブラリ 呼び出し 全体サイズ tree-shaking
lodash CJS 69.2 KiB X
lodash ESM 69.4 KiB X
lodash-es CJS 148 KiB O (24.2 KiB)
lodash-es ESM 81.1 KiB O (7.19 KiB)

この結果から「lodash-es を CJS で依存解決しても TreeShaking でデッドコードを削除することができるが、総バンドルサイズが増えてしまうので一定数読んだ時点で lodash 時よりバンドルサイズが増えてしまう」ということがわかりました。

ただ、lodash-es を CJS で依存解決すると総バンドルサイズが増える原因はよく分からなかったので、実際に ESM, CJS で記述されたモジュールをそれぞれ ESM, CJS で依存解決するときの webpack のバンドルがどういう形になるのかを確認してみました。

webpack では概ね以下のようなコードにバンドルされています。

※そのままでは読みにくいので、読みやすいようにかなり簡素化・改変しています。

;(() => {
  var moduleMap = {
    100: (initilizedModule) => {
      initilizedModule.exports = "export text"
    }
  },
  moduleCache = {}
  function require(id) {
    if (id in moduleCachle) return moduelCache[id];
    const mod = moduelCache[id] = { exports: {} }
    moduleMap[id](mod)
    return mod
  }
  (() => {
    // エントリーポイント
    const resolved = require(100)
  })()
})()

ESM だとエントリーポイント箇所に直接展開されますが、CJS や、CJS のモジュールを ESM から読んだり、ESM のモジュールを CJS から読むときには moduleMap にモジュール定義が書かれ、それをエントリーポイントから require を使って解決するようです。したがって、require や moduleMap 部分が ESM のみの場合と比べて余分なコードになります。

また、ESM で書かれたモジュールを CJS で読むときには、モジュール定義箇所で default export に改変するような処理が追加で必要になるため、以下のようにコード量が増えます。

 var moduleMap = {
-    100: (initilizedModule) => {
+    100: (initilizedModule, _exports, require) => {
+      "use strict"
-      initializedModule.exports = "export text"
+      require.r(_exports), require.merge(_exports, { default: () => t })
+      const t = "export text"
     },
   },

この辺りがサイズ増加の原因になって増えているようです。実際のバンドルでは変数名は1文字なので use strict も比較的コード量が増える部分です。特に lodash-es の場合は、関数ごとにファイルが分かれているので、関数ごとに上記の余分なコードが生成されるため影響が大きくなっていると思われます。

全体のバンドルサイズが増える問題については、CJS から依存解決する以上必要なコードが増えているだけなので、CJS を使うのであれば避けようがないサイズの増加であることが分かります。

原因②: そもそも TreeShaking が効いていなかった

また、原因①の調査をしていて分かったのですが、そもそも今回の書き換えのように

const { findIndex } = require('lodash-es')

の書き方だと、TreeShaking が働かないことも分かりました。 webpack では上記のコードを以下のように展開します。

;(() => {
  var moduleMap = {
    100: (initilizedModule) => {  // lodash-es
      // ここに全 lodash-es のバンドルが書き出される
    }
  },
  // ... 省略
  (() => {
    // エントリーポイント
    const { findIndex } = require(100)
  })()
})()

見てわかるように全てソースコードがバンドルに含まれてしまっています。

TreeShaking が効く書き方に直して、バンドルサイズを減らす

原因①については、将来的に ESM への置き換えが終われば解消しますが、CJS の状態では依存解決のための必要なコードが増えているだけなので許容するしかありません。

一方、原因②については以下のような書き方ならきちんと TreeShaking が働いて使われている箇所だけバンドルしてくれます。

// その1: require からメソッドチェーンで関数を呼ぶ
require('lodash-es').findIndex()

また、TreeShaking ではありませんが個別のモジュールを指定して依存解決することで使用する関数のみバンドルに含めることもできます。

デッドコードさえ削除できれば良いので、こちらの方法でも原因②は解消できます。

// その2: 使用する関数を指定して import する
const { default: findIndex } = require('lodash-es/findIndex');
findIndex()

その1の書き方では、使用箇所に require を書く必要があることに対して、ESModules は基本的にモジュールの先頭でまとめて依存解決を書くので、将来的に ESM を置き換えることを考えてより近いその2の書き方で置き換えることにしました。

バンドルサイズを削減することができた

CJS で個別に import するようにしたことでやや可読性が悪くなりましたが、これでプロジェクトの lodash を全て置き換えることができましたので、再びバンドルサイズを計測しました。

エントリ 元サイズ 置き換え後のサイズ 割合
entry1 493.95KB 459.74KB -6.9%
entry2 369.18KB 327.9KB -11.2%
entry3 362.57KB 325.01KB -10.4%

バンドルサイズを 6-11% 程度削減できていることが分かります。

今回のプロジェクトでは無事削減することができましたが、lodash-es の総バンドルサイズが増える原因①については解消していないので、lodash-es の関数の使用量が一定量を超えるとむしろバンドルサイズが増えてしまうと推測されます。実際にバンドルサイズがどうなったか計測することをおすすめします。

また、このプロジェクトではフロントエンドエコシステムのアップデートに取り組んでいて、その中で CommonJS で記述されたモジュールを ESM に置き換えようとしています。置き換えが完了すれば lodash-es の総バンドルサイズが増える問題も解消されるので、さらにバンドルサイズの削減が期待できます。

おまけ

基本的には、この記事で書いた形で置き換えていけば良かったのですが一部特殊対応が必要だったので紹介します。

wrapper 記法を置き換える

lodash には wrapper 記法という関数をメソッドチェーンで呼ぶことができる書き方があり、一部でこの記法が使われていました。

import _ from 'lodash'

const arr1 = _.map([1, 2, 3, 4, 5])                    // 基本的な書き方
const arr2 = _([1, 2, 3, 4, 5]).map((num) => num * 2)  // wrapper 記法

メソッドチェーンで書かれているので、一意な置き換えができませんでしたが数が多くなかったので手動で書き直すことにしました。

chain を利用しない

lodash に存在する chain 関数が lodash-es には存在しませんでした。

How to use chain with lodash-es while supports tree shaking? · Issue #3298 · lodash/lodash · GitHub

上記の Issue に書かれているように TreeShaking をサポートできないため追加してない様でした。

こちらも1箇所しか使われていなかったのと、無駄な処理が多く書かれていたことから、lodash を使わずに書き換える形になりました。

まとめ

今回は ESM と CJS が共存する環境下で lodash-es を置き換えることでバンドルサイズを削減する知見を共有しました。 バンドルサイズが大きいので lodash-es 使うようにしてサクッとサイズを減らしたいくらいの温度感だったのですが、実際やってみると CJS と ESM 周りで詰まることがあり、webpack で CJS と ESM 周りがどう依存解決するのか、webpack がどういう形でコードをバンドルするのか等学びが多かったです。

CJS 環境下では

  • webpack5 を利用している
  • lodash から使用している関数の量が一定以下

という条件付きではありますが、lodash-es に置き換え、TreeShaking が使えるようにすることでバンドルサイズを削減できることが分かりました。