Webpackを使ってみる

Redux界隈だとHot Module Reloading(HMR)が特徴な事もあって、モジュール管理にWebpackが使われる事が多いです(HMRはWebpackの機能)。今までStarter Kitを利用していたので既存の設定で動かすことが多かったのですが、基本的な動作の確認のためにゼロから設定を作ってみたいと思います。

前提条件

Node v4.x以上のインストールが前提になります。

WebpackでJavaScriptをビルドして動作確認する

今回は下記のKnockout.jsのサンプルをWebpackでビルドします。

まず作業用のフォルダを作成します。

$ mkdir webpack-sample
$ cd webpack-sample
$ mkdir -p assets/src/js

npm initします。対話式で色々聞かれますが全てエンターで大丈夫です。

$ npm init

するとpackage.jsonが作成されます。次にwebpackとwebpack-dev-serverをインストールします。

$ npm install -g webpack
$ npm install -g webpack-dev-server

webpack-dev-serverはなくても良いのですが動作確認が簡単そうだったのでインストールしました。次にknockoutをインストールします。

$ nom install --save knockout

jsFiddleからhtmlとjsを少し変えてコピペします。

<html>
<head>
    <title>Webpack demo using Knockout.js</title>
</head>
<body>

    <div id="todoapp">
        <header>
            <h1>Todos by Knockout.js</h1>
            <input id="new-todo" type="text" placeholder="What needs to be done?" data-bind="value:inputTitle,event: { keyup: createOnEnter}"/>
        </header>

        <section id="main" style="display: block;">
            <div data-bind="visible:todos().length>0">
                <input id="toggle-all" type="checkbox" data-bind="checked:markAll"/>
                <label for="toggle-all">Mark all as complete</label>
            </div>
            <ul id="todo-list" data-bind="template:{ name:'item-template',foreach: todos}">
            </ul>
        </section>

        <footer style="display: block;">
            <div data-bind="visible:todos().length>0">
                <div class="todo-count"><b data-bind="text:todos().length"></b> items left</div>
                <!-- ko if: doneTodos() > 0 -->
                <a id="clear-completed" data-bind="click:clear">
                    Clear <span data-bind="html:countDoneText(true)"></span>.
                </a>
                <!-- /ko -->
                <br style="clear:both"/>
            </div>
        </footer>
    </div>

    <script type="text/template" id="item-template">
        <li data-bind="event:{ dblclick :$root.toggleEditMode},css : {done:done() }">
            <div class="view" >
                <input class="toggle" type="checkbox" data-bind="checked:done"/>
                <label data-bind="text:title"></label>
                <a class="destroy"></a>
            </div>
            <input class="edit" type="text" data-bind="value:title,event: { keyup: $root.editOnEnter}" />
        </li>
    </script>
    
    <script src="assets/dist/js/app.js"></script><!-- jsの読み込みを追加 -->

</body>
</html>

jsFiddleのHTMLはbodyの中身だけなのでhtml,head,bodyを追加しJavaScriptの読み込みのコードを最後のほうに入れています。

var ko = require('knockout');

var Todo = function(title, done, order,callback) {
    var self = this;
    self.title = ko.observable(title);
    self.done = ko.observable(done);
    self.order = order;
    self.updateCallback = ko.computed(function(){
        callback(self);
        return true;
    });        
}

var viewModel = function(){
    var self = this;
    self.todos =  ko.observableArray([]);
    self.inputTitle = ko.observable("");
    self.doneTodos = ko.observable(0);
    self.markAll = ko.observable(false);

    self.addOne = function() {
       var order = self.todos().length;
       var t = new Todo(self.inputTitle(),false,order,self.countUpdate);
       self.todos.push(t);
    };
    
    self.createOnEnter = function(item,event){
        if (event.keyCode == 13 && self.inputTitle()){
            self.addOne();
            self.inputTitle("");
        }else{
            return true;
        };           
    }
    
    self.toggleEditMode = function(item,event){
        $(event.target).closest('li').toggleClass('editing');
    }
    
    self.editOnEnter = function(item,event){
        if (event.keyCode == 13 && item.title){
            item.updateCallback();
            self.toggleEditMode(item,event);
        }else{
            return true;
        };           
    }

    self.markAll.subscribe(function(newValue){
        ko.utils.arrayForEach(self.todos(), function(item) {
            return item.done(newValue);
        });        
    });

    self.countUpdate = function(item){
        var doneArray = ko.utils.arrayFilter(self.todos(), function(it) {
            return it.done();
        });
        self.doneTodos(doneArray.length);
        return true;
    };
    
    self.countDoneText = function(bool){
        var cntAll = self.todos().length;
        var cnt  = (bool ? self.doneTodos() : cntAll - self.doneTodos());
        var text = "<span class='count'>" + cnt.toString() + "</span>";
        text += (bool ? " completed" : " remaining");
        text += (self.doneTodos() > 1 ? " items" : " item");
        return text;
    }
    
    self.clear = function(){
        self.todos.remove(function(item){ return item.done(); });
    }
 };

ko.applyBindings(new viewModel()); 

JavaScriptに関しては、即時関数が不要なので消去し代わりに1行目のknockoutのrequire文を追加しています。

次にwebpack.config.jsを作成します。

module.exports = {
    entry: {
        app: './assets/src/js/app.js'
    },
    output: {
        path: './assets/dist/js',
        filename: "[name].js"
    }
}

次のコマンドでビルドが実行されWebpackの開発サーバーが起動します。

$ webpack && webpack-dev-server

下記のURLにアクセスするとサンプルの動作を確認することができます。

http://localhost:8080/webpack-dev-server/

NewImage

複数のJavaScriptファイルをまとめて出力する

次にひとつJavaScriptのクラスを別ファイルで作って、そのクラスをapp.jsで利用します。下記のファイルを作成します。

var Alert = function(text) {
    this.text = text;
};
Alert.prototype.say = function(){
    console.log(this.text);
    alert('alert: ' + this.text);
};
module.exports = Alert;

それをapp.jsで呼び出します。

var ko = require('knockout');
var Alert = require('./modules/Alert'); // ここを追加
var ko = require('knockout');
var Alert = require('./modules/Alert'); // ここを追加

...(省略)...

    self.addOne = function() {
        var order = self.todos().length;
        var t = new Todo(self.inputTitle(),false,order,self.countUpdate);
        self.todos.push(t);

        var alert = new Alert(self.inputTitle()); // 追加
        alert.say(); // 追加
    };

これで先ほどと同様に下記のコマンドを実行して試してみます。

$ webpack && webpack-dev-server

動作確認してみます。TODOを追加したタイミングでメッセージが出ればOKです。

NewImage

webpack.config.jsでentryに指定しているapp.jsでrequire/importしていれば自動的に依存性解決してoutputのJavaScriptが出力されます。

開発コードとライブラリを別々のJavaScriptに出力する

自分で開発しているコードとknockout.jsのコードを分けてapp.jsとvendor.jsの2つに出力します。ファイルを分けて出力できると開発部分は軽量になりますし、ページ毎に読み込むJavaScriptが違ったとしてもライブラリ部分のvendor.jsは1度読み込めば良いだけなので負荷が軽減します。

webpack.config.jsの設定を下記のように変更します。

const webpack = require('webpack')

module.exports = {
    entry: {
        app: './assets/src/js/app.js',
        vendor: ['knockout']
    },
    output: {
        path: './assets/dist/js',
        filename: "[name].js"
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            names: ['vendor']
        })
    ]
}

HTMLでvendor.jsを読み込むようにします。

    <script src="assets/dist/js/vendor.js"></script> <!-- 追加 -->
    <script src="assets/dist/js/app.js"></script>

ビルドします。app.jsとvendor.jsに分けて出力されているのが分かります。

$ webpack
$ ls -l assets/dist/js/
total 592
-rw-r--r--  1 takayukii  staff    2823  3  3 19:59 app.js
-rw-r--r--  1 takayukii  staff  296718  3  3 19:59 vendor.js

Scssも同時にビルドする

次のScssを保存します。下記はjsFiddleにあったCSSをCSS2SASSのサイトで変換したものです。

body {
  font-size: 14px;
  background: #eeeeee;
  color: #333333;
  width: 520px;
  margin: 0 auto;
}

#todoapp {
  background: #fff;
  padding: 20px;
  margin-bottom: 40px;
  h1 {
    font-size: 36px;
    text-align: center;
  }
  input[type="text"] {
    width: 466px;
    font-size: 24px;
    line-height: 1.4em;
    padding: 6px;
  }
}

#main {
  display: none;
}

#todo-list {
  margin: 10px 0;
  padding: 0;
  list-style: none;
  li {
    padding: 18px 20px 18px 0;
    position: relative;
    font-size: 24px;
    border-bottom: 1px solid #cccccc;
    &:last-child {
      border-bottom: none;
    }
    .edit {
      display: none;
    }
    &.editing {
      border-bottom: 1px solid #778899;
      .view {
        display: none;
      }
      .edit {
        display: block;
        width: 444px;
        padding: 13px 15px 14px 20px;
        margin: 0;
      }
    }
    &.done label {
      color: #777777;
      text-decoration: line-through;
    }
  }
  .destroy {
    position: absolute;
    right: 5px;
    top: 20px;
    display: none;
    cursor: pointer;
    width: 20px;
    height: 20px;
  }
}

#todoapp footer {
  display: none;
  margin: 0 -20px -20px -20px;
  overflow: hidden;
  color: #555555;
  background: #f4fce8;
  border-top: 1px solid #ededed;
  padding: 0 20px;
  line-height: 37px;
}

.todo-count {
  float: left;
  .count {
    font-weight: bold;
  }
}

#clear-completed {
  float: right;
  line-height: 20px;
  text-decoration: none;
  background: rgba(0, 0, 0, 0.1);
  color: #555555;
  font-size: 11px;
  margin-top: 8px;
  margin-bottom: 8px;
  padding: 0 10px 1px;
  cursor: pointer;
}

app.jsでstyle.scssをrequireします。

var ko = require('knockout');
var Alert = require('./modules/Alert');
require('../scss/style.scss'); // ここを追加

Scssのビルドに必要なローダーをインストールします。

$ npm install --save-dev extract-text-webpack-plugin style-loader sass-loader raw-loader

webpack.config.jsを下記のように変更します。

const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')

module.exports = {
    entry: {
        app: './assets/src/js/app.js',
        vendor: ['knockout']
    },
    output: {
        path: './assets/dist/js',
        filename: "[name].js"
    },
    module: {
        loaders: [
            {
                test: /\.scss$/,
                loader: ExtractTextPlugin.extract( 'style', 'raw!sass' )
            }
        ]
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            names: ['vendor']
        }),
        new ExtractTextPlugin('../../dist/css/style.css', {
            allChunks: true
        })
    ]
}

出力されたcssを読み込み用にHTMLを編集します。

<head>
    <title>Webpack demo using Knockout.js</title>
    <link rel=stylesheet href="assets/dist/css/style.css"> <!-- 追加 -->
</head>

ビルドを実行して動作確認します。

$ webpack && webpack-dev-server

NewImage

出力されたCSSによってスタイルが適用されています。

普通のCSSの出力が少し微妙

上記で設定したようにScssをビルドするにはJavaScript側でrequireする必要がありました。WebpackではJavaScriptの中でコンポーネントの中で必要なScssをrequireして適用するのが主流なのかな。従来のようなScssのビルドは少し変なやり方じゃないとできません。ここは少し残念というか素直にWebpackじゃなくgulp等のタスクランナーを使えという事なのかもしれません。