Webpackerと付き合うために、DocBaseで取り入れたいくつかのtips ![39a582bd-8ca0-4888-bede-12eec07c71f3-668x350.png](https://image.docbase.io/uploads/6dc642ee-e257-4fa3-939c-bf6a0311ad29.png =1200xH) こんにちは、クレイの阪本です。 最近長らく住んでいた東京を離れ神戸市へ移住しました。 蒙古タンメン中本や二郎など好みのラーメンに手が届きづらくなってしまったので新たな好物を探索中です。 本題に移りますが、DocBaseのシステムは**Ruby on Rails + ReactのSPA構成**となっています。また、フロントエンドのビルドシステムは[webpacker@5.4](https://github.com/rails/webpacker)を採用しています。 内部的にはwebpack v4が使われており、webpacker@6.xはまだbetaリリースの状態です(2021年6月時点)。webpacker@6.xではwebpack v5ベースへのマイグレーションが入るようです。 今回はwebpackベースのビルドシステムを構築する際に、DocBaseで利用したtipsをいくつかご紹介できればと思います。 ## Webpackerのコンフィグは部分的にejectして運用する Webpackerはwebpackのコンフィグをwrapして生成してくれるのですが、公式ドキュメントに従いコンフィグの変更を行った場合に、どのような動作になるのか分かりづらくなる印象がありました。 [Webpackerの公式ドキュメント](https://github.com/rails/webpacker/blob/v5.4.0/docs/typescript.md)では、cssに関する設定の`splice`や`map`などがWebpacker内部のコードに依存していました。最終的に出力されるコンフィグを整形するために、苦労された方も多いのではないでしょうか? loaderへの変更をかける場合を一例としてあげてみます。 * 例1 ```js // resolve-url-loader must be used before sass-loader environment.loaders.get('sass').use.splice(-1, 0, { loader: 'resolve-url-loader' }); ``` * 例2 ```js // webpack/environment.js const { environment } = require('@rails/webpacker') // replace css-loader with typings-for-css-modules-loader environment.loaders.get('moduleSass').use = environment.loaders.get('moduleSass').use.map((u) => { if(u.loader == 'css-loader') { return { ...u, loader: 'typings-for-css-modules-loader' }; } else { return u; } }); ``` コンフィグを変更したいだけなのに、やたら複雑なコードになってしまいました。ここは改善したいところです。 例えば、[create-react-app](https://create-react-app.dev/docs/available-scripts/#npm-run-eject)では素のcreate-react-appを実行した場合、各パッケージの設定ファイルは隠蔽されています。ですがパッケージの設定を細かくやりたい場合、`npm run eject`を実行することで各パッケージの設定をwebpack.config.jsとして出力できます。 これに近いことをWebpackerで行えないかを検討してみました。 ### webpackコンフィグを深い階層まで出力してみる まずWebpackerのコンフィグが、webpackのコンフィグとしてどのように設定されているのかを出力することにしました。 `@rails/webpacker`から得られるwebpackのコンフィグをprintするのに、node.js付属の`inspect`を組み合わせて、深い階層まで出力できるようにしました。 ※ 普段はノイズになるのでコメントアウトしています * environment.js ```js const { environment } = require('@rails/webpacker') const inspect = (obj) => console.log(require('util').inspect(obj, false, null)) // loaderの設定をすべて出力 inspect(environment.loaders.values) // webpackのコンフィグをすべて出力 inspect(environment.toWebpackConfig()) ``` ### プロジェクト独自のコンフィグを作成 sass/cssのコンフィグを前述の手段で出力された内容をベースに、改変したい箇所だけ変更します。これをプロジェクトのコンフィグとして取り出して配置します。 * `config/webpack/loaders/css.js` ```js const MiniCssExtractPlugin = require('mini-css-extract-plugin') const { config } = require('@rails/webpacker') const { resolve } = require('path') const sass = { test: /\.(scss|sass)$/i, sideEffects: true, use: [ ( config.extract_css ? MiniCssExtractPlugin.loader : { loader: 'style-loader' } ), { loader: 'css-loader', options: { sourceMap: true, importLoaders: 2, modules: false } }, { loader: 'postcss-loader', options: { config: { path: resolve() }, sourceMap: true } }, { loader: 'resolve-url-loader' // 独自設定として追加 }, { loader: 'sass-loader', options: { sourceMap: true, implementation: require('sass'), sassOptions: { includePaths: config.additional_paths } } } ] } const css = { test: /\.(css)$/i, sideEffects: true, use: sass.use.slice(0, -1) // spliceは単一ファイル内のコンフィグから取り出すので視認性は確保できる } module.exports = { css, sass } ``` * environment.js ```js const { css, sass } = require('./loaders/sass') // デフォルトのコンフィグは削除 environment.loaders.delete('css') environment.loaders.delete('sass') environment.loaders.delete('moduleCss') // 使っていない不要なloader environment.loaders.delete('moduleSass') // 同上 // 独自に改変したコンフィグを挿入 environment.loaders.append('css', css) environment.loaders.append('sass', sass) module.exports = environment ``` Webpackerはloaderごとにラベルが割り当てられているので、一度デフォルトのコンフィグをすっぱり削除します。その後にプロジェクト独自のコンフィグを追加するようにしました。 これにより内部的なコンフィグが可視化でき、他のメンバーが見ても理解しやすい設定になったのではないかと思います。 このやり方はstorybookやvue-cliなど、他のWebpackをwrapしたビルドシステムにも使えるかもしれません。 ### Webpackerのバージョンアップに追従するかどうか Webpackerのバージョンは開発中にも継続してアップデートを重ねましたが、sass/cssのloaderの変更があった場合に、変更したファイルも追従するかは都度判断になります。 バージョン毎の変更も、create-react-appにおける`eject`レベルの大きなものはなく、特に困るケースには当たりませんでした。 このように部分的に`eject`を行うほうが、DocBaseの開発スタイルにフィットしていたように思います。 ## 開発環境のビルドパフォーマンスは定期的に見直す アプリケーションが大きくなるにつれ、ビルド時間は徐々に低速になっていくものです。 DocBaseではビルドパフォーマンスを改善するため、以下のことをおこないました。 ### assetsのコンパイル結果をstatsに出力しない Webpackerではwebpack-dev-serverの起動時・再コンパイル時にデフォルトでassetsのログが出力されます。 数十行にわたる「成功しました」というログを見せられてもノイズにしかならないので、これを開発環境ではoffにしています。 ```js const environment = require('./environment') environment.config.merge({ devServer: { stats: { assets: false } } }) ``` consoleには端的な情報だけを出力して、再コンパイル時の時間など表示が目立つようにしています。 これが普段より大きく跳ね上がったりしていることがあると、手の入れ時だと気づきやすくするためです。 ``` ℹ 「wdm」: Compiling... [hardsource:e3385075] Using 107 MB of disk space. [hardsource:e3385075] Tracking node dependencies with: yarn.lock. [hardsource:e3385075] Reading from cache e3385075... ℹ 「wdm」: Hash: 7eb2c10b2d258ffb3272 Version: webpack 4.46.0 Time: 2889ms Built at: 2021/05/25 16:37:41 ℹ 「wdm」: Compiled successfully. No issues found. ``` ### ビルドを遅くしている犯人を探す 具体的にビルド時間の改善を行う際は[speed-measure-webpack-plugin](https://www.npmjs.com/package/speed-measure-webpack-plugin)が各loader/pluginのボトルネックを調べるのに有用でした。 DocBaseではrenovateによるパッケージアップデートを行っているのですが、CIを通過していても実は開発環境のビルド時間に影響を与えていたことがありました。 例えば[eslint-webpack-plugin](https://github.com/webpack-contrib/eslint-webpack-plugin)とは相性が良くなかったのか、古いバージョンの方が動作が安定しているなどがありました。 このような調査や改善は不定期に行うため、`--analyze`オプションを付与してビルドした場合は`speed-measure-webpack-plugin`のレポートもコンソールに出力できるようにしています。 * config/webpack/development.js ```js if (process.argv.includes('--analyze')) { const smp = new (require('speed-measure-webpack-plugin'))() module.exports = smp.wrap(environment.toWebpackConfig()) } else { module.exports = environment.toWebpackConfig() } ``` ### ビルドが遅くなっていないかメンバーに聞く webpack-dev-serverの起動やファイル保存に対し「遅い」と感じられているうちはまだ良いですが、慣れのせいか「開発サーバーはこういうもんだ」、「たぶん自分の環境だけ」と思ってしまうこともあるようです。 チームにはビルドシステムにほぼ触らないエンジニアもいるため、ビルド時間やlinterルールなど開発体験に不満はないかをヒアリングしてみるといいかもしれません。「実はなんとなく〇〇が不都合に感じていた」という意見が出る可能性があります。 「他のプロジェクトではDocBaseよりもっとビルド時間がかかっているので遅いとは思わなかった」などの意見もありました。 どこかのタイミングで、ビルドパフォーマンスが遅くなっていないかメンバーに聞いてみたり、「ビルドが遅いです」と言いやすくする環境作りも大切だと感じました。 ### node_modules以下をトランスパイルしない [webpacker@5.4](https://github.com/rails/webpacker)ではデフォルトでnode_modules以下もトランスパイルされるようになっていますが、DocBaseではビルド速度を上げるため、node_modulesをloaderの適用範囲から外しています。 node_modulesをトランスパイルしないようにするには、environment.jsに以下の行を追加します。 ``` const { environment } = require('@rails/webpacker') // NOTE: nodeModulesはv6で本家から削除されるためwebpackerのアップグレードの際にこの行を削除 // ref: https://github.com/rails/webpacker/blob/master/CHANGELOG.md#600---2021-tbd environment.loaders.delete('nodeModules') ``` #### 検証 node_modules以下をトランスパイルしなかった場合、どのぐらい処理時間を短縮できるかspead-measure-pluginを使って計測してみました(はじめ読み方がわからなかった)。 最下行のresolve-url-loaderを起点に下から上へloaderの処理が行われ、babel-loaderの処理が終了したのが**52.041sec**から**25.44sec**となり、**30sec近く改善したことが確認できます。** * before * after また、`modules with no loaders`については`modules count`の値が増加しています。つまりnode_modules以下パッケージのコードに対しては、トランスパイルが行われなかったことも合わせて確認できます。 #### 注意点 ただし**レガシーブラウザへの対応が必要なプロジェクトでは、この改善を取り入れるには注意が必要です。**node_modulesをloaderの適用範囲から外すとbabel-loaderのpolyfillが適用されなくなるため、レガシーブラウザでランタイムエラーが発生する可能性があります。 DocBaseはIE11/LagacyEdgeがサポート対象外であり、polyfillを適用する必要がないので、node_modulesをまるっとloaderから削除しています。 レガシーブラウザに対応する場合は、Vue-Cliの`transpileDependencies`のように、特定パッケージのみをトランスパイルの対象にすることを検討していただくと良いかと思います。 https://cli.vuejs.org/config/#transpiledependencies ちなみに[Webpacker@6.0.0](https://github.com/rails/webpacker/blob/master/CHANGELOG.md#600---2021-tbd)では、デフォルトでnode_modules以下がトランスパイルされなくなります。 #### CaseSensitivePathsプラグインも削除 CaseSensitivePathsプラグインも特にプロジェクトには必要ないので削除していますが、こちらはせいぜい2sec程度の改善となっていました。 * environment.js ``` const { environment } = require('@rails/webpacker') environment.plugins.delete('CaseSensitivePaths') ``` ### その他のビルドパフォーマンス改善 端的に開発環境のサーバを停止/起動した際に効果が一番出たのは、コンパイル結果をファイル化して次回起動時のキャッシュとする[hard-source-webpack-plugin](https://github.com/mzgoddard/hard-source-webpack-plugin)でした。 こちらはwebpack5でPersistent Cachingが使えるようになるのでアップグレードの際には置き換わる予定です。 その他デフォルトのwebpackerの設定からビルドに時間を取る割に効果が薄い設定を削除を行っています。 ## production環境のビルドパフォーマンス改善 [webpackerのproductionビルドのデフォルト](https://github1s.com/rails/webpacker/blob/master/package/environments/production.js)と突き合わせていき、不要なものを削除、差し替えたいものを置き換えしています。 ### gz/brotliへの圧縮処理をWebpakerで行わない Cloudfrontへビルド結果をアップロードしているため、gz/brotliの生成はビルドでは行わないようにしています。これらファイルの生成と配信はCloudfrontが担当しています。[参考](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/ServingCompressedFiles.html) ### minify処理でesbuildのランタイムを利用する DocBaseの本番環境では、とある事情で低負荷かつ高速にビルドを完了させる必要がありました。 調査すると、minifyの処理にデフォルトで使われるTerserWebpackPluginが大きくリソースを専有していることが判明し、代替え手段として[esbuild-loader](https://github.com/privatenumber/esbuild-loader)を検証することにしました。 esbuild-loaderはwebpackの世界にesbuildのランタイムを持ち込むことができるパッケージです。 js/tsのトランスパイルが主要な機能ですが、minify処理のみをesbuildに置き換えることも可能です。 未だ[v1を迎えておらずproduction readyではない](https://esbuild.github.io/faq/#production-readiness)とのことだったので、慎重に検証を重ねた上で導入を決めました。 TerserPluginとの性能差としてはさすがesbuildといったところで、**14倍以上高速にminify処理が改善され、現在も特に問題なく動作しています。** * before * after 他、例外通知や`--analyze`フラグを渡した際に、ビルドの検証モードに入るコンフィグをざっと並べてみました。DocBaseでは以下のようなコンフィグで運用しています。 * config/webpack/production.js ```js const environment = require('./environment') const sentry = require('./plugins/sentry') const s3upload = require('./plugins/s3upload') const { ESBuildMinifyPlugin } = require('esbuild-loader') environment.config.optimization.minimizer = [ new ESBuildMinifyPlugin({ target: 'es2015', charset: 'utf8', minifyWhitespace: true, minifyIdentifiers: true, minifySyntax: true, jsxFactory: 'h', loader: 'jsx' }) ] // NOTE: CloudFrontで対応するのでビルド時にgz/br化しない environment.plugins.delete('Compression') environment.plugins.delete('Compression Brotli') environment.plugins.append('s3upload', s3upload) environment.plugins.append('SentryWebpackPlugin', sentry) // sentryにsourcemapをアップロードしつつ公開は行わない environment.config.merge({ devtool: 'hidden-source-map' }) if (process.argv.includes('--analyze')) { const smp = new (require('speed-measure-webpack-plugin'))() module.exports = smp.wrap(environment.toWebpackConfig()) } else { module.exports = environment.toWebpackConfig() } ``` ## おわりに バンドルサイズの削減などは他のサイトでも多く語られていたため、DocBaseで採用したtipsを紹介させていただきました。 もし今回の内容がお読みいただいた方のプロジェクトのお役に立てば幸いです。