Grunt

タスクランナーJavaScript自動化フロントエンドNode.jsプラグイン

ビルドツール

Grunt

概要

Gruntは、JavaScript向けのタスクランナーです。Ben Alman氏によって2012年に開発され、フロントエンド開発において繰り返し作業の自動化を目的としています。設定ベースのアプローチを採用し、Gruntfile.jsで定義されたタスクを実行することで、ファイル圧縮、Sass/LESSコンパイル、JSHint、テスト実行、ファイル監視などの開発プロセスを自動化します。豊富なプラグインエコシステムを持ち、6,000以上のプラグインが公開されています。現在はWebpackやViteなどのモダンツールに置き換えられつつありますが、設定の分かりやすさから継続して使用されています。

詳細

主要機能

  • タスクベース自動化: 定義されたタスクの順次または並列実行
  • 豊富なプラグインエコシステム: 6,000以上の公開プラグイン
  • ファイル監視: ファイル変更時の自動タスク実行
  • 設定ベース: Gruntfile.jsでの宣言的タスク定義
  • 柔軟なファイル操作: globパターンによるファイル選択
  • 多段階タスク: 複数タスクの組み合わせと条件分岐
  • ライブリロード: 開発時のブラウザ自動更新

アーキテクチャ

Gruntfile.jsに定義されたタスク設定を読み込み、プラグインを通じて各タスクを実行。ファイルベースの入出力システムと豊富なAPIによる拡張性を提供。

エコシステム

grunt-contrib-*(公式プラグイン群)、Yeoman、Bower、npm scriptsとの統合。現在はWebpack、Gulp、Parcel等のモダンツールが主流。

メリット・デメリット

メリット

  • 分かりやすい設定: JSON形式による直感的なタスク定義
  • 豊富なプラグイン: 幅広い用途に対応する大量のプラグイン
  • 安定性: 成熟したツールとしての高い安定性
  • 学習コストの低さ: 設定ベースで理解しやすい
  • 段階的導入: 既存プロジェクトへの導入が容易
  • 詳細な制御: 細かなタスク設定と条件分岐
  • 豊富な情報: 長期間の使用による豊富なドキュメントとサンプル

デメリット

  • パフォーマンス: ファイルI/Oが多く、大規模プロジェクトでは遅い
  • 設定の複雑化: 大規模プロジェクトでは設定ファイルが肥大化
  • モダンツールとの差: WebpackやViteと比べて機能不足
  • メンテナンス状況: 新機能開発が減少傾向
  • 学習曲線の転換: 他のモダンツールへの移行コスト
  • エコシステムの停滞: 新しいプラグインの開発減少

参考ページ

書き方の例

インストールとプロジェクトセットアップ

# Grunt CLI をグローバルインストール
npm install -g grunt-cli

# プロジェクトでのGrunt本体インストール
npm install grunt --save-dev

# プロジェクト初期化
npm init -y

# 基本的なプラグインインストール
npm install grunt-contrib-concat --save-dev
npm install grunt-contrib-uglify --save-dev
npm install grunt-contrib-cssmin --save-dev
npm install grunt-contrib-watch --save-dev
npm install grunt-contrib-jshint --save-dev

# Gruntfile.js作成
touch Gruntfile.js

# タスク実行
grunt                    # デフォルトタスク実行
grunt build              # buildタスク実行
grunt watch              # watchタスク実行
grunt --help             # ヘルプ表示

基本的なGruntfile.js

module.exports = function(grunt) {
  
  // プロジェクト設定
  grunt.initConfig({
    // package.jsonの読み込み
    pkg: grunt.file.readJSON('package.json'),
    
    // JavaScriptファイルの結合
    concat: {
      options: {
        separator: ';',
        stripBanners: true,
        banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - ' +
                '<%= grunt.template.today("yyyy-mm-dd") %> */\n'
      },
      dist: {
        src: ['src/js/**/*.js'],
        dest: 'dist/js/<%= pkg.name %>.js'
      }
    },
    
    // JavaScript圧縮
    uglify: {
      options: {
        banner: '/*! <%= pkg.name %> <%= grunt.template.today("dd-mm-yyyy") %> */\n'
      },
      dist: {
        files: {
          'dist/js/<%= pkg.name %>.min.js': ['<%= concat.dist.dest %>']
        }
      }
    },
    
    // CSS圧縮
    cssmin: {
      options: {
        mergeIntoShorthands: false,
        roundingPrecision: -1
      },
      target: {
        files: {
          'dist/css/main.min.css': ['src/css/**/*.css']
        }
      }
    },
    
    // コード品質チェック
    jshint: {
      files: ['Gruntfile.js', 'src/js/**/*.js'],
      options: {
        globals: {
          jQuery: true,
          console: true,
          module: true,
          document: true
        }
      }
    },
    
    // ファイル監視
    watch: {
      files: ['<%= jshint.files %>', 'src/css/**/*.css'],
      tasks: ['jshint', 'concat', 'uglify', 'cssmin']
    }
  });

  // プラグインの読み込み
  grunt.loadNpmTasks('grunt-contrib-concat');
  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-contrib-cssmin');
  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-watch');

  // カスタムタスクの登録
  grunt.registerTask('test', ['jshint']);
  grunt.registerTask('build', ['jshint', 'concat', 'uglify', 'cssmin']);
  grunt.registerTask('default', ['test', 'build']);
};

Sass/SCSSコンパイルとライブリロード

module.exports = function(grunt) {
  
  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    
    // Sassコンパイル
    sass: {
      options: {
        implementation: require('node-sass'),
        sourceMap: true
      },
      dist: {
        files: {
          'dist/css/main.css': 'src/scss/main.scss'
        }
      }
    },
    
    // CSS後処理(Autoprefixer)
    postcss: {
      options: {
        processors: [
          require('autoprefixer')({browsers: 'last 2 versions'})
        ]
      },
      dist: {
        src: 'dist/css/*.css'
      }
    },
    
    // ブラウザ同期とライブリロード
    browserSync: {
      dev: {
        bsFiles: {
          src: [
            'dist/css/*.css',
            'dist/js/*.js',
            '*.html'
          ]
        },
        options: {
          watchTask: true,
          server: './',
          port: 3000
        }
      }
    },
    
    // 画像最適化
    imagemin: {
      dynamic: {
        files: [{
          expand: true,
          cwd: 'src/images/',
          src: ['**/*.{png,jpg,gif,svg}'],
          dest: 'dist/images/'
        }]
      }
    },
    
    // ファイル監視
    watch: {
      scss: {
        files: 'src/scss/**/*.scss',
        tasks: ['sass', 'postcss']
      },
      js: {
        files: 'src/js/**/*.js',
        tasks: ['jshint', 'concat', 'uglify']
      },
      images: {
        files: 'src/images/**/*',
        tasks: ['imagemin']
      }
    }
  });

  // プラグイン読み込み
  grunt.loadNpmTasks('grunt-sass');
  grunt.loadNpmTasks('grunt-postcss');
  grunt.loadNpmTasks('grunt-browser-sync');
  grunt.loadNpmTasks('grunt-contrib-imagemin');
  grunt.loadNpmTasks('grunt-contrib-watch');
  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-concat');
  grunt.loadNpmTasks('grunt-contrib-uglify');

  // タスク登録
  grunt.registerTask('serve', ['browserSync', 'watch']);
  grunt.registerTask('build', ['sass', 'postcss', 'imagemin', 'jshint', 'concat', 'uglify']);
  grunt.registerTask('default', ['build']);
};

TypeScriptとテスト統合

module.exports = function(grunt) {
  
  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    
    // TypeScriptコンパイル
    ts: {
      default: {
        src: ['src/ts/**/*.ts', '!node_modules/**'],
        dest: 'dist/js/app.js',
        options: {
          module: 'commonjs',
          target: 'es5',
          sourceMap: true,
          declaration: false
        }
      }
    },
    
    // Karma - テストランナー
    karma: {
      unit: {
        configFile: 'karma.conf.js',
        singleRun: true
      },
      continuous: {
        configFile: 'karma.conf.js',
        singleRun: false,
        autoWatch: true
      }
    },
    
    // ESLint
    eslint: {
      target: ['src/js/**/*.js', 'src/ts/**/*.ts']
    },
    
    // ファイルコピー
    copy: {
      html: {
        files: [{
          expand: true,
          cwd: 'src/',
          src: ['**/*.html'],
          dest: 'dist/'
        }]
      },
      assets: {
        files: [{
          expand: true,
          cwd: 'src/assets/',
          src: ['**/*'],
          dest: 'dist/assets/'
        }]
      }
    },
    
    // ファイル削除
    clean: {
      dist: ['dist/'],
      temp: ['.tmp/']
    },
    
    // ファイル監視
    watch: {
      typescript: {
        files: 'src/ts/**/*.ts',
        tasks: ['ts', 'karma:unit']
      },
      html: {
        files: 'src/**/*.html',
        tasks: ['copy:html']
      }
    }
  });

  // プラグイン読み込み
  grunt.loadNpmTasks('grunt-ts');
  grunt.loadNpmTasks('grunt-karma');
  grunt.loadNpmTasks('grunt-eslint');
  grunt.loadNpmTasks('grunt-contrib-copy');
  grunt.loadNpmTasks('grunt-contrib-clean');
  grunt.loadNpmTasks('grunt-contrib-watch');

  // タスク登録
  grunt.registerTask('test', ['eslint', 'karma:unit']);
  grunt.registerTask('build', ['clean:dist', 'ts', 'copy', 'test']);
  grunt.registerTask('dev', ['build', 'karma:continuous', 'watch']);
  grunt.registerTask('default', ['build']);
};

複雑なワークフローと条件分岐

module.exports = function(grunt) {
  
  // 環境変数の設定
  var environment = grunt.option('env') || 'development';
  var isProduction = environment === 'production';
  
  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    env: environment,
    
    // 設定の条件分岐
    uglify: {
      options: {
        banner: '/*! <%= pkg.name %> */\n',
        compress: {
          drop_console: isProduction // 本番環境のみconsole削除
        }
      },
      build: {
        src: 'dist/js/app.js',
        dest: 'dist/js/app.min.js'
      }
    },
    
    // CSS圧縮(本番環境のみ)
    cssmin: {
      options: {
        level: isProduction ? 2 : 1
      },
      target: {
        files: {
          'dist/css/main.min.css': ['dist/css/main.css']
        }
      }
    },
    
    // 開発用サーバー
    connect: {
      server: {
        options: {
          port: 8000,
          hostname: 'localhost',
          base: 'dist',
          livereload: true,
          open: true
        }
      }
    },
    
    // バージョン付きファイル名
    filerev: {
      options: {
        algorithm: 'md5',
        length: 8
      },
      assets: {
        src: [
          'dist/js/*.js',
          'dist/css/*.css'
        ]
      }
    },
    
    // HTMLファイル内のアセット参照更新
    usemin: {
      html: 'dist/*.html',
      css: 'dist/css/*.css',
      options: {
        dirs: ['dist']
      }
    },
    
    // 条件付きタスク実行
    conditional: {
      production: {
        condition: function() {
          return isProduction;
        },
        tasks: ['uglify', 'cssmin', 'filerev', 'usemin']
      },
      development: {
        condition: function() {
          return !isProduction;
        },
        tasks: ['connect', 'watch']
      }
    }
  });

  // プラグイン読み込み
  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-contrib-cssmin');
  grunt.loadNpmTasks('grunt-contrib-connect');
  grunt.loadNpmTasks('grunt-filerev');
  grunt.loadNpmTasks('grunt-usemin');
  
  // カスタムタスク定義
  grunt.registerTask('conditional', function(target) {
    var config = grunt.config('conditional.' + target);
    if (config && config.condition()) {
      grunt.task.run(config.tasks);
    }
  });
  
  // 環境別タスク
  grunt.registerTask('build:dev', ['conditional:development']);
  grunt.registerTask('build:prod', ['conditional:production']);
  
  // メインタスク
  grunt.registerTask('default', function() {
    if (isProduction) {
      grunt.task.run(['build:prod']);
    } else {
      grunt.task.run(['build:dev']);
    }
  });
  
  // 情報表示タスク
  grunt.registerTask('info', function() {
    grunt.log.writeln('Environment: ' + environment);
    grunt.log.writeln('Production: ' + isProduction);
    grunt.log.writeln('Package: ' + grunt.config('pkg.name'));
  });
};

カスタムタスクとプラグイン開発

module.exports = function(grunt) {
  
  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    
    // カスタムタスク設定
    deploy: {
      staging: {
        options: {
          server: 'staging.example.com',
          username: 'deploy',
          path: '/var/www/staging'
        }
      },
      production: {
        options: {
          server: 'production.example.com',
          username: 'deploy', 
          path: '/var/www/production'
        }
      }
    }
  });

  // カスタムタスクの定義
  grunt.registerMultiTask('deploy', 'Deploy files to server', function() {
    var options = this.options({
      protocol: 'rsync',
      excludes: ['.git', 'node_modules']
    });
    
    var done = this.async();
    var target = this.target;
    
    grunt.log.writeln('Deploying to ' + target + ' server...');
    grunt.log.writeln('Server: ' + options.server);
    grunt.log.writeln('Path: ' + options.path);
    
    // デプロイロジック(例:rsync)
    var spawn = require('child_process').spawn;
    var rsync = spawn('rsync', [
      '-avz',
      '--delete',
      '--exclude=' + options.excludes.join(' --exclude='),
      'dist/',
      options.username + '@' + options.server + ':' + options.path
    ]);
    
    rsync.stdout.on('data', function(data) {
      grunt.log.write(data.toString());
    });
    
    rsync.stderr.on('data', function(data) {
      grunt.log.error(data.toString());
    });
    
    rsync.on('close', function(code) {
      if (code !== 0) {
        grunt.fail.warn('Deployment failed with code ' + code);
      } else {
        grunt.log.ok('Deployment completed successfully');
      }
      done();
    });
  });
  
  // ファイル処理用カスタムタスク
  grunt.registerTask('process-templates', 'Process template files', function() {
    var templateData = {
      version: grunt.config('pkg.version'),
      buildDate: new Date().toISOString(),
      environment: grunt.option('env') || 'development'
    };
    
    grunt.file.expand('src/templates/**/*.html').forEach(function(templatePath) {
      var template = grunt.file.read(templatePath);
      var processed = grunt.template.process(template, {data: templateData});
      var outputPath = templatePath.replace('src/templates/', 'dist/');
      
      grunt.file.write(outputPath, processed);
      grunt.log.writeln('Processed: ' + templatePath + ' -> ' + outputPath);
    });
  });
  
  // 条件付き実行タスク
  grunt.registerTask('conditional-build', function() {
    var environment = grunt.option('env');
    var tasks = ['process-templates'];
    
    if (environment === 'production') {
      tasks.push('uglify', 'cssmin');
    }
    
    if (grunt.option('deploy')) {
      tasks.push('deploy:' + environment);
    }
    
    grunt.task.run(tasks);
  });

  // タスクエイリアス
  grunt.registerTask('build', ['conditional-build']);
  grunt.registerTask('default', ['build']);
};

高度なファイル操作と変換

module.exports = function(grunt) {
  
  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    
    // マルチターゲットファイル操作
    processFiles: {
      options: {
        encoding: 'utf8'
      },
      javascript: {
        options: {
          processor: function(content, filepath) {
            // JavaScript前処理
            return content.replace(/console\.log\(/g, 'logger.debug(');
          }
        },
        files: [{
          expand: true,
          cwd: 'src/js/',
          src: ['**/*.js'],
          dest: 'tmp/js/',
          ext: '.processed.js'
        }]
      },
      css: {
        options: {
          processor: function(content, filepath) {
            // CSS変数置換
            return content.replace(/\$primary-color/g, '#007bff');
          }
        },
        files: [{
          expand: true,
          cwd: 'src/css/',
          src: ['**/*.css'],
          dest: 'tmp/css/'
        }]
      }
    }
  });

  // 複雑なファイル処理タスク
  grunt.registerMultiTask('processFiles', 'Process and transform files', function() {
    var options = this.options({
      encoding: 'utf8',
      processor: function(content) { return content; }
    });
    
    this.files.forEach(function(file) {
      file.src.filter(function(filepath) {
        if (!grunt.file.exists(filepath)) {
          grunt.log.warn('Source file "' + filepath + '" not found.');
          return false;
        } else {
          return true;
        }
      }).map(function(filepath) {
        var content = grunt.file.read(filepath, {encoding: options.encoding});
        var processed = options.processor(content, filepath);
        
        grunt.file.write(file.dest, processed, {encoding: options.encoding});
        grunt.log.writeln('File "' + file.dest + '" created.');
      });
    });
  });

  grunt.registerTask('default', ['processFiles']);
};