🚄

Next.js製アプリケーションのコンパイルを約100倍高速化した話

2023/01/22に公開

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ファイルを吐き出す機構があることがわかりました。
https://github.com/vercel/next.js/issues/29559#issuecomment-938431883

実際に手元の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アカウントを作ったのでフォローしていただけると嬉しいです。
https://twitter.com/n_mkto

Discussion