gruntで簡単JSビルド

gruntってのは、JS/CSSをまとめたり、JS lint, Qunit, minifyできたりってのができるビルドツールです。
また、minifyなどの組み込みタスクの他に、RakeやAntと同じようにカスタムタスクを記述して実行することも可能です。
さらに、watchというので、対象のファイルを監視して、それをトリガーとしてタスクを実行することも可能です。(inofityみたいなやつ)
他の類似ツールと比べて、簡単に使えたのでメモとして残しときます。

環境

OS: MacOSX 10.7
実行環境: Node.js(Homebrew)
パッケージ管理: npm

手順

node環境のインストール

面倒なので(ry

この辺みてインストールしてください。
naveを使ったnode.jsインストールと、最近のnpmの使い方 - ラシウラ
Nodeとnpmのインストール - 自分の感受性くらい

MacOSX/homebrewでnodeを、パッケージ管理にnpmを使っているという想定でいきます。

gruntのインストール

$ npm install -g grunt

プロジェクトディレクトリセットアップ

$ cd project_dir
$ npm init  #<= package.jsonを作る(※あれば不要)

`npm init`で対話式にpackage.jsonを作成します。(自前で作成してもいいけど)
gruntは、package.jsonの内容を読み込むことができるので、Client側であっても作成しておくと便利です。

package.jsonのサンプル

{
author: "Satoshi Ohki <roothybrid7@gmail.com>",
name: "grunt-sandbox",
description: "Grunt sandbox project.",
version: "0.0.0",
repository: {
type: "git",
url: "git://github.com/roothybrid7/grunt-sandbox.git"
},
engines: {
node: "~0.6.14"
},
noAnalyze: true,
dependencies: { },
devDependencies: {
grunt: "~0.3.2",
grunt-sample: "~0.1.0"
},
optionalDependencies: { }
}

gruntセットアップ

本日の主役、下記のコマンドを実行し、何個か質問に答えるとカレントディレクトリにgrunt.jsが作成されます。

$ grunt init:gruntfile
gruntの設定

設定は、この中に記述します。

grunt.initConfig({
[...]
});
ファイルをまとめる、minifyする

まとめたいファイルを、srcに指定して、destにまとめたファイル名を指定します。

'<json:package.json>'

は、jsonファイルをしている組み込みのディレクティブです。
destに、<%= pkg.name %>とありますが、package.jsonのnameを使うように指定しています。
metaのbannerで、package.jsonの値をいろいろ使用しています。ファイルのヘッダコメントとして使用しています。

'<config:concat.dist.dest>'

の箇所は、ディレクティブを使って、initConfig内のconcat.dist.destの値を取得しています。
このディレクティブを使えば、minifyでconcatに指定したのと同じファイルを簡単に指定できるようになります。

設定サンプル

grunt.initConfig({
    pkg: '<json:package.json>',
    meta: {
      banner: '/*! <%= pkg.title || pkg.name %> - v<%= pkg.version %> - ' +
        '<%= grunt.template.today("yyyy-mm-dd") %>\n' +
        '<%= pkg.homepage ? "* " + pkg.homepage + "\n" : "" %>' +
        '* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' +
        ' Licensed <%= _.pluck(pkg.licenses, "type").join(", ") %> */'
    },
    concat: {
      dist: {
        src: [
          '<banner>',
          'js/snip/**/*.js'
        ],
        dest: 'dist/<%= pkg.name %>.js'
      }
    },
    min: {
      dist: {
        src: ['<banner>', '<config:concat.dist.dest>'],
        dest: 'dist/<%= pkg.name %>.min.js'
      }
    }
});

concatやminは、別にdistという名前のプロパティでなくても任意のプロパティ名を使うことが可能です。しかも複数のプロパティを宣言できます。
例えば、ページ毎にまとめるファイルが異なる場合は、下記のような設定が可能です。

設定サンプル

grunt.initConfig({
[...]
    concat: {
      about: {
        src: [
          '<banner>',
          '<file_strip_banner:js/snip/0-testscript.js>'
        ],
        dest: 'dist/<%= pkg.name %>-about.js'
      },
      config: {
        src: [
          '<banner>',
          'js/snip/1-testscript.js',
          'js/snip/2-testscript.js'
        ],
        dest: 'dist/<%= pkg.name %>-config.js'
      },
      dashboard: {
        src: [
          '<banner>',
          'js/snip/3-testscript.js',
          'js/snip/4-testscript.js',
          'js/snip/5-testscript.js',
          'js/snip/6-testscript.js',
          'js/snip/7-testscript.js',
          'js/snip/8-testscript.js',
          'js/snip/9-testscript.js'
        ],
        dest: 'dist/<%= pkg.name %>-dashboard.js'
      },
    },
    min: {
      about: {
        src: ['<banner>', '<config:concat.about.dest>'],
        dest: 'dist/<%= pkg.name %>-about.min.js'
      },
      config: {
        src: ['<banner>', '<config:concat.config.dest>'],
        dest: 'dist/<%= pkg.name %>-config.min.js'
      },
      dashboard: {
        src: ['<banner>', '<config:concat.dashboard.dest>'],
        dest: 'dist/<%= pkg.name %>-dashboard.min.js'
      }
    },
[...]
});
JS Lintの設定

JS lintを実行してくれるんですが、はっきりいってつかってません!!
なぜかといえば、jQueryの'$'とかunderscoreの'_'とかをErrorとして報告してきやがるからです。

下記のように設定すると、grunt.js自身と、js/snip/ディレクトリ以下のjavascriptファイルをすべてにJS lintを実行してくれます。

設定サンプル

grunt.initConfig({
    lint: {
      files: [
        'grunt.js',
        'js/snip/**/*.js'
      ]
    },
});
testの設定

qunit使っている人は使えばいいと思う。(これもつかってない)
下記のように設定すればいいらしいです。

設定サンプル

grunt.initConfig({
    qunit: {
      files: ['test/**/*.html']
    },
});

gruntタスク実行

defaultタスクの実行

grunt.jsのあるディレクトリで、`grunt`とだけ実行すると、defaultタスクが実行されます。(他のビルドツールと一緒)

$ grunt

defaultタスクは下記のように設定します。
実行したいタスクを、第2引数にスペース区切りで指定します。

  // Default task.
  grunt.registerTask('default', 'concat min');
その他の組み込みタスクの実行

concat, min, lintなどの組み込みタスクを実行する場合、`grunt [task]`とtaskに実行したいタスクを指定します。

サンプル

$ grunt concat

タスクの一部だけ実行する場合には、grunt.jsで指定したプロパティ名を引数に指定します。

サンプル

$ grunt concat:about #<= concatタスクのaboutのみ実行される

デモ

100個のスクリプトをまとめて、さらにminifyするサンプルを書いてみました。

grunt.js設定ファイル

/*global module:false*/
module.exports = function(grunt) {
    var utils = grunt.utils,
        task = grunt.task,
        helper = grunt.helper,
        file = grunt.file,
        config = grunt.config,
        log = grunt.log,
        os = require('os').platform();

    /**
     * Compare function.
     *
     * @param {*} a A comparable value1.
     * @param {*} b A comparable value2.
     * @return {number} The compare result.
     */
    var compareFilename = function(a, b) {
            var numA = parseInt(a, 10),
                numB = parseInt(b, 10);
            return numA - numB;
        };

    /**
     * Returns the rest of the elements in an array.
     *
     * @param {Array} arr An source array.
     * @param {number=} index The values of the array that index onward.
     * @return {Array} The rest array.
     */
    grunt.registerHelper('rest', function(arr, index) {
        if (utils.kindOf(index) !== 'number') {
            index = 1;
        }
        return arr.slice(index);
    });

    /**
     * Returns basename.
     *
     * @param {string} filepath A file path string.
     * @param {string=} suffix A remove suffix.
     * @return {string} basename string.
     */
    grunt.registerHelper('basename', function(filepath, suffix) {
        var base = '',
            separator = os.match(/^win/) ? '\\' : '/';

        if (filepath === '.' || filepath === separator) {
            base = filepath;
        } else {
            var entries = filepath && filepath.split(separator);
            while (entries.length) {
                if ((base = entries.pop())) break;
            }
        }

        if (utils.kindOf(suffix) === 'string' && suffix.length > 0) {
            base = base.replace(suffix, '');
        }

        return base;
    });

    /**
     * Returns sorted filelist.
     *
     * @param {string} patterns A file path wildcard.
     * @return {string} sorted filelist.
     */
    grunt.registerHelper('sort_files', function(patterns) {
        var files = file.expandFiles(patterns);

        return sortedFiles = utils._.sortBy(files, function(file) {
            var basename = helper('basename', file);
            return parseInt(basename, 10);
        });
    });

    // Project configuration.
    grunt.initConfig({
        pkg: '<json:package.json>',
        meta: {
            banner: '/*! <%= pkg.title || pkg.name %> - v<%= pkg.version %> - ' + '<%= grunt.template.today("yyyy-mm-dd") %>\n' + '<%= pkg.homepage ? "* " + pkg.homepage + "\n" : "" %>' + '* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' + ' Licensed <%= _.pluck(pkg.licenses, "type").join(", ") %> */'
        },
        lint: {
            files: ['grunt.js', 'js/snip/**/*.js']
        },
        concat: {
            about: {
                src: ['<banner>', '<file_strip_banner:js/snip/0-testscript.js>'],
                dest: 'dist/<%= pkg.name %>-about.js'
            },
            config: {
                src: ['<banner>', 'js/snip/1-testscript.js', 'js/snip/2-testscript.js'],
                dest: 'dist/<%= pkg.name %>-config.js'
            },
            dashboard: {
                src: ['<banner>', 'js/snip/3-testscript.js', 'js/snip/4-testscript.js', 'js/snip/5-testscript.js', 'js/snip/6-testscript.js', 'js/snip/7-testscript.js', 'js/snip/8-testscript.js', 'js/snip/9-testscript.js'],
                dest: 'dist/<%= pkg.name %>-dashboard.js'
            },
            dist: {
                src: ['<banner>', helper('sort_files', 'js/snip/**/*.js')],
                dest: 'dist/<%= pkg.name %>.js'
            },
            distStrip: {
                src: ['<banner>', utils._.map(helper('sort_files', 'js/snip/**/*.js'), function(file) {
                    return '<file_strip_banner:' + file + '>';
                })],
                dest: 'dist/<%= pkg.name %>-strip.js'
            }
        },
        min: {
            about: {
                src: ['<banner>', '<config:concat.about.dest>'],
                dest: 'dist/<%= pkg.name %>-about.min.js'
            },
            config: {
                src: ['<banner>', '<config:concat.config.dest>'],
                dest: 'dist/<%= pkg.name %>-config.min.js'
            },
            dashboard: {
                src: ['<banner>', '<config:concat.dashboard.dest>'],
                dest: 'dist/<%= pkg.name %>-dashboard.min.js'
            },
            dist: {
                src: ['<banner>', '<config:concat.dist.dest>'],
                dest: 'dist/<%= pkg.name %>.min.js'
            }
        },
        watch: {
            files: '<config:lint.files>',
            tasks: 'lint'
        },
        jshint: {
            options: {
                curly: true,
                eqeqeq: true,
                immed: true,
                latedef: true,
                newcap: true,
                noarg: true,
                sub: true,
                undef: true,
                boss: true,
                eqnull: true,
                browser: true
            },
            globals: {}
        },
        uglify: {}
    });

    // Default task.
    grunt.registerTask('default', 'concat min');

    grunt.registerTask('sort_files_with_printout_test', 'print out sort files.', function() {
        log.writeln(this.name + ', ' + this.nameArgs);
        var sortedFiles = helper('sort_files', 'js/snip/**/*.js');
        log.writeln(helper('concat', sortedFiles));
    });
};