画像ファイルもCSSやJSファイルと一元化したディレクトリで管理する

ウェブサイトのソースファイルはファイル形式にもとづいてディレクトリ分けされている場合が多いと思う。たとえば次のように。

.
├── dist/
│   ├── images/
│   │   ├── components/
│   │     └── header/
│   │         ├── background.png
│   │         └── logo.png
│   │   └── icons/
│   │       └── chevron.svg
│   ├── scripts/
│   │   └── main.js
│   ├── styles/
│     └── main.css
│   └── index.html
└── src/
    ├── images/
    │   ├── components/
    │     └── header/
    │         ├── background.png
    │         └── logo.png
    │   └── icons/
    │       └── chevron.svg
    ├── scripts/
    │   ├── components/
    │   │   └── header.js
    │   └── main.js
    ├── styles/
   ├── components/
   │   └── _header.scss
   └── main.scss
    └── index.html

imagesscriptsstylesディレクトリがルートにあって、ファイルはまずそのいずれかに格納される。各ディレクトリの中では、その中にサブディレクトリを作成して、あるファイルが特定の目的にもとづいていることを表現していく。

ここからファイル形式によるディレクトリ分けを取っ払って、最初からその目的にもとづいた構造にしたい。たとえばこうなる。

.
├── dist/
│   ├── assets/
│   │   ├── components/
│   │   │   └── header/
│   │   │       ├── background.png
│   │   │       └── logo.png
│   │   ├── icons/
│   │   │   └── chevron.svg
│   │   ├── main.css
│   │   └── main.js
│   └── index.html
└── src/
    ├── assets/
    │   ├── components/
    │   │   ├── header/
    │   │   │   ├── background.png
    │   │   │   └── logo.png
    │   │   ├── header.js
    │   │   └── header.scss
    │   ├── icons/
    │   │   └── chevron.svg
    │   └── main.js
    └── index.html

ファイル形式で区別するのをやめて、すべてのファイルをassetsディレクトリに一元化するようにした。ファイルを探したり編集したりする上ではまずファイルが何のために存在しているかが重要になるし、近い場所に集まっている方がファイル同士に関連性を見いだしやすい。

ちなみに、componentsディレクトリの直下にheader.jsheader.scssが配置されている点については気になる人がいるかもしれない。たぶん次のような構造の方が一般的だろう。

.
└── components/
    └── header/
        ├── header.js
        ├── header.scss
        ├── background.png
        └── logo.png

しかしこれは、特定のコンポーネントに含まれるファイル数が少ない場合には冗長になる。今回の例では、アプリケーション的ではなくドキュメント的なウェブサイトのソースコードを想定していて、そういう場合、JavaScriptファイルを必要としないCSSだけのコンポーネントがほとんどになったりする。

.
└── components/
    ├── breadcrumb/
    │   └── breadcrumb.scss
    ├── button/
    │   └── button.scss
    ├── card/
    │   └── card.scss
    ├── footer/
    │   └── footer.scss
    └── header/
        ├── header.js
        ├── header.scss
        ├── background.png
        └── logo.png

すると、複数ファイルを必要とするごく一部のコンポーネントに合わせたディレクトリ構造を実現するために、その他大多数のコンポーネントを取り扱うための手間が増えてしまう。ならば逆に複数ファイルを必要とするコンポーネントの方を例外として捉えて、単一ファイルでの完結が基本の構造にした方が合理的。

.
└── components/
    ├── header/
    │   ├── background.png
    │   └── logo.png
    ├── breadcrumb.scss
    ├── button.scss
    ├── card.scss
    ├── footer.scss
    ├── header.js
    └── header.scss

このようなディレクトリ構造を実現するためにはこれに合わせたビルド設定を行わなければならない。ブラウザに読み込まれたアセットファイルのキャッシュを破棄するために、ファイル名にハッシュ文字列を加えるCache bustingが利用できると望ましいので、その利用も前提として解説する。

webpackで画像などのファイル名をハッシュ化する場合、そのファイル名を指定するためには直接パスを書き込むのではなく、webpack経由で読み込んだデータを割り当てる必要がある。

NG:

// `src/assets/components/header.js`

const img = document.createElement('img');
img.src = '/assets/components/header/logo.png';

OK:

// `src/assets/components/header.js`
import logo from './header/logo.png';

const img = document.createElement('img');
img.src = logo;
// -> `/assets/components/header/logo.[contenthash].png`

CSSファイル内のurl()関数からもファイルを参照するためには、CSSファイル自体もwebpack経由でビルドする必要がある。そのように設定すれば次のように参照できる。

// `src/assets/components/header.scss`

.header {
  background-image: url('./header/background.png');
  // -> `/assets/components/header/background.[contenthash].png`
}

またwebpackの外からファイル名を参照したい場合には、Webpack Manifest Pluginを利用するとファイル名の対応付けをJSONファイルとして出力できる。出力されるJSONファイルを読み込めばwebpackの外の仕組みからもファイル名を参照できるようになる。

これらを前提とするとwebpack.config.jsはだいたい次のような感じになる。いろいろ簡略化している。

const path = require('path');
const sass = require('sass');
const Fiber = require('fibers');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserJSPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const ManifestPlugin = require('webpack-manifest-plugin');

module.exports = {
  context: path.join(__dirname, 'src', 'assets'),
  entry: './main.js',
  output: {
    path: path.join(__dirname, 'dist', 'assets'),
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].chunk.[contenthash:8].js',
    publicPath: '/assets/',
  },
  module: {
    rules: [
      {
        test: /\.(scss|css)$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          {
            loader: 'sass-loader',
            options: {
              implementation: sass,
              sassOptions: {
                fiber: Fiber,
              },
            },
          },
        ],
      },
      {
        exclude: [/\.(js|mjs)$/, /\.json$/, /\.(scss|css)$/],
        use: [
          {
            loader: 'file-loader',
            options: {
              name: '[path][name].[contenthash:8].[ext]',
            },
          },
        ],
      },
    ],
  },
  optimization: {
    minimizer: [new TerserJSPlugin(), new OptimizeCSSAssetsPlugin()],
  },
  plugins: [
    new ManifestPlugin({
      fileName: path.join(__dirname, 'dist', 'webpack-manifest.json'),
    }),
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash:8].css',
    }),
  ].filter(Boolean),
};

そしてmain.jsにはこのように書いておく。

// `components`ディレクトリ直下にあるすべてのSCSSファイルを読み込む
importAll(require.context('./components', false, /\.scss$/));

function importAll(context) {
  context.keys().forEach(context);
}

// webpackのモジュールからは読み込まれていないファイルもビルドに含めることで、
// `assets`ディレクトリに配置したあらゆるファイルを`webpack-manifest.json`経由で参照できるようにする
require.context(
  '.',
  true,
  /\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$/,
);

Sassファイルを読み込む上での注意点として、main.scssのようなSass用のエントリーポイントを作成せずに、JavaScriptファイルからファイルをひとつずつ読み込むようにしておいた方が良い。Sass固有のモジュール機能を使うと、css-loaderに渡ってくる前にコンパイルが済んで結合されてしまうので、url()関数で参照するファイルのパス解決がすべてmain.scssの位置を基準に行われてしまう。するとcomponentsディレクトリ内のファイルにおいてもmain.scssの位置からパスを記述しなければならなくなる。

次に、webpackのモジュールからは読み込まれていないファイルも、出力されるwebpack-minifest.jsonから参照できるようにするために、あらゆるファイルが自動的に読み込まれるようにしておく。もしこの設定を行わない場合も、JavaScriptからimportされたり、CSSのurl()関数に指定されれば、対象のファイルがwebpackのビルドに含まれつつJSONファイルにもパスが追加される。しかしそれ以外のファイルはビルドには含まれなくなってしまうので、JavaScriptやCSSから読み込まれていないファイルをwebpackの外から参照できなくなってしまう。


Jestを利用している場合、JavaScriptファイルから画像ファイルなどを読み込むとエラーになるので、jest.config.jsに次のように設定しておくと回避できる。

module.exports = {
  moduleNameMapper: {
    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
      '<rootDir>/src/assets/test/__mocks__/fileMock.js',
  },
};

src/assets/test/__mocks__/fileMock.js:

module.exports = 'test-file-stub';

設定ファイルの詳細な書き方などは、前述したような設定に開発用ビルドなども含めたボイラープレートを公開しているので参照されたし。