こんにちは、シニアアプリケーションエンジニアのid:taraoです。はてなエンジニアアドベントカレンダー2014の21日目として、依存先のファイルが更新されたらコンパイルしなおす処理をgulpで実現する方法について、とくにLessを例にとって紹介します。
はてなでは、JavaScriptやCSSなどの静的ファイルをTypeScriptやLessなどからコンパイルして生成することが増えています。CSS(Less)は主にデザイナが書くため、コンパイル手順はできる限り簡単にする必要があります。多くのチームでは、サーバアプリケーションをローカル環境で実行している最中はファイルの変更に応じて自動的にコンパイルしなおすようになっています。
ファイルの更新監視からコンパイルまでの処理にはGruntなどを使ってきましたが、コンパイル対象のファイルに依存関係がある場合、依存先のファイルの変更に応じて依存元のファイルのみをコンパイルしなおすのが難しいため、諦めて1つのファイルが更新されたら全ファイルをコンパイルしなおすようにするか、依存解析機能つきのコンパイルサーバを独自に実装してGruntとは別に使用していました。
最近になって一部のチームでgulpを使うようになり、依存解析の結果からコンパイルしなおすべきファイルを絞るのが比較的容易に実現できることがわかったので、Lessの依存指定(@import)を解析するgulpプラグインを実装したというのが、今回のお話です。
gulpの基本的なしくみ
まずはstatic/less以下のLessファイルをコンパイルして、static/css以下の同名ファイルとして保存するgulpfile.jsを書いてみましょう。@importするファイルはCSSを生成する必要がないので、そのようなファイルはファイル名の先頭に_をつけてコンパイル対象から除くこととしましょう。
'use strict'; var LESS_DIR = 'static/less'; var CSS_DIR = 'static/css'; var path = require('path'); var gulp = require('gulp'); var logger = require('gulp-logger'); var filter = require('gulp-filter'); var lessc = require('gulp-less'); gulp.task('less', function() { gulp.src(path.join(LESS_DIR, '**/*.less'), { base: LESS_DIR }) .pipe(filter([ '*', '!**/_*.less' ])) .pipe(lessc()) .pipe(gulp.dest(CSS_DIR)) .pipe(logger({ beforeEach: '[less] wrote: ' })); });
これでgulp lessでLessファイルを一通りコンパイルできるようになりました。
> gulp less [hh:mm:ss] Using gulpfile /path/to/gulpfile.js [hh:mm:ss] Starting 'less'... [hh:mm:ss] Finished 'less' after xxx ms [hh:mm:00] [less] wrote: static/css/test.css
gulp.src()はまず処理対象のファイルを列挙し、それぞれのファイルに対して.pipe()された処理を順に実行していきます。列挙されたファイルには、どこからが相対パスかという情報(baseで指定)も含まれていて、最終的な処理結果はgulp.dest()されたディレクトリ以下の対応する相対パスに書き出されます。
gulp.src()と.pipe()の関係はArrayとArray.prototype.map()の関係に似ていますが、もう少し柔軟性が高く、.pipe()された処理が新たなファイルを列挙したり、列挙されていたファイルを取り除くこともあります(つまりどちらかというとArray.prototype.map()よりはArray.prototype.reduce()だとおもった方がよいですね)。上の例の.pipe(filter(...))はちょうどArray.prototype.filter()のように、具体的な処理はせずに受け取ったファイルのうち条件に合うものだけを後段の.pipe()に渡します。
ファイルの更新監視
ファイルの更新を監視してタスクを実行するにはgulp.watch()が使えますが、上で説明したArrayとその操作関数というメタファーに合致しているgulp-watchを使ってみましょう。
'use strict'; var LESS_DIR = 'static/less'; var CSS_DIR = 'static/css'; var path = require('path'); var gulp = require('gulp'); var logger = require('gulp-logger'); var watch = require('gulp-watch'); var filter = require('gulp-filter'); var lessc = require('gulp-less'); gulp.task('less', function() { gulp.src(path.join(LESS_DIR, '**/*.less'), { base: LESS_DIR }) .pipe(watch(path.join(LESS_DIR, '**/*.less'))) .pipe(filter([ '*', '!**/_*.less' ])) .pipe(lessc()) .pipe(gulp.dest(CSS_DIR)) .pipe(logger({ beforeEach: '[less] wrote: ' })); });
.pipe(watch())は前段から渡されてきたファイルに加えて、将来に渡って更新ファイルが要素として含まれる無限リストを後段に渡す、と理解することができます。実行直後は、gulp.src()で指定されたファイルが後段に渡されて、前述の通りコンパイルされます。その後、watch()に指定したファイルに変更があると、そのファイルをリストの続きとして後段に渡していきます。
依存解析
gulp.src()で指定したファイル以外のものをwatch()が後段に渡すように、前段から渡されたファイルに依存しているファイルを後段に渡すようにすることで、依存解析のみを実行する部品を組み込む形で目的を達成できそうです。まずはこの部品の仕様をもう少し細かく決めてみましょう。
- 前段から渡されたファイルは依存解析の対象
@import先のファイルを覚える
- 前段から渡されたファイルに依存しているファイルも後段に渡す
この仕様では、依存先ファイルも依存元ファイルも、どちらも最低一度は前段から渡されてくる必要があります*1。依存先のファイルがライブラリコードのようなものなら後段でコンパイルする必要はないはずですが、いったんぜんぶ渡しておいて不要なものはfilter()で取り除くというモデルです。以下のlessDependents()のように使うイメージです。
'use strict'; var LESS_DIR = 'static/less'; var CSS_DIR = 'static/css'; var path = require('path'); var gulp = require('gulp'); var logger = require('gulp-logger'); var watch = require('gulp-watch'); var lessDependents = require('gulp-less-dependents'); var filter = require('gulp-filter'); var lessc = require('gulp-less'); gulp.task('less', function() { gulp.src(path.join(LESS_DIR, '**/*.less'), { base: LESS_DIR }) .pipe(watch(path.join(LESS_DIR, '**/*.less'))) .pipe(lessDependents()) .pipe(filter([ '*', '!**/_*.less' ])) .pipe(lessc()) .pipe(gulp.dest(CSS_DIR)) .pipe(logger({ beforeEach: '[less] wrote: ' })); });
プラグインの実装
依存解析部分をgulpのプラグインにしてみます。gulpプラグインはストリームオブジェクトを返す関数として実装します。through2を使うとストリームオブジェクトを簡単に定義できます。実際の依存解析部分はLessDependencyというクラスでやることにして、まずはプラグインの外形を定義します。
'use strict'; var _ = require('lodash'); var fs = require('fs'); var through = require('through2'); var LessDependency = require('./lib/less-dependency'); module.exports = function() { var dep = new LessDependency(); var stream = through.obj(function(file, enc, callback) { dep.parseImports(file); _.uniq(dep.accumulateDependentsOn(file.path)).forEach(function(fname) { var f = file.clone(); f.path = fname; f.contents = fs.readFileSync(fname); this.push(f); }.bind(this)); return callback(); }); return stream; };
through.obj()に渡しているfunctionの中に、1つのファイルが前段から渡ってきたときにやるべき処理を書けばよいようになっています。
まず、dep.parseImports(file)でfileが依存しているファイルについての情報を蓄積します。これはfileが依存元ファイルだった場合の処理で、集めた情報は通常は次回以降の実行のときに使われます。
続いて、dep.accumulateDependentsOn(file.path)でfileに依存しているファイルを列挙します(つまりfileが依存先ファイルだった場合の処理です)。列挙されるファイルの中にはfile自身も含まれるものとします。
こうして列挙されたファイルをthis.push()していくと、それらが後段に渡されるファイルということになります。this.push()に渡しているfをfile.clone()して作っているのは、相対パスの情報を維持するためです。
あとはLessDependencyを定義するだけですが、このクラスの実装にはLessコンパイラ本体のクラスを利用するのがよいでしょう。そうすれば依存解析結果がコンパイラと乖離せずに済みます。
'use strict'; var _ = require('lodash'); var path = require('path'); var less = require('less'); var LessDependency = function() { this.dependents = {}; }; LessDependency.prototype.depend = function(dependent, on) { var list = _.uniq((this.dependents[on] || []).concat([dependent])); this.dependents[on] = list; }; LessDependency.prototype.parseImports = function(input) { /* input の @import を、lessを使って集める */.forEach(function(file) { this.depend( input.path, path.resolve(path.dirname(input.path), file) ); }.bind(this)); }; LessDependency.prototype.accumulateDependentsOn = function(path) { if (this.dependents[path]) { return this.dependents[path].reduce(function(r, f) { return r.concat(this.accumulateDependentsOn(f)); }.bind(this), [path]); } else { return [path]; } }; module.exports = LessDependency;
parseImports()はLessコンパイラを使って依存先ファイルの情報を集めます。実際の依存関係は、依存先から依存元への対応関係としてthis.dependentsに保存されます(depend())。accumulateDependentsOn()は、this.dependentsを使ってpathに依存しているファイルを再帰的に列挙します。
このようにしてできたLessの依存解析プラグインはgulp-less-dependentsとして公開しているので、どうぞご利用ください。
おわりに
Lessのコンパイルを例に、gulpで依存関係をていねいに扱うプラグインを作成する方法を紹介しました。実はgulpのプラグインを作るのもnpmのモジュールを作るのも初めてでしたが、gulpの概念がおおよそわかればスマートに実装できますね!
はてなでは、開発ツールを積極的に改善していけるエンジニアも募集しています。
*1:他にオプションで解析対象を渡すという方法もありえます