このページの本文へ

まだ使ってないの? Web開発を超効率化するwebpack 2はもはや必須ツールだ

2017年03月10日 08時00分更新

文●Mark Brown

  • この記事をはてなブックマークに追加
本文印刷
タスクランナーに代わって、ここ最近人気が高まっているビルドツールといえばwebpack。「難しそう」「面倒くさそう」——まだ導入していないなら、いますぐ試してみる価値はありそうです。

webpackは現在のWeb開発シーンにおいてもっとも重要なツールになりました。基本的には自分のJavaScriptファイルにほかのモジュールをバンドル(1つに束ねる)してくれるものですが、ほかにもHTML、CSS、さらに画像といったフロントエンド開発で使うファイルすべてに適用できます。webpackを使えばアプリからのHTTPリクエストの数をうまく制御できますし、ほかのツール、たとえばJade、Sass、ES6も使用できます。npmからほかのパッケージを参照するのも簡単です。

この記事ではwebpackを使うのが初めての人を対象に、初期設定、モジュール、ローダー、プラグイン、コードの分割、稼働中のモジュール交換を説明していきます。動画チュートリアルが必要なら、Glen Madernによるwebpack from First Principlesを見ればwebpackのすごさが分かるのでおすすめです。

記事の説明を追っていくなら、Node.jsがインストール済みである必要があります。また、Githubリポジトリからこの記事のデモアプリをダウンロードできます。

セットアップ

それではnpmで新規プロジェクトを始め、webpackをインストールします。

mkdir webpack-demo
cd webpack-demo
npm init -y
npm install webpack@beta --save-dev
mkdir src
touch index.html src/app.js webpack.config.js

インストール後、ファイルを以下のように編集します。

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello webpack</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="dist/bundle.js"></script>
  </body>
</html>
src/app.js
const root = document.querySelector('#root')
root.innerHTML = `<p>Hello webpack.</p>`
webpack.config.js
const webpack = require('webpack')
const path = require('path')

const config = {
  context: path.resolve(__dirname, 'src'),
  entry: './app.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  module: {
    rules: [{
      test: /\.js$/,
      include: path.resolve(__dirname, 'src'),
      use: [{
        loader: 'babel-loader',
        options: {
          presets: [
            ['es2015', { modules: false }]
          ]
        }
      }]
    }]
  }
}

module.exports = config

この設定は一般的な「はじめの一歩」です。webpackは、エントリーポイントであるsrc/app.jsファイルをコンパイルして/dist/bundle.jsとして出力し、すべての.jsファイルをBabelによってES2015からES5に変換します。

これを動かすために3つのパッケージをインストールします。babel-core、webpackのローダーであるbabel-loader、書きたいJavaScriptの種類に合わせてプリセットのbabel-preset-es2015が必要です。{ modules: false }では、Tree Shaking機能を有効にして、エクスポートしたのに使われていない部分をバンドルファイルから取り除いてサイズを抑えます。

npm install babel-core babel-loader babel-preset-es2015 --save-dev

最後にpackage.jsonファイルのscriptsの部分を以下のように書き換えます。

package.json
"scripts": {
  "start": "webpack --watch",
  "build": "webpack -p"
},

コマンドラインからnpm startを実行すればwebpackがウォッチモードで開始され、srcディレクトリ内の.jsファイルが変更されたらいつでも再コンパイルするようになります。生成されたバンドルファイルの情報がコンソールに表示されるので、バンドルの番号やサイズを忘れずに見ておきます。

Console output of running webpack 2 in watch mode

ブラウザーでindex.htmlを読み込めば「Hello webpack」と表示されるはずです。

open index.html

dist/bundle.jsファイルを開いて、webpackがどのように働いたのかを確認します。先頭はwebpackがモジュールを起動させるためのコード、下には自分のモジュールがあります。まだはっきり理解できないかもしれませんが、ここまで来ればもうES6のモジュールを作成でき、全ブラウザーで動作可能な本番用のバンドルファイルを生成できる状態です。

Ctrl + Cを押してwebpackを停止しnpm run buildコマンドを実行して本番用(プロダクションモード)でコンパイルしてください。

バンドルサイズが2.61KBから585Bまで下がったことに注目してください。

もう一度dist/bundle.jsファイルを見ると、醜くて汚いコードが現れます。バンドルファイルはUglifyJSで最小化されたので、コードは同じでも最小限の文字数になったのです。

モジュール

初期状態でもwebpackはいろいろなJavaScriptモジュール参照形式に対応していて、注目は下の2つです。

  • ES2015形式のimport
  • CommonJS形式のrequire()

上の2つは、lodashをインストールしてapp.jsから読み込むと確かめられます。

npm install lodash --save
src/app.js
import {groupBy} from 'lodash/collection'

const people = [{
  manager: 'Jen',
  name: 'Bob'
}, {
  manager: 'Jen',
  name: 'Sue'
}, {
  manager: 'Bob',
  name: 'Shirley'
}, {
  manager: 'Bob',
  name: 'Terrence'
}]
const managerGroups = groupBy(people, 'manager')

const root = document.querySelector('#root')
root.innerHTML = `<pre>${JSON.stringify(managerGroups, null, 2)}</pre>`

コマンドnpm startを実行してwebpackを開始しindex.htmlを更新すれば、上司の名前ごとに分けられた従業員名の一覧が表示されます。

この人名を入れた配列を、単独のモジュールpeople.jsへ移動します。

src/people.js
const people = [{
  manager: 'Jen',
  name: 'Bob'
}, {
  manager: 'Jen',
  name: 'Sue'
}, {
  manager: 'Bob',
  name: 'Shirley'
}, {
  manager: 'Bob',
  name: 'Terrence'
}]

export default people

これを単にapp.jsから相対パスで読み込めばいいのです。

src/app.js
import {groupBy} from 'lodash/collection'
import people from './people'

const managerGroups = groupBy(people, 'manager')

const root = document.querySelector('#root')
root.innerHTML = `<pre>${JSON.stringify(managerGroups, null, 2)}</pre>`

:相対パスを使わないで'lodash/collection'のようにすると/node_modulesにインストールしたnpmのモジュールを指すので、自分のモジュールを読み込むのと区別するには常に'./people'のような相対パスを使用してください。

ローダー

異なる形式のファイルをどのようにインポートするかwebpackに指示するため、すでに数あるローダーの中からbabel-loaderを導入しました。ローダーをつなげれば連続した処理ができるので、JavaScriptファイルからSassを読み込んでその動作を確認します。

Sass

変換には3つの別々のローダーとnode-sassライブラリーが関わっています。

npm install css-loader style-loader sass-loader node-sass --save-dev

Sassの.scssファイルを処理するため、configファイルに新しいルールを追加します。

webpack.config.js
rules: [{
  test: /\.scss$/,
  use: [
    'style-loader',
    'css-loader',
    'sass-loader'
  ]
}, {
  // ...
}]

webpack.config.jsファイル内でルールを変更をした際は毎回Ctrl + Cを押してnpm startコマンドで再度起動してください。

配列にある一連のローダーは、逆順に処理されます。

  • sass-loader:SassファイルをCSSへ変換
  • css-loader:CSSを解析してJavaScirptで扱えるようにして、すべての依存オブジェクトを解決
  • style-loader:ドキュメントの<style>タグ内にCSSを出力

1つのローダーの出力が次のローダーの入力になっており、関数の実行のように理解して良いでしょう。

styleLoader(cssLoader(sassLoader('source')))

Sassソースファイルを加えます。

src/style.scss
$bluegrey: #2B3A42;

pre {
  padding: 20px;
  background: $bluegrey;
  color: #dedede;
  text-shadow: 0 1px 1px rgba(#000, .5);
}

ここでJavaScriptからSassを直接読み込みます。app.jsファイルの先頭で次を読み込んでください。

src/app.js
import './style.scss'

// ...

index.htmlを更新すればスタイルが反映されているのが確認できます。

JavaScriptでCSSを扱う

たったいま、モジュールとしてSassファイルをJavaScriptに読み込みました。

dist/bundle.jsファイルを開き「pre {」を検索してください。実際にSassがCSSの文字列にコンパイルされ、モジュールとしてバンドルファイルに保存されています。JavaScriptでこれを読み込んだときに、style-loaderがこのCSSを埋め込まれた<style>タグの中に流し込むのです。

なにを考えているのかは分かります。「なぜ?

これに深入りするつもりはありませんが、理由を以下にあげておきます。

  • プロジェクトで使うJavaScriptコンポーネントは、HTML、CSS、画像、SVG画像などの別ファイルを参照し利用していることがあるため、それらを1つに束ねれば読み込んで使用するのも簡単になる
  • 不要なコードを削減できる。コードの中から読み込まれないJavaScriptがあれば、関連するCSSも読み込まれない。バンドルファイルには使うコードしか含まれていない
  • CSSモジュールの利用。グローバルの名前空間では、CSSに加えた変更が余計なところに影響を及ぼさないか常に心配する必要がある。CSSモジュールならCSSを標準でローカル化して、JavaScriptから参照するための一意のクラス名が得られる
  • コードを賢く結合・分割することでHTTPリクエスト数を減らせる

画像

最後に取りあげるローダーの実例は、画像を取り扱うurl-loaderです。

普通のHTMLファイルでは、画像はブラウザーが<img>タグ、またはbackground-imageプロパティによって読み込まれます。webpackでは最適化するため、小さな画像なら文字列化した状態でJavaScriptのコード内に埋めておけます。こうすれば画像を先にロードできるので、ブラウザーがあとから別途、読み込む必要がなくなります。

npm install file-loader url-loader --save-dev

画像の読み込みのためにルールをもう1つ追加します。

webpack.config.js
rules: [{
  test: /\.(png|jpg)$/,
  use: [{
    loader: 'url-loader',
    options: { limit: 10000 } // Convert images < 10k to base64 strings
  }]
}, {
  // ...
}]

Ctrl + Cを押してnpm startコマンドでアプリを再起動します。

次のコマンドでテスト用画像をダウンロードしてください。

curl https://raw.githubusercontent.com/sitepoint-editors/webpack-demo/master/src/code.png --output src/code.png

次でapp.jsファイルの先頭で画像ファイルを読み込めます。

src/app.js

import codeURL from './code.png'
const img = document.createElement('img')
img.src = codeURL
img.style.backgroundColor = "#2B3A42"
img.style.padding = "20px"
img.width = 32
document.body.appendChild(img)

// ...

src属性で画像自身のデータURIを指定したimgタグも含められます。

<img src="data:image/png;base64,iVBO..." style="background: #2B3A42; padding: 20px" width="32">

またcss-loaderのおかげでurl()で参照されている画像ファイルはurl-loaderで処理されてCSSに直接インライン配置されます。

src/style.scss

pre {
  background: $bluegrey url('code.png') no-repeat center center / 32px 32px;
}

コンパイルすると次のようになります。

pre {
    background: #2b3a42 url("data:image/png;base64,iVBO...") no-repeat scroll center center / 32px 32px;
}

モジュールを静的なファイルに変える意義

ここまでで、サイトのリソース同士の複雑な依存関係を構成するのにローダーがいかに役立つか分かったはずです。以下はwebpackのサイトにある図です。

JS requires SCSS requires CSS requires PNG

エントリーポイントはJavaScriptでも、そのほかのリソース(HTML、CSS、SVGファイルなど)の相互の依存関係をビルドプロセスでは考慮する必要があることをwebpackは分かっているのです。

プラグイン

ビルトインのwebpackプラグインの例はすでに1つ登場しました。npm run buildコマンドによって実行されるwebpack -pは、webpack導入時に一緒にインストールされたUglifyJsPluginを使って本番用のバンドルファイルを最小化します。

ローダーが1つのファイルを処理するのに対し、プラグインはもっと多数のコードの一群を一括処理します。

コードの共有

webpack導入時に一緒にインストールされるもう1つのコアプラグインはcommons-chunk-pluginで、複数のエントリーポイントで共用されるコードを持ったモジュールを別途生成します。ここまでの説明ではエントリーポイントは1つしかなく、出力されるバンドルファイルも1つでした。しかし、現実的なシナリオではエントリーポイントと出力先ファイルを複数に分割したほうが良いことはよくあります。

もし自分のアプリで、別々の2つのエリアが同じモジュールを共有するとき、たとえば、ユーザー向けエリアのapp.jsと管理者用エリアのadmin.jsがある場合では、以下のようにして別々のエントリーポイントを作ります。

webpack.config.js
const webpack = require('webpack')
const path = require('path')

const extractCommons = new webpack.optimize.CommonsChunkPlugin({
  name: 'commons',
  filename: 'commons.js'
})

const config = {
  context: path.resolve(__dirname, 'src'),
  entry: {
    app: './app.js',
    admin: './admin.js'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js'
  },
  module: {
    // ...
  },
  plugins: [
    extractCommons
  ]
}

module.exports = config

ここで注目してほしい変更点は、output.filename[name]が付いている点です。この部分が上で付けた名称に置き換えられるので、設定では2つのバンドルが出力されるはずです。それが2つのエントリーポイントとなる、app.bundle.jsadmin.bundle.jsです。

このcommonschunkプラグインは、複数エントリーポイントで共有されているモジュールを入れた3つ目のファイルcommons.jsを生成します。

src/app.js
import './style.scss'
import {groupBy} from 'lodash/collection'
import people from './people'

const managerGroups = groupBy(people, 'manager')

const root = document.querySelector('#root')
root.innerHTML = `<pre>${JSON.stringify(managerGroups, null, 2)}</pre>`
src/admin.js
import people from './people'

const root = document.querySelector('#root')
root.innerHTML = `<p>There are ${people.length} people.</p>`

これらエントリーポイントにより以下のファイルが出力されます。

  • app.bundle.jsファイルにはstylelodash/collectionモジュールが含まれている
  • admin.bundle.jsには追加のモジュールは含まれていない
  • commons.jsには先ほどのpeopleモジュールが含まれている

ここで両方のエントリーポイントから共通モジュールを読み込みます。

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello webpack</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="dist/commons.js"></script>
    <script src="dist/app.bundle.js"></script>
  </body>
</html>
admin.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello webpack</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="dist/commons.js"></script>
    <script src="dist/admin.bundle.js"></script>
  </body>
</html>

ブラウザーでindex.htmladmin.htmlを開けば、自動生成された共用部分(common.js)がちゃんと読み込まれていることが分かります。

CSSの抽出

もう1つの人気プラグインはextract-text-webpack-pluginで、モジュールをバンドルから抽出してファイルに出力します。

以下では.scssルールに変更を加えてSassからコンパイルされたCSSを読み込んで、CSSバンドルとして抽出します。つまりCSSをJavaScriptバンドルファイルから抜き出しているわけです。

npm install extract-text-webpack-plugin@2.0.0-beta.4 --save-dev
webpack.config.js
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const extractCSS = new ExtractTextPlugin('[name].bundle.css')

const config = {
  // ...
  module: {
    rules: [{
      test: /\.scss$/,
      loader: extractCSS.extract(['css-loader','sass-loader'])
    }, {
      // ...
    }]
  },
  plugins: [
    extractCSS,
    // ...
  ]
}

webpackを再起動すると、通常のCSSファイル読み込みのように直接リンクできる形式の新規バンドルファイルapp.bundle.cssが生成されているはずです。

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello webpack</title>
    <link rel="stylesheet" href="dist/app.bundle.css">
  </head>
  <body>
    <div id="root"></div>
    <script src="dist/commons.js"></script>
    <script src="dist/app.bundle.js"></script>
  </body>
</html>

ページを更新したら、コンパイルされたCSSが以前のapp.bundle.jsではなくapp.bundle.cssに移っているのを確認してください。成功です!

コードの分割

すでにここまでで、コードを分割する方法を説明してきました。

  • 手動で複数のエントリーポイントを作る
  • commons chunkプラグインで共用部分を自動分割する
  • extract-text-webpack-pluginを使い、コンパイルされたバンドルファイルから一部分を抜き出す

バンドルを分割する別の方法はSystem.importまたはrequire.ensureです。これらの関数でコードの特定部分をラップすれば、アプリ実行の際に要求時にだけ読み込まれるようにできます。こうすれば最初にクライアントにすべてを送信しなくて済むため、読み込み時間が大きく改善します。System.importは引数としてモジュール名を受け取りPromiseを返します。require.ensureは引数として依存オブジェクトのリスト、コールバック関数名、該当する部分に付ける名前(オプション)を受け取ります。

もしアプリのある部分だけが大きな依存オブジェクトを使用し、ほかの部分が必要としていないなら、その部分だけ分割するのは良い方法です。このデモでは依存先d3を必要とする新規モジュールdashboard.jsを加えます。

npm install d3 --save
src/dashboard.js
import * as d3 from 'd3'

console.log('Loaded!', d3)

export const draw = () => {
  console.log('Draw!')
}

app.jsの末尾でdashboard.jsを読み込みます。

src/app.js
// ...

const routes = {
  dashboard: () => {
    System.import('./dashboard').then((dashboard) => {
      dashboard.draw()
    }).catch((err) => {
      console.log("Chunk loading failed")
    })
  }
}

// demo async loading with a timeout
setTimeout(routes.dashboard, 1000)

ここで非同期読み込みのモジュールを加えたので、webpackに対してどこから取ってくるかを知らせるためのoutput.publicPathプロパティをconfigに加える必要があります。

webpack.config.js
const config = {
  // ...
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/',
    filename: '[name].bundle.js'
  },
  // ...
}

アプリを再起動すると、謎の新規バンドルファイル0.bundle.jsがあるはずです。

webpack 2 outputs a mysterious new bundle

親切にもwebpackはサイズの大きいバンドルに[big]を表示して注意を促している点に注目してください。

0.bundle.jsは、JSONPによる要求時にだけフェッチ(取得)されます。したがってローカルでこのファイルを直接読み込んでも機能しません。なんでもよいのでサーバーを走らせる必要があります。

python -m SimpleHTTPServer 8001

http://localhost:8001/を開いてください。

読み込みのほんの1秒後にはGETリクエストにより動的に生成されたバンドル/dist/0.bundle.jsが取得され、コンソールには「Loaded!」のログが表示されます。成功です!

webpack Dev Server

ファイルを変更したらいつでも自動で更新してくれるライブリロード機能は、開発者の負担を大きく軽減してくれます。単にwebpack-dev-serverをインストールして開始するだけで準備は完了です。

npm install webpack-dev-server@2.2.0-rc.0 --save-dev

package.jsonファイル内のstartスクリプトを変更します。

"start": "webpack-dev-server --inline",

npm startコマンドでサーバーを開始し、ブラウザーでhttp://localhost:8080を開いてください。

試しにいろいろとsrcファイルを変更してみてください。たとえばpeople.js内の人名を変えてみたり、style.scss内のスタイルを変えてみたりして、目の前で変更が反映されていくのを見てください。

稼働中のモジュール差し替え

ライブリロード機能に興味を持った人なら、Hot Module Replacement (HMR、稼働中のモジュール差し替え)にはもっと驚くことでしょう。

2017年現在なら、なんらかのシングルページアプリをすでに動かしたことがあるでしょう。シングルページアプリの開発の最中は、コンポーネントに数多くの小さな変更を加えて、出力結果をすぐにブラウザーで見て動きを確認したいはずです。しかし手動でのページ更新やライブリロード機能による更新では、現在の状態を一度ぶち壊して、すべてを再度読み込みます。HMRでこの状況が大きく変わります。

開発者にとっての夢のワークフローとして、モジュールに変更を加えてコンパイルしたら、ブラウザーの更新(ローカル状態を壊す)もせず、ほかのモジュールを変更することもなく、稼働中に変更を反映できたらすばらしいですね。HMRはまだ手動更新が必要なときがあるものの、それでも多大な時間を節約してくれる、近未来的な機能です。

package.jsonファイル内のstartスクリプトに最終変更を加えます。

"start": "webpack-dev-server --inline --hot",

app.jsファイルの先頭でwebpackに対して、すべてのモジュールや依存オブジェクトの稼働したままの読み込み(hot reloading)を許可することを伝えます。

if (module.hot) {
  module.hot.accept()
}

// ...

webpack-dev-server --hotによりmodule.hottrueにされますが、これは開発モード(development)でのみ読み込まれます。本番モード(production)ではmodule.hotの値はfalseになるため、最終的なバンドルファイルからは取り除かれます。

webpack.config.jsファイルのプラグインリストにNamedModulesPluginを加えて、コンソール上のログ表示を改良します。

plugins: [
  new webpack.NamedModulesPlugin(),
  // ...
]

最後にページに<input>要素を追加します。ここに文字を入力すれば、モジュールを変更しても全ページ更新はされない(更新されるとフォームに入力中の文字は消える)ことが確認できるでしょう。

<body>
  <input />
  <div id="root"></div>
  ...

npm startコマンドでサーバーを再起動したら、全体を更新せずに再読み込みができている様子を見てください!

入力フォームにHMR Rulesとでも書いてからpeople.jsファイル内の人名を変更すると、ページを更新することなく、フォームに入力中の文字も保ったまま人名が入れ替わる様子が分かります。

これはごく単純な例ですが、とてつもなく役立つことに気がついてもらえたら幸いです。特にReactのように、別々に分かれている数多くのコンポーネントをベースにした開発ではなおさらでしょう。状態を保ったままでモジュールを変更して再描画できるので、素早い試行錯誤ができるのです。

稼働中のCSS再読み込み

ここでstyle.scssファイルの<pre>要素の背景色を変更して、この変更がHMR(稼働状態のまま差し替え)で差し替え「できない」ことを確認します。

pre {
  background: red;
}

実は、CSSにおけるHMR機能は単にstyle-loaderを使うだけで実現できているので、特別なことはしなくてよいのです。ただ、ここではすでにCSSを抽出して外部CSSファイル化したことでそのつながりを絶っており、入れ替えはできません。

もしSassルールを元の状態に戻してプラグインリストからextractCSSを削除したなら、Sassでも稼働中の差し替えができることを確認できるはずです。

{
  test: /\.scss$/,
  loader: ['style-loader', 'css-loader','sass-loader']
}

HTTP/2

webpackのようなモジュールバンドラーの最大の恩恵は「サイトのリソースをどのように構築して、どのようにクライアント側で取得されるか」を開発者が制御できるようになり、パフォーマンスの向上につながることです。何年ものあいだ、複数ファイルを結合することでクライアント側のリクエスト数を減らすことはベストプラクティスとして考えられてきました。いまでもそれは当てはまります。

しかし、HTTP/2の登場で1つのリクエストで複数ファイルを取得できるようになったので、ファイル結合はもはや唯一の特効薬ではありません。もしかすると、多数の小さなファイルを個別にキャッシュしておきクライアントは変更されたモジュールだけを個別に取得するほうが、ほんの一部しか変更していないのに大きなバンドル丸ごとを再読み込みするよりもよほど良いかもしれません。

webpackの作者Tobias Koppersは「HTTP/2の時代においてもまだバンドルが有効だ」というとても参考になる記事を書いています。さらに詳しく知りたければwebpack & HTTP/2を参照してください。

次はみなさんの番です

このwebpack2の解説がみなさんの役に立ち、使ってみて効果が出たら本当にうれしく思います。webpackの設定方法、ローダー、プラグインに慣れるまで少し時間がかかったとしても、それに見合うだけの価値があります。

公式ドキュメントはまだ制作が続いていますが、もし既存のwebpack1のプロジェクトをできたばかりのバージョンへ移行したければ、便利なバージョン1から2への移行法が用意されています。

※本記事はScott MolinariJoan YinJoyce Echessaが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:A Beginner’s Guide to webpack 2 and Module Bundling

[翻訳:西尾健史/編集:Livit

Web Professionalトップへ

WebProfessional 新着記事