SassとPostCSSの共存:resolve-url-loaderでパス解決の落とし穴を回避する
概要
webpack で Sass をコンパイルし、さらに PostCSS(autoprefixer など)を通す構成は一般的だ。ところがこの構成で、SCSS に書いた url(...) の相対パスが本番ビルドで突然 404 になることがある。原因は Sass が url() の中身を「書いた場所」ではなく「出力される CSS の位置」を基準に解決してしまう点にある。本記事では resolve-url-loader を使った回避法と、PostCSS と共存させるときのローダー順序の勘所をまとめる。
環境
- webpack 5
- sass-loader(Dart Sass /
sassパッケージ) - css-loader
- postcss-loader(autoprefixer など)
- resolve-url-loader
- MiniCssExtractPlugin(CSS 抽出)
発生した問題
SCSS をパーシャルに分割し、各パーシャルの隣に画像を置いて相対パスで参照していた。
src/ ├── styles/ │ ├── main.scss │ └── components/ │ ├── _hero.scss │ └── images/ │ └── bg.png
// src/styles/components/_hero.scss
.hero {
background-image: url("./images/bg.png");
}
// src/styles/main.scss @use "components/hero";
開発中は見えていた背景画像が、ビルドすると読み込めない。生成された CSS を確認すると、url() のパスが _hero.scss から見た相対位置ではなく、main.scss(=出力 CSS)から見た位置として扱われていた。
原因
Sass は @use / @import でパーシャルを取り込むと、すべてを1つの CSS にまとめて出力する。このとき url() の中身は 書き換えずにそのまま出力する。
結果として、url() の相対パスは「元の .scss ファイルの位置」ではなく「最終的に生成される CSS ファイルの位置」を基準に解決される。パーシャルと出力 CSS が別ディレクトリにあると、ここでパスがずれる。Sass 自体は自分がどこに出力されるか知らないため意図的に url を書き換えない。これは仕様だ。
対応方法
resolve-url-loader を導入する。このローダーは Sass が出力した CSS と、そのソースマップを見て、url() が元々どの .scss ファイルに書かれていたかを突き止め、出力 CSS の位置を基準にした正しい相対パスへ書き換える。
ポイントは2つ。
resolve-url-loaderは sass-loader の直後(ローダー配列では sass-loader の直前) に置く- sass-loader 側でソースマップを必ず有効にする(
sourceMap: true)。これが無いと元ファイルを特定できず機能しない
実装例
webpack のローダー配列は「下から上」に実行される。sass-loader が最初に走り、その出力を resolve-url-loader が受け取り、さらに postcss-loader → css-loader と流れる。
// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
module: {
rules: [
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: "css-loader",
options: { sourceMap: true },
},
{
loader: "postcss-loader",
options: { sourceMap: true },
},
{
// sass-loader の出力を直接受け取る位置に置く
loader: "resolve-url-loader",
options: { sourceMap: true },
},
{
loader: "sass-loader",
options: {
// resolve-url-loader が元ファイルを特定するのに必須
sourceMap: true,
},
},
],
},
],
},
plugins: [new MiniCssExtractPlugin()],
};
PostCSS との共存で重要なのは、postcss-loader を resolve-url-loader より上(css-loader 寄り)に置く こと。resolve-url-loader は sass-loader が吐いたソースマップを頼りに元ファイルを辿るため、その間に別の変換を挟むとソースマップが崩れてパス解決に失敗することがある。sass-loader → resolve-url-loader を隣接させ、PostCSS 変換はその後にかけるのが安全だ。
[実行順]
sass-loader
→ resolve-url-loader (ここで url() を正しい相対パスに書き換え)
→ postcss-loader (autoprefixer などを適用)
→ css-loader
→ MiniCssExtractPlugin.loader
確認方法
- ビルド後に抽出された CSS を開き、
url(...)が出力 CSS の位置を基準にした正しい相対パスへ書き換わっているか確認する - ブラウザの Network タブで画像・フォントのリクエストが 200 で返っているか確認する
- わざとパーシャルを別階層へ移動し、
resolve-url-loader有り/無しで出力 CSS のurl()がどう変わるか見比べると挙動が掴みやすい
注意点
- ソースマップが命綱:sass-loader の
sourceMap: trueを外すと静かに壊れる。「設定した気がするのに効かない」ときはまずここを疑う。 - 動的な url は解決できない:
url("#{$var}/bg.png")のような Sass 変数で組み立てた url は、resolve-url-loader が静的に辿れないため書き換え対象にならない。パスはできるだけリテラルで書く。 - ローダー順序が全て:resolve-url-loader を css-loader より上に置いたり、postcss-loader を sass-loader と resolve-url-loader の間に割り込ませると機能しない。
- 絶対パス・エイリアスは対象外:
~始まりや webpack のresolve.alias経由の参照は css-loader の領分。resolve-url-loader が面倒を見るのは相対 url。
まとめ
- Sass は
url()を書き換えず、パーシャルの相対パスは「出力 CSS の位置」基準で解決されてしまう。 resolve-url-loaderを sass-loader の直後に挟み、sass-loader のソースマップを有効化 すれば、元ファイル基準の正しいパスへ書き換わる。- PostCSS と共存させるときは sass-loader → resolve-url-loader を隣接させ、postcss-loader はその後段に置く。
- 動的 url は解決できないので、パスはリテラルで書くのが安全。
「開発では見えるのに本番で画像が消える」という症状の多くは、この url 解決のズレが原因だ。ローダー順序とソースマップの2点を押さえれば回避できる。
