【Node.js7.5対応】gulp.js/Gruntを卒業すべき3つの理由とgulp非依存の本格タスクランナーの作り方

じぇしー


【Node.js7.5対応】gulp.js/Gruntを卒業すべき3つの理由とgulp非依存の本格タスクランナーの作り方

突然ですが、皆さんのgulp.js(gulp)タスク、依存関係が複雑になりすぎて、こんな風になってませんか?

gulpやGruntを使って開発していると、 やれ「Node.js(Node)のバージョンが違っていて動かない」、やれ「エラーログがぐちゃぐちゃで追えない」など、様々なトラブルに遭遇しますよね。

もちろん、Node や依存モジュールのバージョンを固定したり、ドキュメントをしっかり作るなどすればこれらのエラーに遭遇する可能性は下がりますが、できれば異常系の処理をするより、新機能の開発をしていたいのが人情ってものです。

今回はそんな方が gulp.js やGrunt に頼らずに、Sassのビルドを監視して自動ビルド & ライブリロードする、本格的なタスクランナーの構築を通して、サーバーサイドjs に触れるきっかけになればと思います。

前提知識
本記事は、以下のような方を対象としています。

・コマンドラインがある程度( cd ls mkdir 程度)が使える
・Node をgulpなどを通して使用したことがある
・ES6(JavaScriptの規格)の概要を知っている

「まだ敷居が高い・・・」と感じる方は、以下に参考をあげましたので、参考にしてみてください。

デザイナーのわたしがターミナルをこわいと思っていた話 | デザイナーのイラストノート
デザイナーの方が書かれたターミナル超入門記事です。コマンドはとりあえずcdだけ覚えておけばいいっていうのは真理ですね。

5分で導入! タスクランナーGulpでWeb制作を効率化しよう – ICS MEDIA

そもそもタスクランナーを使ったことがない方向けの記事。gulpは後述の問題は別にしても導入が簡単なのは大きな利点なので、まずはgulpでタスクランナーの便利さを体験するのもありかと思います。

ECMAScript 2015(ES6)の概要と次世代JavaScriptの新たな機能 | HTML5Experts.jp

特にPromiseについてはこの記事でも多用しています。

本文中のソースコード
本文中で使用したソースはgithubリポジトリに上がっていますので、参考にしてください。

https://github.com/sundaycrafts/examples/tree/master/20170210

gulp.js/Gruntを卒業すべき理由3つ

1. gulp.jsの限界

gulpやGruntはNode 界のjQueryのような存在で、Node が登場した当初、見通しの良いタスクを組むために必要不可欠でした。

私も当初は愛用していたのですが、タスクを作り込んでいくにつれ、以下のようなストレスを感じるようになりました。

  • 謎のエラーが出ているが依存関係が複雑すぎて何のモジュールが起因しているかわからない
  • 利用しているgulpプラグインの開発が最近滞っている
  • 便利なモジュールがあっても、gulpプラグイン化していないことが多くなってきた
  • webpackなど、gulpと組み合わせることで逆に利用方法がわかりにくくなるプラグインがでてきた
  • gulpで得た知見はgulpでしか活用できない

特に、 「gulpを学ぶ」ということが、「JavaScriptを学ぶ(サーバーサイドjsを学ぶ)」ということにダイレクトに直結しない という点は、個人的に大きなマイナスでした。

2. Node.jsの進化

gulpが登場した当初〜2015年始め頃まで(Node のフォークであるio.jsがNode にマージされるまで)は、まだPromiseやclass構文など、非同期プログラミングを支援する多くのES6構文が未サポートであったため、タスクランナーを構築するにあたってgulpとても有用でした。

しかし現在のNode v7では、すでにES6構文のほぼすべての機能をサポートしており、gulpが登場した頃に比べて非同期処理やモジュール化が格段にしやすくなりました。このようなNodeの進化によって、 現在ではgulpの必要性は以前に比べて薄れてきているように思います。

3. プログラミングスキルの向上

実際、gulp非依存の、ピュアなnpm scriptを書くようになってから、実案件でも非同期処理などを積極的に使うようになったり、エラーハンドリングや単体テストなどの、可用性の高いアプリケーションを作るために必須の知識にも目が向くようになりました。

gulp非依存のコードは、間違いなく私のプログラミングスキルを向上させる起爆剤になりました。

【本格タスクランナーを作る】STEP 1 — ファイルの読み取り「Hello World!」

タスクランナーを卒業すべき理由をご理解いただけた(?)ところで、さっそく素のサーバーサイドjsで、ファイルを読み取る処理を書いてみましょう。 Hello World!!

以降のコードは、 MacOS Sierra(v10.12) / Node v7.5.0 にて検証しています。特にWindowsの場合はファイルシステムの違いなどからうまく動かないことがあるかもしれないので、仮想マシン上のLinuxなどの上でテストすることをおすすめします。

テスト用に step1 ディレクトリを作成して、以下のコードを index.js としてそのディレクトリ内に保存します。

// index.js
const fs   = require('fs')

const args = process.argv

let target = args[2]

fs.readFile(target, 'utf-8', (err, str) => {
  if (err) throw err
  console.log(str)
})

target.txt というダミーのテキストを作成して、 index.js が置いてあるディレクトリ上で以下のように実行すると、 target.txt に書かれた文字列を出力します。

ちなみに # より右側は注釈なので、実際には入力しないでください。

$ cd step1 # テスト用ディレクトリに移動
$ echo 'Hello World!' > target.txt # 「Hello World」と書かれたtarget.txtを作成
$ node index.js './target.txt' # 読み取ったテキストを印字
Hello World!

それではコードの内容を一行一行見ていきましょう。

2行目 — モジュールのインクルード

const fs   = require('fs')

2行目では、使用するモジュールをインクルードしています。

ここでは、 require('fs') でファイルを扱うためのモジュールをインクルードしています。

fs はNode にデフォルトで同梱されているモジュールで、npm などを使って外部からインストールしなくても初めから使えます。

Node でES6のimport/export構文は使えない?
Babelなどを使ってjsを書いたことがある方は、 import fs from 'fs' のような、ES6記法での書き方ができないのか疑問に思ったかもしれません。

Node は現在、これらのES6構文には対応していないため、例に上げたようなCommonJS記法で記述する必要があります。

むちゃくちゃ使えるNode モジュール「path」
詳しくは後述しますが、Node 同梱のモジュールは、意図的に低レベル(基本的な処理しかしないという意味)な処理のみをするように設計されており、実際にはこのようなNode同梱のモジュールだけを直接使って処理を組み立てるようなことはほとんどありません。

しかし、「 path 」という、ファイルパスを処理するためのモジュールは非常に有用で、個人的によく使います。「ファイルのパスを取得して操作したい」というときにはほぼ確実に使うかと思いますので、ぜひ覚えておいてください。

5行目から7行目 — 引数の取得

const args = process.argv

let target = args[2]

process.argv にアクセスして、コマンドラインから渡した引数を取得します。

process は、Node におけるグローバルオブジェクト(ブラウザのjsで言う window オブジェクト)で、現在のNode プロセスの様々な情報を保持しています。なお、グローバルオブジェクトは require() でインクルードしなくても使うことができます。

ここで実行したNode コマンドを改めて見てみましょう。

$ node index.js './target.txt' # 読み取ったテキストを印字

process.argv は、Node を実行した時にコマンドラインから渡した引数を保持していて、 process.argv[0] には実行されたNodeファイルのパス、 process.argv[1] には実行したスクリプトのパスが格納されています。

そのため、 node index.js './target.txt' とした時の './target.txt' を取得したい場合は、 process.argv[2] として、3番目の配列を取得する必要があります。

つまり、 process.argv の配列は、以下のようにコマンドラインに入力したそれぞれの値に対応しています。覚えやすいですね。

// コンソールに入力した文字列
(1)node (2)index.js (3)'target.txt'

// process.argv が格納している文字列
[
  (1) '<Node までのパス>/node', 
  (2) '<テスト用ディレクトリまでのパス>/index.js',
  (3) 'target.txt = 渡した引数'
]

9行目から12行目 — ファイルの内容を読み取って出力

いよいよこの行で引数として送られてきた target.txt の内容を読み取って、内容をターミナルに出力します。

あらためて、該当の部分を見てみます。

fs.readFile(target, 'utf-8', (err, str) => {
  if (err) throw err
  console.log(str)
})

fs はファイルの扱いに関する様々なメソッドを持つモジュールです。

fs.readFile() は、ファイルの内容を読み取るためのメソッドで、第一引数にファイルのパスを取り、第二引数にファイルの文字コード、第三引数に読み取った後に実行する関数(コールバック関数)を指定します(別の引数のとり方もありますが、今回は説明を割愛します)。

(err, str) => {} というのは、ES6の記法で、この場合は function (err, str) {} と同じ働きをします。 function () {} よりも () => {} の方が短くていいですよね。

fs.readFile() は、コールバック関数の第一引数にエラーオブジェクトを割り当て、第二引数に取得したデータを割り当てて実行します。この場合、取得したデータは文字列になります。

文字列を取得したら、 console.log(str) で取得した文字列をコンソールに出力して、処理は終了です。

多くのNode モジュールメソッドはコールバックの第一引数にエラーオブジェクトを渡す
基本的に、多くのNode モジュールのメソッドは、コールバック関数の第一引数にエラーオブジェクトを渡します。

そのため、サーバーサイドjsを書いたりソースを読んでいると、コールバック関数の行頭でエラー処理をして早期リターンしたり、エラーを投げるコードをよく見ることになるかと思います。

【本格タスクランナーを作る】STEP 2 — 外部モジュールを使う

サーバーサイドjsの「Hello World」が終わったところで、次はもう一歩進んで、外部モジュールを利用したスクリプトを書いていこうと思います。

テスト用のディレクトリ step2 を作成して、その中に filecopy.js を作って、以下のようなスクリプトを書きこみます。見た感じはファイルをコピーするスクリプトとほとんど同じですね(実際ほとんど同じです)。 target.txtstep1 ディレクトリからコピーして step2 ディレクトリ内に置いておきます。

// filecopy.js
const fs   = require('fs-extra')

const args = process.argv

let from = args[2]
let to   = args[3]

fs.copy(from, to)

準備ができたら先ほど同様、 step2 ディレクトリ内で、

$ node filecopy.js './target.txt' './to.txt'

と実行してみましょう。以下のようなエラーが出るかと思います。

$ node filecopy.js './target.txt' './to.txt'
module.js:472
    throw err;
    ^

Error: Cannot find module 'fs-extra'
    at Function.Module._resolveFilename (module.js:470:15)
    at Function.Module._load (module.js:418:25)
    at Module.require (module.js:498:17)
    at require (internal/module.js:20:19)
    at Object.<anonymous> (/Users/user/step2/filecopy.js:2:14)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)

エラーの内容の中に「Error: Cannot find module ‘fs-extra’」とあります。
これは「’fs-extra’というモジュールが見つかりません」というエラーです。

fs-extra は初めからNode に含まれているモジュールではないため、次のコマンドで npm コマンドを使用して外部からインストールする必要があります。コマンドは step2 ディレクトリ内で実行するようにしてください。

$ npm install fs-extra

実行すると、 step2 ディレクトリ内に node_modules というディレクトリが作成されます。

Node は require() 文を発見すると、インクルードされているのが fs のようなデフォルトモジュールでない場合、 node_modules ディレクトリ内から対象のモジュールを探し出します。

npm はNode のためのパッケージマネージャーで、ブラウザのプラグインのように、世界中の開発者が作った様々なモジュールが登録・公開されていて、 npm install コマンドで公開されているモジュールを自分のPCにインストールできます。

fs-extra はnpmに登録されているモジュールの中でも人気のモジュールです。標準の fs モジュールよりも便利なメソッドをたくさん持っています。

npm install fs-extra したあとに再度 node filecopy.js './target.txt' './to.txt' を実行すると、今度は正常に target.txtto.txt という名前でコピーされます。

基本的にNode のスクリプトはこのようにして、Node 標準のモジュールだけでなく、npm からインストールした小さなモジュールをいくつも組み合わせて作成するのが基本です。

npm にユーザー登録すると人気のモジュールを探せる
npm は、ユーザー登録(無料)をすると、トップページに「最もインストールされたパッケージ(モジュール)」「最近アップデートされたパッケージ」「最も多く依存でインストールされているパッケージ」が表示されるようになります。

中でも役に立つのが「最も多く依存でインストールされているパッケージ(most depended-upon packages)」です。 シンプルで使い勝手がよく、依存モジュールの少ない、小さなモジュールが多くリストアップされています。

npm では、サーバーサイドに限らず、クライアントサイドでも使える汎用性の高いモジュールが非常に多く見つかります。また、最近のトレンドもモジュールから読み取れるので、定期的に目を通すと面白いですよ。

【本格タスクランナーを作る】STEP 3 — Promiseを理解してタスクの実行順を制御

ここまでは単一のタスクを個別に実行してきましたが、次はそれぞれのタスクの実行順を制御してみましょう。

JavaScriptは 非同期 で実行されます。これはJavaScriptの強みでもありますが、同時に コールバック地獄 (原文) に代表される、数々の苦しみを生み出してきた諸刃の剣もあります。

非同期 とはどのようなものなのかイメージがつかない方のために、具体的なコードで実験してみましょう。

これまでと同じように、 step3 ディレクトリを作り、その中に async.js を作成します。

let msg = ''

// #1
console.log('#1')
msg = msg + 'Hello '

// #2
setTimeout(() => { // 時間のかかる処理
  console.log('#2')
  msg = msg + "I'm "
}, 500)

// #3
console.log('#3')
msg = msg + 'Jeccy'

// #4
console.log('#4')
console.log(msg)

このスクリプトの最終行 console.log(msg) の出力結果はどのようになるでしょうか?(ちなみにこのスクリプトはブラウザにjsを読み込ませても実行できます)

  • A) 「Hello I’m Jeccy」
  • B) 「Hello Jeccy I’m」
  • C) 「Hello Jeccy」

Aになってくれると一番嬉しいですよね。

それでは実際に node async.js として実行してみましょう。

$ node async.js
#1
#3
#4
Hello Jeccy
#2

非同期でない言語であれば、3行目の setTimeout() で処理が一旦止まり、スクリプトに書いた順番に文字列が出力されるところですが、 非同期言語であるJavaScriptでは処理が止まらず、 #1 が実行されたあと、即座に #3 以降が実行されてしまいます。

Node(JavaScript)のこの挙動は一見すると直感的でない動作ですが、Node は非同期であることで、DBの読み書きなどの、時間のかかる処理によって全体の処理が滞ることなく、高速なレスポンスを実現しています。タスクランナーでいえば、ファイルの読み書きなどが「時間のかかる処理」に当たります。

とはいえ、ひとつひとつの処理に順序をつけられないのは困ります。そこで、この強力ながらも扱いづらい非同期処理を扱いやすくするのが Promise です。

実際にPromiseを使用して、先ほどのスクリプトの実行順を制御してみます。

let promise = new Promise((resolve, reject) => { // #1
  console.log('#1')
  resolve('Hello ')
})

promise.then((msg) => { // #2
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('#2')
      resolve(msg + "I'm ")
    }, 500)
  })
}).then((msg) => { // #3
  console.log('#3')
  return msg + 'Jeccy.'
}).then((msg) => { // #4
  console.log('#4')
  console.log(msg)
}).catch(() => { // エラーハンドリング
  console.error('Something wrong!')
})

実行してみます。
今度は期待通りの結果が帰ってきました。

$ node async.js
#1
#2
#3
#4
Hello I'm Jeccy.

実際の挙動を確認したところで、Promiseによって処理が制御される様子を詳しく見ていきましょう。

用語のおさらい
ここまでも何度かメソッドやコールバックといった用語が登場していますが、Promiseの説明ではこれらの用語を多用するので、改めてここで説明をしようと思います。たくさん難しい用語がありますね・・・。

オブジェクト は関数や配列や文字列などの実体を指します。

初期化 はある関数を new をつけて実行することを指します。普通は new をつけないで実行した場合と、実行結果が異なります。

クラス とは、最初は new をつけて実行されることを前提とした関数(オブジェクト)と思って差し支えないかと思います。

インスタンス とはクラスが生成したオブジェクトです。クラスは関数なので、実行されると何らかの値を返します。その返された値を指します。大抵関数を内包したオブジェクトであることが多いです。

メソッド とはクラスやインスタンスに内包されている関数です。特に、クラスを初期化しなくても、クラスオブジェクトが最初から内包していて使える関数を スタティックメソッド 、 クラスを初期化した返り値であるインスタンスが使える関数を インスタンスメソッド と呼びます。

コールバック とは new Promise(fn) のようにあるクラスや関数を実行した時に渡して、その関数やクラス自身が実行する関数のこと( new Promise(fn) の中で言えば fn のこと )です。 コールバック関数 とよく表記ゆれします。

1行目から4行目 — Promiseインスタンスの生成

let promise = new Promise((resolve, reject) => { // #1
  console.log('#1')
  resolve('Hello ')
})

new Promise() はインスタンスとしてpromiseオブジェクトを返します。promiseオブジェクトには以下の3つの状態があります。

  • Fulfilled – コールバック関数内でresolve()が呼ばれた時
  • Rejected – コールバック関数内でreject()が呼ばれた時
  • Pending – 初期状態

このpromiseオブジェクトの状態は、 new Promise() のコールバックで、 resolve() (第一引数に渡される関数)を実行したか reject() (第二引数に渡される関数)を実行したかで確定します。そしてこの状態は一度確定すると、その後変化することはありません。

つまり、コールバック内で resolve() を呼んだあとに reject() を呼んでも意味がないということです。

なぜpromiseオブジェクトが内部的に持っている状態を理解しなくてはいけないのでしょうか?それはこのpromiseオブジェクトの状態が変化したタイミングで、次に実行されるメソッドが異なるからです。

6行目以降 — Promise Chain

promise.then((msg) => { // #2
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('#2')
      resolve(msg + "I'm ")
    }, 500)
  })
}).then((msg) => { // #3
  console.log('#3')
  return msg + 'Jeccy.'
}).then((msg) => { // #4
  console.log('#4')
  console.log(msg)
}).catch(() => { // エラーハンドリング
  console.error('Something wrong!')
})

promiseオブジェクトは、 then()catch() という2つのメソッドを持っており、promiseオブジェクト内部の状態が Fulfilled になると、 then() メソッドのコールバックが呼ばれ、逆に Rejected になると catch() メソッドのコールバックが呼ばれます。

つまり、実質的に new Promise() のコールバック内で resolve() を実行すれば then() メソッドのコールバックが呼ばれ、 reject() を実行すれば catch() メソッドが呼ばれるということです。

また、 then() メソッドはコールバックがどのような値を返したとしても、すべてpromiseオブジェクトに包んだ返り値を返します。 つまり、 then() メソッドは、実行すると then() メソッドを持ったpromiseオブジェクトを返すということです。この性質によって、promiseチェインは実現しています。

また、このpromiseオブジェクトに包まれた返り値は、新しく生成されたpromiseオブジェクトの then() メソッドのコールバックに渡され、利用することが可能です。例では各処理の中で渡されている msg という引数がそれに当たります。

ところで、 #2 をみると、 then() メソッドのコールバック内で、promiseオブジェクト自体を生成して返しています。

promise.then((msg) => { // #2
  return new Promise((resolve, reject) => {

then() メソッドがコールバックの返り値をpromiseオブジェクトに包んで返すなら、コールバックがpromiseオブジェクトそのものを返した場合はどうなるんだろう?」と思いませんか?promiseオブジェクトが入れ子になって正常に動かなくなりそうですが、そんなことはありません。試しに #3 の処理を、promiseオブジェクトを返すように書き換えてみましょう。

}).then((msg) => { // #3
  console.log('#3')
  return new Promise((resolve, reject) => {
    resolve(msg + 'Jeccy.')

このように処理を書き換えたとしても、結果は同じになります。 then() メソッドのコールバックの中で、promiseオブジェクトを返した場合、 resolve() 実行するまでは次の処理に進まないので、自分の指定したタイミングで次の処理に移れるようになりました。

以上のことから、 #2 のように promiseチェイン内で時間のかかる処理をしたい場合は、 then() メソッドのコールバックの中で新しいpromiseオブジェクトを作成し、それを返却するようにします。

最後に、 catch() メソッドで、それより上で実行された処理中に起きるエラーをキャッチ(エラーハンドリング)します。 エラーハンドリングをしないと、Node v7から怒られるようになったのでご注意ください。

Promiseをしっかり知りたいなら
今回は駆け足でPromiseの性質を紹介しましたが、もっとしっかり知りたい方は、 JavaScript Promiseの本 などが参考になります。

【本格タスクランナーを作る】STEP 4 — Promiseを活用してSassをビルドする

さて、Promiseの使い方と仕組みがわかったので、実際にPromiseを使ってSassをビルドするタスクを組んでみましょう。

コマンドラインから指定したsassファイルを、同じくコマンドラインから指定した場所にCssビルドする 」というタスクを作っていきます。

それでは、新しく step4 というディレクトリを作成して、その中で作業をしていきましょう。

step4 ディレクトリの中に、 sass.js を作成します。内容は以下のようにします。おおむねここまで説明してきたことの復習ですが、一部初めてみる書き方も出てくるので、その部分についてはあとで説明します。

const fs   = require('fs-extra')
const sass = require('node-sass')
const path = require('path')

const args = process.argv // 引数を取得

let src  = args[2] // 入力(sass)
let dist = args[3] // 出力(css)

// sassをビルドするオプション
let option = {
  // sassのimport時の相対パスの起点を、
  // import元のsassファイルのディレクトリにする
  includePaths: [path.dirname(src)]
}

Promise.resolve()
  .then(() => {
    // sassファイルの内容を読み込む
    return new Promise((resolve, reject) => {
      console.log('Read File...')

      fs.readFile(src, (err, data) => {
        if (err) reject(new Error(err))
        else resolve(data)
      })
    })
  }).then((data) => {
    // sassをcssに変換する(ファイルに出力はしない)
    return new Promise((resolve, reject) => {
      console.log('Compile Sass...')

      option.data = data.toString()

      sass.render(option, (err, data) => {
        if (err) reject(new Error(err))
        else resolve(data)
      })
    })
  }).then((data) => {
    // 変換後のcssをファイルに出力する
    return new Promise((resolve, reject) => {
      console.log('Output...')

      fs.outputFile(dist, data.css, (err) => {
        if (err) reject(new Error(err))
        else resolve()
      })
    })
  }).then(() => {
    console.log('Completed!')
  })
  .catch((error) => {
    // エラーハンドリング
    console.error(error)
  })

変換元となるSassファイルも必要となるので style.scss を準備しましょう。内容は正しいscssならどのような内容でも構いません。

body {
  color: red;
}

依存モジュールをインストールします。

npm install node-sass fs-extra

実際にタスクを実行してみます。

$ node ./sass.js './style.scss' './style.css' 
Read File...
Compile Sass...
Output...
Completed!

ディレクトリ内に、 style.css ができたと思います(もしも何かエラーがあると、エラーログが出力されます)。

基本的にはここまで説明してきたことを組み合わせたものですが、いくつか補足します。

Sassのビルドオプション

// sassをビルドするオプション
let option = {
  // sassのimport時の相対パスの起点を、
  // import元のsassファイルのディレクトリにする
  includePaths: [path.dirname(src)]
}

Sassをビルドする時に渡すオプションを定義しています。 includePaths は、Sassのimport構文の、相対パスの起点となるディレクトリです。「相対パスの起点」とは、Sass内で、 import './included'; と書いた時の、 ./ が指し示すパスのことですね。

現在のディレクトリと、ビルド元のSassファイルがあるディレクトリが異なる場合に、Sassのimport構文が正しくインポート先を探せるように指定します( path.dirname() は、ファイルのパスからファイル名を除いた文字列を返すメソッドです)。

例えば、 /foo/bar/my/ にいる状態で、 /foo/bar/my/style/src.scss をビルドしようとしたとき、 includePaths/foo/bar/my/style ( src.scss のあるディレクトリ)に設定しておかないと、node-sassはインポートする対象のファイルを見つけられずエラーを出します。

node-sassの全オプションはsass/node-sassをご覧ください。

Promise.resolve()

Promise.resolve()
  .then(() => {
    // sassファイルの内容を読み込む

Promise.resolve() は、先ほど説明した、 new Promise() のショートカットです。つまり上のコードは、以下のように書き換えられます。

new Promise((resolve, reject) => { resolve() })
    .then(() => {
      // sass

Promise.resolve() の方がシンプルでいいですね。

fs.readFileがコールバックに渡す値

      fs.readFile(src, (err, data) => {
        if (err) reject(new Error(err))
        else resolve(data)
      })

ここでの fs.readFile() の使い方は、最初に書いたコードとは少し違います。

最初に書いたコードと見比べてみましょう。最初のコードは、第二引数に文字コードを渡していました。

fs.readFile(target, 'utf-8', (err, str) => {
  if (err) throw err
  console.log(str)
})

fs.readFile() は、第二引数に文字コードを渡すと、コールバックに文字列を渡しますが、文字コードを渡さない場合は、文字になる前のバッファデータを渡します。

このバッファデータは toString() メソッドをもっており、いつでも文字列に変換できますが、この時点ではまだその必要はないため、そのまま次の処理に引き渡しています。

以上、ここまでで実際にSassをビルドできるようになりましたので、次はSassファイルを自動でコンパイルしてくれる機能を実装していきます。

【本格タスクランナーを作る】STEP 5 — Browsersyncとgazeを利用したライブリロード

ここまでの知識を使って、いよいよ現実世界でも使えるタスクランナーを組んでみます。この章では以下の2つの機能を実装していきます。

  • Sassファイルの変更を監視して、自動でCssにコンパイル
  • Cssの変更を監視して、htmlをライブリロード

準備

それでは早速構築していきましょう。 livereloadtask という新しいディレクトリを作成してください。中身は以下のようにファイルを配置しておきます。なお、今後livereloadtaskディレクトリのことを、「プロジェクトルート」と呼ぶことにします。

livereloadtask
├── config/
│   └── config.js
├── index.js
├── lib/
│   └── sass.js
├── public/
│   ├── index.html
│   ├── media/
│   └── script/
│       └── app.js
└── src/
    └── style/
        └── style.scss

src内のscssファイルはすべて、publicディレクトリにコピーされることになります。

style.scssは以下のようになっています。

body {
  color: red;
}

config.jsは以下のようになっています。Sassのオプションがそのまま入っています。

module.exports = {
  sass: {
    includePaths: ['src/style']
  }
}

index.htmlは以下のようになっています。 ライブリロードを正しく動作させるため、必ずheadタグとbodyタグがあるようにしてください。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
  <link rel="stylesheet" href="./style/style.css">
</head>
<body>
  <h1>It Works!</h1>
</body>
</html>

Sassのビルドタスクをつくる

まずはsass.jsファイルを作りましょう。今回は引数を受け取ったり、オプションを指定するのはindex.jsが担当するので、先ほどよりもずっとシンプルになっています。

const sass = require('node-sass')
module.exports = function buildSass (option, buffer) {
  let arg = Object.assign(option, {data: buffer.toString()})
  return new Promise((resolve, reject) => {
    sass.render(arg, (err, data) => {
      if (err) reject(err)
      else resolve(data)
    })
  })
}

全体を制御するindex.jsを作る

次はindex.jsを作っていきます。まずはコードを見てみます。Promiseやbind、mapなどを多用しているのがわかります。手始めとして、監視はせずに、src/style内のscssファイル すべてを ビルドするタスクを実装しました。

startServer関数はまだ未実装です。

const fs     = require('fs-extra')
const path   = require('path')
const glob   = require('glob')
const sass   = require('./lib/sass')
const config = require('./config/config')
const cmd    = process.argv[2]

let srcDir = 'src'
let distDir = 'public'
let sassPtn = path.join(srcDir, '/style/**/!(_)*.scss')

/* ターミナルから受け取ったコマンドを実行 */
switch (cmd) {
  case 'sass':
    fileList(sassPtn)
      .then(files => {
        Promise.all(files.map(buildSass.bind(null, config.sass)))
      })
      .then(() => console.log('Sass build finished!'))
      .catch(err => console.error(err))
    break
  case 'server':
    startServer(config.server)
    break
}

/* ビルド関数 */
function buildSass (config, file) {
  readFile(file)
    .then(sass.bind(null, config))
    .then(data => data.css.toString())
    .then(outputFile.bind(null, distPath('css', file)))
    .catch(err => console.error(err))
}

function startServer () {}

/* ユーティリティ */
function readFile (path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, (err, data) => {
      if (err) reject(err)
      else resolve(data)
    })
  })
}

function fileList (pattern, option = {}) {
  return new Promise((resolve, reject) => {
    glob(pattern, option, (err, files) => {
      if (err) reject(err)
      else resolve(files)
    })
  })
}

function outputFile (file, data) {
  return new Promise((resolve, reject) => {
    fs.outputFile(file, data, err => {
      if (err) reject(err)
      else resolve()
    })
  })
}

function distPath (ext, file) {
  let parse = path.parse(file)
  return path.join(parse.dir.replace(srcDir, distDir), `${parse.name}.${ext}`)
}

補足すると、 Promise.all() は、Promiseのスタティックメソッドで、新しいpromiseオブジェクトを返します。引数に渡されたpromiseオブジェクトの配列の状態がすべてresolve()された時、 新しいpromiseオブジェクトはresolveされます。

そのほか、globなども大変便利なモジュールで、globパターンにマッチしたファイルパスをコールバックに配列で渡してくれます。

Package.jsonを準備する

さて、タスクを動かすために依存モジュールを npm install しましょう。

毎回手作業で一つ一つの依存モジュールを入力するのは手間なので、依存モジュールのリストを作成して、一括でインストールできるようにしましょう。

次のコマンドをプロジェクトルートで実行し、依存モジュールリストを作成します。

$ npm init -y

コマンドを実行すると、package.jsonというファイルができます。中を見ると、ライセンスや名前など、既にいくつかの項目が埋められているのがわかります。これは -y をつけたので、自動でデフォルト値が入ったためです。

{
  "name": "step5",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
}

依存モジュールリストに必要なモジュールのリストを記録していくには、 npm install fs-extra --save のように、 --save オプションをつけてモジュールをインストールします。

それでは、以下のコマンドで今回必要なモジュールを実際にインストールをしましょう。

npm install fs-extra glob node-sass --save

次回新しいプロジェクトを始める場合は、このタスクの雛形を新しく配置し、プロジェクトルートで単に npm install とするだけで、package.jsonにリストアップされた依存モジュールがすべてインストールされます。

それではSassのビルドタスクを実行してみましょう。

$ node index.js sass
Sass build task finished!

public/style/style.css としてビルド後のファイルが書き出されたかと思います。

ライブリロードの実装

仕上げに監視とライブリロードのタスクを実装していきます。

ファイルの変更監視にはgaze、ライブリロードにはBrowsersyncを使用します。

まずは依存モジュールをnpm installしましょう。

npm install browser-sync gaze --save

index.jsの先頭で、browser-syncをインクルードするのと一緒に create() メソッドを実行します。

const bs = require('browser-sync').create()

startServer関数を以下のように書き換えます。この例では、cssに加えて、html、jsの変更も感知してライブリロードされるようにしてみました。

function startServer () {
  bs.init({
    server: distDir,
    files: path.join(distDir, '/**/+(*.html|*.js|*.css)')
  })
  gaze(path.join(srcDir, '/**/*.scss'), (err, watcher) => {
    if (err) console.error(err)
    watcher.on('all', (ev, file) => buildSass(config.sass, file))
  })
}

それではタスクを実行してみましょう。ブラウザが自動で開いて、html、js、cssの変更を検知してリロードやcssのリフレッシュが走るはずです。

node index.js server

もしもこの時点でcssがpublicディレクトリに吐き出されていなければ、一旦 Ctrl + C で監視タスクを停止して、 node index.js sass を実行してください。

仕上げ — npm startで実行できるようにする

最後に、毎回 node index.js server と、スクリプトファイルを指定して実行するのは、スマートではないので、改良します。

package.jsonの scripts: {} の中を以下のように編集ます。

  "scripts": {
    "start": "npm run sass && npm run serve",
    "sass": "node index.js sass",
    "serve": "node index.js server",
    "test": "echo "Error: no test specified" && exit 1"
  },

これで、準備ができたので、実際に実行してみます。

$ npm start

これだけで、npmがpackage.jsonの start に書き込まれたコマンドを代わりに実行してくれます。なお start は特別なキーワードで、 start 以外のタスクは以下のように run をつけて実行します。

$ npm run sass

以上で、gulpやGruntに依存せず、本格的なタスクランナーを組み上げることができました。

今回はSassのみをビルドしましたが、TypeScript(js)やPug(html)も同様にしてビルドすることが可能なので、ぜひ自分好みのタスクランナーを構築してみてください。

Next Step — つぎはどうする?

いかがでしたでしょうか。今回は説明の関係上、ほとんどのコードを1ファイルにまとめていたり、馴染みのない構文などは使わないようにしたのでコードがごちゃついていますが、もっときれいにまとめることももちろん可能です。

また、 なにかトラブルが起きた場合でも、各種ビルドツールを直接使用しているので、原因特定がしやすいというのは大きなメリットです。

なにより嬉しいのは、ここで紹介した非同期やPromiseについての知識は、 そのままクライアントサイドのJS プログラミングやサーバーサイドJS を書く際に役に立つ、あるいはプログラミングそのもののスキル向上につながるということです。

Node は、他のサーバーサイド環境に比べると、まだまだ新しい技術なので、体系的な資料は、少なくとも国内に限れば多くありません。

情報収集のために、海外の情報やnpm のインストール数、githubのexploreなどを積極的にチェックしてみましょう。

例えばTwitterだと、

などをがJS 関連の最新情報をキュレーションしています。

また、メソッドの使い方などがわからないときは、公式のドキュメントにも目を通しましょう。

以上、長くなってしまいましたがいかがでしたでしょうか?疑問・間違い・その他ご意見ありましたら、Twitterとはてブでつぶやいてもらえればありがたいです。密かに直します。

この記事がワンステップレベルアップしたい、皆さんのお役に立てば幸いです。

じぇしー
この記事を書いた人
じぇしー

フロントエンドエンジニア

関連記事