こんにちは、Wantedly のDX Squadでエンジニアをしている原です。 (DXはDeveloper Experienceの略で、開発者が心地よくプロダクトを作れる環境を作ることを目標に頑張る部門です)
本稿は、WANTEDLY TECH BOOK 10 から「Webpackがモジュールを2回読み込まないためにした3つのこと」という章を抜粋し加筆修正を加えたものです。ウォンテッドリーでは WANTEDLY TECH BOOK のうち最新版を除いた電子版を無料で配布しています。ぜひ読んでみてください。
以下、本文です。
Wantedlyの歴史的フロントエンド
Wantedlyでは、よいプロダクトを作るために継続的に新しい技術を取り入れています。一方で、古い技術で作られたコードの作り直しはなかなか行われてこなかったため、古い技術スタックで書かれたコードが現存しています。また、新しい技術を取り入れる過程でアーキテクチャの分断も起こってしまっているため、古いコードベースに新しい変更が加えられることもしばしばあります。
たとえば、Wantedly Visitのフロントエンドアーキテクチャは以下のように大きく4世代に分けられます。第4世代はフロントエンドが独立したリポジトリを持ち、バックエンドとは独立にデプロイされる構成になっています。
現在でも大部分のコードは1〜3世代に存在していて、現在行われているUI/UXリニューアルでもこれらの世代のコードが頻繁に変更されています。そのため、1〜3世代フロントエンドのDX改善が急務になっています。
このうち第2世代フロントエンドと第3世代フロントエンドは、同じサーバー上であるにもかかわらず、別々のWebpackビルドとして提供されていました。実はこれが導入された経緯は https://www.wantedly.com/companies/wantedly/post_articles/117067 に書いてあります。Webpackに注目して時系列を整理すると以下のようになります。
- 2016年2月にReact導入とあわせてアセットパイプラインが再検討された。browserifyを導入しようとしたが合わなかったため、Webpackが導入された。これが第2世代フロントエンドになった。このときのWebpackは1系だった。 (内部向け情報: #19197)
- 2017年6月にWebpackが2系になった。 (#31570)
- 2017年12月に第3世代フロントエンドが導入された。TypeScript, redux-saga, immer, universal-routerなど今まで使っていなかったフレームワークを導入し、またSSRが可能な設計にするため、第2世代とは分ける形で作られた。このときのWebpackは2系だった。 (#36162)
- 2018年1月にWebpackが3系になった。 (#36360)
- 2018年6月にWebpackが4系になった。 (#38824)
時間の限られていた中で新しいアーキテクチャを導入するために、当時として仕方なかった面はあると思いますが、もちろん好ましい状況ではありません。実際これにより、第2世代フロントエンドと第3世代フロントエンドのコードを混ぜるのが困難な状態になっていました。特に styled-components は多重呼び出しに関する制約が強いため開発に大きな支障が出ます。これについては別記事で詳しく扱っています。
この問題を解消するため、Webpackの設定を統合する作業を行うことにしました。これを実現するために20個ほどのプルリクエストに分けて少しずつ設定を近づけていきました。その中のいくつかをピックアップして紹介します。
jsonpFunction の設定
Webpack統合前に、まず一時的な対応として行ったのが jsonpFunction の設定です。これは複数のWebpackビルドを安全に共存させるために本来必要な設定ですが、長らく設定されていませんでした。
Webpack 4.x時点では jsonpFunction はその名前に反してJSONPでもなければ関数でもありませんが、本質的にはそれほど間違いではありません。これは タグの読み込み完了をフックするためのイベントキューで、デフォルトでは window.webpackJsonp に置かれます。複数のチャンクに分けられた <script> タグ同士が情報を共有するためのものなので、グローバルに置かざるを得ないというわけです。
ということは、複数の Webpack ビルドが同じ変数名を参照していたら、意図しない結果になる可能性があります。この名前は output.jsonpFunction で設定できるため、2つのwebpack.config.js に別々の名前を設定することで安全な状態にしました。その上で、根本対応としてWebpackのビルド統合を始めました。
webpack-serveの廃止
webpack-serveはwebpack-dev-serverを置き換える目的で開発された開発用サーバーです。一時期はwebpack-dev-serverが非推奨化されwebpack-serveへの置き換えの動きがありましたが、結果としてはwebpack-dev-serverが存続する形での方針転換が起こり、webpack-serveのほうが非推奨化されてしまいました。この経緯についてはこちらの日本語資料 https://qiita.com/IgnorantCoder/items/74b60ef53c3c1aa4aebc がわかりやすいです。なお、この資料の時系列のあとにwebpack-serveの作者は非推奨化を撤回していますが、これはあくまでの作者の個人プロジェクトとしての存続のようですので、webpack-dev-serverからの避難先としての意義はもはやないといってよいでしょう。
この騒動は2018年の1月から9月ごろにかけて発生していました。Wantedlyでも2018年6月のWebpack 4アップグレードに合わせて第3世代フロントエンドのwebpack-serve移行を行っており、見事にこれに巻き込まれた形になっていました。webpack-serveを使っていたものの、Hot Module Replacementの設定はwebpack-dev-serverのものだったため、Hot Module Replacementの機能も使えていない状態でした。
また、webpack-serveを起動するためになぜか専用のスクリプトが用意されていましたが、特別な設定が必要なわけではなかったため、単に既存の webpack-dev-server
コマンドを使うような形で置き換えることで設定のスリム化も行いました。
devtool設定の見直し
devtoolとは、「source mapの生成方法」と「バンドルされたコードの生成方法」を合わせたオプションです。ビルド効率や開発時の利便性、パフォーマンスなどのトレードオフがあるため、ターゲット環境ごとに設定を分けるのが一般的です。第2世代と第3世代では設定が異なっていたため、統合のついでに見直しを行いました。
まず開発時の設定には cheap-module-source-map
を使っています。これは開発時の利便性とビルド効率の中間を取ったような設定です。本当は cheap-module-eval-source-map
にしようと思ったのですが、こちらにすると謎のエラーが発生するため一旦動く設定を使っています。
プロダクションでは hidden-source-map
を使っています。生成されたsource mapはS3にはアップロードせずに、エラー収集サービスにだけアップロードしています。これによりスタックトレースが解読可能になります。生成されるコードはデフォルト値の false
と同じで、コードサイズとパフォーマンスが優先です。
Ruby on Railsのfeature test用には source-map
を使って生成したコードを使っています。できるだけプロダクションに近づけつつ、デバッグを容易にするためです。
<script> タグの統一
今回のケースでは、Webpackの生成したバンドルを呼び出す <script> タグはRuby on Rails側で埋め込まれています。この埋め込み方にも世代によって違いがありました。ざっくり言うと以下のようになっていました。
<!doctype html>
<html>
<head>
<title>...</title>
<!-- 第3世代frontendが使っているライブラリのバンドル -->
<script src="/assets/v3/vendor.js" defer></script>
<!-- 第3世代frontendのJavaScript -->
<script src="/assets/v3/bundle.js" defer></script>
</head>
<body>
<h1>...</h1>
<!-- 第2世代frontendが使っているライブラリのバンドル -->
<script src="/assets/v2/vendor.js" crossorigin></script>
<!-- 第2世代frontendのJavaScript -->
<script src="/assets/v2/bundle.js" crossorigin></script>
</body>
</html>
この状態を維持したまま統合するのは不可能ではないですが、少なくとも vendor.js
は統合しなければいけないこともあり、ロード順やパフォーマンスに影響が出る可能性がありました。その可能性を潰すため、あらかじめ <script>
タグの埋め込み方法を統一することにしました。具体的には
- 順番は第3世代に揃える。つまり、 `defer` をつけて `<head>` 内で呼び出す。
- crossoriginをつける。
という形に変更しました。deferをつけたスクリプトのほうが後に呼び出されているはずなので、実行順序をなるべく変えないよう、第2世代→第3世代の順に並べました。
<!doctype html>
<html>
<head>
<title>...</title>
<script src="/assets/vendor.js" defer crossorigin></script>
<script src="/assets/v2/bundle.js" defer crossorigin></script>
<script src="/assets/v3/bundle.js" defer crossorigin></script>
</head>
<body>
<h1>...</h1>
</body>
</html>
副産物として、第3世代フロントエンドにも crossorigin
属性を設定したことにより、エラー情報をより詳細に取得できるようになりました。
npm scriptsの整理
第2世代と第3世代で別々にwebpackを起動していたので、対応するnpm scriptも別々に存在していました。ざっくり言うと、 webpack:build
が webpack:build:v2
と webpack:build:v3
を順に起動するようになっていました。
Webpack統合のさいにはこれらのnpm scriptsも統合する必要があるため、それをどのように行うか検討したところ、コマンドの関係がとても複雑なことになっていました。以下がそれを整理したグラフです。
命名規則がばらばらだったり、v2とv3が対等ではないなど、何が起こっているのか非常に掴みにくい状態になっていました。そこで、あらかじめこれらのコマンドを整理しておくことにしました。まず webpack:
というprefixは要らないので除外し、 serve
は start
に統一しました。 build
/ watch
/ start
という動詞を名前の先頭に置き、それらに対して v2
/ v3
/ ssr
という接尾辞を必要に応じて付与しました。
こうしてコマンドを整理した結果、以下のように見通しのよいグラフに生まれ変わりました。
なお、上記の v2 / v3 の区別はWebpack統合によりなくなり、以下のようにさらにシンプルになりました。
Jest設定の統合
上記のnpm scriptsの整理のついでに、Jestによるテストも統合しました。
普通に統合することも可能だったのですが、第2世代フロントエンドは(サーバーサイドで定義される)グローバル変数に依存している一方、第3世代フロントエンドはSSRのためにグローバル変数に依存しないようになっていました。それらを区別してテストするために、 jest.config.js
自体は2種類用意することにしました。かわりに、 package.json
に以下のような設定を書きました。
{
"jest": {
"projects": [
// 第2世代フロントエンド
"frontend/assets/javascripts",
// 第3世代フロントエンド
"frontend/src"
]
}
}
こうすると、 frontend/assets/javascripts/jest.config.js
と frontend/src/jest.config.js
の2つの設定ファイルが両方採用され、両方のテストを織り交ぜて実行することができます。カバレッジもまとめて報告できます。
Wantedlyのフロントエンドの今後
2020年時点では、本記事で書いたようにWebpackの設定を全て自力で構成するのではなく、構成済みのものに乗っかるのがベストプラクティスではないかと思います。実際、Wantedlyの第4世代フロントエンドはCreate React Appを使っています。それでもWebpack職人のようなことを続けているのは以下のような理由からです。
- 第1世代から第3世代までのコードはRuby on Railsサーバーと不可分であり、この条件での構成済みパイプラインはRails側に寄ったもの (Sprockets, Webpacker) 以外あまり良いものがない。
- 第1世代から第3世代までのコードを急に放棄することはできず、中期的にはメンテナンスを続けていく必要がある。
- 長期的に第4世代フロントエンドへの移行を促すためにも、第1世代から第3世代までと第4世代のコード品質の断絶をできるだけ小さくするべきである。
もちろん、ただ古いコードをメンテナンスするという仕事だけをしているわけではなく、新旧のコードを繋ぐための共通コンポーネントの拡充や、第4世代フロントエンドを書きやすくする取り組みも進めています。
この記事が、同じような課題を抱えている人の助けになればさいわいです。
まとめ
Webpackの設定が2つあることによって抱えていた問題を解決しました。これはより広い、アーキテクチャの分断問題を解決する第一歩でもあります。