🌐

webpackの苦手意識を無くす

2022/05/08に公開

はじめに

これまでRailsエンジニアとして、フロントエンドのbuildツールでwebpackerを使用し続け、良くも悪くも便利すぎるという恩恵を受けてきた。

がしかし、webpackerにはさまざまなデメリットが存在する。
良くも悪くもRailsに依存してしまっている部分が厄介になりやすい。

そこでフロントエンドのbuild周りを疎結合にするべく脱webpackerの第一歩としてwebpackを基礎の基礎から入門することにした。

本記事のハンズオン部分は、専用のリポジトリを作成してGitHubにあげています。
専用のリポジトリ

webpackについて

そもそもwebpackとは?
今や、JavaScriptはファイル(モジュール)分割することが当たり前になった。
そんな分割した多数のJavaScriptファイルをbundle(一つのファイルにまとめる)してくれるいわゆるbundler(バンドラ)と言われるもの。

webpackのようなモジュールバンドラにはwebpack以外にもesbuildやParcelなどがある。

webpackを実際に使ってみる

webpackを使う際の大まかな手順としては以下の通り。

  1. webpackとwebpackをcliから使うためのwebpack cliをインストールする。
bash
npm install --save-dev webpack
npm install --save-dev webpack-cli
  1. 実際に使用するモジュールファイルとエントリーポイントファイルを用意する。
hello.js
// モジュールファイルのサンプル

export class Hello {
	name;
	
	constructor(name) {
		this.name = name;
	}
	
	greet() {
		console.log(`Hello, ${this.name}!!!`);
	}
}
index.js
// エントリポイントファイルのサンプル

import { Hello } from './hello';

const taro = new Hello('taro');
taro.greet();
  1. バンドルを実行する
    webpackはデフォルトで、src/index.js(エントリポイントファイル)とそこから読み込まれているモジュールファイルをバンドルして、dist/main.jsというファイルを出力する。
    ※あくまでデフォルトの挙動なので、設定ファイルの記述次第で挙動は変わる。

webpackでバンドルは以下のどちらかのコマンドで実行可能。

bash
# バンドルの実行
./node_modules/.bin/webpack

# または、以下のコマンド
npx webpack

以上のコマンドでバンドルを行うと、dist/main.jsというバンドルされたファイルが生成される。

バンドルファイルについて

上記のバンドル実行手順通りにバンドリングを行うと、dist/main.jsファイルには以下のようなjavascriptコードが生成されているはず。
※改行を加えて見やすくしてあります。

main.js
(() => {
  "use strict";
  new (class {
    name;
    constructor(e) {
      this.name = e;
    }
    greet() {
      console.log(`Hello, ${this.name}!`);
    }
  })("Jiro").greet();
})();

現状、バンドル後のjavascriptファイルのバージョンなどを指定していないため、class記法を使用したES2015以降の記法が採用されている。

ただし、このままだとES2015以降の記法が使えないブラウザでこのスクリプトが実行できない

そこで必要になってくるのが、ポリフィル(機能の実装の提供)/トランスパイラ(古いバージョンのコードに自動で変換) によるES5記法への変換。

webpackにBabelを追加する

BabelはJavaScript用のトランスパイラ兼ポリフィル。

ES6(ES2015+) を ES5 へ変換するだけではなく、JSX などの非標準の JavaScript 構文を変換することも可能。
(Reactとか使う時に知らず知らずのうちにお世話になっているかも)

Babel導入手順

Babelを実際に導入する手順としては、以下の通り。

  1. 現在のプロジェクトにてBabel本体と、「ES6 → ES5」の変換に必要なプリセットをインストールする。
    ※babelで登場するcore-jsってのがポリフィルみたいな役割だった気がする(違ったらごめん)
bash
# Babel本体
npm install --save-dev @babel/core

# ES6 -> ES5の変換に必要なプリセット
npm install --save-dev @babel/preset-env
  1. ローダーを導入
    webpackでは、javascriptの形式を変換したり、他にもCSS(スタイルシート)やアセット画像などをJSへバンドルしたりするときにはローダーと呼ばれるプログラムを利用します。
    ここでいう、「javascriptの形式の変換」というのは「ES6 → ES5」のようなバージョンの変換の他にも、例えば「jsx -> js」や「ts -> js」のような変換がある。
    どういう変換を行うかだったり、バンドルしたいファイル形式に応じて適切なローダーをwebpackに利用させる必要がある。
    ※ローダーにはいろんな種類が存在するため。
    今回は、ES6からES5に変換させるのでbabel-loaderを使用する。
bash
# babel-loaderのインストール
npm install --save-dev babel-loader
  1. babelの設定ファイルを記述する
    babelの設定はプロジェクトの直下(場合によっては、フロントエンドのルートディレクトリの直下)に.babelrcファイルを作成し、それに諸々の設定を記述する。
    以下では、ES6からES5に変換する際のプリセットを@babel/preset-envを使用するように設定。
.babelrc
{
  "presets": ["@babel/preset-env"]
}
  1. webpack.config.jsファイルを作成して、webpackにbabel用の設定を追加する。
    ここまではwebpackの設定をいじることなくデフォルトの挙動でwebpackを使っていたが、ここからはbabelを使うため、webpackでbabelを使うための設定を記述する必要がある。
    ※webpack の設定ファイルを JavaScript で記述する場合にはCommonJS形式を使う。

プロジェクトフォルダ直下へ webpack.confg.js を作成
webpack.config.jsファイルの詳しい書き方についてはこちら

webpack.config.js
// webpack とbabelを一緒に使えるようにするための設定を記述している
module.exports = {
  target: ['web', 'es5'],
  mode: "development",
  module: {
    // 変換やバンドルのルールは module.rules 配列に指定する
    // 多くの場合、test でファイル形式を指定し、loader(または use 配列)へローダーを指定することになる
    rules: [
      {
        // 拡張子 js のファイル(正規表現)
        test: /\.js$/,
        // ローダーの指定
        loader: "babel-loader",
      },
    ],
  },
};

変換やバンドルのルールは module.rules 配列に指定する。
多くの場合、testでファイル形式を指定し、loader(またはuse配列)へローダーを指定する。

  1. バンドルを実行する
bash
npx webpack

上記のコマンドでバンドルを行うと、dist/main.jsの記法ES5の記法になっている。(らしいです。自分は何も変わっていないけど、、、、)

こんな感じのclass記法がない形のコードに変換されているのが正しいっぽい。

main.js
(() => {
  "use strict";
  function e(e, n) {
    for (var t = 0; t < n.length; t++) {
      var r = n[t];
      (r.enumerable = r.enumerable || !1),
        (r.configurable = !0),
        "value" in r && (r.writable = !0),
        Object.defineProperty(e, r.key, r);
    }
  }

モードとソースマップについて

現状の設定だとバンドルを実行すると下記のような警告文が出ていた。

bash
WARNING in configuration
Set 'mode' option to 'development' or 'production'
to enable defaults for each environment.

mode オプションを 'development'(開発時)または 'production'(デプロイ時)の環境に応じて設定する必要がある。

バンドルを実行するコマンドからこのモードを設定する場合は、以下のようにオプションを設定すればOK。

bash
npx webpack --mode development

設定ファイルwebpack.config.jsへ指定することもできる。
(自分はなぜかできていない、、、)

webpack.config.js
  module.exports = {
+   mode: "development",
    module: {
      rules: [

【番外編】筆者のwebpack.config.jsの設定が読み込まれていなかった問題について

上記のいくつかの箇所で、「なぜか自分はできていない」という文面があったと思うが、それらの問題はそもそも筆者のwebpack.config.jsファイル自体が読み込まれておらず、設定が反映されていなかったから。

どう対応したか?

結論、バンドルを実行するコマンドを下記のようにconfigファイルをしているようにしたところ、webpack.config.jsファイルに記述した設定が反映されるようになった。

bash
# うまくいっていなかったときのコマンド
npx webpack

# うまくいったコマンド(configファイルをオプションで指定)
npx webpack --config=./webpack.config.js

参考になった記事:webpack 5入門

ソースマップ

Babelでは、ES6からES5への変換を行なっているため、変換前後のコード対応表のようなものがあればデバッグに非常に役立つ。

webpack にソースマップも出力させるには devtool エントリを指定します。

※ソースマップというのは、変換前と後のコードを比較できるように紐づけられるもののこと。

webpack.config.js
mode: "development",
// または 'inline-source-map' など
devtool: "source-map",

webpack-dev-server でホットリロードしよう

ここまではVSCodeの拡張機能LiveServerを利用してきたが、実はローカルサーバもwebpackから立ち上げることができる。
そのためにまず、webpack-dev-serverをインストールします。

bash
# npm install --save-dev と同義
npm i -D webpack-dev-server

デフォルトではdevServerはホストのルートディレクトリ/を起点として起動するため、サーブすべきディレクトリをwebpack.config.js内で指定してあげる必要がある。

ここではバンドルした後のindex.htmlを置いているプロジェクト直下の./distを指定する。

webpack.config.js
// webpack.config.jsファイル
module.exports = {
  target: ['web', 'es5'],
  mode: "development",
  devtool: "source-map",
  // webpack-dev-serverのサーブするディレクトリを指定する
  //(デフォルトはプロジェクトのルートディレクトリ)
  devServer: {
    static: {
      directory: "./dist"
    },
  },
  module: {
    // 変換やバンドルのルールは module.rules 配列に指定する
    // 多くの場合、test でファイル形式を指定し、loader(または use 配列)へローダーを指定することになる
    rules: [
      {
        // 拡張子 js のファイル(正規表現)
        test: /\.js$/,
        // ローダーの指定
        loader: "babel-loader",
      },
    ],
  },
};

※devServer の起動には serve を付け加える。

bash
--config ./webpack.confg.jsのオプションをつけてconfigファイルを指定する
npx webpack serve  --config ./webpack.confg.js

localhost:8080をブラウザで開くとバンドル結果が表示されているはず。

serveコマンドを用いて起動した場合には --watchオプションと同様に自動的にファイルへの変更が反映(=ホットリロード)が適応される。

JSX (React) もバンドルしてみる

1. (React用の)Babelプリセットを追加する

Babelを用いて「JSXなどの非標準のJavaScript構文を変換する」ことが可能であるが、そのためには「JSX -> JS」変換用のプリセットを用意する必要がある。

Babelのreact用のプリセットをインストールする。

bash
npm i -D @babel/preset-react

さらにBabelの設定ファイルである.babelrcファイルにreact用のプリセットを使用することを追加で記述する。

.babelrc
{
	"presets": ["@babel/preset-env", "@babel/preset-react"]
}

2. Reactをインストールする

ReactとReact-domをインストールする。

bash
# npm install --save と同義
npm i -S react react-dom

3. Reactアプリを準備

手順としては、まず/distフォルダ内のバンドルファイルを削除し、/srcフォルダ内のファイルをシンプルなReactアプリ (JSX) で置き換えます。

  1. 既存のバンドルファイルとjsのソースコードファイルを削除する。
bash
rm dist/*.js* src/*.js
  1. /srcディレクトリのソースコードやエントリファイルのコードをjsxに書き換える
src/App.jsx
import React from "react";

export const App = () => {
  return (
    <div className="container">
      <h1>Hello.</h1>
    </div>
  );
};

エントリファイル

src/index.jsx
import React from "react";
import { createRoot } from "react-dom/client";

import { App } from "./App";

createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
  1. /distフォルダ内のindex.htmlへReactアプリのマウントポイントとなるJSファイルを追加する。
dist/index.html
    <body>
      <!-- Reactアプリがマウントするためのポイント --->
+     <div id="root"></div>
      <script src="./main.js"></script>
    </body>

4. エントリーファイルと依存関係の解決

最初の方で「デフォルトで/src/index.js(=エントリーファイル)とそこから読み込まれているモジュールをバンドルして」と記述したが、このデフォルトの挙動は使えない。

すでにデフォルトのエントリポイントとなるsrc/index.jsは存在せず、他のモジュールをインポートするエントリーファイルはsrc/index.jsxとなったため。

従前のバンドルのためのコマンドを実行すると次のようなエラーとなる。

bash
ERROR in main

Module not found: Error: Can't resolve './src' in '/Users/**/webpack/learn-webpack'
resolve './src' in '/Users/**/learn-webpack'

上記の「srcディレクトリ内でのモジュール解決(=resolve)が出来ません」というエラーメッセージの通り、モジュール間の依存関係の解決にどの種類のファイルを参照すべきなのかを明示的にwebpackへ伝える必要が発生した。

これにはwebpack.config.jsファイルないで、resolveエントリーを用いて、jsxファイルがエントリファイルとして認識されるように設定する。

webpack.config.js
module.exports = {
  // 依存関係解決に参照するファイルの拡張子を指定
  resolve: {
    extensions: [".js", ".json", ".jsx"],
  },
  
  // ...

また、新たなjsの形式変換のルールを追加するため、modules.rulesを更新しなければならない。

babelでトランスパイルする対象のファイルがjsxファイルになったことをwebpackに伝える。

webpack.config.js
    module: {
      rules: [
        {
          // 拡張子 js または jsx のファイル(正規表現)
+         test: /\.jsx?$/,
-         test: /\.js$/,
          loader: "babel-loader",
        },
      ],
    },

エントリーファイル名や出力ファイル名をカスタマイズする

エントリファイルをカスタマイズする

上記のwebpack.cofig.jsファイルのresolveオプションの設定によってsrc/index.jsxをエントリーファイルとすることが出来ましたが、index以外のファイル名(たとえば main.jsx など)を使いたい場合もあるでしょう。

そういう場合はwebpack.config.jsファイルのentryオプションへ、直接そのファイル名を明示的に指定する必要がある。

webpack.cofig.js
  module.exports = {
    resolve: {
      extensions: [".js", ".json", ".jsx"],
    },
+   entry: "./src/main.jsx",

出力先のファイル名をカスタマイズする

加えて、エントリーファイル同様に出力されるバンドルファイルの名前や出力先フォルダもカスタマイズすることができる。これにはwebpack.cofig.jsファイルのoutputオプションを使って設定する。

webpack.config.js
// Node.js の path モジュールをインポート
const path = require("node:path");

module.exports = {
  entry: "./src/main.jsx",
  output: {
    // ファイル名
    filename: "bundle.js",
    // 出力するフォルダ
    path: path.resolve(__dirname, "dist"),
  },

ここでは node:path モジュールを使って出力フォルダの絶対パスを指定している。

※注意:webpackでのバンドル後の出力先のファイルをoutputオプションで更新したら、./dist/index.htmlのマウントポイントのファイルも変更しないといけない。

チャンク名でエントリファイルと出力ファイルの名前を揃える

また、entryオプションでエントリーファイルにチャンク名を付けた場合には出力ファイルにもその名前を適用することができる。

webpack.config.js
  entry: {
    // チャンク名 "app"
    app: "./src/main.jsx",
  },
  output: {
    // [name] -> "app"
    filename: "[name].js",
    path: path.resolve(__dirname, "dist"),
  },

これは、複数のエントリーファイルがある場合などに自動的に出力名を割り振ってくれるのでめちゃくちゃ便利。

プロダクションビルドする

本番環境にデプロイするためのビルドは、モード(mode)にproductionを指定し、serveコマンドをbuildへ置き換えるだけです。

コマンド

bash
npx webpack build --mode production  --config ./webpack.confg.js

このコマンドを実行するだけで、本番環境用のビルドが実行される。

NPM スクリプトを登録する

毎回ターミナルへ長いコマンドを打ち込むのも疲れてきたので、package.jsonscriptsへ NPMスクリプトを登録します。

ちなみに、NPMスクリプトは npm run スクリプト名 で実行することができます。

package.json
"scripts": {
  "dev": "webpack serve --mode development --config ./webpack.confg.js",
  "build": "webpack build --mode production --config ./webpack.confg.js"
},

実際にコマンドを使う際は下記のようにする。

bash
# 開発時
npm run dev

# プロダクションビルド
npm run build

NODE_ENV で処理を分岐する

ソースマップを作成するか否かなどの処理を環境変数NODE_ENVの値によって分岐できるようにする。

上記で設定した開発環境用と本番環境用のコマンドにNODE_ENVという定数に値を代入する箇所を追加する。

package.json
"scripts": {
  // ...

  "dev": "NODE_ENV=\"development\" webpack serve --config ./webpack.confg.js",
  "build": "NODE_ENV=\"production\" webpack build --config ./webpack.confg.js",
  
  // ..
},

さらに、webpck.config.jsファイルの中身もNODE_ENVの値によって分岐できるように修正する。

webpack.conifg.js
const isDevEnv = process.env.NODE_ENV === 'development'

// webpack とbabelを一緒に使えるようにするための設定を記述している
module.exports = {
  target: ['web', 'es5'],
  mode: isDevEnv ? "development" : "production",
  // ソースマップは開発環境でのみ有効にする
  devtool: isDevEnv ? "source-map" : undefined,
  
// ...

CSS(スタイルシート)もバンドルしてみる

webpackでは、CSSファイルもJSへバンドルしてしまうことが可能。
webpackでCSSをバンドルするには、style-loadercss-loaderの2つのローダーが必要です。

それぞれのローダーについて、

  • style-loader: <link /> タグへCSSを展開します。
  • css-loader: CSS をJSへバンドルします。

ローダーは css-loader -> style-loader の順で適用する必要があります。
(依存関係的な話か???)

1. 必要なローダーをインストールする

bash
npm i -D style-loader css-loader

2. module.rulesを追加する

CSS用のバンドルルールをmodule.rulesに追加する。
複数のローダーを適用する場合にはloaderの代わりにuse配列を用いる。

webpack.config.js
module: {
  // 変換やバンドルのルールは module.rules 配列に指定する
  // 多くの場合、test でファイル形式を指定し、loader(または use 配列)へローダーを指定することになる
  rules: [
    {
      // 拡張子 jsxのファイル(正規表現)
      test: /\.jsx?$/,
      // ローダーの指定
      loader: "babel-loader",
    },
    // css関連のローダーを用意する
    {
      test: /\.css$/,
      // css-loader -> style-loaderの順で適応される。
      use: ["style-loader", "css-loader"]
    },
  ],
},

use配列のローダーは配列の最後尾から順に適用されます。
よってcss-loader -> style-loaderの順で適応となります。

3. ソースファイルの用意

適当なCSSをstyles.cssへ記述して、それをApp.jsxの中でインポートする。

src/styles.css
body {
  margin: 0;
}

.container {
  height: 100vh;
  overflow: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
}
src/App.jsx
  import React from "react";
+ import "./styles.css";

  export const App = () => {
  // ...

4. dev serverを起動する

bash
npm run dev

5. CSSにもソースマップをつける

デバッグ時にはバンドル前のCSSファイルへのソースマップも必要になる。
CSSにソースマップをつけるには、optionsエントリをmodule.rulesのcss-loaderの部分に追加することでソースマップのオプションを設定することができる。

webpack.config.js
{
  test: /\.css$/,
  use: [
    "style-loader",
    {
       loader: "css-loader",
       options: {
         // dev モードではソースマップを付ける
         sourceMap: isDev,
       },
    },
  ],
},

これで、開発環境下では、devtoolからCSSのソースマップを確認することが可能になった。

Sassもバンドルする

Sassファイルをバンドルするにはsasssass-loaderの追加が必要。

それぞれのローダーについて、

  • sass: sass(scss) -> cssの変換を行います。スタイルシート版のBabelのようなもの。
  • sass-loader: webpackにSassを扱わせます。

必要なローダーをインストールする。

bash
npm i -D sass sass-loader

ローダーの適用順はsass-loader -> css-loader -> style-loaderとなる。
(これまた依存関係かな???)

webpack.config.js
// ...

module: {
  // 変換やバンドルのルールは module.rules 配列に指定する
  // 多くの場合、test でファイル形式を指定し、loader(または use 配列)へローダーを指定することになる
  rules: [
    {
      // 拡張子 jsxのファイル(正規表現)
      test: /\.jsx?$/,
      // ローダーの指定
      loader: "babel-loader",
    },
    // 以下のcssまたはscssのバンドルに関わるルールを更新
    {
      // 拡張子 scss または css のファイルが対象
      test: /\.s?css$/,
      use: [
        "style-loader",
        {
          loader:  "css-loader",
          options: {
            // dev モードではソースマップを付ける
            sourceMap: isDevEnv,
          }
        },
        {
          loader: "sass-loader",
          options: {
            sourceMap: isDevEnv,
          },
        },
      ]
    },
  ],
},

// ...

さらにCSSで記述していた箇所をSCSSの記法に変更する。

styles.scss
// scssの記法に変更

body {
  margin: 0;

  .container {
    height: 100vh;
    overflow: hidden;
    display: flex;
    justify-content: center;
    align-items: center;
  }
}

最後に、jsxのソースファイルでインポートしている部分をSCSSファイルをインポートするように修正する。

src/App.jsx
- import "./styles.css";
+ import "./styles.scss";

// ...

最後に、SCSSに変更してもスタイルが崩れていなければOK!

画像などのアセットファイルをバンドルする

webpack v5.x系では別途ローダーを必要とせずにJSから読み込まれる画像やフォントなどのアセットファイルをバンドルできるようになりました。

アセットファイルのバンドルにはmodules.rulestypeエントリーを用います。

webpack.config.js
module: {
  rules: [
    {
      // 画像やフォントファイル
      test: /\.(ico|png|svg|ttf|otf|eot|woff?2?)$/,
      type: "asset",
   },
   
// ...

typeエントリの値にはいくつかの選択肢がありますが、通常は以下の3つから選択することになります。

  1. asset/inline: アセットをJSファイルへバンドルする。
  2. asset/resource: アセットを別ファイルとして出力する。
  3. asset: アセットをバンドルするか、別のファイルとして出力するかを自動的に選択する。

アセットをJSファイルへ含めてしまうとバンドルサイズが大きくなりすぎる(=読み込みに時間がかかる)場合のために別ファイルとして出力するオプションが用意されています。

このオプションを選択するケースには、ブラウザのリクエスト数を増やしてでもバンドルファイルの読み込みを早めるほうがメリットが大きい場合などが該当する。

アセット類の出力先を設定する

typeエントリで、asset/resourceを選択した場合のアセット類の出力先もoutputエントリでカスタマイズすることができます。
これにはassetModuleFilenameを利用する。

webpack.config.js
// ...

entry: {
	// ここでチャンクを利用しているため[name] -> 'app'になる
	app: "./src/entry.jsx"
},
output: {
	// ファイル名 [name] -> 'app'
	filename: "[name].js",
	// 出力するフォルダ
	path: path.resolve(__dirname, "dist"),
	// "dist/asset/名前.拡張子" として出力される
	// (※ここでの[name]はチャンクではなくアセットのファイル名っぽい)
	assetModuleFilename: "asset/[name][ext]",
},


// ...

※上のfilenameエントリとは異なり、ドット.は不要であることに注意が必要です。

自分で用意したアセットを使用する

以下の手順でアセット(今回は画像リソース)を使用できるようになる。

  1. アセットをimportする
  2. バンドル出力後のアセットへの参照をimgタグのsrc属性に渡す

assetをimportした状態であればbundleで出力した画像データを参照して表示することもできる。
※ただ、bundleした後のファイルがdistのディレクトリの配下に作成されない。(imageだけでなく、jsもcssも)
→ production buildするとbuild結果のファイルたちが出力される。

App.jsx
import React from "react";
import "./styles.scss";
import './favicon.ico';

export const App = () => {
  return (
    <div className="container">
      <img src="asset/favicon.ico" alt="" />
      <h1>Hello....</h1>
    </div>
  );
};

アセットのファイルサイズによってバンドルの可否を分ける

アセットファイルの容量によって自動的にasset/inlineasset/resourceをアセットごとに使い分けることもできる。
これにはparser.dataUrlCondition.maxSizeオプションを使用する。

webpack.config.js
rules: [
	{
		test: /\.(ico|png|svg|ttf|otf|eot|woff?2?)$/,
		// type は自動モード
		type: "asset",
		parser: {
		  dataUrlCondition: {
		    // 2kb以上なら`asset/resource`を使用する
		    // 2kb以下なら`asset/inline`を使用する
		    maxSize: 1024 * 2,
		  },
		},
	},

HTML も webpack から出力する

これまではdistディレクトリに置いたHTMLファイルをdevServerでサーブしていましたが、実はこれもwebpackから出力することができます。

html-webpack-pluginというライブラリをインストールして、使用する。

bash
npm i -D html-webpack-plugin

html-webpack-pluginはデフォルトでsrc/index.ejsをテンプレートとし、それにバンドル済みのJSを<script> ~ </script>タグとして差し込んだHTMLファイルを出力する。

これ以外のファイル(たとえば*.htmlファイル)をテンプレートにしたい場合にはtemplateオプションへ明示的にそのファイルを指定する必要があります。

webpack.config.js
// プラグインの読み込み
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  // "plugins" エントリーを追加
  plugins: [
    // プラグインのインスタンスを作成
    new HtmlWebpackPlugin({
      // テンプレート
      template: "./src/index.html",
      // <script> ~ </script> タグの挿入位置
      inject: "body",
      // スクリプト読み込みのタイプ
      scriptLoading: "defer",
      // ファビコンも <link rel="shortcut icon" ~ /> として挿入できる
      favicon: "./src/favicon.ico",
    }),
  ],

CSSも別ファイルとして出力する

CSP (Content-Security-Policy) の設定によってはインライン・スタイルの使用が禁じられているような場合があります。

CSS を JS へバンドルすることはインライン・スタイルに該当するため、これもアセット類と同様に別ファイルとして出力したいケースがあるでしょう。

これを実現するプラグインがmini-css-extract-pluginです。

mini-css-extract-pluginのインストール。

bash
npm i -D mini-css-extract-plugin

webpack.config.jsでmini-css-extract-pluginを読み込み、style-loaderに代わってこのプラグインのローダーを使用します。

webpack.config.js
// プラグインの読み込み
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
webpack.config.js
        {
          test: /\.s?css$/,
          use: [
-           "style-loader",
+           MiniCssExtractPlugin.loader,
            {
              loader: "css-loader",
              options: {
                sourceMap: isDev,
              },
            },

上のhtml-webpack-pluginを利用しない場合、HTMLファイルへ手動で<link rel="stylesheet" ~ />としてインポートする必要があります。

こんな感じる↓↓↓

dist/index.html
    <head>
      <meta charset="UTF-8" />
      <title>Webpack</title>
+     <link rel="stylesheet" href="main.css" />
    </head>

逆にhtml-webpack-pluginを利用する場合には、自動的に<link rel="stylesheet" ~ />タグが HTML へ挿入されます。

ここまで時点のwebpack.config.jsファイルの中身(コメント・メモ付き)

※buildごとにdistディレクトリに溜まっていく不要なファイルを自動で削除するプラグインの導入周りはオリジナルで追加。
参考サイト:【webpack5】clean-webpack-pluginを使ってコンパイル毎に出力先フォルダ内を削除する

またhtml-webpack-pluginのところで以下のサイトを参照。
参考サイト:htmlを自動生成するプラグインの使い方

webpack.config.js
const path = require("node:path");
const isDevEnv = process.env.NODE_ENV === 'development';
// webpackのhtml用のプラグインの読み込み
const HtmlWebpackPlugin = require("html-webpack-plugin");
// mini-css-extract-pluginの読み込み
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
// buildするごとに溜まっていく./distディレクトリの不要なファイルを削除するプラグイン
const { CleanWebpackPlugin } = require('clean-webpack-plugin');



// webpack とbabelを一緒に使えるようにするための設定を記述している
module.exports = {
  // "plugins"エントリーを追加
  plugins: [
    new CleanWebpackPlugin(),
    new MiniCssExtractPlugin(),
    // プラグインのインスタンスを作成
    new HtmlWebpackPlugin({
      // テンプレート(バンドル後の出力先を指定)
      template: "./src/index.html",
      // <script> ~ </script> タグの挿入位置
      inject: "body",
      // スクリプト読み込みのタイプ
      scriptLoading: "defer",
      // ファビコンも <link rel="shortcut icon" ~ /> として挿入できる
      favicon: "./src/favicon.ico",
    }),
  ],
  target: ['web', 'es5'],
  mode: isDevEnv ? "development" : "production",
  // ソースマップは開発環境でのみ有効にする
  devtool: isDevEnv ? "source-map" : undefined,
  devServer: {
    static: {
      directory: "./dist"
    },
  },
  // 依存関係解決に参照するファイルの拡張子を指定
  resolve: {
    extensions: [".js", ".json", ".jsx"],
  },
  entry: {
    app: "./src/entry.jsx"
  },
  output: {
    // ファイル名 [name] -> 'app'
    filename: "[name].js",
    // 出力するフォルダ
    path: path.resolve(__dirname, "dist"),
    // "dist/asset/名前.拡張子" として出力される(※ここでの[name]はチャンクではなくアセットのファイル名っぽい)
    assetModuleFilename: "assets/[name][ext]",
  },
  module: {
    // 変換やバンドルのルールは module.rules 配列に指定する
    // 多くの場合、test でファイル形式を指定し、loader(または use 配列)へローダーを指定することになる
    rules: [
      {
        // 画像やフォントファイル
        test: /\.(ico|png|svg|ttf|otf|eot|woff?2?)$/,
        type: "asset/resource",
      },
      {
        // 拡張子 jsxのファイル(正規表現)
        test: /\.jsx?$/,
        // ローダーの指定
        loader: "babel-loader",
      },
      {
        // 拡張子 scss または css のファイルが対象
        test: /\.s?css$/,
        use: [
          // "style-loader",
	  // mini-css-extract-pluginのローダーを使用する
	  //(アセット類と同様にCSSも別ファイルに出力したいため)
	  MiniCssExtractPlugin.loader,
          {
            loader:  "css-loader",
            options: {
              // dev モードではソースマップを付ける
              sourceMap: isDevEnv,
            }
          },
          {
            loader: "sass-loader",
            options: {
              sourceMap: isDevEnv,
            },
          },
        ]
      },
    ],
  },
};

TypeScriptをバンドルする

1. 必要なパッケージをインストールする

bash
# TypeScript本体のインストール
npm i -D typescript

# ローダーのインストール
npm i -D ts-loader

2. tsconfig.jsonの作成

TypeScriptの挙動を規定するtsconfig.jsonを作成します。
また、Reactを利用する場合には"jsx": "react"の追記も必要です。

tsconfig.jsonファイルを生成するためのコマンド

bash
npx tsc --init

実際に生成されるtsconfig.jsonファイルは下記
※ファイル生成時に記述されている子メメントを削除して、Reactを利用する場合に必要な"jsx": "react"を追記

tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "jsx": "react"
  }
}

3. webpack.config.jsのアップデート

TSのバンドルにはbabel-loaderの代わりに、ts-loaderが必要になる。
※古いブラウザへの対応などの理由でbabel-loaderts-loader併用するケースもあります。

webpack.config.js
module.exports = {
  entry: {
    // "tsx" へ変更
    main: "./src/index.tsx",
  },
  resolve: {
    // TS ファイルを追加
    extensions: [".js", ".jsx", ".ts", ".tsx"],
  },
  module: {
    rules: [
      {
        // "tsx" へ変更
        test: /\.tsx?$/,
        // "babel-loader" -> "ts-loader"
        loader: "ts-loader",
      },
    ],
  },
};

上記は、実際に変更箇所のみを抜粋している。
実際のwebpack.config.jsファイル全体としては下記のようになっている。

webpack.config.js
const path = require("node:path");
const isDevEnv = process.env.NODE_ENV === 'development';
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  plugins: [
    new CleanWebpackPlugin(),
    new MiniCssExtractPlugin(),
    new HtmlWebpackPlugin({
      template: "./src/index.html",
      inject: "body",
      scriptLoading: "defer",
      favicon: "./src/favicon.ico",
    }),
  ],
  target: ['web', 'es5'],
  mode: isDevEnv ? "development" : "production",
  devtool: isDevEnv ? "source-map" : undefined,
  devServer: {
    static: {
      directory: "./dist"
    },
  },
  resolve: {
    extensions: [".js", ".json", ".jsx", ".ts", ".tsx"],
  },
  entry: {
    main: "./src/entry.tsx"
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js",
    assetModuleFilename: "asset/[name][ext]",
  },
  module: {
    rules: [
      {
        // test: /\.(ico|png|svg|ttf|otf|eot|woff?2?)$/,
        test: /\.(ico|png|svg|jpg|jpeg|gif)$/i,
        type: "asset/resource",
      },
      {
        test: /\.tsx?$/,
        // ローダーの指定
        // "babel-loader" -> "ts-loader"
        loader: "ts-loader",
      },
      {
        // 拡張子 scss または css のファイルが対象
        test: /\.s?css$/,
        use: [
          MiniCssExtractPlugin.loader,
          {
            loader:  "css-loader",
            options: {
              // dev モードではソースマップを付ける
              sourceMap: isDevEnv,
            }
          },
          {
            loader: "sass-loader",
            options: {
              sourceMap: isDevEnv,
            },
          },
        ]
      },
    ],
  },
};

webpack.configをTypeScriptで記述する

ちょっと番外編っぽいけど一応まとめておく。
webpack.config.jsの拡張子を.tsへ変更するには以下の2つのパッケージが必要。

  • ts-node: TypeScriptのままNode.jsを実行できるようにするモジュール。
  • @types/node: TypeScript用Node.jsの型定義ファイル。

上記の二つのパッケージをインストールする。

bash
npm i -D ts-node @types/node

また、tsconfig.jsonにはts-nodeセクションを追加する必要がある。

最終的にはtsconfig.jsoファイルは下記のようになった。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "lib": ["DOM", "ES2020"],
    "esModuleInterop": true,
    "moduleResolution": "Node",
    "strict": true,
    "jsx": "react",
  },
  "ts-node": {
    "compilerOptions": {
      "module": "CommonJS"
    }
  },
  "include": [
    "./src"
  ],
}

また、webpack.config.tsファイルは下記のようになった。

webpack.config.ts
import path from 'node:path';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { CleanWebpackPlugin } from 'clean-webpack-plugin';

// 以下の二行は保管を効かせるためのインポート
import 'webpack-dev-server';
import { Configuration } from 'webpack';

const isDevEnv: boolean = process.env.NODE_ENV === 'development';

const config: Configuration = {
  // target: ['web', 'es5'],
  mode: isDevEnv ? "development" : "production",
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
  },
  entry: {
    main: "./src/entry.tsx"
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js",
    assetModuleFilename: "asset/[name][ext]",
  },
  module: {
    rules: [
      {
        test: /\.(ico|png|svg|jpg|jpeg|gif)$/i,
        type: "asset/resource",
      },
      {
        test: /\.tsx?$/,
        loader: "ts-loader",
      },
      {
        test: /\.jsx?$/,
        loader: "babel-loader",
      },
      {
        test: /\.s?css$/,
        use: [
          MiniCssExtractPlugin.loader,
          {
            loader:  "css-loader",
            options: {
              sourceMap: isDevEnv,
            }
          },
          {
            loader: "sass-loader",
            options: {
              sourceMap: isDevEnv,
            },
          },
        ]
      },
    ],
  },
  plugins: [
    new CleanWebpackPlugin(),
    new MiniCssExtractPlugin(),
    new HtmlWebpackPlugin({
      template: "./src/index.html",
      inject: "body",
      scriptLoading: "defer",
      favicon: "./src/favicon.ico",
    }),
  ],
  devtool: isDevEnv ? "source-map" : undefined,
  devServer: {
    static: {
      directory: "./dist"
    },
  },
}

// 上記の設定をデフォルトエクスポートする
export default config;

以上で、今回勉強した記事の学習まとめは終了。
以下のPRに今回の諸々の実装がまとまっている。
https://github.com/msy7822-ux/learn-webpack

Discussion