Make it to make it

いろいろ作ってアウトプットするブログ

Webpackをゼロから学ぶ(その1)

WebpackなどのフロントエンドDevOps的な分野は、なかなか面倒なイメージで今まで避けてきた部分だったので、改めてゼロからしっかりと学んでみる。

Webpack学習の問題点

  • 初学者にとっていきなり理解するのは難しい。実践が必要
  • 無関係なこともブラックボックス的に行われている
  • まず先ずは設定(Configuration)が必要で、それなしだと動かない
  • ドキュメントを読んで理解するのに時間を費やさないといけない

Webpackはどのような問題を解決するのか

JSにモジュール化を可能にさせる

Webpackを使わないとどうなるのか

htmlのスクリプトタグに読ませる/gulpなどでconcatする際の問題点

Webpackの実例

以下のようなプロジェクト構造があるとする。

.
├── css
│   └── main.css
├── index.html
├── js
│   ├── first.js
│   ├── index.js (entry point)
│   └── second.js
└── package.json

Webpackの基本的なアイデアは、このindex.jsというエントリーポイントファイルに、必要となるcssやjsファイルを読み込ませ、バンドルさせるというもの。 このエントリーファイルが、Webpackの全ての基本となる。

Webpackを使用するために必要となるのが webpackwebpack-cli の2種類のパッケージなので、早速インストールを行う。

yarn init -y
yarn add -D webpack webpack-cli

この状態でwebpackのバイナリファイルを実行してみる。

# yarnの場合
yarn -s run webpack

# npmの場合
npx webpack

# または直接下記を実行
./node_modules/.bin/webpack

すると以下のようなErrorメッセージが返ってくる。

ERROR in Entry module not found: Error: Can't resolve './src' in ...

これはなぜかというと、WebpackのConfiguration設定前の基本的なディレクトリ構造が違うから。 Webpackのデフォルト設定では、

Entry: `src/index.js` => Output: `dist/main.js`

という方式になっている。

改めてWebpackのデフォルト方式に従って、以下のようなディレクトリ構造にする。

.
├── dist
│   └── main.js
├── package.json
├── src
│   ├── css
│   │   └── main.css
│   ├── index.html
│   ├── index.js
│   └── js
│       ├── first.js
│       └── second.js
└── yarn.lock

各ファイルの中身はこんな感じ。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Webpack</title>
</head>

<body>
  <h1>Learning Webpack!</h1>
  <script src="js/index.js"></script>
</body>

</html>
// first.js
console.log('First JS!')
// second.js
console.log('Second JS!')
// index.js
require('./js/first')
require('./js/second')

以上のようなファイルの中身で実行すると、実行が成功して dist/main.js が生成される。

!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="",t(t.s=0)}([function(e,n,t){t(1),t(2)},function(e,n){console.log("First JS!")},function(e,n){console.log("Second JS!")}]);

中身はminifyされてしまっているが、console.logの内容が含まれていることが確認できる。

ここで、以下のようなcssファイルをエントリーポイントの index.js で読もうとすると、これまたエラーを起こしてしまう。

/* main.css */
body {
  color: tomato;
}
// index.js
require('./css/main.css')
require('./js/first')
require('./js/second')

これは各ファイル形式に適したローダー(loader)での処理が必要なためである。

Webpackのモードについて

デフォルトではWebpackはproductionモードに設定されている。

yarn -s run webpack --mode=development

で実行をdevelopmentモードにして行うとビルド後のJSもminifyされない。

Webpackの設定(Config)

ここまではコマンド上だけで実行をしてきたが、Webpackを導入するときに欠かせないのがconfigファイル( webpack.config.js

必要な4ステップ

  1. Configファイル
  2. LoaderとPlugin
  3. devServerとHMR

基本のconfig

webpack.config.js

const path = require('path')

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  }
}

outputのパスは絶対パスでなければいけない。

css-loader, style-loaderの導入

cssなどを処理するときには、css-loader, style-loaderを入れる。

yarn add -D style-loader css-loader

以下のように使う。

webpack.config.js

const path = require('path')

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  }
}

useで指定しているloaderは、後ろから処理されることを知っておかなければいけない。

こうすることで、エントリーポイントのjsファイルでcssを読み込みすることができる。

file-loaderの導入

さらに画像とcss内での背景画像で読み込み記述を追加してみる。

.
├── index.html
├── package.json
├── src
│   ├── css
│   │   └── main.css
│   ├── img
│   │   └── lgtm.jpg
│   ├── index.js
│   └── js
│       ├── first.js
│       └── second.js
├── webpack.config.js
└── yarn.lock

画像の読み込み処理時にはfile-loaderが必要になる。

yarn add -D file-loader

webpack.config.js

const path = require('path')

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(png|jpg|jpeg|svg|gif)$/,
        use: ['file-loader']
      }
    ]
  }
}

このままの設定でも問題なくコンパイルされるが、問題が生じる。 imgフォルダがdist内にコピーされず、画像がそのままコンパイルされるので、publicPathを追加する。 outputPathとnameを設定することで、コンパイル後もディレクトリ構造とファイル名が保たれる。

webpack.config.js

const path = require('path')

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(png|jpg|jpeg|svg|gif)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              publicPath: '/dist/img/',
              outputPath: 'img/',
              name: '[name].[ext]'
            }
          }
        ]
      }
    ]
  }
}

clean-webpack-pluginの導入

毎回distディレクトリ内を削除して再度バンドルするのは面倒なので、clean-webpack-pluginを導入することで簡易化する。

webpack.config.js

const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(png|jpg|jpeg|svg|gif)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              publicPath: '/dist/img/',
              outputPath: 'img/',
              name: '[name].[ext]'
            }
          }
        ]
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin()
  ]
}

html-webpack-pluginの導入

htmlもdistフォルダにコンパイルするときは、html-webpack-pluginを導入する。

webpack.config.js

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',  // filename: '[name].bundle.[hash].js' などとハッシュ付きファイル名でも指定可能。html-webpack-pluginが上手く取り扱ってくれる。
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(png|jpg|jpeg|svg|gif)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              publicPath: '/dist/img/',
              outputPath: 'img/',
              name: '[name].[ext]'
            }
          }
        ]
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'html webpack plugin',
      filename: 'index.html',  // distの先のhtmlファイル名の指定
      template: 'index.html'  // srcのテンプレートとなるhtmlファイル
    }),
    new CleanWebpackPlugin()
  ]
}

distのhtmlの中身を見てみると、htmlの内容もjsファイルにバンドルされたということがわかる。

開発時に便利な設定

ソースマップの有効化

module.exports.devtool = 'inline-source-map'  // 'inline-source-map' または 'source-map' が便利

を設定ファイルに追加すると、ソースマップが有効化されてデバッグしやすい。

webpack-dev-serverの導入とHot Module Replacement (HMR) の有効化

効率的な開発のためにはホットリロードを有効化したいところ。そこで、webpack-dev-server (WDS)を導入する。

yarn add -D webpack-dev-server

WDSを設定に追加する際は、以下のように設定を追加する。

module.exports.devServer = {
  contentBase: './dist',
  port: 1234,
  hot: true
}
yarn run -s webpack-dev-server

HMRはwebpackモジュールからインスタンス化してpluginsに追加して有効化する。

ここまで設定ファイルをまとめると、以下のようになる。

package.json

{
  "name": "js-infra",
  "version": "1.0.0",
  "main": "index.js",
  "author": "Sungjoon Park",
  "license": "MIT",
  "devDependencies": {
    "clean-webpack-plugin": "^3.0.0",
    "css-loader": "^2.1.1",
    "file-loader": "^3.0.1",
    "html-webpack-plugin": "^3.2.0",
    "standard": "^12.0.1",
    "style-loader": "^0.23.1",
    "webpack": "^4.32.2",
    "webpack-cli": "^3.3.2",
    "webpack-dev-server": "^3.5.1"
  }
}

webpack.config.js

const path = require('path')
const Webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  mode: 'development',
  devtool: 'inline-source-map',
  devServer: {
    contentBase: './dist',
    port: 1234,
    hot: true
  },
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.[hash].js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(png|jpg|jpeg|svg|gif)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              publicPath: '/dist/img/',
              outputPath: 'img/',
              name: '[name].[ext]'
            }
          }
        ]
      }
    ]
  },
  plugins: [
    new Webpack.HotModuleReplacementPlugin(),
    new HtmlWebpackPlugin({
      title: 'html webpack plugin',
      filename: 'index.html',
      template: 'index.html'
    }),
    new CleanWebpackPlugin()
  ]
}