Next.js製アプリケーションのコンパイルを約100倍高速化した話
Next.jsアプリケーションの開発時においてコンパイルが長時間に及ぶ問題が起きていたので、その原因を特定した手法と採用した解決策について記載します。
今回は結果的にコンパイル時間を100倍以上高速化することができました。
前提
今回の対応は以下のバージョンで行いました。
- React@18.2.0
- next@12.2.4
- tailwindcss@3.2.4
- postcss@8.4.14
Next.js の開発中に、コンパイル時間が長くなっていることに気づく
最近、Next.jsアプリケーションのローカル開発時に待ち時間が長くて生産性が低いのでなんとかしたい、という相談を受け、調査を開始しました。
まず、おもむろにyarn dev
でプロセスを立ち上げてみたところ、以下のようなコンパイル時間を示すログが表示されました。
yarn dev
yarn run v1.22.19
$ next dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
event - compiled client and server successfully in 99s (401 modules)
コンパイルに99秒かかっているようです。
これが起動時だけであればまだ耐えられるかもしれませんが、コード変更時にも発生している状況だったので、開発体験を悪化させていました。
.next/trace
を解析して、原因を特定する
状況は確認できたので、次にコンパイルを遅くさせている原因を特定していきます。
調べていくと、以下のissueに対するコメントで、Next.jsはプロセスの終了時に.next
ディレクトリにtrace
ファイルを吐き出す機構があることがわかりました。
実際に手元のtraceファイルの中身を見てみたところ、以下のようなオブジェクトが50個区切りの配列で行ごとに並んだテキストファイルになっていました。
{
"traceId":"b4f8b2d7a2c4df93",
"parentId":253,
"name":"webpack-compilation-optimize",
"id":265,
"timestamp":1589516098596,
"duration":184,
"tags":{},
"startTime":1674288534478
}
直感的なプロパティ名なので特に説明することはないかと思いますが、このduration
プロパティが処理にかかった時間を示しています。このようなオブジェクトが大量に存在しています。
ここまで分かってくると、配列を1つにまとめてdurationの降順にソートして...と自前で解決しようとしてしまいがちかと思いますが(自分がそうでした)、parentId
プロパティに見られるように、これらのオブジェクトは親子関係になっていたり、それなりにパースに手間暇がかかるのでオススメしません。
そこで、今回は next.js/scripts/trace-to-tree.mjs の力をお借りしました。
このスクリプトは前掲のコメントをされていたtimneutkens氏謹製のもので、その名の通りtraceファイルのパスをパラメタとして渡すことで各処理時間をツリー構造で表示してくれます。
これを実行したところ以下のようなログが表示されました。(抜粋)
❯ node trace-to-tree.mjs trace
hot reloader
├─ start 27 ms (self 0 µs)
│ ├─ clean 2.6 ms
│ └─ get-webpack-config 24 ms
│ ├─ get-page-paths 487 µs
│ ├─ create-pages-mapping 192 µs
│ ├─ create-entrypoints 848 µs
│ └─ generate-webpack-config 22 ms
(省略)
├─ client recompilation (/Users/n-makoto/app/apps/web/pages/index.tsx) 99s
│ ├─ client compilation 99s
│ │ ├─ entry next/dist/compiled/@next/react-refresh-utils/dist/runtime.js
│ │ ├─ entry next/dist/client/dev/amp-dev
│ │ ├─ entry next/dist/client/next-dev.js
│ │ ├─ entry next-client-pages-loader?absolutePagePath=private-next-pages%2F_app&page=%2F_app!
│ │ ├─ entry next/dist/client/router.js
│ │ ├─ entry next-client-pages-loader?absolutePagePath=private-next-pages%2F_error&page=%2F_error!
│ │ ├─ entry next-client-pages-loader?absolutePagePath=%2FUsers%2Fn-makoto%2Fapp%2Fapps%2Fweb%2Fpages%2F404.tsx&page=%2F404!
│ │ ├─ entry next-client-pages-loader?absolutePagePath=%2FUsers%2Fn-makoto%2Fapp%2Fapps%2Fweb%2Fpages%2Findex.tsx&page=%2F!
│ │ ├─ module /Users/n-makoto/app/apps/web/styles/globals.css 99s [read-resource 5.7 ms, postcss-loader 99s, css-loader 90 ms]
│ │ ├─ module /Users/n-makoto/app/apps/web/pages/index.tsx 41 ms [next-swc-loader 16 ms]
│ │ ├─ webpack-compilation-seal 128 ms
│ │ ├─ webpack-compilation-chunk-graph 7.8 ms
│ │ ├─ webpack-compilation-optimize 913 µs
│ │ ├─ webpack-compilation-optimize-modules 12 µs
│ │ ├─ webpack-compilation-optimize-chunks 96 µs
│ │ ├─ webpack-compilation-optimize-tree 12 µs
│ │ ├─ webpack-compilation-hash 5.9 ms
│ │ ├─ NextJsBuildManifest-createassets 426 µs
│ │ └─ NextJsBuildManifest-generateClientManifest 205 µs
│ ├─ make 99s
│ └─ emit 49 ms
└─ server recompilation (/Users/n-makoto/app/apps/web/pages/index.tsx) 99s
├─ server compilation 70 ms
│ ├─ entry private-next-pages/_app
│ ├─ entry private-next-pages/_error
│ ├─ entry private-next-pages/_document
│ ├─ entry ./pages/index.tsx
│ ├─ module /Users/n-makoto/app/apps/web/pages/index.tsx 13 ms [next-swc-loader 3 ms]
│ ├─ webpack-compilation-seal 20 ms
│ ├─ webpack-compilation-chunk-graph 301 µs
│ ├─ webpack-compilation-optimize 297 µs
│ ├─ webpack-compilation-optimize-modules 3 µs
│ ├─ webpack-compilation-optimize-chunks 44 µs
│ ├─ webpack-compilation-optimize-tree 13 µs
│ └─ webpack-compilation-hash 863 µs
├─ make 48 ms
└─ emit 6.8 ms
(以後省略)
また、細かいですが実際の実行結果は以下のように色付きで表示されるので、一定以上遅い処理はひと目で判別がつくようになっています。
これで何に時間を取られていたのかがわかりました。
│ │ ├─ module /Users/n-makoto/app/apps/web/styles/globals.css 99s [read-resource 5.7 ms, postcss-loader 99s, css-loader 90 ms]
これです。globals.css
に対するpostcss-loaderの処理に99秒かかっていました。
コンパイル全体が99秒なので、この処理を高速化することで問題が解決できそうです。
postcss-loaderの処理時間を悪化させる原因を探す
ここまでのステップでかなり詳細に近づいてきましたが、まだ解決策が見えていないのでブレイクダウンします。
実はここまでの調査でissueなどからtailwindcssによってパフォーマンスが悪化することがあることは見かけていたのでやや恣意的ではあったものの、globals.css
を二分探索的に削り取りながらコンパイル時間を見ていきました。
すると、見立て通り@tailwind base
などとtailwindcssのスタイルを読み込んでいる箇所によってパフォーマンスの悪化を引き起こしていることが確認できました。
tailwindcss の JIT が正しく機能していないことが原因であることを発見
tailwindcssはバージョン2の途中からJITを搭載しており、これはバージョン3からは標準となっています。
簡易的な説明ですが、JITはコードを実行時にコンパイルすることで、パフォーマンスの向上を狙うものです。(JITに関する説明は他の記事に譲ります)
このアプリケーションはすでに3系を採用しているのでJITが効いているはずでしたが、実際には効いていませんでした。
これはユーティリティ系のコンポーネント群をライブラリ化してNextのディレクトリ外に切り出していたことに起因したものでした。
このあたりの事情は特殊なので省略しますが、今回はJITが動作するようにディレクトリの整理を行って解決しました。
再度計測する
上記の解決策を導入してみたところ、良さそうな感触だったので再度.next/trace
ファイルを生成して測定してみました。
hot reloader
├─ start 29 ms (self 0 µs)
│ ├─ clean 4.9 ms
│ └─ get-webpack-config 24 ms
│ ├─ get-page-paths 432 µs
│ ├─ create-pages-mapping 198 µs
│ ├─ create-entrypoints 1.1 ms
│ └─ generate-webpack-config 22 ms
(省略)
└─ client recompilation (/Users/n-makoto/app/apps/web/pages/index.tsx) 908 ms
├─ client compilation 875 ms
│ ├─ entry next/dist/compiled/@next/react-refresh-utils/dist/runtime.js
│ ├─ entry next/dist/client/dev/amp-dev
│ ├─ entry next/dist/client/next-dev.js
│ ├─ entry next-client-pages-loader?absolutePagePath=private-next-pages%2F_app&page=%2F_app!
│ ├─ entry next/dist/client/router.js
│ ├─ entry next-client-pages-loader?absolutePagePath=private-next-pages%2F_error&page=%2F_error!
│ ├─ entry next-client-pages-loader?absolutePagePath=%2FUsers%2Fn-makoto%2Fapp%2Fapps%2Fweb%2Fpages%2F404.tsx&page=%2F404!
│ ├─ entry next-client-pages-loader?absolutePagePath=%2FUsers%2Fn-makoto%2Fapp%2Fapps%2Fweb%2Fpages%2Findex.tsx&page=%2F!
│ ├─ module /Users/n-makoto/app/apps/web/styles/globals.css 819 ms [read-resource 8.2 ms, postcss-loader 598 ms, css-loader 200 ms]
│ ├─ webpack-compilation-seal 29 ms
│ ├─ webpack-compilation-chunk-graph 4.9 ms
│ ├─ webpack-compilation-optimize 1.1 ms
│ ├─ webpack-compilation-optimize-modules 30 µs
│ ├─ webpack-compilation-optimize-chunks 106 µs
│ ├─ webpack-compilation-optimize-tree 18 µs
│ ├─ webpack-compilation-hash 5.4 ms
│ ├─ NextJsBuildManifest-createassets 403 µs
│ └─ NextJsBuildManifest-generateClientManifest 206 µs
├─ make 841 ms
└─ emit 3.3 ms
(以後省略)
postcss-loaderにかかる時間が598 ms、コンパイル全体にかかる時間が908 msということでした。
対策前は99秒かかっていたので、99000 / 908
で約109倍高速になりました。
コード変更から1秒以内に変更が反映されるので、開発体験としても十分向上したと言えそうです。
まとめ
というわけで、今回はNext.jsアプリケーションのコンパイルの処理の内訳を紐解く方法や今回採用したtailwindcssのJIT有効化による高速化について記載しました。
tailwindcssのJITは効果が絶大だったので、現在バージョン2を利用しているプロジェクトは積極的にメジャーを上げる価値があると思います。(公式のマイグレーションガイドも置いておきます)
また、今回のようにバージョン3を採用していてもJITが有効化されていないケースがあるかもしれませんので、.next/trace
ファイルを用いて一度確認してみるとよいかもしれません。
今回の記事は以上です、Twitterアカウントを作ったのでフォローしていただけると嬉しいです。
Discussion
記事を拝見させていただきました。全く同じ症状が再現されており、postcss-loaderのビルドに1800秒かかっています。記事にて省略されているディレクトリを詳しく教えていただけますか?
解決しました!
tailwindのスキャン対象ファイルにnode_modulesが含まれていたためでした