30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Ruby on Rails のアセットパイプラインの挙動を環境ごとに学ぶ

アセットパイプラインとは、Ruby on RailsにおいてJavaScriptファイル、スタイルシート、画像等(これらを総称してアセットと呼ぶ)を管理する仕組みのこと。

以下のバージョンで確認した。

まずはプロジェクトの作成。

$ rails new myapp --skip-turbolinks
$ cd myapp
$ bundle exec rails g scaffold post title:string
$ bundle exec rails db:migrate

これで$ bundle exec rails sするとローカルサーバーが立ち上がり、http://localhost:3000/postsにアクセスできる。
以降、基本的にはこのページで動作確認していく。

プロダクションモード

Railsにはproductiondevelopmenttestの、3つの環境が用意されている。
ローカルで開発する際はデフォルトではdevelopmentが使われ、そして本番環境ではproductionを使うのが一般的。

だがdevelopmentproductionではアセットパイプラインの挙動が異なるため、その挙動を正しく学ぶためにはproductionもローカルで使う必要がある。

その具体的な方法は後述するが、事前に用意しておくべきものがいくつかある。

まずconfig/master.key。このファイルがないと、production環境でサーバーを起動することが出来ない。
rails newしたときに生成されるが、Gitの管理から外すのが一般的なので(デフォルトでもそうなっている)、作業環境によっては存在しないかもしれないので注意が必要。

そしてデーターベースの設定。
先程scaffoldで作成したモデルはdevelopment環境のデータベースにしか存在しないので、production環境でもそのデータベースを見るようにしておく。
以下のようにconfig/database.ymlを編集する。

--- a/config/database.yml
+++ b/config/database.yml
@@ -22,4 +22,4 @@ test:
 
 production:
   <<: *default
-  database: db/production.sqlite3
+  database: db/development.sqlite3

概要

アセットパイプラインでは、以下の処理を順番に行っていく。それぞれの内容については後述する。

  1. コンパイル
  2. 統合
  3. 圧縮
  4. ダイジェスト付与
  5. 完成したファイルをpublic/assets/に保存する

developmentでは235は行われない。14のみが自動的に行われる。
productionでは予め明示的に1~4を行って、その結果をpublic/assets/に保存しておく(この作業を、プリコンパイルという)。そしてそれをHTML側が参照する形になっている。

画像

画像では45のみを行う。

まず、使用したい画像をapp/assets/imagesのなかに置く。
例えばapp/assets/images/welcome.png

それを、image_tagで呼び出す。

--- a/app/views/posts/index.html.erb
+++ b/app/views/posts/index.html.erb
@@ -2,6 +2,8 @@
 
 <h1>Posts</h1>
 
+<%= image_tag 'welcome.png' %>
+
 <table>
   <thead>
     <tr>

image_tagを使う時はapp/assets/imagesがルートパスになる。

これで$ bundle exec rails sを実行してhttp://localhost:3000/postsを見ると、無事画像が表示されている。

これはdevelopmentなので、productionでも確認してみる。
production環境でローカルサーバーを立ち上げるには、以下のコマンドを実行する。

$ bundle exec rails s -e production

こうするとサーバーが立ち上がるが、エラー画面になってしまっている。
表示されている場合はdevelopmentで表示したときのキャッシュが効いているだけなので、キャッシュをクリアすれば表示されないはず。

これを解決するため、一度サーバーを停止させて以下を行う。

$ bundle exec rails assets:precompile RAILS_ENV=production

すると、yarn.lockと、public/assets以下にいくつかのファイルが生成される。
これがプリコンパイルである。

生成されたファイルのなかにはpublic/assets/welcome-xxx.pngもある。
xxxの部分には乱数のような文字列があると思うが、これがダイジェストである。ブラウザのキャッシュ対策のために付与される。

だがこの状態で再度productionでサーバーを立ち上げてページを確認しても、やはり表示されない。

ブラウザの開発者ツールのコンソールログで確認すると、画像だけでなくJavaScriptファイルやスタイルシートも見つからないというエラーが出ている。
該当するファイルは先程のプリコンパイルpublic/assets/に生成されているのだが、それを読み込めていない。

実はproductionでアセットを読み込むにはもう一つ、やっておくべき設定がある。
それが、RAILS_SERVE_STATIC_FILESという環境変数である。
この値が存在しないと、アセットを正しく読み込んでくれない。
値さえ存在すれば中身はなんでもよいのだが、今回は1にしておく。

$ export RAILS_SERVE_STATIC_FILES=1

これで再度サーバーを起動すれば、画像が表示され、コンソールログに出ていたエラーも消えているはず。

なお、RAILS_SERVE_STATIC_FILESという環境変数を見ているのはデフォルトではproductionだけなので、developmentの動作には影響しない。

マニフェストファイル

JavaScriptファイルとスタイルシートについては、ダイジェスト付与だけでなく、コンパイル、統合、圧縮も行う。

developmentではコンパイルとダイジェスト付与だけなので、まずはそれを見ていく。

アセットパイプラインにおいては、個々のJavaScriptファイルやスタイルシートを読み込むのではなく、マニフェストファイルと呼ばれるものを読み込むのが原則になっている。
もちろん個々に読み込むことも可能であり、それについては後述する。

マニフェストファイルは設定ファイルのようなもので、利用するJavaScriptファイルやスタイルシートをここで指定しておくことで、マニフェストファイルを読み込んだときにまとめて読み込まれる仕組みになっている。

実はマニフェストファイルはデフォルトで既に作成されており、しかも既にapp/views/layouts/application.html.erbで読み込まれている。

<%= stylesheet_link_tag    'application', media: 'all' %>
<%= javascript_include_tag 'application' %>

JavaScriptファイルはjavascript_include_tagで、スタイルシートstylesheet_link_tagで読み込まれる。
前者はapp/assets/javascripts/、後者はapp/assets/stylesheets/がルートパスになるので、上記の場合は以下の2つのファイルをマニフェストファイルとして読み込んでいることになる。

  • app/assets/javascripts/application.js
  • app/assets/stylesheets/application.css

2つのファイルにはいろいろと書かれているが、特に重要と思われるのは、どちらにも登場するrequire_tree .という記述。
require_treeは、指定されたディレクトリ以下の全てのファイルを読み込む。
今回はカレントディレクトリを指しているので、それ以下の全てのファイルを読み込む。

例として、app/assets/javascripts/sample.jsapp/assets/stylesheets/sample.cssを作成して確認してみる。

diff --git a/app/assets/javascripts/sample.js b/app/assets/javascripts/sample.js
new file mode 100644
index 0000000..8d46f25
--- /dev/null
+++ b/app/assets/javascripts/sample.js
@@ -0,0 +1 @@
+console.log('This is sample.js');
diff --git a/app/assets/stylesheets/sample.css b/app/assets/stylesheets/sample.css
new file mode 100644
index 0000000..ccf22a0
--- /dev/null
+++ b/app/assets/stylesheets/sample.css
@@ -0,0 +1,3 @@
+h1 {
+  color: blue;
+}

この状態でdevelopmentでサーバーを起動してページを見てみると、h1が青くなっており、コンソールログにはThis is sample.jsと表示されているはず。

また、ページの<head>の中身を見てみると、以下のようになっており、マニフェストファイルで指定したファイルが全てダイジェスト付きになって読み込まれている。

    <link rel="stylesheet" media="all" href="/assets/posts.self-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.css?body=1" />
<link rel="stylesheet" media="all" href="/assets/sample.self-d9b74f2b3926f9fbae3ce3ea3396c2c9ef70eafed818d184eab580a54595ce02.css?body=1" />
<link rel="stylesheet" media="all" href="/assets/scaffolds.self-8d6bd2e12bf8382f31c1c80fa0db365cd6866611c10f592870958f179690ed59.css?body=1" />
<link rel="stylesheet" media="all" href="/assets/application.self-f0d704deea029cf000697e2c0181ec173a1b474645466ed843eb5ee7bb215794.css?body=1" />
    <script src="/assets/rails-ujs.self-3b600681e552d8090230990c0a2e8537aff48159bea540d275a620d272ba33a0.js?body=1"></script>
<script src="/assets/activestorage.self-0525629bb5bac7ed5f2bfc58a9679d75705e426dafd6957ae9879db97c8e9cbe.js?body=1"></script>
<script src="/assets/action_cable.self-69fddfcddf4fdef9828648f9330d6ce108b93b82b0b8d3affffc59a114853451.js?body=1"></script>
<script src="/assets/cable.self-8484513823f404ed0c0f039f75243bfdede7af7919dda65f2e66391252443ce9.js?body=1"></script>
<script src="/assets/posts.self-877aef30ae1b040ab8a3aba4e3e309a11d7f2612f44dde450b5c157aa5f95c05.js?body=1"></script>
<script src="/assets/sample.self-ebf98f6f89562fbfb67cf4e34fbb1605c9aa1492e9c9d8cdc11495da3a1ad7fa.js?body=1"></script>
<script src="/assets/application.self-eba3cb53a585a0960ade5a8cb94253892706bb20e3f12097a13463b1f12a4528.js?body=1"></script>

マニフェストファイルの具体的な書き方については以下を参照。
railsguides.jp

コンパイル

ダイジェスト付与以外にdevelopmentproductionで共通して行われる処理として、コンパイルがある。
これは、CoffeeScriptなどを、ブラウザが解釈できる形に変換してくれる機能のことである。

app/assets/javascripts/cs.coffeeを作成して確認してみる。

diff --git a/app/assets/javascripts/cs.coffee b/app/assets/javascripts/cs.coffee
new file mode 100644
index 0000000..3f32e7a
--- /dev/null
+++ b/app/assets/javascripts/cs.coffee
@@ -0,0 +1,2 @@
+name = "Coffee Script"
+console.log "My name is #{name}!"

コンソールログを見るとMy name is Coffee Script!と表示されている。

ページが読み込んでいるsample.self-xxx.jsの中身を見ると、以下のようにJavaScriptの記法になっている。

console.log('This is sample.js');

しかし、ES2015+のトランスパイルは行われないので、注意が必要。
以下のようなコードは、トランスパイルされることなくそのまま出力される。

diff --git a/app/assets/javascripts/sample.js b/app/assets/javascripts/sample.js
index 1980b45..2171aa1 100644
--- a/app/assets/javascripts/sample.js
+++ b/app/assets/javascripts/sample.js
@@ -1 +1,2 @@
-console.log('This is sample.js');
+const hoge = BigInt;
+console.log(hoge);

そのため、BigIntをサポートしている最新版のChromeでは動くが、サポートしていないSafari12.0.1では以下のエラーになる。

ReferenceError: Can't find variable: BigInt

JavaScriptスタイルシートをプリコンパイルする

続いて、production環境での挙動を見てみる。

そのためにまず、プリコンパイルを行う。

$ rails assets:precompile RAILS_ENV=production

すると、エラーが出るはず。

rails aborted!
Uglifier::Error: Unexpected token: keyword (const). To use ES6 syntax, harmony mode must be enabled with Uglifier.new(:harmony => true).

これは、JavaScriptの圧縮を行っているライブラリがES2015+に対応していないのが原因。
config/environments/production.rbを編集して設定を変える必要がある。

diff --git a/config/environments/production.rb b/config/environments/production.rb
index a52e199..ab7d911 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -23,7 +23,7 @@ Rails.application.configure do
   config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
 
   # Compress JavaScripts and CSS.
-  config.assets.js_compressor = :uglifier
+  config.assets.js_compressor = Uglifier.new(harmony: true)
   # config.assets.css_compressor = :sass
 
   # Do not fallback to assets pipeline if a precompiled asset is missed.

その上で再度プリコンパイルすると、上手くいくはず。

productionで起動してページを見てみると、sample.jscs.coffeesample.cssの内容が正しく反映されている。
しかし<head>を見ると、読み込まれているのはapplication-xxxだけになっている。
これは、マニフェストファイルで指定したファイルをひとつにまとめているためである。これが、「概要」で述べた「統合」である。

    <link rel="stylesheet" media="all" href="/assets/application-79a17794ee143f211af72e257c0af173f9903be5aab393f962afb547892b4671.css" />
    <script src="/assets/application-4a7737dfce7f40904dec8d86d4ffb0662fab4f59c9bc0a9f975e6eee1a4381f4.js"></script>

先程ES2015+の対応を行ったが、それはあくまで圧縮できるようになったというだけで、コードの変換が行われているわけではない。
そのためSafari12.0.1でページを見るとやはりエラーになる。
BigIntはあまり使わないと思うが、constletなどもそのまま出力されるため、IE対応が必要な場合などは注意が必要。

マニフェストファイルを変える

マニフェストファイルはapp/assets/javascripts/application.jsapp/assets/stylesheets/application.cssである必要はない。
例えば、application.jsの階層を変えてみる。

renamed:    app/assets/javascripts/application.js -> app/assets/javascripts/base/application.js

erb側でパスを変えれば、何の問題もなく読み込める。

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 3a1b560..ebd1eb6 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -6,7 +6,7 @@
     <%= csp_meta_tag %>
 
     <%= stylesheet_link_tag    'application', media: 'all' %>
-    <%= javascript_include_tag 'application' %>
+    <%= javascript_include_tag 'base/application' %>
   </head>

ただ、require_tree .マニフェストファイルのカレントディレクトリ以下を読み込むので、その対象外であるapp/assets/javascripts/cs.coffeeapp/assets/javascripts/sample.jsは読み込まれない。

この挙動は、developmentでもproductionでも同じ。

個別に読み込む

特定のページだけで読み込みたいファイルなどは、そのファイルを直接指定することも出来る。

例として、http://localhost:3000/posts/newでのみ、app/assets/javascripts/sample.jsを読み込ませてみる。

javascript_include_tagで、読み込めばいい。

diff --git a/app/views/posts/new.html.erb b/app/views/posts/new.html.erb
index fb1e2a1..d692c12 100644
--- a/app/views/posts/new.html.erb
+++ b/app/views/posts/new.html.erb
@@ -3,3 +3,5 @@
 <%= render 'form', post: @post %>
 
 <%= link_to 'Back', posts_path %>
+
+<%= javascript_include_tag 'sample.js' =%>

まずはdevelopmentで確認。
http://localhost:3000/posts/newを開くと、エラーページが表示されてしまう。

これを解消するには、config/initializers/assets.rbRails.application.config.assets.precompile += %w( *.js )を追記しなければならない。

diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
index 4b828e8..d6464dc 100644
--- a/config/initializers/assets.rb
+++ b/config/initializers/assets.rb
@@ -12,3 +12,4 @@ Rails.application.config.assets.paths << Rails.root.join('node_modules')
 # application.js, application.css, and all non-JS/CSS in the app/assets
 # folder are already added.
 # Rails.application.config.assets.precompile += %w( admin.js admin.css )
+Rails.application.config.assets.precompile += %w( *.js )

これでサーバーを立ち上げ直すと、上手くいく。
app/assets/javascripts/sample.jshttp://localhost:3000/posts/newでのみ読み込まれ、他のページでは読み込まれない。

プリコンパイルすれば、productionでも問題なく動く。

webpack@4 で出力するファイルをリビジョン管理する

ここでいうリビジョン管理とは、JavaScriptファイルやスタイルシートのファイル名に、ハッシュ値などのユニークな値(リビジョン)を付与すること。
そうすることで、ブラウザが古いファイルのキャッシュを利用してしまい変更が反映されない、という事態を回避できる。

手動でファイル名を更新することも可能ではあるが、ビルドの際に自動的に付与されるようにするのが一般的。
ここでは、webpackv4でリビジョンを付与する方法を書いていく。

この記事で出てくるライブラリについては以下のバージョンで動作確認している。

  • webpack@4.23.1
  • webpack-cli@3.1.2
  • html-webpack-plugin@3.2.0
  • css-loader@1.0.1
  • mini-css-extract-plugin@0.4.4
  • clean-webpack-plugin@0.1.19

出力するファイルにハッシュをつける

ファイル名にリビジョンをつけること自体は簡単に出来る。

まずはwebpackをインストールする。

$ npm i -D webpack webpack-cli

ビルドの対象であるsrc/index.jsと、webpack の設定ファイルであるwebpack.config.jsを用意する。

// src/index.js
const body = document.querySelector('body');
body.innerHTML += 'First revision.';
// webpack.config.js
const path = require('path');

const OUTPUT_DIR_NAME = 'dest';

module.exports = {
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name].[contentHash].js',
    path: path.resolve(__dirname, OUTPUT_DIR_NAME),
  },
}

filename: '[name].[contentHash].js',[contentHash]がポイント。
これによってハッシュが付与される。

$ npx webpack --mode developmentでビルドして確認してみる。
以降、この記事で「ビルド」と言った場合は$ npx webpack --mode developmentを指す。

ビルドの結果、dest/main.cbcf6aafd0f9c03b23ea.jsが出力された。
出力する内容が変わらない限り、何度ビルドしても必ずこのファイルが生成される。

今度は、src/index.jsを編集した上でビルドしてみる。

diff --git a/src/index.js b/src/index.js
index b9d89a1..f855296 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,2 +1,2 @@
 const body = document.querySelector('body');
-body.innerHTML += 'First revision.';
+body.innerHTML += 'Update!';

すると、dest/main.88efe3ee8faaf3d9d00f.jsが生成された。

このように、出力されるファイル名にリビジョンをつけることは難しくない。

だがフロントエンドの場合、JSファイルはHTMLファイルで読み込まれて初めて意味がある。
今回はdest/index.htmlを用意して、そこでJSファイルを読み込む形にする。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Revision control</title>
  </head>
  <body>
  <script type="text/javascript" src="main.88efe3ee8faaf3d9d00f.js"></script></body>
</html>

取り敢えずはこれで動くが、今後ハッシュ値が変わる度にHTMLファイルも更新しないといけない。
これを手動で管理するのは煩雑なので、JSファイルのリビジョンが変わる度にHTMLファイルも自動的に更新されるようにしたい。

HTMLを動的に生成する

そのために使うのがhtml-webpack-pluginというプラグイン
これを使うことで、HTMLファイルを webpack で書き出せるようになる。

github.com

$ npm i -D html-webpack-pluginでインストール。

リビジョンを新しくするために、src/index.jsを更新する。

diff --git a/src/index.js b/src/index.js
index f855296..062a926 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,2 +1,2 @@
 const body = document.querySelector('body');
-body.innerHTML += 'Update!';
+body.innerHTML += 'Introduce HtmlWebpackPlugin!';

そして、webpack.config.jsを以下のように書き換える。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const OUTPUT_DIR_NAME = 'dest';

module.exports = {
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name].[contentHash].js',
    path: path.resolve(__dirname, OUTPUT_DIR_NAME),
  },
  plugins: [
    new HtmlWebpackPlugin({title: 'Revision control'}),
  ],
}

これでビルドするとdest/main.8e6f23081c0f0133bd09.jsが生成される。
さらに、dest/index.htmlが更新され、以下の内容に変わる。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Revision control</title>
  </head>
  <body>
  <script type="text/javascript" src="main.8e6f23081c0f0133bd09.js"></script></body>
</html>

読み込むJSファイルの名前が書き換わっているのが分かる。

これで、JSファイルが更新されて出力される度にリビジョンが付与され、しかもそれを読み込むHTML側も自動的に更新されるようになった。

html-webpack-plugin のオプション

先程出力したHTMLファイルはかなりシンプルな内容だった。
現実的には、他にも様々なメタタグやコンテンツを入れたいはず。

html-webpack-pluginには豊富なオプションが用意されているので、それを使うことで対応できる。
https://github.com/jantimon/html-webpack-plugin#options

先程の例で既にtitleオプションを使っていたが、そこにmetaオプションを加えて任意のmetaタグを出力してみる。

diff --git a/webpack.config.js b/webpack.config.js
index 9a58bce..3476b91 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -12,6 +12,9 @@ module.exports = {
     path: path.resolve(__dirname, OUTPUT_DIR_NAME),
   },
   plugins: [
-    new HtmlWebpackPlugin({title: 'Revision control'}),
+    new HtmlWebpackPlugin({
+      title: 'Revision control',
+      meta: {description: 'サイトの説明。'},
+    }),
   ],
 }
diff --git a/dest/index.html b/dest/index.html
index 8c97a8f..81d7f7c 100644
--- a/dest/index.html
+++ b/dest/index.html
@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8">
     <title>Revision control</title>
-  </head>
+  <meta name="description" content="サイトの説明。"></head>
   <body>
   <script type="text/javascript" src="main.8e6f23081c0f0133bd09.js"></script></body>
 </html>
\ No newline at end of file

問題なく書き出されている。

テンプレートファイルを用意してそれを使うことも出来る。

まず、テンプレートファイルとしてsrc/index.htmlを作る。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Template File</title>
    <link rel="stylesheet" type="text/css" href="css/base.css">
  </head>
  <body>
    <p>This is static content.</p>
  </body>
</html>

動作確認としてCSSdest/css/base.css)を読み込んでいるので、それも作成する。

body {
  background-color: beige;
}

templateオプションを指定。

diff --git a/webpack.config.js b/webpack.config.js
index 3476b91..db59371 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -13,6 +13,7 @@ module.exports = {
   },
   plugins: [
     new HtmlWebpackPlugin({
+      template: './src/index.html',
       title: 'Revision control',
       meta: {description: 'サイトの説明。'},
     }),

これでビルドすると、以下の結果になる。

diff --git a/dest/index.html b/dest/index.html
index 81d7f7c..b4f6fb2 100644
--- a/dest/index.html
+++ b/dest/index.html
@@ -2,8 +2,10 @@
 <html>
   <head>
     <meta charset="UTF-8">
-    <title>Revision control</title>
+    <title>Template File</title>
+    <link rel="stylesheet" type="text/css" href="css/base.css">
   <meta name="description" content="サイトの説明。"></head>
   <body>
+    <p>This is static content.</p>
   <script type="text/javascript" src="main.8e6f23081c0f0133bd09.js"></script></body>
-</html>
\ No newline at end of file
+</html>

src/index.htmlの内容にJSファイルを読み込む<script>タグを差し込んでいる形になっているのが分かる。
なお、titleオプションは無視されテンプレートファイルの内容が採用されるので、注意が必要。

不要なので削除しておく。

diff --git a/webpack.config.js b/webpack.config.js
index db59371..4c0d2a3 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -14,7 +14,6 @@ module.exports = {
   plugins: [
     new HtmlWebpackPlugin({
       template: './src/index.html',
-      title: 'Revision control',
       meta: {description: 'サイトの説明。'},
     }),
   ],

CSSファイルを動的に生成する

先程はdest/css/base.cssというスタティックなファイルを読み込んでいたが、CSSファイルを webpack で生成することもある。
そのような場合、JSファイルと同様にhtml-webpack-pluginが自動的に<link>タグを挿入してくれる。

CSSファイルの出力そのものはこちらを参照。
numb86-tech.hatenablog.com

まずsrc/font-size.cssを作成し、それをsrc/index.jsで読み込む。

body {
  font-size: 40px;
}
diff --git a/src/index.js b/src/index.js
index 062a926..6f4d621 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,2 +1,4 @@
+import './font-size.css';
+
 const body = document.querySelector('body');
 body.innerHTML += 'Introduce HtmlWebpackPlugin!';

次に、$ npm i -D css-loader mini-css-extract-pluginで必要なローダーをインストールした上で、webpack.config.jsを編集する。

diff --git a/webpack.config.js b/webpack.config.js
index 4c0d2a3..6e196a5 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,5 +1,6 @@
 const path = require('path');
 const HtmlWebpackPlugin = require('html-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
 
 const OUTPUT_DIR_NAME = 'dest';
 
@@ -11,7 +12,19 @@ module.exports = {
     filename: '[name].[contentHash].js',
     path: path.resolve(__dirname, OUTPUT_DIR_NAME),
   },
+  module: {
+    rules: [
+      {
+        test: /\.css$/,
+        use: [
+          MiniCssExtractPlugin.loader,
+          'css-loader',
+        ],
+      },
+    ],
+  },
   plugins: [
+    new MiniCssExtractPlugin({filename: 'css/[name].[contentHash].css'}),
     new HtmlWebpackPlugin({
       template: './src/index.html',
       meta: {description: 'サイトの説明。'},

これでビルドしてみる。
すると、以下の2つのファイルが出力されている。

dest/css/main.118ef897ded93b64888a.css
dest/main.4aa6d0f7fbe5f0933de8.js

そしてdest/index.htmlでこの2つが読み込まれているのが分かる。

diff --git a/dest/index.html b/dest/index.html
index b4f6fb2..c5c889c 100644
--- a/dest/index.html
+++ b/dest/index.html
@@ -4,8 +4,8 @@
     <meta charset="UTF-8">
     <title>Template File</title>
     <link rel="stylesheet" type="text/css" href="css/base.css">
-  <meta name="description" content="サイトの説明。"></head>
+  <meta name="description" content="サイトの説明。"><link href="css/main.118ef897ded93b64888a.css" rel="stylesheet"></head>
   <body>
     <p>This is static content.</p>
-  <script type="text/javascript" src="main.8e6f23081c0f0133bd09.js"></script></body>
+  <script type="text/javascript" src="main.4aa6d0f7fbe5f0933de8.js"></script></body>
 </html>

このように、html-webpack-pluginCSSファイルも対象にしてHTMLの出力を行ってくれる。

エントリポイントが複数ある場合の対応

複数のエントリポイントがあるときの挙動を確認するために、新たにファイルを作成する。

src/font-color.cssと、それを読み込むsrc/other.js

body {
  color: red;
}
import './font-color.css';

const body = document.querySelector('body');
body.innerHTML += 'I am other.js !';

そして、./src/other.jsをエントリポイントとして設定する。名前はsubとした。

diff --git a/webpack.config.js b/webpack.config.js
index 6e196a5..d9ffb98 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -6,7 +6,8 @@ const OUTPUT_DIR_NAME = 'dest';
 
 module.exports = {
   entry: {
-    main: './src/index.js'
+    main: './src/index.js',
+    sub: './src/other.js'
   },
   output: {
     filename: '[name].[contentHash].js',

これでビルドすると以下のJSファイルとCSSファイルが出力される。

dest/css/main.d3ca65c4e54b7b002e2c.css
dest/css/sub.e48e406758b0bd95f6fc.css
dest/main.68f1aa6e62402727378e.js
dest/sub.fc65f262edb74ee554aa.js

それに合わせてdest/index.htmlも書き換えられ、mainsubも両方読み込まれていることが分かる。

diff --git a/dest/index.html b/dest/index.html
index c5c889c..1777c27 100644
--- a/dest/index.html
+++ b/dest/index.html
@@ -4,8 +4,8 @@
     <meta charset="UTF-8">
     <title>Template File</title>
     <link rel="stylesheet" type="text/css" href="css/base.css">
-  <meta name="description" content="サイトの説明。"><link href="css/main.118ef897ded93b64888a.css" rel="stylesheet"></head>
+  <meta name="description" content="サイトの説明。"><link href="css/main.d3ca65c4e54b7b002e2c.css" rel="stylesheet"><link href="css/sub.e48e406758b0bd95f6fc.css" rel="stylesheet"></head>
   <body>
     <p>This is static content.</p>
-  <script type="text/javascript" src="main.4aa6d0f7fbe5f0933de8.js"></script></body>
+  <script type="text/javascript" src="main.68f1aa6e62402727378e.js"></script><script type="text/javascript" src="sub.fc65f262edb74ee554aa.js"></script></body>
 </html>

このように、複数のエントリポイントがある場合、全てHTMLで読み込まれる。

だが、一部のエントリポイントだけ読み込みたいというケースもある。
そのような場合、読み込むファイルをchunksオプションで指定すればいい。

例えば、mainは読み込まずにsubだけ読み込みたいなら、以下のように記述すればよい。

diff --git a/webpack.config.js b/webpack.config.js
index d9ffb98..3f1f6ba 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -29,6 +29,7 @@ module.exports = {
     new HtmlWebpackPlugin({
       template: './src/index.html',
       meta: {description: 'サイトの説明。'},
+      chunks: ['sub'],
     }),
   ],
 }

これでビルドすると以下のように、subだけを読み込むHTMLファイルが書き出される。

diff --git a/dest/index.html b/dest/index.html
index 1777c27..faf5ae5 100644
--- a/dest/index.html
+++ b/dest/index.html
@@ -4,8 +4,8 @@
     <meta charset="UTF-8">
     <title>Template File</title>
     <link rel="stylesheet" type="text/css" href="css/base.css">
-  <meta name="description" content="サイトの説明。"><link href="css/main.d3ca65c4e54b7b002e2c.css" rel="stylesheet"><link href="css/sub.e48e406758b0bd95f6fc.css" rel="stylesheet"></head>
+  <meta name="description" content="サイトの説明。"><link href="css/sub.e48e406758b0bd95f6fc.css" rel="stylesheet"></head>
   <body>
     <p>This is static content.</p>
-  <script type="text/javascript" src="main.68f1aa6e62402727378e.js"></script><script type="text/javascript" src="sub.fc65f262edb74ee554aa.js"></script></body>
+  <script type="text/javascript" src="sub.fc65f262edb74ee554aa.js"></script></body>
 </html>

最新のファイル以外を削除する

ここまでの例でdestを対象にファイルを出力してきたが、その結果、destには多くのファイルが存在しているはず。
しかしそのほとんどは不要であり、最新のファイルさえあればよいはずなのだから、過去のファイルは自動的に削除されるのが望ましい。

それを実現するためによく使われるプラグインが、clean-webpack-pluginである。 github.com

$ npm i -D clean-webpack-pluginでインストールした上で、プラグインの記述を追加する。

diff --git a/webpack.config.js b/webpack.config.js
index 3f1f6ba..eb6f1b9 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,6 +1,7 @@
 const path = require('path');
 const HtmlWebpackPlugin = require('html-webpack-plugin');
 const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const CleanWebpackPlugin = require('clean-webpack-plugin');
 
 const OUTPUT_DIR_NAME = 'dest';
 
@@ -25,6 +26,7 @@ module.exports = {
     ],
   },
   plugins: [
+    new CleanWebpackPlugin([OUTPUT_DIR_NAME]),
     new MiniCssExtractPlugin({filename: 'css/[name].[contentHash].css'}),
     new HtmlWebpackPlugin({
       template: './src/index.html',

これでビルドすると、ビルド前にdestディレクトリをクリーンにしてからビルドを行う。
そのため、destには今回のビルドで出力されたファイルのみが入っている状態になる。

気を付けないといけないのは、指定したディレクトリを一度完全にクリーンにしてしまうという点である。
そのため、 webpack では出力しないスタティックなファイルとして定義したdest/css/base.cssも削除されてしまう。

これを避けたければ以下のように指定方法を工夫する必要がある。

diff --git a/webpack.config.js b/webpack.config.js
index eb6f1b9..9644ee4 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -26,7 +26,7 @@ module.exports = {
     ],
   },
   plugins: [
-    new CleanWebpackPlugin([OUTPUT_DIR_NAME]),
+    new CleanWebpackPlugin([`${OUTPUT_DIR_NAME}/*.*.js`, `${OUTPUT_DIR_NAME}/css/*.*.css`]),
     new MiniCssExtractPlugin({filename: 'css/[name].[contentHash].css'}),
     new HtmlWebpackPlugin({
       template: './src/index.html',

こうするとdest/css/base.cssは対象から外れるので、削除されずに済む。

参考資料