Sass を試す

2018年1月17日 3 開発 , ,

年末に PostCSS + cssnext と CSS Modules を試したのだが、コメ欄の指摘とそれを受けた追記を読んでもらえるとわかるとおり次世代 CSS 仕様の先取り = CSS.next のつもりで導入した cssnext が微妙だと感じはじめている。

これらの記事でも言及しているとおり cssnext の採用する PostCSS プラグイン は CSS Working Group の認知していなかったり取り下げられそうなものもある。前者は直せばよいけど後者については直近の仕様だと冗長な記述が避けられないことを意味している。私は少なくとも

  • モジュール管理
    • @import が URL ではなくモジュール参照として解決される
  • 変数 (定数) と Mix-In
    • 色やサイズなどを変数 (定数) として定義して共通化できる
    • Mix-In で拡張性のある共通プロパティーのセットを定義できる
  • ネスト
    • 親子関係にあるクラスで子が複数となるとき、ネストとして定義できる
    • 元が .parent .child1 {} .parent .child2 {} なら
    • .panret { child1 {} child2 {} } とできる

を必要としているのだけどモジュール管理は Bundler の範疇だし Mix-In とネストは採用されないだろうしで、CSS.next としては変数しか恩恵にあやかれない。ならば cssnext に拘る必要はない。また AltCSS としてみた場合、PostCSS のプラガブルな思想はエコ システムが多段になり複雑さを増すだけである。よって Stylus や Sass を採用するほうがよいだろう。

今までは Stylus を採用してきたが、これは PostCSS + cssnext 移行のきっかけとなったとおり開発が停滞している。ならばもう一つの雄である Sass を検討してみたい。というわけで Sass 単体と webpack による CSS Modules を試す。

Sass、SASS、SCSS

SASS (Syntactically Awesome StyleSheets) はツールと言語の側面がある。Web に散見される記事をみるに Sass と表記されたときはプロダクトやツール、SASS なら記法を指すようだ。そして Sass には更に SCSS という記法もある。このあたりの違いは以下の記事がわかりやすい。

ツールとしての Sass には Ruby と Node 版がある。開発環境として依存の少ないほうが好ましいため、Ruby 不要の Node 版を選ぶことにした。記法は SCSS を採用。SCSS は SASS よりも CSS に寄せているため、将来 Sass をやめたくなったときの負担が少なくて済むだろう。この点について Stylus でコロンとセミコロン省略していたことを後悔している。

SCSS の記法は Stylus と似ている。CSS 標準の記法をそのまま取り込めて拡張したスーパー セット的なものだから、拡張部分に注意すれば普通の CSS 感覚で書ける。機能的に大きく異るのは変数ぐらいだろうか。Stylus には透過的変数という概念があって

white = #ff0000;

.page {
  background-color: white;
}

のように CSS 標準定数を上書き可能で、この例だと white なのに赤としている。SCSS は

$white: #ff0000;

.page {
  background-color: $white;
}

のように変数に接頭辞 $ が必須となるため透過的に上書きすることはできない。抽象化としては機能が落ちるけれど、CSS 標準定数を破壊することがないと保証されているともいえる。一方で独自記法を持ち込むことになるから SCSS を捨てる際の枷になるかもしれない。

モジュール機能については拡張子によって @import の扱いが異なる。*.scss なら Stylus と同様に参照かつ結合対象。しかし *.css の場合は CSS 標準の URL 参照として扱われる。そのため npm としてインストールした normalize.cssnode_modules 配下から参照して結合、といった技が使えない。これを解決するためには normalize.scss にするか node-sass-package-importer という node-sass フォークがあるけど、いずれも標準じゃないので採用したくない。

これについて現在は公式資料にて

Notice we’re using @import 'reset'; in the base.scss file. When you import a file you don’t need to include the file extension .scss. Sass is smart and will figure it out for you. When you generate the CSS you’ll get:

と定義されている。ファイルとして取り込みたいなら拡張子を省略すればよい。これについては実際に

@import "../../node_modules/normalize.css/normalize";
@import "../../node_modules/normalize.css/normalize.css";
@import "./Sample";
@import "./Sample.css";

を試した結果、拡張子を省略したものはファイル結合される。.css まで記述すると @import url(path) に変換されることを確認した。なお .scss はどちらでもファイル結合される。つまり外部 CSS のファイル結合はフォークや SCSS 版を持ち出すことなく Sass 標準で対応可能。

面倒なら拡張子を省略して Sass の自動判定に任せるのがよいだろう。@import url(path) はパフォーマンス問題があるため通常は避けるものだから、特に困らないはず。ただし CSS Modules で同名 JavaScript と併置したくなった時などを考慮すると内部ファイルは拡張子まで記述したい。私は ES Modules でも npm など外部由来はモジュール名、内部は拡張子まで記述するようにしている。このあたりは好みがわかれそう。

Sass 単体利用

node-sass による CLI ビルドを試す。

プロジェクト構成。

プロジェクト構成は以下。

.
├── package.json
└── src
    ├── assets
    │   └── index.html
    └── scss
        ├── App.scss
        ├── Base.scss
        ├── Content.scss
        ├── Footer.scss
        ├── Header.scss
        └── Variables.scss

src/scss へ CSS を格納。これを開発版なら src/assets、リリース版は dist/ に変換して bundle.css というファイル名で出力する。開発版では Source Maps にも対応。必要な npm は node-sass のみ。

$ npm i -D node-sass

npm-scripts

Sass は Stylus と同様に CLI オプションのみで設定を完結可能。package.json における定義は以下。

{
  "scripts": {
    "build": "node-sass ./src/scss/App.scss ./src/assets/bundle.css -r --source-map true --output-style expanded",
    "watch": "node-sass ./src/scss/App.scss ./src/assets/bundle.css -r -w --source-map true --output-style expanded",
    "release": "node-sass ./src/scss/App.scss ./dist/bundle.css -r --output-style compressed",
  }
}

オプションについて現時点の公式ページ資料だとわかりにくいため、使用しているものに絞って補足。

オプション 内容
-r ファイルやフォルダーを再帰的に監視する。フォルダーで階層構造を持たせたくなるかもしれないため、指定。
-w ファイルの変更を監視して自動変換する。
--source-map Source Maps を出力する。Boolean 値を指定する必要あり。
--output-style 出力される CSS のスタイル。expanded はネストを展開してくれる。規定値の nest だとインデントでネストっぽくなるけど余計である。なぜこれを規定値にしたのか。compressed は minify。リリース版で指定する。

変換してみる

npm-scripts に定義されたコマンドを実行して CSS.next なファイル群をひとつの CSS に変換してみる。コマンドとしては

コマンド 役割
build 開発用。Source Maps あり。単体で変換結果をチェックする場合に使用する。
watch 開発用。Source Maps あり。ファイルを監視して変更を検知したら CSS を自動変換する。browser-sync による Web ブラウザー自動表示も設定している。開発時は通常、こちらを使用する。
release リリース用。Source Maps なし、minify あり。

となるので、それぞれ

$ npm run build
$ npm run watch
$ npm run release

とすればよい。出力されたファイルを見れば適切な変換がおこなわれていることを確認できるはず。

webpack (CSS Modules)

webpack を使用して React から Sass を CSS Modules として参照できるようにする。

プロジェクト構成

プロジェクト構成は以下。

.
├── package.json
├── webpack.config.babel.js
└── src
    ├── assets
    │   └── index.html
    └── js
        ├── App.js
        └── components
            ├── App.js
            ├── Content.scss
            ├── Content.js
            ├── Footer.scss
            ├── Footer.js
            ├── Header.scss
            ├── Header.js
            └── Variables.scss

ファイル出力の仕様は node-sass 版と同じ。webpack 設定が追加されて SCSS ファイルが React コンポーネントに併置されているのが特徴的。

npm

必要な npm をインストール。webpack と Sass 関連だけ、他は割愛。

$ npm i -D webpack css-loader node-sass sass-loader extract-text-webpack-plugin

それぞれの役割をまとめる。

npm 役割
webpack Bundler。JavaScript や CSS の参照を解決、指定されたファイル単位にまとめて出力する。
css-loader webpack loader。CSS の参照を解決してくれる。
node-sass Sass 変換ツール Node 版。 Sass に基づく CSS 変換を担当。
sass-loader webpack loader。node-sass を webpack で利用するためのもの。
extract-text-webpack-plugin webpack plugin。css-loader と sass-loader のテキスト出力を受けて CSS ファイル化する。

webpack.config.babel.js

webpack の設定は以下。JavaScript 分も含まれているため長いが、すべて掲載する。

import WebPack from 'webpack'
import MinifyPlugin from 'babel-minify-webpack-plugin'
import ExtractTextPlugin from 'extract-text-webpack-plugin'

export default (env) => {
  const PROD = !!(env && env.prod)

  return {
    entry: './src/js/App.js',
    output: {
      path: PROD ? `${__dirname}/dist` : `${__dirname}/src/assets`,
      filename: 'bundle.js',
      publicPath: '/'
    },
    devtool: PROD ? '' : 'source-map',
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader'
          }
        },
        {
          test: /\.scss$/,
          use: ExtractTextPlugin.extract([
            {
              loader: 'css-loader',
              options: {
                modules: true,
                importLoaders: 1,
                sourceMap: !(PROD),
                minimize: PROD ? { autoprefixer: false } : false
              }
            },
            {
              loader: 'sass-loader',
              options: {
                outputStyle: PROD ? 'compressed' : 'expanded',
                sourceMap: !(PROD)
              }
            }
          ])
        }
      ]
    },
    devServer: {
      contentBase: './src/assets'
    },
    plugins: PROD ? [
      new WebPack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify('production')
      }),
      new MinifyPlugin({
        replace: {
          'replacements': [
            {
              'identifierName': 'DEBUG',
              'replacement': {
                'type': 'numericLiteral',
                'value': 0
              }
            }
          ]
        }
      }, {}),
      new ExtractTextPlugin({ filename: 'bundle.css' })
    ] : [
      // development
      new ExtractTextPlugin({ filename: 'bundle.css' })
    ]
  }
}

PostCSS + cssnext のサンプルと見比べてもらえるとわかるけど

{
  loader: 'sass-loader',
  options: {
    outputStyle: PROD ? 'compressed' : 'expanded',
    sourceMap: !(PROD)
  }
}

ぐらいしか変更していない。sass-loaderoptions に指定したものは node-sass へそのまま渡される。よって Source Maps や minify 指定はここでおこなう。

実際に変換してみると、React コンポーネントで参照している className がハッシュ値に変換されて CSS 側もそうなっていることを確認できるはず。

まとめ

AltCSS として既に Stylus に馴染んでおり、SCSS の記法は似通っているので違和感はなかった。CSS Modules も PostCSS とほとんど同じ。要素技術としてうまいこと可換にできた感がある。

最後に私がこれまで試した AltCSS 所感をまとめておく。

  • Stylus
    • Stylus を使ってみる が 2015/1/14 なので 3 年ぐらい利用したことになる
    • 当時は Sass の Ruby 依存を忌避して Stylus を採用
    • Pure Node、単体で高機能、開発活発、記述の柔軟性からとても気に入っていた
    • 開発は停滞したものの、現時点の選択肢としては悪くない
    • 個人的には開発が活発化して復活してほしい
  • PostCSS + cssnext
    • 昨年末に試してみた
    • CSS 記法のプラグイン拡張と cssnext の存在から Babel と babel-preset-env 的なものを期待していた
    • cssnext が csswg にないものを採用しており運用に不安がある
    • CSS の将来仕様で Mix-In やネスト (こちらは csswg にない) といった便利機能に相当するものがサポートされないっぽくて微妙
    • PostCSS としては Stylus/Sass から乗り換えたくなるプラグインは今のところない
    • プラグイン拡張に可能性を感じるならあり
    • 他の AltCSS 相当な機能をプラグインかき集めて実現するのはきついので cssnext のようなプリセットを検討したほうがよさそう
  • Sass
    • 記法は Stylus から CSS 標準記法の省略を抜いた感じ
    • Stylus の省略記法に馴染んでると辛いが、それなら SCSS でなく SASS を採用すればよい
    • 私の必要とする機能においては透過的変数をのぞき Stylus と同等
    • Stylus 代替としてはこれかな

これら 3 種について Node 版を npm trends で比較 した結果は 2018/1/16 時点だと PostCSS が Sass にダブル スコア以上の差をつけてトップだった。なんとなく Sass が最も普及していると予想していたので、これは意外。もしかすると gem 版ユーザーが圧倒的に多いとか?gem (Ruby) 版 Sass のダウンロード数がバージョンではなく期間推移も集計していれば npm trends のものと足して比較できたのに。

とりあえず現時点で選ぶとしたら Sass が無難だろう。乗り捨てを考慮しても SCSS 記法にしておけばロック イン度合いも少なくて Stylus/PostCSS への移行しやすいはず。

gulp ありの Web フロントエンド開発

2015年8月6日 0 開発 , , , , ,

gulp なしの Web フロントエンド開発 の追補編として、gulp を利用するケースもまとめておく。元記事と同じプロジェクト、機能を gulp で実装する。

記事の構成は「gulp なしの〜」を踏襲。各項の説明は package.json の scripts にかわって gulp タスクとしている。

目次

設計方針

はじめに設計方針を決めておく。

  1. AltJS から JavaScript へのコンパイルに対応する
  2. AltCSS から CSS へのコンパイルに対応する
  3. ファイル監視による AltJS/AltCSS の自動コンパイルに対応する
  4. ユニット テストに対応する
  5. コードド キュメント生成に対応する
  6. Windows 環境を考慮する
  7. 単体 gulpfile.js のみで実装する

方針 1 〜 6 までは「gulp なしの〜」と一緒。方針 7 は環境に関する設定を package.json と gulpfile.js だけにするというもの。

以前 gulp タスクをファイル分割する という記事を書いたときは gulpfile.js をタスク毎にファイル化していたが、環境設定を変更する機会は滅多にないため、分割は可搬性を損ねるだけという結論に至った。

ファイル監視と Windows 対応

ファイル監視と Windows 環境は gulp 自体の機能で対応済み。そのため各項では触れない。

検証環境

元記事と同じ。再掲する。

Mac は OS X Yosemite 10.10.4、Windows は 8.1 日本語版で検証。Node は共通で v0.12.7 を採用。

プロジェクト構成

実際に npm run を試せるよう、簡単なプロジェクトを用意する。README.md と LICENSE ファイルは除外している。

/
├ package.json
├ esdoc.json
├ gulpfile.js
├ src/
│ ├ index.html
│ ├ fonts/
│ ├ js/
│ └ stylus/
├ test/
├ dist/
├ esdoc/
└ node_modules/

各種ファイル、ディレクトリについては以下を参照のこと。「gulp なしの〜」からの差分は gulpfile.js のみ。

名前 内容
package.json プロジェクト設定ファイル。
esdoc.json ESDoc 設定ファイル。
gulpfile.js gulp タスク定義ファイル。
src/ 開発用ディレクトリ。
src/index.html エントリー ポイントになる HTML ファイル。
src/fonts/ アイコン フォント置き場。
src/js/ JavaScript 置き場。コンパイル結果は src/ 直下へ出力。
src/stylus Stylus 置き場。コンパイル結果は src/ 直下へ出力。
test/ ユニット テスト置き場。
dist/ リリース用イメージ出力ディレクトリ。動的生成される。
esdoc/ コード ドキュメント出力ディレクトリ。動的生成される。
node_modules/ npm ディレクトリ。動的生成される。

gulpfile.js の共通部分

gulpfile.js 内で共通となる部分を実装。以降の gulp タスク解説では、この処理が定義済みであるものとする。

var gulp = require( 'gulp' );
var $    = require( 'gulp-load-plugins' )();

// Common config
var config = {
  src:        './src',
  dest:       './dist',
  isWatchify: false,
  isRelease:  false
};

処理内容について解説する。

1 行目は gulp 定義なので特筆すべきことはない。2 行目は gulp プラグインの呼び出しを簡略化するための定義。gulp-load-plugins は gulp-XXXX という名の npm を統合してくれる。

この定義では gulp-load-plugins を $ としているため、gulp-util なら $.util で参照できる。gulp- 以降の名前にハイフンがある場合は camelCase 化される。例えば gulp-minify-css は minifyCss で呼び出す。

5 〜 12 行目はタスク間で共有される設定。

内容
src 開発用ディレクトリのパス。
dest リリース用ディレクトリのパス。
isWatchify JavaScript のファイル監視と自動コンパイルの開始フラグ。
isRelease リリース用 JavaScript/CSS コンパイル実行フラグ。Uglify/Minify 実行、Source Maps 生成スキップ、コンパイル結果の dest 出力がおこなわれる。

JavaScript コンパイルとファイル監視

AltJS には ES6 を採用。JavaScript のコンパイルと依存解決は gulp-watchify が担当する。Transpiler は babelify を採用。これは ES6 だけでなく React JSX も解釈できる。

// JavaScript ( ES6, React JSX )
gulp.task( 'js', $.watchify( function( watchify ) {
  var buffer    = require( 'vinyl-buffer' );
  var formatter = require( 'pretty-hrtime' );
  var time      = process.hrtime();

  return gulp.src( [ config.src + '/js/App.js' ] )
    .pipe( $.plumber() )
    .pipe( watchify( {
      watch: config.isWatchify,
      basedir:   './',
      debug:     true,
      transform: [ 'babelify' ]
    } ) )
    .pipe( buffer() )
    .pipe( $.if( !( config.isRelease ), $.sourcemaps.init( { loadMaps: true } ) ) )
    .pipe( $.if( config.isRelease, $.uglify() ) )
    .pipe( $.rename( 'bundle.js' ) )
    .pipe( $.if( !( config.isRelease ), $.sourcemaps.write( './' ) ) )
    .pipe( $.if( config.isRelease, gulp.dest( config.dest ), gulp.dest( config.src ) ) )
    .on( 'end', function() {
      var taskTime = formatter( process.hrtime( time ) );
      $.util.log( 'Bundled', $.util.colors.green( 'bundle.js' ), 'in', $.util.colors.magenta( taskTime ) );
    } );
} ) );

gulp-watchify により Browserifywatchify を統合。これらを直に利用する場合、gulp ストリーム化や watch 時のエラー処理がけっこう面倒なのだけど、gulp-watchify ならすべて gulp として対応できる。詳しくは gulp-watchify を試す を参照のこと。

Source Maps ファイル生成、Uglify ( Minify )、コンパイル結果の出力先を変更するため gulp-if を利用している。

これは第一引数に Boolean を取り、 true なら第二引数、false であれば第三引数を gulp ストリームに対して実行する。第三引数は省略可能で、その場合は第一引数が true のときだけ第二引数を実行する。

この仕組を利用すると、フラグ変更する gulp タスクを定義して、その実行により gulp ストリーム処理を分岐できる。10 行目で実施しているファイル監視の分岐もフラグ操作タスクで実現している。

gulp-watchify には問題もある。このプラグインをインストールすると Browserify/watchify も同時にインストールされるのだが、そのバージョンが古い。2015/8/6 時点の最新 Browserify は 11.0.1 だが、gulp-watchify では 9.0.8 になってしまう。package.json を修正して最新版をインストールした場合、gulp-watchify がバージョン互換のエラーになる。

よって、最新版を利用したいなら greypants/gulp-starterwatchify を試す のように自前で処理する必要がある。いつか更新されるだろうと期待して gulp-watchify を採用しているが、Browserify はメジャー バージョン部分で数世代も差が開いたため、そろそろ自前処理に戻そうかと迷っている。

CSS コンパイルとファイル監視

AltCSS には Stylus を採用。コンパイルは gulp-stylus が担当。

// CSS ( Stylus )
gulp.task( 'css', function() {
  return gulp.src( [ config.src + '/stylus/App.styl' ] )
    .pipe( $.plumber() )
    .pipe( $.if( !( config.isRelease ), $.sourcemaps.init() ) )
    .pipe( $.stylus() )
    .pipe( $.rename( 'bundle.css' ) )
    .pipe( $.if( config.isRelease, $.minifyCss() ) )
    .pipe( $.if( !( config.isRelease ), $.sourcemaps.write( '.' ) ) )
    .pipe( $.if( config.isRelease, gulp.dest( config.dest ), gulp.dest( config.src ) ) );
} );

gulp-stylus は Minify 機能を持つのだが、stylus と異なり Source Maps 生成はおこなえない。README のサンプルでも gulp-sourcemaps を利用している。また、JavaScript タスクと同じような処理にしたいので、gulp-stylus は Stylus の CSS コンパイルに徹するように設計した。

そのため、gulp-if による分岐も JavaScript コンパイルと同様の仕組みとなっている。

Web サーバー起動

Web ブラウザの制限 ( Chrome などでローカル ファイルからの Ajax や Web Storage が抑止される問題 ) を回避して Web サーバー経由の動作確認をおこなうための定義。これは「gulp なしの〜」と同様に browser-sync を採用。

// Local web server
gulp.task( 'server', function() {
  var browser = require( 'browser-sync' ).create();
  browser.init( {
    server: {
      baseDir: config.src
    }
  } );
} );

このタスクは JavaScript/CSS のファイル監視と一緒に使用されることを想定している。タスク間の同期は不要なので、gulp.task のコールバックは指定せず gulp ストリームも返していない。

gulp から起動された場合でも、CLI と同様に localhost/IP アドレスの URL をコンソール出力してくれる。ただし gulp プラグインではないためインデントがずれる。

[20:18:11] Starting 'server'...
[20:18:11] Finished 'server' after 513 ms
[20:18:11] Bundling App.js (watch mode)
[BS] Access URLs:
 ------------------------------------
       Local: http://localhost:3000
    External: http://XXX.XXX.XXX.XXX:3000
 ------------------------------------
          UI: http://localhost:3001
 UI External: http://XXX.XXX.XXX.XXX:3001
 ------------------------------------
[BS] Serving files from: ./src
[20:18:12] Finished 'css' after 1.28 s

指定されたディレクトリをルートとする Web サイトのホストと、Web ブラウザの自動起動も CLI と同様に機能する。

開発用タスクの統合

JavaScript と CSS のファイル監視と自動コンパイルを走らせつつ Web サーバー経由で動作確認、という感じで開発したいので、ここまで定義してきたタスクを統合する。

// Enable wachify
gulp.task( 'watch:js', function( done ) {
  config.isWatchify = true;
  done();
} );

// Watch files
gulp.task( 'watch', [ 'watch:js', 'js', 'css', 'server' ], function () {
  gulp.watch( [ config.src + '/stylus/**/*.styl' ], [ 'css' ] );
} );

// Default task
gulp.task( 'default', [ 'watch' ] );

watch:js タスクは JavaScript コンパイルをファイル監視モードで実行するためのフラグを立てる。これを watch タスクの依存設定で js より前に指定することで、フラグを立ててから js タスクが実行され、結果として watchify を起動することになる。

タスク依存からの実行は非同期となるため、確実に順番保証したいなら js タスクの内容を関数としてくくりだし、watch:js に依存するものとしないもので呼び分けるとよいだろう。以下は参考例。

// JavaScript ( ES6, React JSX )
function compileJS() {
  // gulp-watchify を利用した処理
  // 必ず gulp ストリームを return すること!!
}

// Normal build
gulp.task( 'js', function() {
  return compileJS();
} );

// Watch files
gulp.task( 'watch:js', function() {
  config.isWatchify = true;
  return compileJS();
} );

フラグ変更ぐらいなら神経質になる必要もないが、きっちり順番保証したい!という考えも理解できるので代替を提案しておいた。

ユニット テストとコード ドキュメント

これらは gulp を利用しないので割愛。詳しくは gulp なしの Web フロントエンド開発 を参照のこと。

リリース用イメージ生成

Web フロントエンド部分をデプロイするためのイメージ生成。複数のタスクを組み合わせて実装する。

// Clean release image
gulp.task( 'release:clean', function( done ) {
  var del = require( 'del' );
  del( config.dest, done );
} );

// Create release directory & copy files
gulp.task( 'release:copy', function() {
  var src = [
    config.src + '/*.html',
    config.src + '/fonts/**'
  ];

  return gulp.src( src, { base: config.src } )
    .pipe( gulp.dest( config.dest ) );
} );

// Release config
gulp.task( 'release:config', function( done ) {
  config.isRelease = true;
  done();
} );

// Build release image
gulp.task( 'release', function( done ) {
  var runSequence = require( 'run-sequence' );
  return runSequence(
    'release:clean',
    'release:copy',
    'release:config',
    [ 'js', 'css' ],
    done
  );
} );

release:clean はリリース用イメージ出力先ディレクトリを del で削除する。ディレクトリが存在しない場合でもエラーにはならないので安心して実行できる。

release:copy はリリース用ファイルをコピーする。gulp 標準の src/dest を利用するだけ。glob 対応しているので、条件指定も柔軟に対応できる。gulp を採用するメリットとして、この機能によるファイル操作の強力さが挙げられる。

release:config でリリース用フラグを立てる。これ以降に js、css タスクを実行すると Source Maps 無効、Uglify/Minify 有効状態でコンパイルされたファイルがリリース用イメージ出力先に生成される。

Source Maps を単体ファイルとして出力しているなら、それを削除するだけでもよさそうだが、ファイルがない場合でも JavaScript/CSS 内に参照は残ってしまう。この状態でリリースすると、Web ブラウザが JavaScript/CSS を読み込んだときに Source Maps ファイル用の GET リクエストを発行してしまい、無駄な通信となるため避けるべきである。

といいつつ、この記事を書くまでこの問題に気づかず、参照が残ったままリリースしているものがある ( 例えば本ブログの WordPress テーマ ) ため、後で直したい。

release でリリース系タスクを統合する。依存タスクは同期・非同期で実行するものが入り混じるため、gulp の依存設定で対応するのは面倒。よって run-sequence でまとめて管理している。

release:clean 〜 release:config までを同期、js と css は非同期に実行。js で利用している gulp-watchify は出力先ディレクトリが存在しないとファイル生成に失敗するため、release:copy を先に実行することでディレクトリの存在を保証している。gulp の依存タスクもこの書式、つまり Array の要素へ直に定義したタスク間を直列、要素が Array ならそのタスク間を並列、というようにしてくれると嬉しいのだが。

gulp タスクを scripts で抽象化

npm で依存もタスクも一元化する を参考にして gulp タスクを package.json の scripts で抽象化する。この方法には以下のメリットがある。

  • グローバルの gulp インストールが不要
  • コマンド利用側は npm run だけ覚えればよく、gulp の知識は不要
  • gulp を利用しないものと併用しても違和感がない
  • gulp のメリットだけ享受できる

対応は簡単で、package.json の scripts に gulp タスクを対応付けるだけ。

{
  "scripts": {
    "test": "mocha --compilers js:espower-babel/guess test/**/*.test.js",
    "esdoc": "esdoc -c esdoc.json",
    "start": "gulp watch",
    "server": "gulp server",
    "build:css": "gulp css",
    "build:js": "gulp js",
    "release": "gulp release"
  }
}

test と esdoc 以外のコマンドはすべて gulp タスク呼び出しである。開発中は npm start、リリース時は npm run release を実行する。その他のコマンドは好みに応じて定義する。あまりに多いと管理しにくいため、gulp タスクの中でも単体実行したくなりそうなものに限定するとよい。

まとめ

賛否あるものの、私は gulp を素晴らしいツールと認識している。前回の記事を gulp 否定として読まれた方もいたようだが、その意図はなく単純に異なる方法を検討してみたくなっただけである。それを踏まえ、同一構成のプロジェクト運用が gulp の有無でどのように変わるのかを示せたら面白いと思い、今回の記事を書いてみた。

結果、これまでリリース用イメージに Source Maps 参照を含めていたことの問題に気づくなど、gulp 利用時の運用も見直せてよかった。

最後に今回作成したサンプル プロジェクトを公開しておく。これは「gulp なしの〜」で作成したものの gulp 版である。

gulp なしの Web フロントエンド開発

2015年8月5日 5 開発 , , , ,

Web フロントエンド開発において gulp は非常に便利だ。一方、あまりにも gulp に依存しすぎており、これなしで開発できるだろうか?という不安もある。

というわけで、gulp を利用せず package.json と npm だけで同等の機能を実現する方法を検討してみた。

  • 2015/11/4 追記
    babelify v7.2 を試すで babelyfy 7.2 ( とその中の Babel 6.x ) について調べ、npm-scripts の変更が必要なことを確認したので追記。また Windows 環境の動作検証をおこなったところ、最新の watchify なら -o オプションが通ることを確認できた。よって本記事の最後の課題が解決したことになる。
  • 2015/9/23 追記
    cpx と rimraf を試すの内容をファイル処理に反映して簡略化。
  • 2015/9/15 修正
    Stylus 自体にファイル監視機能が備わっていること、styl ファイルを足すと Source Maps 参照できなくなる問題があったので CSS コンパイル関連を修正。
  • 2015/9/1 追記
    npm-run-all v1.2.8 を試すにも書いたとおり、最新の npm-run-all であれば watchify の終了問題は発生しない。よって現在の package.json では npm-scripts の実行を npm-run-all に統一している。これで残る問題は watchify の Source Maps ファイル出力のみ。
  • 2015/8/10 追記
    Windows におけるコマンド連結の問題は npm-run-all と concurrently を試すで対応した。
  • 2015/8/6 追記
    この記事と対になるものとして gulp ありの Web フロントエンド開発を書いてみた。[/note]

以下は参考記事。

目次

かなり長い記事になったので目次を用意した。

設計方針

検討にあたり、設計方針を決めておく。

  1. package.json と npm だけを使用する
  2. AltJS から JavaScript へのコンパイルに対応する
  3. AltCSS から CSS へのコンパイルに対応する
  4. ファイル監視による AltJS/AltCSS の自動コンパイルに対応する
  5. ユニット テストに対応する
  6. コードド キュメント生成に対応する
  7. Windows 環境を考慮する

方針 1 を実現するため npm は CLI を持つものに限定される。自前で Node モジュールを実装すれば何でもできるが、それでは gulp と変わらないので却下。npm に公開されたパッケージだけで構成する。

方針 2 は ES6、方針 3 では Stylus を採用する。CLI を提供する Transpiler さえあれば、他の言語でも通用するはず。

方針 4 は gulp の特徴ともいえる機能であり、これなしの開発は考えられないぐらい便利なので、重視する。

方針 5 と 6 は過去の調査により gulp なしで対応できているが、Web フロントエンド開発に必須なので設計に含めている。

方針 7、これは努力目標ということで。おそらく CLI まわりで苦労するだろう。OS X/Linux とコマンドを共通化できない場合は代替案を考える。すくなくとも放置はしない。

検証環境

Mac は OS X Yosemite 10.10.4、Windows は 8.1 日本語版で検証。Node は共通で v0.12.7 を採用。

プロジェクト構成

実際に npm run を試せるよう、簡単なプロジェクトを用意する。README.md と LICENSE ファイルは除外している。

/
├ package.json
├ esdoc.json
├ src/
│ ├ index.html
│ ├ fonts/
│ ├ js/
│ └ stylus/
├ test/
├ dist/
├ esdoc/
└ node_modules/

各種ファイル、ディレクトリについては以下を参照のこと。

名前 内容
package.json プロジェクト設定ファイル。
esdoc.json ESDoc 設定ファイル
src/ 開発用ディレクトリ。
src/index.html エントリー ポイントになる HTML ファイル。
src/fonts/ アイコン フォント置き場。
src/js/ JavaScript 置き場。コンパイル結果は src/ 直下へ出力。
src/stylus Stylus 置き場。コンパイル結果は src/ 直下へ出力。
test/ ユニット テスト置き場。
dist/ リリース用イメージ出力ディレクトリ。動的生成される。
esdoc/ コード ドキュメント出力ディレクトリ。動的生成される。
node_modules/ npm ディレクトリ。動的生成される。

JavaScript コンパイルとファイル監視

JavaScript ビルドは Browserify、ES6 から ES5 へのコンパイルは babelify を利用する。Source Maps ファイルは exorcist が生成。ファイル監視は watchify が担当。コマンド定義は以下。

{
  "scripts": {
    "build:js": "browserify -t babelify ./src/js/App.js -d | exorcist ./src/bundle.js.map > ./src/bundle.js",
    "watch:js": "watchify -v -t babelify ./src/js/App.js -o \"exorcist ./src/bundle.js.map > ./src/bundle.js\" -d",
    "release:js": "browserify -t babelify ./src/js/App.js | uglifyjs > ./dist/bundle.js"
  }
}

build:js は JavaScript のコンパイルと Source Maps ファイル生成を実行する。ファイル監視が不要で、単体ビルドしたい時に利用する。

watch:js でファイル監視を開始する。以降 Ctrl + C で中断するまでファイル更新を検出するたびに JavaScript を自動コンパイルしてくれる。-v オプションをつけるとコンパイル時間を出力してくれる。watchify は差分検出による処理時間の短縮も重要な機能なので、全体と差分コンパイルの時間が把握できるのはありがたい。

$ npm run watch:js

> front-end-starter@1.0.0 watch:js ../examples-web-app/front-end-starter
> watchify -v -t babelify ./src/js/App.js -o "exorcist ./src/bundle.js.map > ./src/bundle.js" -d

10295 bytes written to exorcist ./src/bundle.js.map > ./src/bundle.js (1.04 seconds)

release:js によりリリース用 JavaScript コンパイルが実行される。uglify-js を利用した圧縮と最適化もおこなう。リリース用なので Source Maps ファイルは生成しない。出力先は dist になる。

2015/11/5 版

babelify 7.2 ( とその中で利用される Babel 6.x 系 ) では ES6 と React JSX のコンパイルがオプションになった。そのため個別にプラグインをインストールし、

$ npm i -D babel-preset-es2015 babel-preset-react

以下のように --presets で指定する必要がある。

{
  "scripts": {
    "build:js": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/App.js -d | exorcist ./src/bundle.js.map > ./src/bundle.js",
    "watch:js": "watchify -v -t [ babelify --presets [ es2015 react ] ] ./src/js/App.js -o \"exorcist ./src/bundle.js.map > ./src/bundle.js\" -d",
    "release:js": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/App.js | uglifyjs > ./dist/bundle.js"
  }
}

朗報として 最新版の watchify なら -o オプションが Windows 環境でも動作するようになった。これは本記事の Windows 対応における最後の課題だったが、ようやく解決したことになる。

Windows 対応

前述のように最新版 watchify なら以下の対応は不要。

watch:js を Windows 環境で実行するとエラーになる。watchify で Source Maps を生成する場合、-o オプションのパイプ機能を利用して JavaScript コンパイル結果を exorcist に渡すのだけど、これが問題になる。

watchify の issue
#16 External source maps and other options. を読むと、パイプ機能に対応したものの Windows 対応はおこなわれなかったようだ。README にも以下のように説明されている。

The -o option can be a file or a shell command (not available on Windows) that receives piped input: readme.markdown

よって Windows 環境で動作させるなら Source Maps ファイルを諦めるか、埋め込み式に変更しなければならない。例えば以下のように定義し直す。

{
  "scripts": {
    "watch:js": "watchify -v -t babelify ./src/js/App.js -o ./src/bundle.js -d"
  }
}

Source Maps の有無は -d オプションで切り替える。

CSS コンパイルとファイル監視

2015/9/15 版

Stylus CLI 自体にもファイル監視機能があること、styl ファイルを追加した時に Source Maps を参照できなくなる問題があることからスクリプトを以下のように修正。

{
  "scripts": {
    "build:css": "stylus -c ./src/stylus/App.styl -o ./src/bundle.css -m --sourcemap-root ./stylus",
    "watch:css": "stylus -c -w ./src/stylus/App.styl -o ./src/bundle.css -m --sourcemap-root ./stylus",
    "release:css": "stylus -c ./src/stylus/App.styl -o ./dist/bundle.css"
  }
}

Stylus 標準のファイル監視機能を使用することで watch パッケージが不要となる。そのためコマンドライン指定のダブルクォートも要らず、Windows を意識しなくても済むようになった。

旧版

今回は AltCSS として Stylus を採用した。公式 npm stylus が CLI を提供してくれるので、これをそのまま使用する。ファイル監視は watch が担当。コマンド定義は以下。

{
  "scripts": {
    "build:css": "stylus -c ./src/stylus/App.styl -o ./src/bundle.css -m",
    "watch:css": "watch \"npm run build:css\" ./src/stylus/",
    "release:css": "stylus -c ./src/stylus/App.styl -o ./dist/bundle.css"
  }
}

build:css では CSS コンパイルの他、-c オプションによる Minify と -m オプションの Source Maps ファイル生成も同時におこなう。これらの機能が標準搭載されているのはありがたい。

watch:css でファイル監視を開始。watch は gulp.watch の代替になるもので、指定されたファイル変更を検出すると他のコマンドを実行してくれる。ここでは build:css を流用している。

release:css はリリース用イメージを生成する。Source Maps は不要。出力先は dist になる。

Windows 対応

watch:css で watch に指定するコマンドをダブルクォートで囲む必要あり。JSON の値内に記述するためシングルクォートにしたくなるが、そうすると Windows 環境でエラーになる。よってバックスラッシュでエスケープしつつダブルクォートにする。

それ以外の対応は特になし。

Web サーバー起動

Web フロントエンド部分を動作確認するとき、ローカル ファイルを直に Web ブラウザで表示すると問題になることがある。例えば Chrome はローカル ファイルの場合、セキュリティを考慮して Ajax や Web Storage などの利用を抑止する。

そのため開発環境に簡易 Web サーバーを起動し、フロントエンド部分をホストさせる。開発用だと browser-sync を利用することが多いようなので、そうする。コマンド定義は以下。

{
  "scripts": {
    "server": "browser-sync start --server src"
  }
}

server を実行すると http://localhost:3000 に src/ をホストする。ここへアクセスした状態なら Chrome のローカル ファイルに対する機能抑止を回避できる。

localhost 部分を Web サーバーを実行しているマシンの IP アドレスにすることで、同一ネットワーク上の他の端末からもアクセスできる。これはスマートフォンやタブレット端末による動作検証に役立つ。コマンド実行時に URL を教えてくれるのも親切だ。

$ npm run server

> front-end-starter@1.0.0 server ../examples-web-app/front-end-starter
> browser-sync start --server src

[BS] Access URLs:
 ------------------------------------
       Local: http://localhost:3000
    External: http://XXX.XXX.XXX.XXX:3000
 ------------------------------------
          UI: http://localhost:3001
 UI External: http://XXX.XXX.XXX.XXX:3001
 ------------------------------------
[BS] Serving files from: src

browser-sync としては、他にもファイル変更を検出して Web ブラウザ表示を自動更新させる機能が有名。ただし私は任意のタイミングで更新したいので利用していない。

例えばある修正をおこなった時、その前後を Web ブラウザで別タブに開いて見比べる、ということをしたいとき自動更新は邪魔になる。

Windows 対応

不要。

開発用コマンドの連結

JavaScript と CSS のファイル監視と自動コンパイルを走らせつつ、Web サーバー経由で動作確認、という感じで開発したいので、ここまで定義してきたコマンドを連結してみる。

{
  "scripts": {
    "watch": "npm run watch:css & npm run watch:js & npm run server",
    "start": "npm run watch"
  }
}

watch は頻繁に利用するので start に割り当てておくとよい。start は npm run の run を省略して npm start で実行できる特別なコマンドである。

連結は UNIX 系シェルと同じ記法を指定できるらしい。代表的なものだと以下がある。

連結方法 内容
one & two 並列実行。one の実行を待たずに two が起動される。
one && two 直列実行。one の実行が成功した後に two が起動される。成否をチェックが不要なら ; で連結する。
one | two 直列実行。one の出力を受け取り two が起動される。

開発用コマンドは依存関係がないため、並列実行の & で連結している。

Windows 対応 2015/9/1 版

npm-run-all あれば、コマンドの直列・並列実行を OS 非依存で実現できる。v1.2.6 時点では watchify の終了に失敗する問題があったものの、最新の v1.2.8 では解決されているため、複数コマンドの連結はこれで統一するのがよいだろう。

"scripts": {
  "start": "npm run watch",
  "watch:css": "watch \"npm run build:css\" ./src/stylus/",
  "watch:js": "watchify -v -t babelify ./src/js/App.js -o \"exorcist ./src/bundle.js.map > ./src/bundle.js\" -d",
  "watch:server": "browser-sync start --server src",
  "watch": "npm-run-all -p watch:css watch:js watch:server"
}

Windows 対応 2015/8/10 版

npm-run-all と concurrently を試すで調査した結果 concurrently で対応できた。連結だけ引用すると以下のようになる。詳細は当該記事を参照のこと。

{
  "scripts": {
    "watch:server": "browser-sync start --server src",
    "watch:js": "watchify -v -t babelify ./src/js/App.js -o ./src/bundle.js -d",
    "watch:css": "watch \"npm run build:css\" ./src/stylus/",
    "watch": "concurrent \"npm run watch:css\" \"npm run watch:js\" \"npm run watch:server\""
  }
}

Windows 対応 2015/8/5 版

連結が動作しない。コマンドをそのまま実行すると初めの watch:css だけ起動される。Windows 8.1 のコマンド プロンプトと PowerShell、SourceTree 付属の MinGW で試したが、いずれも失敗。

後述するリリース用イメージ生成で使用している && は機能するので、並列実行と watch の相性がよくないのかもしれない。

UNIX 系シェルを移植した環境、例えば Cygwinwin-bash なら動作するだろう。ただし package.json と npm のみ、という方針から外れるので推奨したくない。

コマンド プロンプトでも動かしたい場合、連結しているコマンドを個別に起動させればよい。今回は 3 種類のコマンドがあるので、コマンド プロンプトも 3 プロセス起動し、それらで個別にコマンドを実行する。

  • 2015/8/8 追記
    コメント欄にて mysticatea さんに npm-run-all を勧められたので 試してみたところ、結合に成功してファイル監視と Web サーバー起動が動作することを確認できた。しかし Ctrl + C で watch を停止すると ERROR: watch:js: None-Zero Exit(null); というエラーが出て、watch:js が完全に終了しないようだ。
    エラー後、ターミナル上で改めて停止する必要がある。予想だが、npm-run-all はプロセスの終了コードをきちんとチェックしていて、watchify はそれを返さない ( 中断した場合、process.exit にならない? ) ことが原因なのかも。実行には成功したので、この問題さえ回避できれば npm-run-all を採用したい。

ユニット テスト

ユニット テストは ES6 コードをテストするから採用している方法を流用。

{
  "scripts": {
    "test": "mocha --compilers js:espower-babel/guess test/**/*.test.js"
  }
}

test も start と同様に run を省略して npm test で実行できる。

テスト対象を test/**/*.test.js にしている理由は、テスト専用のユーティリティ クラスなどを除外するため。テストには mocha を利用しているのだけど、テストを含まないファイルに対して実行するとエラーになるため、これを回避したい。

また、テストと対象コードを同じテキスト エディタで開いてるとき、.test の有無で見分けられて便利である。特に Sublime Text だと同名ファイルをひとつのウィンドウで開いたとき、区別のためタブにフルパスが表示されて辛いので、地味に重要。

Windows 対応

不要。

コードド キュメント

コード ドキュメント生成も ESDoc を試す から流用。

{
  "scripts": {
    "esdoc": "esdoc -c esdoc.json"
  }
}

コマンドを実行すると esdoc/ 内にコード ドキュメントが出力される。リリース用イメージ生成に含めるか迷ったが、プロジェクトがバージョン管理されていれば好きなタイミングで出力できるので、単体コマンドにしておいたほうがよいと判断した。

Windows 対応

不要。

リリース用イメージ生成

Web フロントエンド部分をデプロイするためのイメージ生成。コマンド定義は以下。

{
  "scripts": {
    "release:clean": "trash ./dist",
    "release:mkdir": "mkdirp ./dist && npm run release:clean && mkdirp ./dist",
    "release:copyfiles": "copyfiles -f ./src/*.html ./dist",
    "release:copydirs": "ncp ./src/fonts ./dist/fonts",
    "release:copy": "npm run release:copyfiles && npm run release:copydirs",
    "release": "npm run release:mkdir && npm run release:copy && npm run release:css && npm run release:js"
  }
}

release:clean はリリース用イメージのディレクトリを削除する。はじめ、gulp でも重宝していた del を採用するつもりだったが CLI 欄に trash があったのでこちらにした。del は CLI を提供しないのだろうか。

release:mkdir でリリース用イメージのディレクトリを新規作成する。mkdirp を利用。gulp の場合 del を先に実行してから gulp.src/dest でディレクトリ構築していたが、trash は対象が存在しないとエラーになる。

逆に mkdirp は対象が存在しても空振りするだけなので、mkdirp、trash、mkdirp の順に実行し、初回実行でも成功するようにしている。

release:copyfilescopyfiles により条件指定でファイルをコピーしている。src/ 直下には index.html の他、コンパイルされた JavaScript や CSS が出力されるが、後者を除外 ( これらは開発用で Source Maps 指定などが含まれている ) したいので条件が必要になる。

release:copydirsncp を利用してディレクトリ構造を維持したコピーを実行する。事前にリリース用の構造を構築してあるディレクトリを対象にする。

release:copy でここまでのコピー系コマンドを連結し、最後にrelease がすべてのリリース用コマンドを統合する。

2015/9/23 版

cpxrimraf の採用により処理を更に簡略化。Windows でも動作することを確認済み。

{
  "scripts": {
    "release:css": "stylus -c ./src/stylus/App.styl -o ./dist/bundle.css",
    "release:js": "browserify -t babelify ./src/js/App.js | uglifyjs > ./dist/bundle.js",
    "release:clean": "rimraf ./dist",
    "release:copy": "cpx \"./src/**/{*.html,*.eot,*.svg,*.ttf,*.woff,package.json}\" ./dist",
    "release": "npm-run-all -s release:clean release:copy -p release:css release:js"
  }
}

Windows 対応 2015/9/1

Windows でも && によるコマンド連結は可能なため従来版でもよいのだが、npm-run-all ならば直列・並列実行を混在させることも可能である。また OS 依存も吸収してくれるため、現在はこちらに移行した。以下はそのサンプル。

{
  "scripts": {
    "release:css": "stylus -c ./src/stylus/App.styl -o ./dist/bundle.css",
    "release:js": "browserify -t babelify ./src/js/App.js | uglifyjs > ./dist/bundle.js",
    "release:clean": "trash ./dist",
    "release:mkdir": "mkdirp ./dist",
    "release:copyfiles": "copyfiles -f ./src/*.html ./dist",
    "release:copydirs": "ncp ./src/fonts ./dist/fonts",
    "release": "npm-run-all -s release:mkdir release:clean release:mkdir  release:copyfiles release:copydirs -p release:css release:js"
  }
}

-s の後に列挙されたものは直列、-p 以降なら並列に実行される。

Windows 対応

不要。

まとめ

リリース用イメージ生成のスクリプトを試しているとき、gulp ストリームと src/dest を使いたくてたまらなかった。glob 対応していて CLI を持つファイル操作系 npm がほしい。

そもそもファイル操作を CLI で実行する需要がないのだろうか、今回採用したものも探すのに苦労した。贅沢を言わせてもらうと、npm の README には CLI 対応の有無を記載してくれるとありがたい。自分で作る機会があったらそうするつもり。

コンソール出力について考えさせられた。gulp だと実行日時と処理時間が表示され、メッセージや色も統一感がある。バラバラな npm をかき集める方法だと、これは望めない。そんな中、browser-sync は読みやすさについての工夫が感じられた。テキストであっても色とレイアウトは大事。

Windows 対応は辛い。とはいえ、Node 自身がきちんと Windows に向き合っているし、職場では Windows 環境が大半なので疎かにはできない。今後も重要視したい。

今回は gulp で実装していた Web フロントエンド開発を個別 npm に置き換えるだけの記事にするつもりだったが、書いているうちに開発スタイルの棚卸し的になっていった。結果、自身の手法を省みる契機になりそうでよかったと感じる。

最後に今回作成したサンプル プロジェクトを公開しておく。~~v1.0.2 でコマンド連結の Windows 対応に成功した。更に v1.0.3 でコマンド連結を npm-run-all v1.2.8 に統一。v1.0.4 で CSS コンパイル周りを修正して Windows を意識したコードが更に不要に。v1.0.5 でファイル処理を簡略化。~~更新を逐次、書き出すのは面倒なので最新版だけ公開する。大きな変更があった場合は記事冒頭にその旨を記載する。