徒然技術日記

Object.prototype.__noSuchMethod__

Differential Serving の実装に module-nomodule pattern を使うべきではない

タイトルはやや誇張気味ですが,最近某所で differential serving を導入しようとして考えたことのまとめです.

なお,differential serving の概念には CSS や画像なども含まれることがありますし,実現方法にも polyfill.io などの外部ソリューションが存在しますが,本稿では JS の bundle を自前でなんとかする場合に絞って議論することをあらかじめお断りしておきます.

TL;DR

  • module-nomodule pattern は differential serving を始める一歩としてはよいが,長期的には負債になる可能性がある
  • きちんとやるなら UA sniffing なり feature detection なりで分岐させるべき

Differential Serving とは

そもそも differential serving とは何かについて簡単に説明します.知っている方は読み飛ばして大丈夫です.

Differential serving とは Progressive Transpilation, Smart Bundling などとも呼ばれるテクニックで,ES2015+ を解釈できるブラウザと解釈できないブラウザとで配信する JS を変えるというものです.

2016年,@babel/preset-env の登場によって我々はターゲットブラウザを指定して柔軟にかつ自動的にトランスパイルの度合いを変更できるようになりました.

しかしながら @babel/preset-env は spec のサポートが最も貧弱なブラウザに合わせてトランスパイルを行うため,サポート環境に Internet Explorer のような古いブラウザが含まれているといくら Firefox/Chrome/Safari が最新の spec を実装しても最終的に ES5 のコードへ変換されてしまう状況が続いてしまいます.

すると本来は不必要なトランスパイルによりコードサイズが増えたり ES2015+ に合わせた JS エンジン側の最適化の恩恵が受けられなかったりなどの問題が発生し, @babel/preset-env の smart さを充分に発揮できなくなってしまいます.

そこで考えられたのが なんとかして ES2015+ のコードが実行できる環境なのかどうかを判別し,使えるブラウザには最低限のトランスパイルだけしたバンドルを配信すればいいのでは? というアイデアです.

これにより Netflix では 20-40%, Google のエンジニア Philip Walton の実験では minified & gzipped で 50% 以上もバンドルサイズを削減できたと報告され,注目が集まりました.

Module-nomodule Pattern とは

Differential serving を実装する方法はいくつかありますが,その中でも "module-nomodule pattern" と呼ばれている手法は Deploying ES2015+ Code in Production Today — Philip Walton にて紹介されたもので,その実装の簡単さから広まりつつあります.

そもそも differential serving の実現には次の二つの課題を解決する必要があります.

  1. ES2015+ が実行できるかどうかをどう判別するか
  2. 判別した結果に応じて異なるバンドルをどう配信するか

Module-nomodule pattern では ESModulesサポートしているブラウザと主要な ES2015+ の構文をサポートしているブラウザがほとんど被っていることに注目し,<script type="module"><script nomodule> を使って a., b. の両方の問題を同時に解決します.

上記二つの script 要素があった場合,ESModules のサポート状況によってブラウザは排他的にどちらかを読み込みます.例えば

<script type="module" src="modern.mjs" />
<script nomodule src="legacy.js" />

という二つの要素があると,読み込まれるスクリプトは以下のようになります.

ESModules のサポート modern.mjs legacy.js
🙆‍♂️ 読み込む 読み込まない
🙅‍♂️ 読み込まない 読み込む

ここで,ES2015+ のサポート状況を合わせるとこうなります.

ES2015+ のサポート ESModules のサポート modern.mjs legacy.js
🙆‍♂️ 🙆‍♂️ 読み込む 読み込まない
🙅‍♂️ 🙅‍♂️ 読み込まない 読み込む

つまり,判別問題と配信問題を一挙に解決できるのです.

あとは modern.mjs を「ESModules をサポートしているブラウザが理解できる構文だけになるようトランスパイルする」ように設定して生成するだけです.幸いなことに @babel/preset-env の targets にはこのための専用の設定である targets.esmodules が実装されているので,設定を true にするだけでよくブラウザを列挙する手間はかかりません.また上で説明した ESModules 読み込みの排他制御部分にバグがあるブラウザが若干存在するのですが,それを自動的に解決する Webpack プラグイン webpack-module-nomodule-plugin もあります.

まさに至れり尽くせり!これで全て解決!といきたいのですが,この方法には長期的な視点で見ると問題があります.

Module-nomodule Pattern の問題点

お気づきの方もいると思いますが,この方法ではターゲットブラウザが「ESModules をサポートしているブラウザ」に固定されてしまいます.具体的には https://github.com/babel/babel/blob/master/packages/babel-compat-data/data/native-modules.json で定義されているブラウザです.

これらブラウザの下限バージョンは,原理上今後どんなに新しいブラウザがリリースされようと変わることがありません.つまりこれでは新たな "babel-preset-es2015 問題" あるいは "last 2 versions 問題" とでも言うべき状態になってしまいます.2 年後の 2022 年,Chrome のバージョンが 100 の大台に乗る頃になってもずっと Chrome 61 をサポートし続けなくてはならないということはないでしょう.

せっかく @babel/preset-env と differential serving でターゲットを柔軟にカスタマイズして配信できる体制を整えようとしているのに,歴史を繰り返す必要はありません.

Module-nomodule Pattern を使わない Differential Serving の実装方法

では module-nomodule pattern を使わない場合にはどのように differential serving を実装したらよいでしょうか? これには二つのアプローチがあります.

Feature Detection

Netflix にて紹介された 方法で,以下のような ES2015 で導入された構文を使ったコードを eval で実行して a. の判別問題を解決します.

この方法は,テストに使用するコードを変更することで柔軟に feature detection を行える点,および一般にアンチパターンとされる UA sniffing を避けられる点が利点です(Netflix では sniffing と併用していたようですが).

一方で欠点としては browserslist ベースのツール (@babel/preset-env や autoprefixer など) との相性があまりよくない点が挙げられます.これらのツールは「サポートするブラウザーのリスト」を指定する必要があるので,「ブラウザーリスト→ ES の構文」という順序で判断が行われます.一方 feature detection では変換対象の ES の構文を決めてビルドしておき,その構文をブラウザがサポートしているかどうかを実行時に判断します.つまり,考える方向が真逆になっているのです.(反対に,TypeScript のコンパイラ tsc のように出力先の ES version を指定して変換するようなツールでは feature detection と相性がよいと思われます)

一方で b. の配信問題についてはスライド中で特に紹介されていませんが,Cookie を使った制御が出てくるところからおそらくバンドル生成時に app.bundle.es5.js と app.bundle.es6.js のようにファイル名を分けておき,Cookie や feature detection の結果からどちらを配信するか判断していたものと考えられます.

User-Agent Sniffing

愚直に User-Agent を見て出し分ける方法です.Feature detection の項で説明したとおり browserslist ベースのツールと相性がよい一方,tsc と一緒に使うには少し工夫が必要と思われます.

ビルド時に指定したターゲットブラウザと同じ分け方でファイルを出し分けるようにすれば a, b の問題はともに解決できることになります.

具体的には,CDN・サーバーサイド・クライアントサイドのいづれかの場所で browserslist に指定したクエリと同じブラウザ振り分けを UA ベースで実現すれば OK です.Node.js や クライアントサイドでは ua-parser-js などが,VCL では @financial-times/useragent-parser または @financial-times/polyfill-useragent-normaliser などが使いやすいと思います(これらの parser や normaliser では browserslist の "last 2 versions" のような指定ができないため,ターゲットブラウザの更新方法など運用を工夫する必要はあります).

github.com github.com github.com

おわりに

Feature detection や User-Agent sniffing と比べると配信部分にほとんど手を加える必要がないという点で module-nomodule pattern の手軽さは際立っています.そのため「まず differential serving を使ったチューニングの第一歩として導入する」というケースでは重宝されるでしょう.

一方で長期的な視点ではこの記事で書いたような問題を孕んでいるため,数年後に技術的負債となる可能性も考えられます.今後も継続的に differential serving によるアプリケーション提供を続けていく予定があるのであれば,運用を含めたより堅牢な実装を検討するメリットはあるのではないかと思います.