Septeni Engineer's Blog

セプテーニエンジニアが綴る技術ブログ

Gruntをnpm-scriptsに置き換えたらバージョンアップが捗った

こんにちは、オリジナル新作マンガの配信サービス、GANMA!のチームに所属する@paralleltoです。
最近はSwift 3を使ってiOSのクラアントアプリを開発していることが多かったのですが、 ひさしぶりにブラウザ上で動作するフロントエンドの開発環境をいじる機会があったので書いてみます。
前提として、フロントエンドの開発環境ではGruntというタスクランナーを介して コンパイルユニットテストを行っています。
今回、Gruntをnpm-scriptsに置き換えるにあたって感じたことをまとめています。

Gruntとは

圧縮、コンパイルユニットテスト、lintなどの反復タスクを最小限のコストで自動化するもの。
https://gruntjs.com/

npm-scriptsとは

package.jsonのscriptsフィールドにシェルコマンドやnpm runコマンドを記述し、 コマンドラインからnpm run <script名>で呼び出すことができるもの。
一部の予約されたscript名はrunを省略してnpm <script名>で呼び出すことができる。
予約されたscript名の例:start、restart、stop、test
https://docs.npmjs.com/misc/scripts

なぜGruntをnpm-scriptsに置き換えるのか

フロントエンドの開発環境のレガシー化を防ぐため、TypeScriptを2.xにアップデートしたかった。
現状、TypeScriptのビルドにはgrunt-tsの5.5.1を利用しているが、TypeScriptのサポート状況は1.8となっている。
TypeScript 2.x系にアップデートするためには、grunt-tsの6.0.0-beta.16を利用する必要があるが安定版ではない。
いい機会なのでgruntをnpm-scriptsに置き換える研究をやってみた。

メリット

  • Gruntプラグイン作者への依存がなくなる
  • Gruntの抽象レイヤーがなくなるためデバッグしやすい
  • ツールとGruntプラグインの両方のドキュメントを行き来する必要がなくなる
  • スクランナーの知識を必要としない(npmの基礎だけ知っていれば読める)

デメリット

  • 置き換えのコストが発生する
  • 便利なGruntのメソッドが使えなくなる
  • 設定がjsonファイル(package.json)なのでコメントが書けない

Gruntをnpm-scriptsに置き換える

ここから具体例を交えてGruntをnpm-scriptsに置き換えていきます。
まずpackage.jsonを見て、依存してるGruntプラグインを確認します。

package.json

{
  "devDependencies": {
    "grunt": "1.0.1",
    "grunt-chmod": "1.1.1",
    "grunt-contrib-clean": "1.0.0",
    "grunt-contrib-compass": "1.1.1",
    "grunt-contrib-copy": "1.0.0",
    "grunt-contrib-jasmine": "1.1.0",
    "grunt-contrib-sass": "1.0.0",
    "grunt-contrib-symlink": "1.0.0",
    "grunt-contrib-watch": "1.0.0",
    "grunt-karma": "2.0.0",
    "grunt-text-replace": "0.4.0",
    "grunt-ts": "5.5.1"
  }
}

Gruntが実際にどのプラグインを使っているかはGruntfile.jsを見るとわかります。

Gruntfile.js

module.exports = function(grunt) {
  grunt.loadNpmTasks('grunt-chmod')
  grunt.loadNpmTasks('grunt-contrib-clean')
  grunt.loadNpmTasks('grunt-contrib-compass')
  grunt.loadNpmTasks('grunt-contrib-copy')
  grunt.loadNpmTasks('grunt-contrib-sass')
  grunt.loadNpmTasks('grunt-contrib-symlink')
  grunt.loadNpmTasks('grunt-contrib-watch')
  grunt.loadNpmTasks('grunt-text-replace')
  grunt.loadNpmTasks('grunt-ts')
}

あれれ~?おかしいぞ~?次の2つの依存は使われていないようです。削除してしまいます。

  • grunt-contrib-jasmine
  • grunt-karma

※余談ですがテストはkarmaをコマンドラインから直接呼び出して行っていました。
メンテナンスする人が変わるとこういった消し損ないも出てきてしまうので、 今回のように定期的に見直すことは、開発環境をクリアに保つ上で大切だと思います!

シェルコマンドの置き換え

次に置き換えられそうなプラグインはこれらです。

  • grunt-chmod
  • grunt-contrib-clean
  • grunt-contrib-copy
  • grunt-contrib-symlink

ファイルの権限変更、削除、コピー、シンボリックリンク、どれもシェルコマンドで行うことができます。

  • chmodコマンド
  • rmコマンド
  • cpコマンド
  • lnコマンド

ではなぜGruntプラグインで行っていたかですが、大きく分けて2つの理由があると思います。

例えば、globのパターンマッチング(*/.tsなど)を使った複数ファイルの繰り返し処理は、 シェルコマンドで書くよりも、JavaScriptで書いた方がメンテナンス性が高いと思います。

これらを必要としない小さなタスクであればシェルコマンドへ、 そうでなければ同等のNPM packagesに置き換える必要があります。
置き換え例:

  • “grunt-chmod" → "shelljs”
  • “grunt-contrib-clean” → “rimraf”
  • “grunt-contrib-copy” → “cpx”
  • “grunt-contrib-symlink" → "shelljs”

そして上記の依存を使ったJavaScriptファイルをそれぞれ用意し、

scripts/chmod.js

const shell = require('shelljs')
const files = require('./files.json')

// いろいろ処理
files.forEach(file => shell.chmod(444, file))
...

npm-scriptsからnodeを経由して呼び出せるようにします。

package.json

scripts: {
  "chmod": "node scripts/chmod.js"
}

コマンドラインから実行します。

npm run chmod

watchの置き換え

特定のファイルが変更されたとき、自動的に関連付けられたタスクを実行するのがwatchです。
複雑なことをやっていなければ、TypeScriptなどのコマンドに標準で付属するwatchで何ら問題ないと思います。

ソースファイルに変更があったら再コンパイルする

tsc --watch

今回はgrunt-contrib-watchプラグインを使って複数のGruntタスクを実行していたため、 grunt-contrib-watchをchokidar-cliに置き換えて、以下のようなnpm-scriptsを書きました。

package.json

scripts: {
  "process1": "何か処理",
  "process2": "何か処理",
  "watch": "chokidar 'src/**/*.ts' -c 'npm run process1 && npm run process2'"
}

これでsrc配下の.tsファイルに変更があったときに、process1とprocess2が逐次実行されます。

grunt-text-replaceの置き換え

ファイルの内容を書き換えるものでした。
私はnodeのfsを使って同等の機能をJavaScriptで実装しましたが、 おそらく需要薄な内容なので割愛させていただきます。

Sass、Compassの置き換え

SassとはCSSの拡張言語のことです。
CSSと完全互換に加え、変数、ミックスイン、継承などの機能が追加されています。 CompassはSassで構築されたフレームワークで、Webの再利用可能なパターンを利用したり、 スプライトを簡単に作成することができます。
詳しくは本家をご覧ください。
http://sass-lang.com/
http://compass-style.org/

grunt-contrib-sassは非常に便利なプラグインです。
コマンドラインに比べて、複数ファイルをソースファイルに指定でき、 また、出力先の親ディレクトリがない場合は自動的に作成してくれます。
なんとかsassコマンドから同等のことが行えないか挑戦してみましたが、長くなりそうなので断念しました。

断念したモノ
  1. 拡張子がscssのファイル名を取得
  2. 拡張子を除いたファイル名を取得
  3. sassでコンパイルし、dist配下の同ディレクトリ配下に出力(ディレクトリが存在しないエラーが出る)
find src -name '*.scss' | sed 's/.scss$//' | xargs -I {} -P 16 sass {}.scss:dist/{}.css -compass --style nested

ここではnode-sass(compass使っている場合はcompass-mixinsを忘れずに)に置き換えて対応しました。

scripts/sass.js

const sass = require('node-sass')

const compile = input => {
  sass.render({
    file: input,
    outputStyle: "nested",
    includePaths: ["node_modules/compass-mixins/lib/"]
  }, (err, result) => {
    // ファイル出力処理
  })
}

grunt-contrib-compassは、単にcompassコマンドに置き換えられます。
compassはconfig.rbに設定をまとめられるのでコマンドラインではシンプルになります。

package.json

scripts: {
  "css:sass": "node scripts/sass",
  "css:compass": "compass compass compile --force"
}

TypeScriptの置き換え

TypeScript 2.x系を使っている人は、何も考えずにtscコマンドに置き換えられます。
--はnpm-scriptsを実行する際にカスタムオプションを渡すものです。
https://docs.npmjs.com/cli/run-script

package.json

scripts: {
  "tsc": "tsc",
  "tsc:dev": "npm run tsc -- -p ./tsconfig.dev.json",
  "tsc:prod": "npm run tsc -- -p ./tsconfig.prod.json",
  "tsc:test": "npm run tsc -- -p ./tsconfig.test.json"
}

ただTypeScript 1.x系を使っている人は注意が必要です。
grunt-tsにはreferenceという本家にはない便利なオプションが存在します。
このオプションを使用しているかどうかで置き換えのコストが大きく変わります。

TypeScript 1.x系では外部ファイルを参照する際に、referenceタグを書く必要がありました。

///<reference path="外部ファイルA.ts">
///<reference path="外部ファイルB.ts">
///<reference path="外部ファイルC.ts">

これを各ソースファイルに一つ一つ書いていくのは大変なコストだと思います。
grunt-tsのreferenceオプションはこの手間を補うためのオプションになります。

referenceオプションに下記のようなファイルを渡すことで、 各ソースファイルにreferenceタグを書かずともコンパイル時に参照を解決してくれます。

reference.js

///<reference path="外部ファイルA.ts">
///<reference path="外部ファイルB.ts">
///<reference path="外部ファイルC.ts">

まとめ

いかがでしたでしょうか。
最終的に、package.jsonのdevDependenciesは以下のようになりました。
パッケージ間の依存がなくなり、パッケージの更新や取捨がやりやすくなりました。

package.json

{
  "devDependencies": {
    "chokidar-cli": "1.2.0",
    "compass-mixins": "0.12.10",
    "cpx": "1.5.0",
    "node-sass": "4.5.3",
    "rimraf": "2.6.1",
    "shelljs": "0.7.8",
    "typescript": "2.4.2"
  }
}

また、今まではGruntfile.jsという大きなJavaScriptに書かれていた処理が、 npm-scriptsの小さなシェルコマンド、またはJavaScriptに分割されたことにより、 Gruntの知識をもたないメンバーでも各タスクの目的と処理がわかりやすくなったかと思います。

package.json

{
  "scripts": {
    "chmod": "node scripts/chmod.js",
    "process1": "何か処理",
    "process2": "何か処理",
    "watch": "chokidar 'src/**/*.ts' -c 'npm run process1 && npm run process2'",
    "css:sass": "node scripts/sass",
    "css:compass": "compass compass compile --force",
    "tsc": "tsc",
    "tsc:dev": "npm run tsc -- -p ./tsconfig.dev.json",
    "tsc:prod": "npm run tsc -- -p ./tsconfig.prod.json",
    "tsc:test": "npm run tsc -- -p ./tsconfig.test.json"
  }
}

スクランナーは知識を持つ人が、ある程度固定化された環境で使うには強力ですが、 開発環境をモダンに保ちたい、開発環境をさわれるメンバーを増やしたい場合は、npm-scriptsも十分選択肢に入りますね!

参考ページ