Webpackを使い倒す

本エントリーJavaScript Advent Calendar 2014 7日目の記事になります。

webpackとは

概要については最近いろんな方が書いてるいるのでそちらを参考にしていただければつかめるかと。

ようはナウいフロントエンドの依存解決ツールですね。

っていうネタで書こうと思っていたら、昨日yutaponさんが既に書いていたりするので(gulp.jsを使ってフロントエンドのビルドをする【webpack, stylus】)、いいところはパクリ参考にしつつ、もうちょっとwebpackに突っ込んだ内容を書いてみようと思います。

Options

entryとoutput

簡単に試すときはentryは1ファイルだけ指定できればよいですが、実際にプロジェクトで使うとなると複数のファイルを指定しなければならない状況になるかと思います。

webpackのentryはオブジェクト形式に対応していて、outputでそのproperty名を使用して動的に名前をつけることでその問題が解決できます。

1
2
3
4
5
6
7
8
    entry: {
        top: './app/main.js',
        list: './app/list/main.js'
    },
    output: {
        filename: '[name].bundle.js',
        publicPath: '/assets/'
    }

上記のような設定をすることで結果的に top.bundle.jsとlist.bundle.jsの2ファイルがdestに指定したディレクトリに置かれることになります。

loader

Webpackの大きな特徴としてjs以外のどんなファイルでもloaderさえ使えば読み込むことができる、というのがあります。

loader一覧

上記を見てわかるようにかなりの数のloaderがあるので、いくつかpuckupして紹介します。

  • html-loader

    htmlファイルを読み込んで文字列としてjs内で使用することが可能。underscoreのtemplateなど、フロントでrenderingをするときに非常に便利。

  • css-loader(sass-loader, stylus-loader)

    cssファイルをテキストとして読み込んで、headに埋め込むことが可能。sass-loaderやstylus-loaderの場合compileも同時に行う。angularやreact等を使ってHTMLをcomponentとして扱っている場合に有効。

  • json-loader

    jsonファイルを読み込んで、jsのオブジェクトに変換して使うことが可能。

  • coffee-loader

    coffee scriptで書かれたファイルをjsに変換して読み込むことが可能。

  • jade-loader

    jadeで書かれたファイルをhtmlに変換して読み込むことが可能。

  • es6-loader

    es6で書かれたコードをes5互換にして読み込むことが可能。

  • url-loader ファイルサイズが小さい場合data-uriに変換して読み込み、大きい場合はそのままpathとして読み込むことが可能。

  • expose-loader

    指定したファイル内のオブジェクトをグローバル変数として外に公開することが可能。詳しくは下の例を見るとわかりやすい。

  • export-loader

    commonJS形式に対応していない(module.exportsがない)jsファイルにmodule.exportsの記述を追加し、内部の変数を外に公開することが可能。

などなどwebpackを強力なツールたらしめているのがloaderです。是非使いこなしましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// configで指定する場合
module: {
    loaders: [
        // htmlファイルを読み込んだ場合にhtml-loaderを使用する。その際minimizeする。
        { test: /\.html$/, loader: 'html?minimize' },

        // cssファイルを読み込んだ場合にcss-loaderを使用する。その際minimizeする。
        { test: /\.css$/, loader: 'css?minimize' },

        // jsonファイルを読み込んだ場合にjson-loaderを使用する。
        { test: /\.json$/, loader: 'json' },

        // coffeeファイルを読み込んだ場合にcoffee-loaderを使用する。
        { test: /\.coffee$/, loader: 'coffee' },

        // jadeファイルを読み込んだ場合にjade-loaderを使用する。
        { test: /\.jade$/, loader: 'jade' },

        // es6で記述されたjsファイルを読み込んだ場合にes6-loaderを使用する。
        { test: /\.js$/, loader: 'es6' },

        // 画像ファイルを読み込んだ場合にurl-loaderを使用する。ファイルサイズが8kb以下であればdata-uriに変換する
        { test: /\.png$/, loader: 'url?limit=8192' },

        // jQueryを読み込んだ場合に"jQuery"をグローバルオブジェクトにする。
        { test: /\.jquery.js$/, loader: 'espose?jQuery' },
        // var $ = require('jquery');
        // でグローバル変数にjQUeryが登録される

        // angularJsを読み込んだ場合に"angular"を外に公開する。
        { test: /angular\.js$/, loader: 'exports?angular' }
        // var angular = require('angular');
        // で変数"angular"が取得できる
    ],
}

// requireのタイミングでloaderを指定することもできる。
// settings.jsonをjsのオブジェクトとして使用する。
var settings = require('json!./data/settings.json');

// angularオブジェクトを使用する
var angular = require('exports?angular!angular');

resolve

extention

読み込む際に拡張子を省略できるようにする。jsはデフォルトで入っている。個人的にはコンポーネントを作成した場合に名前を被らせることが多いのでjs以外の省略はしていない。

1
2
3
4
5
6
resolve: {
    extenstions: ['', '.js', '.json', '.html']
}
// 上記のようになっているとき、下記のように省略することが可能。
// require('settings.json') ->require('settings')
// require('item-template.html') ->require('item-template')

root

requireで読み込むときのrootのpathを指定できる。配列で複数の指定が可能。下記のようなディレクトリ構造の場合

1
2
3
4
5
6
gulpfile.js
gulp  config.js
      tasks  各タスクファイル
app  js  app.js
           modules  dialog.js
                      tab.js

通常であれば

1
2
3
//@file app.js
var dialog = require('./modules/dialog');
var tab = require('./modules/tab');

としなければならないところを、

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//@ config.js
var path = require('path'),
    current = process.cwd();
module.exports = {
    // 他の設定
    resolve: {
        root: path.join(current, 'app/js/modules')
    }
    // 他の設定
};


//@app.js
var dialog = require('dialog');
var tab = require('tab');

のように記述することが可能。

alias

ファイル単位でaliasをはる。root使えばいらない子のようなきがする。用途としてはbowerでとってきたときのmainに設定されてないファイルを使いたい場合くらいかな。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//@ config.js
var path = require('path'),
    current = process.cwd();
module.exports = {
    // 他の設定
    resolve: {
        alias: {
            bar: path.join(current, 'bower_components/foo/plugins/bar.js')
        }
    }
    // 他の設定
};


//@app.js
var bar = require('bar');

plugins

loaderと並んでwebpackの強力な機能。というか一部どっち使えばいいか迷うものもある。後述。

plugins一覧

見てわかるように非常に沢山あるので自分も見切れてないです。なので便利なやつだけ一部紹介します。

ResolverPlugin

これとresolveのrootの設定と組わせると、bowerでとってきたmoduleをそのままrequireすることが可能。非常に便利。他の用途もあるんだろうけど知らない。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//@ config.js
var path = require('path'),
    current = process.cwd();

module.exports = {
    // 他の設定
    resolve: {
        root: [
            path.join(current, 'bower_components')
        ]
    },
    plugins: {
        new webpack.ResolverPlugin(
            new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin('.bower.json', ['main'])
        ),
    }
};


//@app.js
var $ = require('jquery');

※ bowerモジュールの中にはbower.jsonをignoreに設定しているものがあるため、bowerが動的に作成する.bower.jsonを参照する方が安全。

ProvidePlugin

指定した変数を他のモジュール内で使用できるようにする。globalには置かない。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//@ config.js
var path = require('path'),
    current = process.cwd();

module.exports = {
    // 他の設定
    resolve: {
        root: [
            path.join(current, 'bower_components')
        ]
    },
    plugins: {
        new webpack.ResolverPlugin(
            new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin('.bower.json', ['main'])
        ),
    },
    new webpack.ProvidePlugin({
        jQuery: "jquery",
        $: "jquery"
    })
};


//@app.js
var $ = require('jquery');
require('jquery-ui');

jQueryとかAngularのプラグインは、jQueryやangular変数に対してオブジェクトを追加する形となるため、その変数を参照できないとエラーになってしまいます。

そのときこのprovidePluginを使用するとこの問題を解決できるのですが、グローバル変数を生成するexpose-loaderでも解決することができます。

グローバル汚染しない分providePluginを使用する方が望ましいですが、別機能でwebpackに依存しないモジュールが画面内にあったりする場合はexpose-loaderで解決してもよいでしょう。

開発環境向けplugin

  • HotModuleReplacementPlugin

    webpack-dev-serverを使っているときに、画面をリロードすることなくモジュールの差し替えを可能にする。(実験的機能)

  • NoErrorsPlugin

    compile時にエラーが出たらskipする。

本番環境向けplugin

  • DedupePlugin

    被ってるモジュールがいたらひとつにまとめる。

  • UglifyJsPlugin

    compile時にuglifyでminimizeする。

  • OccurenceOrderPlugin

    よく使われるモジュールに降るIDの桁数をより短くすることでよりコードを圧縮する。

  • AggressiveMergingPlugin

    ファイルを細かく分析し、まとめられるところはできるだけまとめてコードを圧縮する。Closure CompilerのADVANCED_OPTIMIZATIONみたいなことはしない。

Webpack Deb Server

webpackが提供している開発環境向けのサーバー。compile時に時間がかかる問題を、修正がかかった箇所だけcompileする方式を採用することで高速化している。

上記で紹介したHotModuleReplacementPluginを使用するとreloadなしにモジュールの再読み込みが可能。

が、これ実際に使おうとすると情報が少なかったりして結構苦戦したので備忘録。

今回サンプルのアプリケーションを作成したときの開発環境、本番環境、webpack dev server、gulp-serveでたてたstaticサーバのそれぞれのconfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// 本番環境
{
    entry: {
        top: dir.js + '/main.js',
    },
    output: {
        filename: '[name].bundle.js',
        publicPath: '/js/'
    },
    resolve: {
        extensions: ['', '.js'],
        root: [
            path.join(current, 'bower_components'),
            path.join(current, dir.js, 'modules'),
            path.join(current, dir.js, 'templates')
        ]
    },
    debug: false,
    devtool: false,
    stats: {
        colors: true,
        reasons: false
    },
    plugins: [
        new webpack.ResolverPlugin(
            new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin('.bower.json', ['main'])
        ),
        new webpack.optimize.DedupePlugin(),
        new webpack.optimize.UglifyJsPlugin(),
        new webpack.optimize.OccurenceOrderPlugin(),
        new webpack.optimize.AggressiveMergingPlugin(),
        new webpack.ProvidePlugin({
            jQuery: "jquery",
            $: "jquery"
        })
    ],
    module: {
        loaders: [
            { test: /\.html$/, loader: 'html?minimize' }
        ]
    }
}

// 開発環境
{
    entry: {
        top: dir.js + '/main.js',
    },
    output: {
        path: path.join(current, 'app',  'js'),
        filename: '[name].bundle.js',
        publicPath: '/js/'
    },
    resolve: {
        extensions: ['', '.js'],
        root: [
            path.join(current, 'bower_components'),
            path.join(current, dir.js, 'modules'),
            path.join(current, dir.js, 'templates')
        ]
    },
    cache: true,
    debug: true,
    devtool: false,
    stats: {
        colors: true,
        reasons: false
    },
    plugins: [
        new webpack.ResolverPlugin(
            new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin('.bower.json', ['main'])
        ),
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NoErrorsPlugin(),
        new webpack.ProvidePlugin({
            jQuery: "jquery",
            $: "jquery"
        })
    ],
    module: {
        loaders: [
            { test: /\.html$/, loader: 'html?minimize' }
        ]
    }
}

// webpack dev server
{
    contentBase: "http://localhost:3000",
    hot: true,
    quiet: false,
    noInfo: false,
    lazy: false,
    watchDelay: 300,
    publicPath: "http://localhost:9000/js/",
    stats: { colors: true }
}

// static server by gulp-serve
{
    port: 3000,
    root: [dir.tmp, dir.app]
}

ハマりポイント1

webpack dev serverでは”http://localhost:9000/webpack-dev-server/“のようなURLでページを確認することになるため、pathをどう通していいかわからない。

→ と思っていたらiframeで”/webpack-dev-server/“の部分を削ったページを表示していたので、pathに関してはやってみたら問題なかった。

ハマりポイント2

webpack dev serverでは、baseが一箇所しか指定できないため、開発環境でcompileしたファイルだけtmpディレクトリに置き、compileせずにそのまま使うファイルはappディレクトリに置いてどちらも参照する方法がわからない。

→ 別でたてたサーバをcontentBaseに設定することが可能。そのためgulp-serveを使って.tmpとappをrootにしたサーバを立ち上げ、webpack dev serverをそこに向けることができる。

contentBase: "http://localhost:3000", のところ。

はまりポイント3

2の対策をした場合に、webpack dev serverが動的にcompileをかけるjsファイルの参照の仕方がわからない。

→ jsファイルのみwebpack dev serverの方のportを参照する必要あり。自分の場合”http://localhost:9000/js/top.bundle.js“を参照している。

またその場合に本番環境ではそのままにするわけにはいかないので、自分の場合はgulp-ectでhtmlも本番用と開発用を分けてbuildできるようにした。

ただ正直ここは自動で書き換えられるようにwebpack側で対応入れて欲しい。

はまりポイント4

webpack dev serverのwatchとgulp-watchの住み分けをどうすればいいかわからない。

→ webpack dev serverとwatchを並列で実行してしまえば問題ない。もちろんwatchの方でwebpackで管理しているファイルの監視をする必要はない。

以上、なかなか厄介でしたがとりあえず使えるようになりました。

gruntで使いたい場合はgenerator-react-webpackがやっているので参考にすると良いかもしれない。

AMD形式の読み込み

requireの第二引数のfunctionを入れれば、結果がcallbackで返ってくる。

この後紹介するsampleでは使っていないが、publicPathに設定したディレクトリに非同期で読み込むファイルが置かれる。

requirejsとは違い、非同期で読み込む先のファイルも最適なbuildがかかった状態になるためパフォーマンスがよい。また、複雑なファイル解析はせず、単純にjsonp形式で追加モジュールを読み込むため非常にシンプル。

その他

  • webpackでは全てのモジュールが閉じたスコープ内で管理される。(もちろんexpose-loaderを使用しない限り)
  • 何度requireしてもキャッシュされた同じ箇所を参照するのみなので、パフォーマンスに悪影響を与えない。例えばurl-loaderで何度画像をrequireしても、文字列として展開される箇所は一箇所。

サンプルアプリケーション

今回紹介したポイントをほとんど抑えたサンプルを作成しました。

demo

やっていることは

  1. instagramのAPIで画像及びテキストデータ取得。
  2. pinterest風に並べる。

の2つです。環境は

  • gulp
  • webpack
  • webpack-dev-server
  • stylus
  • ect

を用いて開発環境と本番環境をそれぞれ別々に構築していて、Backbone、Angular、react等のフレームワークには依存していないので、応用しやすいかなと思います。

source

ふー、なんとか日にち間に合った。駆け足だったので変なこと書いていたら教えて下さい。使い倒すとか言ってますがまだまだ使い倒せてません><

JavaScript Advent Calendar 2014の8日目は、muyuuさんです!よろしくお願いします!