Next.js と 非同期分割 CSS の悲劇
こんにちは!10 月からリクルートテクノロジーズに join した吉井です。今年は Next.js が大いに盛り上がった年でしたね。本稿では Next.js の CSS 関連で「いつか遭遇するかもしれないバグ」を紹介したいと思います。
これは「あるルール」を守ってさえいれば遭遇することのないバグ、且つ現状の Nex.js の仕組みでは起こり得る事象になりますので、どうしてこの様なことが発生するのか?をきちんと理解することを目的としています。
【バグ概要】色が不規則に変動する
手順 | 操作 |
---|---|
1 | Red / Green / Blue いずれかのページから表示を開始する |
2 | 別色画面への遷移を経て、TOP ページを表示する |
3 | 「赤・緑・青」の適用がランダムで発生する |
【サンプル】https://github.com/takefumi-yoshii/nextjs-cssmodules-rainbow
サンプルコードは非常に単純で、component に hooks などは書いておらず「ただページを巡回しているだけ」で発生します。 これは CSS 仕様と非同期分割読み込みがもたらす悲劇です。
実装内容の確認
まずはファイル構成です。次の様なものになっています。
./src
├── components
│ ├── Blue.tsx
│ ├── Green.tsx
│ ├── Red.tsx
│ └── Top.tsx
├── pages
│ ├── blue.tsx
│ ├── green.tsx
│ ├── index.tsx
│ └── red.tsx
└── styles
├── colors.module.css
├── common.module.css
└── shared.module.css
以下はstyles
ディレクトリに含めた 3 枚の.module.css
ファイル内訳です。事象紹介のため、あえてこの様にバラバラのファイルに定義しているのであしからず。
/* src/styles/common.module.css */
.red {
color: #f00;
}
/* src/styles/colors.module.css */
.green {
color: #0f0;
}
/* src/styles/shared.module.css */
.blue {
color: #00f;
}
つぎに、問題になっている TOP ページのコンポーネントです。さて、この段階で何色になるか分かりますか?色に関して競合するこんな奇妙な指定をすることはまずないと思いますが、マルチクラスを用いたスタイリングは、実装次第では採用される手法ではないでしょうか。
import * as React from "react";
import Nav from "./Nav";
import shared from "../styles/shared.module.css";
import colors from "../styles/colors.module.css";
import common from "../styles/common.module.css";
// _____________________________________________________________________________
//
const Component = () => (
<div className={`${common.red} ${colors.green} ${shared.blue}`}>
<h1>Rainbow</h1>
<Nav />
</div>
);
ビルド内容の確認
ビルドを実行し、それぞれのページで import した.module.css
の静的 CSS チャンクファイル内訳を確認していきます。セレクタに hash 値が付与され、セレクタ名称が競合しない様に(local scope が付与)されていることが確認できます。
/* .next/static/css/9ec1f7fe0241b8d2fe1a.css */
.colors_green__9sX5x {
color: #0f0;
}
/* .next/static/css/5373b7cb8b0c11d15c48.css */
.shared_blue__1C81Z {
color: #00f;
}
/* .next/static/css/a051605e66b313704c13.css */
.common_red__2ckaP {
color: red;
}
こちらは TOP に対応するファイルです。表示に必要な定義が、同じセレクタ名称で含まれていることが確認できます。静的ファイル出力ですから、ここまでの話を総合すると、不確定に変動する要素は見当たりません。
.common_red__2ckaP {
color: red;
}
.colors_green__9sX5x {
color: #0f0;
}
.shared_blue__1C81Z {
color: #00f;
}
CSS 仕様に立ち返る
バグの根本原因を理解するため一旦 Next.js から離れ、CSS の仕様について確認していきます。スタイル確定において重要な次の事柄についてです。
CSS order "should matter"
「CSS はセレクタ詳細度が同一の場合、後勝ちになる」 という性質をもっています。以下の p タグ を見るとそれぞれ「赤・青」になる様に感じますが、実際はいずれも 「赤」 になります。p タグの class 指定前後はスタイル確定に関与しません。根本的なバグの原因はこの性質にまつわるものになります。
<html>
<head>
<style>
.blue {
color: #00f;
} /* 詳細度 10 */
.red {
color: #f00;
} /* 詳細度 10 こちらが適用される */
</style>
</head>
<body>
<p class="blue red">red?</p>
<p class="red blue">blue?</p>
</body>
</html>
どんな複雑な hash 値が付与されても class セレクタは一律「10」のままであり「スタイルの確定は宣言順」ということになります。CSS における宣言の前後関係は重要な要素です。
改めて実装内容の確認
実はこのバグ再現のために、もうひとつ仕込んでいたことがありました。それはnext/dynamic
を用いたコンポーネントの dynamic import をしていることです。head タグに挿入されるリソースを確認すると、dynamic import をしているもの・そうでないものに差分があります。
以下のキャプチャはいずれも、全ての画面遷移を完了した際の出力結果です。
【dynamic import なし】
ページ単位でチャンクされたファイルが、遷移に応じて前後が指し変わることが確認できます。この前後順操作により、今回とりあげている事象は起こることがありません。
【dynamic import あり】
こちらは共通指定が上で読み込まれているほか、下の方にコンポーネントの js と css が追加されていることが確認できます。このキャプチャは全画面遷移が完了した後のものですから、TOP に戻っても表示は安定します。 悲劇は、このスタイル読み込みが全て完了していないタイミングで発生します。
- 共通の指定が読み込まれている
- RGB のいずれか読み込まれている
この状況下で「後勝ちで読み込まれている RGB のいずれかが適用される」というのがバグの全容です。dynamic import の差込順は、予測することができません。こちらに関しては以下の issue でも取り上げられており、CSS の読み込み順制御はとても難解な問題であることがわかります。
【Inconsistent loading order of CSS Modules + global CSS】https://github.com/vercel/next.js/issues/10148
CSS Modules だから起こる問題というわけではなく、これは非同期でスタイルが挿入されるどんなソリューションでも起こり得る話です。
バグに遭遇しないために守る、ただ一つのこと
ここまでの話をまとめると、dynamic import やマルチクラス指定がよくないのか?という様に感じますがそうではありません。私たちが実装するうえで守るべきことはただ一つです。
「.module.css はコンポーネントと 1 対 1 で定義すること」
このルールさえ守っていれば、先の前後関係にまつわるトラブルには遭遇しません。なぜなら、付与された hash 値が local scope として機能し、順不動に読み込まれても競合することはないからです。
組み込みの webpack loader MiniCssExtractPlugin 設定に書かれているコメントを引用します。
Next.js guarantees that CSS order "doesn't matter", due to imposed restrictions:
- Global CSS can only be defined in a single entrypoint (_app)
- CSS Modules generate scoped class names by default and cannot include Global CSS (:global() selector).
${common.red} ${colors.green} ${shared.blue}
の様なマルチクラスが全てダメというわけではなく、コンポーネントと対のファイル内の指定を複数指す場合は問題になりません。非同期分割 CSS と「共有.module.css」の組み合わせでおかしな表示に遭遇したら、まずこの指定の読み込み順を疑うようにしましょう。
Discussion