interprism's blog

インタープリズム株式会社の開発者ブログです。

公式ドキュメントやコードから理解するGrunt入門

こんにちは、kannoです。

流行のGruntを入門してみて便利だなと思ったものの、いくつか分からない点がありました。

  • なぜgrunt-cliとgruntを別々にインストールする必要があるのか
  • なぜgrunt-cliはグローバル(-g)インストールで、gruntはそうでないのか
  • package.jsonは必要なのか
  • プラグインを使わずに独自でタスク定義する方法
  • そもそもgruntはどういう風に動いているのか
  • registerTaskの引数の指定の仕方の違い
  • initConfigの書き方はどういうルールか
  • registerTaskとregisterMultiTaskの違い
  • プラグインを使うためのloadNpmTasksは何をしているのか

分からないまま使うのは気持ち悪い。ということで調べてみました。

想定読者

  • Gruntを使ったことがあるor何となく見聞きしたことがある
  • ネットからコピペしているだけで意味を理解していない
  • nodejsを使ったことがある、もしくは以下について知っている
    • npm installするとnode_modulesにインストールされること
    • require()の使い方
    • module.exportsの使い方

環境

以下の環境で動作確認をしています。

  • Mac OS X 10.9.2
    • Windowsとかでも内容に違いはないはず
  • nodejs v0.10.24
  • grunt-cli v0.1.13
  • grunt v0.4.4

なお、nodejsのインストールはすでに済んでいるものとします。

サンプルプロジェクトの準備

これから色々試すために、サンプルのプロジェクトを作成します。
ターミナル上で以下のように実行します。

# ディレクトリを作成
/Users/kanno/workspace% mkdir grunt-sample
# 作成したディレクトリに移動
/Users/kanno/workspace% cd grunt-sample
# package.json作成
# 対話式で色々質問されますが、全てEnterで構いません
/Users/kanno/workspace/grunt-sample% npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sane defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg> --save` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
name: (grunt-sample) 
version: (0.0.0) 
description: 
entry point: (index.js) 
test command: 
git repository: 
keywords: 
author: 
license: (ISC) 
About to write to /Users/kanno/workspace/grunt-sample/package.json:

{
  "name": "grunt-sample",
  "version": "0.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}


Is this ok? (yes) yes

# grunt-cliのインストール
/Users/kanno/workspace/grunt-sample% npm install -g grunt-cli
# (略)

# gruntのインストール(-gを付けないようにすること)
# --save-devはなくてもいい(付けるのがいいけど、今回説明することの本質ではないので)
/Users/kanno/workspace/grunt-sample% npm install grunt --save-dev
# (略)

# インストールできたことを確認
# なおgruntが入っているプロジェクトで--versionすると両方出力されますが、
# それ以外の場所ではgrunt-cliのバージョンしか出力されません。
/Users/kanno/workspace/grunt-sample% grunt --version
grunt-cli v0.1.13
grunt v0.4.4

それでは疑問点について見ていきます。

なぜgrunt-cliとgruntを別々にインストールする必要があるのか

この答えは「なぜgrunt-cliはグローバル(-g)インストールで、gruntはそうでないのか」
という疑問についても同じことが言えます。

公式にはこのように書かれています。

Note that installing grunt-cli does not install the Grunt task runner!
The job of the Grunt CLI is simple: run the version of Grunt which has been installed next to a Gruntfile.
This allows multiple versions of Grunt to be installed on the same machine simultaneously.
(※改行は私が勝手に入れています)

「別々にすることでプロジェクト毎に異なるバージョンのGruntを使うことができるよ」という感じでしょうか。

コマンド実行の利便性のためにgruntというコマンドはグローバルにインストールしたい。
けどそこにタスクランナーの実体まで含めると、異なるバージョンのGruntを使いたいときに困る。
反対にプロジェクトごとのGruntだけでグローバルにはインストールさせないと、コマンド実行がめんどくさい。

ということかなと解釈しました。
gruntのv0.3までは一つにまとまっていましたが、0.4になったときにgrunt-cliとgruntで分かれたようです。

grunt-cliとは何か

grunt-cliをインストールすると入るgruntコマンドの中身を見てみましょう。

/Users/kanno/workspace/grunt-sample% which grunt | xargs cat
#!/usr/bin/env node
(略)
// Everything looks good. Require local grunt and run it.
require(gruntpath).cli();

出力1行目のshebangにある通り、このファイルはnodejsを介して処理されます。
そして最終的にgruntpathを読み込んでcliメソッドを実行します。
このgruntpathはgrunt-cliではなくgruntの方のjsファイルです。

たとえば今回のサンプルで言うならば次のファイルが読み込まれています。

  • /Users/kanno/workspace/grunt-sample/node_modules/grunt/lib/grunt.js

このことからもgrunt-cliはあくまでインターフェースで、
実体はそれぞれのgruntモジュールだということが分かるかと思います。

package.jsonは必要なのか

公式にもpackage.jsonが必要と書かれてはいますが、実はなくても動きます。
動きますが、package.jsonを用意しないメリットは特になく困ることの方が多いはずです。

gruntのプラグインを入れてプロジェクトメンバーでその依存関係を共有する場合、
package.jsonを使うのが一般的なはずですので。
(npm install plugin_name --save-dev)

素直に作っておきましょう。

プラグインを使わずに独自でタスク定義する方法

registerTaskかregisterMultiTaskを使います。
両者の違いは後述しますが、ここではregisterTaskを使ってサンプルを記述します。
以下の内容のGruntfile.jsをプロジェクトのルートディレクトリに作成します。

// Gruntfile.js
module.exports = function(grunt) {
  grunt.registerTask('hello', 'oyakusoku', function() {
    console.log('Hello');
  });
};

独自でタスクを定義するには上記のようにregisterTaskに関数を渡すだけです。
実行してみて、問題なければ次のように出力されます。

/Users/kanno/workspace/grunt-sample% grunt hello
Running "hello" task
Hello
 
Done, without errors.

ちなみに実行可能なタスクの一覧は--helpで見ることができます。
(もっとシンプルに一覧だけ見る方法ないのかな)

/Users/kanno/workspace/grunt-sample% grunt --help
Grunt: The JavaScript Task Runner (v0.4.4)
(略)
Available tasks
         hello  oyakusoku

余談:詳細を見たい。エラーを見たい

タスク実行の詳細が見たくなった場合は--verboseオプションを付けます。

/Users/kanno/workspace/grunt-sample% grunt --verbose hello
Initializing
Command-line options: --verbose
 
Reading "Gruntfile.js" Gruntfile...OK
 
Registering Gruntfile tasks.
Loading "Gruntfile.js" tasks...OK
+ hello
 
Running tasks: hello
 
Running "hello" task
Hello
 
Done, without errors.

もしエラーが起きて、その詳細を見たい場合は--stackを付けます。
わざと失敗するようにして試してみます。

# オプションなし
/Users/kanno/workspace/grunt-sample% grunt hello
Running "hello" task
Warning: Object #<Console> has no method 'lo' Use --force to continue.
 
Aborted due to warnings.
 
# オプションあり
/Users/kanno/workspace/grunt-sample% grunt --stack hello
Running "hello" task
Warning: Object #<Console> has no method 'lo' Use --force to continue.
TypeError: Object #<Console> has no method 'lo'
    at Object.<anonymous> (/Users/kanno/workspace/grunt-sample/Gruntfile.js:3:13)
    at Object.thisTask.fn (/Users/kanno/workspace/grunt-sample/node_modules/grunt/lib/grunt/task.js:82:16)
    at Object.<anonymous> (/Users/kanno/workspace/grunt-sample/node_modules/grunt/lib/util/task.js:296:30)
    at Task.runTaskFn (/Users/kanno/workspace/grunt-sample/node_modules/grunt/lib/util/task.js:246:24)
    at Task.<anonymous> (/Users/kanno/workspace/grunt-sample/node_modules/grunt/lib/util/task.js:295:12)
    at Task.start (/Users/kanno/workspace/grunt-sample/node_modules/grunt/lib/util/task.js:304:5)
    at Object.grunt.tasks (/Users/kanno/workspace/grunt-sample/node_modules/grunt/lib/grunt.js:161:8)
    at Object.module.exports [as cli] (/Users/kanno/workspace/grunt-sample/node_modules/grunt/lib/grunt/cli.js:38:9)
    at Object.<anonymous> (/usr/local/lib/node_modules/grunt-cli/bin/grunt:45:20)
    at Module._compile (module.js:456:26)
 
Aborted due to warnings.

そもそもgruntはどういう風に動いているのか

処理の流れ

gruntの大まかな流れは次の通りです。

  1. gruntコマンドを実行
  2. node_modules配下のgrunt.jsが読み込まれる
  3. Gruntfileが読み込まれ、中に記述したタスクやプラグインが登録される
  4. 登録されたタスクやプラグインが実行される

もう少し具体的に言うと次のような流れです。

  1. (grunt-cliの)gruntコマンドを実行
  2. node_modules/grunt/lib/grunt.jsを読み込み
  3. 上記オブジェクトを介してnode_modules/grunt/lib/grunt/cli.jsのcli()を実行
  4. cli()はnode_modules/grunt/lib/grunt.jsのtasks()を実行
  5. tasks()の中で...
    1. node_modules/grunt/lib/grunt/task.jsのinit()が呼ばれる
    2. init()の中でloadTask()が呼ばれてGruntfileが読み込まれる
    3. Gruntfileの中でregisterTask/registerMultiTask/loadNpmTasksが呼ばれてタスクが登録される
    4. Gruntfileの読み込み終了
    5. 登録されたタスクを実行

Gruntfileの見つけ方

Gruntfileはgruntの実行ディレクトリから上に辿って最初に見つかったファイルを参照します。
通常はプロジェクトのルートディレクトリで実行することが多いと思いますので、
ルートディレクトリに置かれているGruntfileが参照されることになります。
コマンドの実行オプション(--gruntfile)で指定することも可能です。

helloタスクのコードの意味

先ほどのコードについて理解していきます。
まずは一番外側のこの部分から。

module.exports = function(grunt) {
    // 省略
};

nodejsやCommonJSモジュールで馴染みのある構文だと思います。
module.exports自体の説明についてはここでは割愛します。

Gruntfileはnode_modules/grunt/lib/grunt/task.jsのloadTask()の中で読み込まれます。

    // Load taskfile.
    fn = require(path.resolve(filepath));
    if (typeof fn === 'function') {
      fn.call(grunt, grunt);
    }

filepathはGruntfileのパスのことです。
また、gruntオブジェクトはnode_modules/grunt/lib/grunt.jsをrequireした結果です。
このことから次の3点が分かります。

  • 内部でrequire()されるのでmodule.exportsが必要
  • require()した戻り値を関数として実行するのでexportsの値は関数の必要がある
  • 関数の引数で渡ってくるgruntオブジェクトはgrunt.jsのこと

続いて残りの部分です。

module.exports = function(grunt) {
  grunt.registerTask('hello', 'oyakusoku', function() {
    console.log('Hello');
  });
};

registerTaskは(タスク名、タスクの説明、タスクの処理)という引数を受け取ります。
説明は省略可能で、registerTask(タスク名、タスクの処理)とすることも可能です。

このタスク名をキーにして説明や処理が保存されます。
例えばhelloタスクの登録情報は次のようになっています。

{ hello: { name: 'hello', info: 'oyakusoku', fn: [Function] } }

このようにregisterTaskで処理が登録され、gruntコマンドの最後で実行されます。

registerTaskの引数の指定の仕方の違い

registerTaskで関数ではなく文字列もしくは文字列の配列を渡すと、エイリアスが作成できます。

module.exports = function(grunt) {
  grunt.registerTask('hello', 'oyakusoku', function() {
    console.log('Hello');
  });
  grunt.registerTask('hello-2', 'alias', 'hello');
  // エイリアスでももちろん説明は省略可能
  grunt.registerTask('hello-3', ['hello', 'hello-2']);
};

引数の型や数の違いを吸収するコードは、JavaScriptのライブラリではよく見るパターンですね。
第3引数(説明がなければ第2引数)が関数でなければ、そのタスクのエイリアスとして処理されるようになっています。
(上記例のhello-2は、helloタスクのただのエイリアス)

配列を受け取る場合は、それを順にこなすタスクとして登録されます。
(上記例のhello-3は、hello -> hello-2と実行するタスク)

initConfigの書き方はどういうルールか

initConfigについて

Gruntでは各タスクの設定のためにinitConfigを使います。
このメソッドを呼ぶと引数で渡したオブジェクトを保持します。
そしてgruntオブジェクトや、タスク内でthisを介して参照できるようになります。
なお単なる代入による保持ですので、複数回呼ぶと上書きされます。

initConfigの記述ルール

initConfigに渡すオブジェクトには次のようなルールがあります。

基本

  • 書式
  grunt.initConfig({
    タスク名: {
      キー: 値
    }
  });
  • サンプル
module.exports = function(grunt) {
  grunt.initConfig({
    hello: {
      from: 'Alice',
      to: 'Bob'
    }
  });
 
  grunt.registerTask('hello', function() {
    // config(タスク名)で取得
    console.log(grunt.config('hello'));
    // => { from: 'Alice', to: 'Bob' }
  });
};

特別なプロパティ:options

タスクの中でthis.options()とすると参照できます。
options()にはデフォルト値としてのオブジェクトを指定できるので便利です。

  • 書式
  grunt.initConfig({
    タスク名: {
      options: {
        キー: 値
      }
    }
  });
  • サンプル
module.exports = function(grunt) {
  grunt.initConfig({
    hello: {
      options: {
        msg: '<%= grunt.template.today("yyyy-mm-dd") %> Hello',
      }
    }
  });
 
  grunt.registerTask('hello', function() {
    console.log(this.options({
      msg: 'no message',
      name: 'bob'
    }));
    // => { msg: '2014-03-24 Hello', name: 'bob' }
  });
};

特別なプロパティ:src/dest

registerMultiTaskの場合、src/destを指定すると特別なプロパティとして扱われます。
(registerMultiTaskについては後述)
this.filesにオブジェクトの一覧が作られます。
このオブジェクトを経由すると、存在しないsrcは弾かれていたりするので便利です。

  • 書式
  grunt.initConfig({
    タスク名: {
      ターゲット名: {
        src: ['ファイル名A', 'ファイル名B'],
        dest: 'ファイル名Z
      }
    }
  });
  • サンプル
// grunt greet:helloで実行
module.exports = function(grunt) {
  grunt.initConfig({
    greet: {
      hello: {
        src: ['Gruntfile.js', 'package.json', 'not_found.js'],
        dest: 'dest/foo.js'
      }
    }
  });
 
  grunt.registerMultiTask('greet', function() {
    this.files.forEach(function(f) {
      console.log(f);
      // { src: [Getter],
      //   dest: 'dest/foo.js',
      //   orig: 
      //    { src: [ 'Gruntfile.js', 'package.json', 'not_found.js' ],
      //      dest: 'dest/foo.js' } }
 
      console.log(f.src);
      // [ 'Gruntfile.js', 'package.json' ]
      // ※存在しないnot_found.jsは含まれていない
 
      console.log(typeof f.src);
      // object
      // ※Arrayではない
    });
  });
};

src/dest(files)に関しては他にも書式があります。
詳細は公式を参照してください。

initConfigで設定した値の取得について補足

initConfigに渡したオブジェクトは内部ではgrunt.config.dataとして保持されます。
参照するにはgrunt.config.data.タスク名grunt.config(タスク名)とします。
ただしconfig()経由ならテンプレート文字列を使えたり色々処理が走るようです。

サンプルのコードを書いてみます。
options.nowの値がconfig()経由だとテンプレート処理されています。

module.exports = function(grunt) {
  grunt.initConfig({
    hello: {
      name: 'Alice',
      options: {
        now: '<%= grunt.template.today("yyyy-mm-dd") %>'
      },
      files: {
        src: ['**/*.js'],
      },
    }
  });
 
  grunt.registerTask('hello', function() {
    console.log(grunt.config('hello'));
    // { name: 'Alice',
    //   options: { now: '2014-03-20' },
    //   files: { src: [ '**/*.js' ] } }
 
    console.log(grunt.config.data.hello);
    // { name: 'Alice',
    //   options: { now: '<%= grunt.template.today("yyyy-mm-dd") %>' },
    //   files: { src: [ '**/*.js' ] } }
 
    // this.options()でoptionsキーの値を取得できる
    // 引数にはデフォルト値をオブジェクトで渡せる
    console.log(this.options({ aa: 1 }));
    // { aa: 1, now: '2014-03-22' }
  });
};

registerTaskとregisterMultiTaskの違い

同じタスクでもケース毎に設定を切り替えたい場合があります。
「開発環境と本番環境」、「コンパイルとテスト」などです。
Grunt的にはこのケースのことをターゲットと呼びます。

ターゲットを使うにはregisterTaskではなくてregisterMultiTaskを利用します。
registerMultiTaskを使うとthis.datathis.options()でターゲット毎の値を参照できます。
this.dataはgrunt.config([name, target]);の結果なので、gruntオブジェクトを使って参照するよりお手軽です。

これまで通りgrunt タスク名とすると全てのターゲットが実行され、
grunt タスク名:ターゲット名とすると特定のターゲットだけを実行できます。

サンプルはこちらです。

module.exports = function(grunt) {
  grunt.initConfig({
    greet: {
      hello: {
        msg: '<%= grunt.template.today("yyyy-mm-dd") %> Hello',
        options: {
          name: 'Bob'
        },
      },
      goodbye: {
        msg: 'Goodbye',
      },
    }
  });
 
  grunt.registerMultiTask('greet', function() {
    // gruntではなくthisからdataを参照できる
    // 以下の書き方はどれも同じ
    console.log(this.data);
    console.log(grunt.config([this.name, this.target]));
    console.log(grunt.config(['greet', 'hello']));
    // grunt greet:hello => { msg: '2014-03-22 Hello', options: { Name: 'Bob' } }
 
    // gruntではなくthisからoptions()を呼び出せる
    // options()にはデフォルト引数を渡せる
    console.log(this.options({
      name: 'Unknown',
      decorate: '*******'
    }));
    // grunt greet:hello => { decorate: '*******', Name: 'Bob' }
  });
};

プラグインを使うためのloadNpmTasksは何をしているのか

loadNpmTasksは主に次のような処理をします。

  1. node_modulesディレクトリを探す
  2. node_modules/プラグイン名/package.jsonがあればそれを読み込む
  3. 上記package.jsonのkeywordsにgruntcollectionという文字列があれば、プラグインのpackage.jsonに書かれているdependenciesを読み込む
  4. node_modules/プラグイン名/tasksというディレクトリがあれば、そのディレクトリを対象にタスクを読み込む
    • 対象のパスがディレクトリなだけで、最終的には通常のGruntfile読み込みと同じ挙動
    • 読み込まれたファイルの中でregisterMultiTaskとかしている

最後のregisterMultiTaskとかのおかげで、
利用者はloadNpmTasksをするだけでプラグインのコマンドが使えるようになっています。
(プラグインによりますが)

よく使うプラグインさえ同梱されていない理由

gruntのv0.3までは同梱されていたらしいです。
以下の理由から分けるようにしたのかなと思います。

  • 同梱するとGrunt本体がでかくなる
  • 何かプラグインがアップデートされただけでGrunt自体をアップデートする必要がでてくる
  • プラグインも含めてメンテするの大変そう

おまけ。grunt.jsのコード

Gruntfileのfunction(grunt)で渡されるgruntオブジェクトについて。
(node_modules/grunt/lib/grunt.js)

このあたりのコード(gExpose)とか面白いなと思いました。
一般的な書き方なのでしょうか。

// Expose specific grunt lib methods on grunt.
function gExpose(obj, methodName, newMethodName) {
  grunt[newMethodName || methodName] = obj[methodName].bind(obj);
}
gExpose(task, 'registerTask');
(略)
gExpose(config, 'init', 'initConfig');

実際の処理はそれぞれファイルが分かれていて、
gruntオブジェクトにはインターフェースとしてのエイリアスを持たせます。
愚直に書くと以下のようになりそうですが、あえて上記のようにしている理由はなんでしょう。
メソッド名を重複して書かなくて良いというメリットはありそうです。

grunt.registerTask = task.registerTask

おわりに

Gruntについて、ドキュメントやコードから理解してみました。
公式ドキュメントは少量な上に丁寧なのでとても読みやすいです。
また、ソースコードもコメントがよく書かれていたり丁寧な印象を受けました。

コードリーディングの練習にもなって面白かったです。

PAGE TOP