ES2015+を書きつつ、Tree Shakingも有効化して、ES2015のmoduleも使う


免責・補足

※ここで書く内容は、Webpackのコアに関わる部分なので、数ヶ月以内には間違いなく改善されます。最終更新日時とコメント欄も参照してください。

※2018/01/29 追記:ここで提示したmoduleの問題のうち、「副作用が含まれているとTree Shakingが効かずに全部バンドルするために重くなる問題」のみ、 Webpack 4 の本家メジャーバージョンアップで解決しました https://medium.com/webpack/webpack-4-beta-try-it-today-6b1d27d7d7e2

※ここで紹介するWebpackのフレームワークとしてのアーキテクチャはv3以降ですが、一部の仕様はv4で大幅に変更されています。

結論

Webpackで作ったライブラリを、Webpackに依存しないES2015のmoduleとして読み込めるようにすれば実現できる。

Webpackの公式がまだ実装していない機能なので、不可能。

解決策(現実的な解法)

.babelrc をルートに置くか package.json で正しく指定する。自分のライブラリのユーザがそれを正しく参照してライブラリごとリビルドしてくれることを神に祈る。

結果的に最適化周りが1つ残らず全て正しく設定できている場合のみ (詳細はこの記事で解説します)、Tree ShakingとES2015のネイティブなmoduleが両立した状態でバンドルがビルド出来る。


これをやりたい背景

まず Webpack でライブラリを作るときの最高のマニュアルが公式にあるのでこれを読んでください。

極限まで Tree Shaking したい

Webpack (v2以降) や Rollup には Tree Shaking という機能があり、コードを静的解析した上で参照されていないコードを削ってバンドルサイズを小さくしてくれる。

これをきちんとやらないと、1つのWebアプリで読み込む1つのjsが2MBとかいう地獄になる。これを削減するのは、古典的ないわゆるuglifyの範疇では出来ない最適化なので、バンドラを使う最大の理由の1つ。

バンドラ固有のモジュールローダーが含まれていると Tree Shaking が効かない

別のバンドラが生成した ES2015 互換のコードには、たとえそのコードが最終的に ES2015 互換であっても、そのバンドラ固有のモジュールローディングルーチンが含まれていることがある。

つまり、Webpackでいうところの __webpack_require__ が非互換を生み出す。

(※Webpackのような思想だと含まれる。Rollupには含まれない)

このような、「バンドラ固有のモジュールローダー」が生成されたスクリプトに1つでも含まれていると、そのスクリプトの依存先ではTree Shakingが一切効かない(ファイルサイズが肥大化するのを原理上避けられない)。

ユーザが最終的にどのバンドラを使うかは強制できない

つまり、アプリケーションの膨大な依存グラフの中で、1つでも別のバンドラを使って生成されたライブラリがあれば、その時点で Tree Shaking のような最適化は阻害される。

ただし、ライブラリを作る場合に、最大公約数レベル(つまりES2015で、なおかつmoduleとして認識可能なもの。moduleもトランスパイルされていると無理)として頒布すれば、Tree Shakingは自前で有効化できる(というか、そうするしか解法が無い)。

Webpackが生成するコードには前述のローダーが含まれているので、Webpackを混ぜた時点で、Tree Shakingは死ぬ。


そもそもJavaScriptのバンドラシステムって何

今時のJavaScriptの人気のモジュール(バンドル)システムは2つある。1つがこの記事のメインとなるWebpack 、2つ目がRollup

両者の違いについては webpackとRollup:似て非なるもの | プログラミング | POSTD で詳しく解説されているが、この記事でも少し説明する。

★ Webpackとは

Webpackは、要するに「全部入り」のバンドラ。

WebpackにあってRollupに無い目玉機能

  • 必要なモジュールだけを動的にリロードすることで開発効率を上げる Hot Module Replacement (本番ではなく開発環境のみで使うことが奨励)
  • アセット(画像などのjsじゃない物)をjsから読み込んで使う機能(依存関係は全て静的に正しく解析される)
  • バンドルレベルの最終生成コード分割。依存ライブラリをバンドルレベルで切り出したり、ユーザが同じ依存ライブラリを使っている場合はそれの読み込みをユーザの方に丸投げしたりできる。

WebpackがRollupと比べてダメな点

  • 最終的に読み込むモジュールが50万個を超えたあたりからロード時間が極端に遅くなる
  • Tree Shakingは後付けで実装したのでまだ雑(前章の理由で阻害される)
  • この記事で説明している以上に設定ファイルの書き方が闇

★ Rollup とは

最近のモダンかつ軽量かつメジャーなライブラリがだいたい採用してるのがこっち。

RollupにあってWebpackに無い目玉機能

  • 特に無い。

RollupがWebpackより良い点

  • なるべくES2015のネイティブを尊重する。
  • 不必要なモジュールローディングルーチンを挿入しないので、結果として速いし、Tree Shakingも阻害しない
  • 思想的にWebpackより高速化の余地がある

RollupがWebpackと比べてダメな点

  • Webpackに入っている目玉機能が入っていない
  • js以外の依存関係は一切解決しないし、解釈できない
  • バンドルの分割はできないので、依存ライブラリがでかいと自分もでかくなる。肥大化を防ぐためには、実行環境がES2015のmoduleシステムを ネイティブで サポートしていることを期待するしかない
  • Webpackのように痒い所に手が届くプラグイン類が少ない

★ つまりWebpackとRollupのどちらが優れているか

  • 好み。
  • 有名なライブラリがどっちを使っているからどっちを使うという話ではない。
  • 両者には前述したような明確な思想の違いがあり、どちらの方が優れているからどちらが淘汰されて消えるとかいう話ではない。
  • SPA(シングルページアプリケーション、単一Webページ単一スクリプトで提供されるでかいサービスのこと)にはWebpackが便利
  • Webpackの目玉機能が必要無いような小さいライブラリや、綺麗にコンポーネントが分割されているライブラリ、レガシー環境の事を一切考えないアプリを作る時は、Rollupの方がスマート
  • ただし、今書いたことと逆の選択をしている例も、もちろんたくさんある

★バンドラのパフォーマンスについて

前章で書いたパフォーマンスの問題というのは、「ブラウザがmoduleをロードするパフォーマンス」の話なので、 アプリケーションの読み込み速度 の話。

一方、 アプリケーション自体のパフォーマンス は、バンドラの範疇ではなく、むしろ core-js と Polyfill のスペック、つまり babel-runtime とか babel-polyfill の選択にかかってくる。(蛇足だが、ライブラリなら transform-runtime 一択、アプリなら Polyfill 一択)

一方、バンドラ自体のパフォーマンス 、つまり 開発効率 は、バンドル工程のキャッシュを正しく設定して、不必要なものをビルドしない設定 (node_modules/* を exclude とか) することでかなり良くなる。

また、 開発効率 に関しては、自分の体感では Hot Module Replacement があるか無いかでだいぶ違う。


これからのES2015+なコードを書くときの大前提

すでに背景がだいぶ長くなっているが、もう少し踏み込んだ話を書きたい。

前提1. モジュールの記法は全てES2015の importexportimport AAA from 'aaa' など)を使う

ただし、実際にこれがネイティブのES2015として解釈されるとは限らない。普通は、WebpackやRollupなどのバンドラがこの記法を見つけた時にしっかり変換してくれる。そしてこの変換こそが真に我々が求めているものであって、これに従うことでバンドラの各種機能の真価が発揮される。

CommonJSやAMDなどのモジュール機能(const AAA = require('aaa')module.exports = ...など)は絶対に使わない。これは仕様レベルで根本的に互換性が無く、静的解析や最適化を阻害する上に、バンドラの設定によってはそもそもエラーとして処理されて書けないようにされていることもある(これの実例はこの文章の末尾に書いておく)。

ただし、 require(...) 構文には1つだけ例外がある。webpack.config.js のような設定ファイルの中ではこちらを使うのが正しい。何故なら、Webpackの設定ファイルはアプリケーションの文脈とは別物で、ここでは(まだ)ES2015相当の構文しか解釈されない。ただ、将来的な改善(やプラグインの利用)でもっとモダンな書き方ができるようになる可能性はある。

前提2. babel のプリセットでは "modules": false を入れておく

これを設定しないと、babelのトランスパイラの段階でモジュール周りが変換されてしまうので、そもそもWebpackレベルに到達する頃にはTree Shakingが不可能になる。(ここは改善されたという話をどこかで見た記憶があるけど忘れた)

前提3. トランスパイラをまたぐキャッシュ処理は全て明示的にオフにする

トランスパイラが節操無くくっついているせいで混乱してきたので、少し整理する。

-【レイヤー1】---------------------------------------
自分が書きたいレベルのjs
[★ここでは babel-env とか ES2017+ 相当]
----------------------------------------------------
  ↓
  ↓
-【レイヤー3】---------------------------------------
Webpackの `babel-loader`。

レイヤー2の前にレイヤー3が来ているが、これはtypoではない。

Webpackが、webpack.config.jsに書かれている設定に従って、
ソースコード自身のローダー(つまり Webpack の `babel-loader`)
を呼び出した結果が今のここ。

つまり、この段階でWebpackが知っているのは、
 「なんか .js とかいうファイル来たんだけど」
 「何書いてあるのか意味不明だし、とりあえず設定に従って babel に投げて翻訳頼むわ」
ということ。
----------------------------------------------------
  ↓
  ↓
[★ここでは何も変換が働いていないので、jsのレベルは前と同じ]
  ↓
  ↓
-【レイヤー2】---------------------------------------
babel のトランスパイラ。
これは普通にbabelの本体そのもの。
つまり、新し目のESを受け取って、ES2015相当を返す。

> ただし、前述の "前提2" の設定のおかげで、
> レガシー環境向けに通常変換されるはずの module は変換しない。
----------------------------------------------------
  ↓
  ↓
[★ここで ES2015相当、ただし module はネイティブ]
  ↓
  ↓
-【レイヤー3】---------------------------------------
Webpackの各種 loader。

つまり、jsのソース内で `require('画像ファイル')` などをしている場合の、
アセット関係のローダー (`style-loader` など) 全て。

> jsからアセットを読み込めるのは、言わずもがなWebpackの目玉機能。
> Rollupのようなものでは不可能。
> この段階で初めて、Webpackが全ての依存関係を構文レベルで正しく認識する(※重要)。
> Webpackが全てのソースのグラフ構造を静的に解析して、モジュールシステムを構築する。

Webpackが最終出力をする。
----------------------------------------------------
  ↓
  ↓
[
 ★ここで必ずES2015相当。
  ES2015相当にならないと、
  ブラウザが違ったり、ライブラリユーザのESレベルが違った時に、
  解釈する方法が無い。

  ただし、Tree Shakingのサポートはあるかもしれないし、無いかもしれない。
  これはこの文章の前の章で書いた理由によるもので、
  Rollupのようにお行儀の良いネイティブmoduleサポートをしたバンドラじゃないと不可能。

  つまり、Webpackではこの段階でTree Shakingが死ぬ。
]
 ↓
 ↓
-【レイヤー4】---------------------------------------
生成された .js ファイル。
----------------------------------------------------

ものすごく長くなったが、多分これ以上省略すると逆に理解不能になる……。

つまり、このレイヤーのどれかをまたぐようなキャッシュが1つでも不適切に設定されていると、その時点でバンドラ自体のパフォーマンスが著しく悪化する可能性や、静的解析を放棄する可能性、最適化が阻害される可能性が高まる。

Webpackのバンドル工程でキャッシュをきちんと設定するとリビルド速度がかなり上がるのは事実だが、一方で、 babel-loader のキャッシュ設定が不適切などの理由で Tree Shaking が死んだりする事例は色々な人がハマっているようなので要注意。

また蛇足になりますが、ビルド速度を上げる目的で中間ファイルをメモリ上に置いたりする行為はほぼ意味が無いのでやめましょう。

上の図を読んで理解していれば、もうWebpackとRollupの違いとか含めてバンドラとかモジュールシステムについて理解していると言ってもいいはず。

本題: 今の Webpack で ES2015+ を書きつつ、Tree Shaking も有効化して、ES2015 のmodule も使うには

この記事のタイトルを覚えているだろうか?(やっと本題です)

Webpack の Owner であるところの sokra は、

don't bundle. Point the "module" field to a tree of files. The user of your lib will bundle it.

意訳:

「バンドルすんなよ。 package.json の "module" を ES2015 相当のソースツリーのルートに指定しておけば、あとはあんたのライブラリのユーザが勝手にバンドルするから」

と言っていて、これは要するに下の方法Aにあたる。

  • 方法A: 自分のライブラリを使うユーザが、自分のライブラリに固有の babel とかのオプションを完全に同一なものに設定した上で、 ユーザの方で正しくトランスパイルし直して読み込んでくれることを神に祈る
  • 方法B: module とか全てを放棄して 完全に ES2015化 されたファイルを頒布する(もちろん Tree Shaking も放棄する)

※参照: https://github.com/webpack/webpack/issues/1979#issuecomment-273876820

それで、この方法Aをやるためには、最初に書いたように .babelrc をルートに置くか package.json で正しく指定する。それで、自分のライブラリのユーザがそれを正しく参照してライブラリごとリビルドしてくれることを神に祈る。

最初は以下のような回りくどいコードをわざわざ書いていたんだけど、

module: {
  rules: [
    {
      test: /^node_modules\/my-library\/src\/.*\.js$/,
      use: [
        {
          loader: 'babel-loader',

          // この設定ファイルだけ 生ES2015
          options: require('my-library/babel-options'),
        },
      ],
    },
  ],
},

これはそもそも .babelrc を正しく指定していれば Webpack が自動で見てくれるようなので、こんな書き方をする必要は無い。結局これは非標準なことを忖度しているだけなので、手法としてもあまり良くない。

以下みたいな指定でいい。

{
  test: /\.js$/,
  use: [
    {
      loader: 'babel-loader',
    },
  ],
  include: [
    path.resolve(__dirname, 'js'),
    path.resolve(__dirname, 'node_modules', 'my-library'),
  ],
},

ただし 様々な事情によって、これが不可能な時がある。例えば、

  • サードパーティのライブラリがbabelrcをどこにも置いてない
  • 非標準の場所に設定ファイルがある時に package.json の中で .babelrc を正しく参照していない
  • そもそも設定段階で動的に babel の設定を生成しているせいで .babelrc 相当のファイル(つまり JSON5)に吐き出すこと自体が不可能
  • バンドラのプラグイン (つまり Webpack でいうところの babel-loader )の設定で babelの設定をしているせいで .babelrc に babel の設定を抜き出すという発想自体が無い

自分は色々な条件が重なってヤバいbabelrcを一部で使っていたのでこれに気づくのが遅れた。.babelrc は必要最低限の静的な設定に留めて、きちんと標準の方法で指定しておきましょう(教訓)。babel-loader みたいなやつにインラインで書くのは完全に闇。この世の全ての babelrc: false を消し去りたい。

で、この方法があまりにも理想論なので、Webpack自体がWebpack固有のモジュールローダーに依存しないES2015ネイティブなjsを吐けるようにしたい、というのが webpack/webpack#2933 の内容。

ここで書いたような .babelrc の設定の闇みたいなことが #2933 には一切書かれていないので背景が迷子だけど、#2933 が実装されれば .babelrc を正しく指定することはますます重要になる。

ただし、 Webpack の目玉機能を利用したキモいjsを書いていると、モジュールローダーは原理的に消せない。例えば、

  • js以外の import / require
  • Webpackの context に依存した動的ロード
  • ContextReplacementPlugin のように context を更に深くハックした暗黙のロード
  • CommonsChunkPlugin のような Webpack 特有のバンドルコード分離/動的ロード

これはそもそもWebpackの機能であって、 ES2015 とか JavaScript とかブラウザの機能ではないし、標準化もされていないわけで、これらを使った時点で ES2015ネイティブなmoduleシステムを尊重するような最終生成ファイルについては諦めるしかない。

なので、自分は個人的には webpack/webpack#2933 はリジェクトされるんじゃないかと思っている。


おわり

自分は業務でWebpackやRollupを使ったことは無いので、業務での事例などがあればコメント欄に書いてください。知りたいです。

補足: Tree Shaking の設定方法

WebpackのTree Shakingは、実際には 不要なコードをマーキングするだけ です。最終的なバンドルのファイルサイズの削減は、バンドラ固有のUglifyが走った段階で初めて働きます。

つまり、 module 周りの仕様を正しく理解して設定していても、production の時には追加で以下の設定は必要です。

js: {
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
  ],
},

要するに、これは uglify という名前を聞いて普通イメージするような古典的な単なるuglifyではなく、バンドラのTree Shakingまで含めてminifyしてくれるいわば強化版のUglifyです。

補足2: require(...) がバンドラによって強制的にエラーにされる例

let userInputText = 'wombat'
const themeFileConfig = userInputText + '-config.js'
this.currentTheme = require(themeFileConfig)

上記の例のように、 require の中身が変数だったりする場合。

これは、野良のライブラリだとたまにあるが、Webpackなどの文脈ではこのように動的に require した時点で コンテキスト (Webpackにおける固有名詞) が変わるため、静的解析を放棄せざるをえない。

そのため、Webpackはこれを見つけた時点でエラーとして処理する。ただ、これを分かった上でエラーにせずに動的なrequireを許す設定方法も、一応あることはある。