概要

本記事では、Node.jsでconnect-assetsを使ってアセットをビルドする方法を説明し、またこの仕組みの上でbourbonneatおよびbittersを使ったWebページを提供する方法を一例として説明する。connect-assetsはRailsのSprocketsのようなものだ。

なお、本記事によって作成される最終的な成果物の例は、https://github.com/mecab/blog-20151228-connect-assets に公開されている。

背景

最近はフロントエンドのJSをずっと書いているものの、CSSもJSも生のそれらをベタ書きして静的に垂れ流しているだけで、ちゃんとビルドしたりminifyしたりということをしていなかった。これはさすがにフロントエンド力が足りなさすぎるのでは...と不安になったのでようやくこの辺りの環境を作ってみることにした。

主な目標は1)JSとCSSといったアセットをビルドすること。特にCSSはSASS/SCSSからビルドすること。 2)開発中は元のアセットに変更を加えたら即時に反映されるようにする一方、本番運用時は事前にすべてのアセットをビルドして、静的に提供できる状況を作ること。である。

上記の環境を試すにあたって、適当にSCSSを書いても良いのだが、最近bourbon、neat、bittersというCSSフレームワークがいい感じらしいという記事を見て、またこれらはSCSSで書かれているので、ついでにこれらを使ったSCSSをビルドしてみることにした。

今回はGruntやGulpで変更を監視してビルドするスクリプトを書く方法は取らなかった。ちょっとしたプロジェクトには大掛かりすぎるなと思ったこと(一度スクリプトを書いてしまえば使いまわせるのだろうけれど、それはそれでコピペしまくることになって、後から変更するときによくわからないことになりそうだし)と、ビルドツールが乱立していて、将来のことを考えるとあまり依存したくないと思ったからだ。調べてみると実際に、npm scriptsだけでビルドが完結するようにしようという動きもあるようだ

そこで、connect-asssetsを使った。このパッケージは内部でMincerを使ってコンパイルされたアセットを提供する。Mincerは事前にコンパイルをするのではなく、クライアントからの要求に応じてアセットをコンパイルして返すサーバとして動作するものだ。RailsのSprocketsという仕組みにインスパイアされている。connect-assetsはExpressのミドルウェアとして動作するので使いやすいうえ、view用の便利なヘルパー関数を提供してくれる。その上、Mincerではやってくれないminifyまで面倒をみてくれるのでありがたい。

ブロジェクトの準備

Expressで1つのviewを持つシンプルなプロジェクトを作成する。簡単のため、viewエンジンには、ビュー変数や関数呼び出し以外の部分はHTMLでそのまま書けるswigを使った。

mkdir playground; cd playground
npm init
# ... 適当に設定。entry pointは好みでapp.jsに変更した
npm install express swig --save
emacs app.js

# 以下のような内容を書いた。
var path = require('path');
var express = require('express');
var swig = require('swig');

var app = new express();
app.listen(3000, err => {
    console.log("Express is runnning on port 3000");
});
app.engine('html', swig.renderFile);
app.set('view engine', 'html');
app.set('views', path.join(__dirname, 'views'));
if (process.env.NODE_ENV !== 'production') {
    app.set('view cache', false);
    swig.setDefaults({ cache: false });
}

app.get('/', (req, res) => {
    res.render('index.html');
});

views/index.htmlには以下のように適当なhtmlを書いておく。

<!DOCTYPE html>
<html>
  <head>
    <title>Playground</title>
  </head>
  <body>
    <p>Hello</p>
  </body>
</html>

node app.jsして、http://localhost:3000/ で書いたhtmlが表示されることを確認する。

connect-assetsの導入と設定

まずはnpm経由で導入する。また、作成するアセットに応じて適切なコンパイラも導入しなければならない。今回はSCSSをコンパイルするため、node-sassを導入する。

$ npm connect-assets --save
$ npm node-sass --save

app.jsに、connect-assetsの設定を書く。

var includePaths = ['assets/css', 'assets/js'];
app.use(require('connect-assets')({
    paths: includePaths,
    precompile: ["style.css", "main.js"]
}));

以上により、 assets/cssassets/js に配置したファイルを元に[1]style.cssmain.js が生成されるようになる。なお、precompileは指定しなくても開発モード(NODE_ENV !=== 'production')では動作するが、本番モードにしたり、アセットのビルドのみを行う場合に事故ることがあるので指定したほうが良いように思う。詳しくは後述する。その他のオプションについてはREADMEを参照のこと。

アセットの作成

早速、コンパイルされるべきアセットを作成しよう。assets/css/style.css.scssに以下のような簡単なSCSSを記述する。connects-assetsの規約により、ファイル名からstyle.cssを生成するためのSCSSだと認識される。

$color: #dd0000;
body {
    p {
        color: $color;
    }
}

JSは、ファイルが結合されることを確認するために2つのファイルを作成する。まずはassets/js/main.jsとして

//= require dependency.js
(function() {
    // 無駄にラッパーを入れたのはminifyされていることを確認しやすくするため
    function alertWrapper(msg) {
        alert(msg);
    }

    alertWrapper(msg);
})();

assets/js/dependency.jsとして

var msg = "hello~";

を作成する。コメントとして挿入したrequire文がMincerによって認識され、main.jsの前にdependency.jsが結合される。

ビューへの変更

これらのファイルから生成されたJS/CSSを読み込むように、ビューindex.htmlを編集する。

<!DOCTYPE html>
<html>
  <head>
    <title>Playground</title>
    {{ css('style') }}
  </head>
  <body>
    <p>Hello</p>
    {{ js('main') }}
  </body>
</html>

connect-assetsはビュー関数css()js()を提供する。これらは引数で与えられたファイル名(拡張子なし)に対応するビルド済みのアセットを読み込むHTMLタグを生成する。

確認とビルド

Nodeを再起動してブラウザを開くと、文字の色が変わり、またhello~とアラートが表示されることが確認できる。このとき、ソースを表示すると、JSについてはdependency.jsおよびmain.jsの両方のscriptタグが、依存関係を考慮した順序で挿入されていることが確認でき、またそれらのファイルは書いたソースそのままであることが分かる。また、ファイルを編集して、例えば、alertされる文字を書き換えたり、文字の色を変えてみると、サーバを再起動することなく変更が反映されることが分かる
。このため、開発やデバッグが行いやすいことが確認できた。

次に、アプリケーションを本番設定で起動してみる。

NODE_ENV=production node app.js

そして、同じくブラウザを開き、ソースを確認してみると、JSが1つのファイルに結合されたうえでminifyされていることが分かる。また、ファイルを更新するたびに提供されるファイル名の後ろにダイジェストがつき、ブラウザのキャッシュの問題が回避される。これで、本番時には実際に提供するべきファイルが生成されていることが確認できた。

この時、app.jsと同じディレクトリにbuiltAssetsというディレクトリが生成される。このディレクトリにはビルド済みのファイルと、あるファイル名にどのダイジェストが対応するかといった情報が書かれたmanifest.jsonが格納されている。静的コンテンツ提供用のサーバを別に用意したり、CDNを利用したりする場合は、これらのファイルを当該サーバにアップロードし、connect-assets初期化時のservePathオプションに配信のベースになるURLを指定すればよい。例えば:

app.use(require('connect-assets')({
    paths: includePaths,
    precompile: ["style.css", "main.js"],
    servePath: "http://static.example.com/assets"
}));

buildAssetsはアプリケーションの起動時に生成されるが、CDN等に配置することを考えるとビルドだけを行いたい状況が起こりうるはずだ。このために、connect-assetsはビルドをおこなうCLIがある。典型的な例は、

$(npm bin)/connect-assets \
  --output OUT_DIR \
  --include INC_DIR INC_DIR ... \
  --compile ASSET_1 ASSET2 ..."

--outoputは出力先、--includepaths--compileprecompileに相当する。今回の場合だと、

$(npm bin)/connect-assets \
  --output builtAssets \
  --include assets/css assets/js \
  --compile style.css main.js

でビルドすることができる。これをpackage.jsonscriptsの項にbuildなどとして追加すればnpm run buildでビルドができるようになる。

ここまでで、開発時は編集が自動的に反映されてデバッグしやすく、また本番時はビルド済みのアセットを提供できる環境が整った。

bourbon, neat, bittersの導入

本題は以上までで終わり、アセットのビルドができるようになったが、副題のbourbon、neatおよびbittersの導入を以下で行ってみる。

これらのCSSフレームワークはnpmでも公開されているのでこれを利用する。
neatを導入する。bourbonも依存関係として登録されているので一緒に入ってくる。うれしい。

$ npm install node-neat --save

bittersはnpmで提供されていないのでgemを使う。bittersはテンプレとして使って、直接ガリガリ書き換えていくスタンスらしいので、パッケージとして提供しないらしい。ここでrubyが必要になってしまうのは残念だが、最初の一度だけなのでまあいいだろう。気になるならbittersのリポジトリからファイル群を直接ダウンロードして展開してもいい。

$ sudo gem install bitters
$ mkdir -p assets/css; cd assets/css
$ bitters install

この操作によりassets/css/baseにbittersのファイルが展開される。

では、これらのフレームワークを使ったSCSSをコンパイルできるようにconnect-assetsを設定する。bourbonやneatをnpmで導入すると、実際に展開されたファイルの場所を返すヘルパー関数neat.with()も導入される。これを使ってpathsを構築すればよい。app.jsの該当部分を

var neat = require('node-neat');
var includePaths = neat.with([
    'assets/css',
    'assets/js'
]);
app.use(require('connect-assets')({
    paths: includePaths,
    precompile: ["style.css", "main.js"]
}));

と変更する。neat.with()はneatのファイル群が置かれたパスと、引数で与えられたパスの配列を結合する。なお、bourbonのファイル群に関しても当然含められるので楽で良い。

では、実際にこれらを使ったHTMLとSCSSと作成してみる。適当なシンプルな3カラムレイアウトとして、index.htmlに以下を

<!DOCTYPE html>
<html>
  <head>
    <title>Playground</title>
    {{ css('style') }}
  </head>
  <body>
    <div class="container">
      <div class="column">
        <h1>Title1</h1>
        <p>text1</p>
        <button>button1</button>
      </div>
      <div class="column">
        <h1>Title2</h1>
        <p>text2</p>
        <button>button2</button>
      </div>
      <div class="column">
        <h1>Title3</h1>
        <p>text3</p>
        <button>button3</button>
      </div>
    </div>
    {{ js('main') }}
  </body>
</html>

style.css.scssとして、以下を書いた。

@import "bourbon";
@import "base/base";
@import "neat";

.container {
    @include outer-container;

    .column {
        @include span-columns(4);
        background: $secondary-background-color;
        padding: $small-spacing;
        border-radius: $base-border-radius;
    }
}

結果として、以下のようなページになる。

bourbon、neat、bittersを使って作られたページの例。3カラムになっており、文字やボタンがいい感じのスタイルになっている。

neatによって簡単に3カラムのレイアウトが実現されているし、また、文章やボタンはbittersによりデフォルトでいい感じのスタイルが当たっていて悪くない。

なお、node-neatを利用する場合の、アセットのビルドのみを行うコマンドは

$(npm bin)/connect-assets \
  --output builtAssets \
  --include `node -p "require('node-neat').with(['assets/css', 'assets/js']).join(' ')"` \
  --compile style.css main.js

と、includeを取得するためにコマンドの内部で一度nodeを実行するのが良いと思う。なお、上の方で書いたが、僕のケースではcompileを明示的に指定しないと、フレームワークのSCSSを先に、しかも順番を考えずにコンパイルしようとしてエラーが起きてしまった。アンダースコアで始まるファイル名は依存関係であるため、積極的にコンパイルしないのが規則のはずなので、connect-assetsまたはMincerのバグのように思う。

おわりに

本記事では、Node.jsでconnect-assetsを使ってアセットをビルドする方法と、この仕組みの上でbourbon、neatおよびbittersを使ったWebページを提供する方法を説明した。

今回説明した方法では、GruntやGulpといったビルドツールに頼らず、一方、先に上げた記事のようにnpm scriptsが爆発する[2]ことなく、数行追加することで楽にビルドされたJSやCSSを提供できるし、ファイルに加えた変更がすぐに反映される上、開発段階ではJSがそのまま、複数のscriptタグで読み込まれるのでデバッグが行いやすいと思う。小規模なプロジェクトでは結構使える構成ではないだろうか。

一方、規模が大きくなったり、ビルドのためにやるべきことが多かったり、もっと細かくカスタマイズしたかったりする場合は対応できないので、npm scriptsとgulp等をうまく組み合わせて低依存のgulpfileを書いていく必要があると思う。

その他の選択肢として、webpackを使うのも良さそうだと思った。

なお、本記事によって作成される最終的な成果物の例は、https://github.com/mecab/blog-20151228-connect-assets に公開されている。


  1. 実はassets/cssassets/jsはデフォルトで入っているので指定する必要はないが、変更が必要な時の分かりやすさのために指定している。以下同じ。 ↩︎

  2. この記事のnpm scriptsでは単純な結合とminify以外にも色々やっているので、単純な比較はできないが ↩︎