話題の記事

Grunt + TypeScript + Middleman によるフロントエンド開発環境を作ってみる

2014.04.30

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Middleman

Middleman を使うようになってしばらく経ちますが、2014年4月現在 TypeScript に対応していないというのがどうも気になります。Ruby on Rails や Sinatra といった他の Ruby 製フレームワークと同様、Middleman が対応している Alt JS は CoffeeScript のみです。

CoffeeScript は機能が軽量であることから学習コストが低く、記述されるコード量も少なくなるので個人的に結構気に入っているのですが、Web アプリケーションの規模が大きく複雑になるにつれて静的型付けの機能を持っていないことがデメリットとして浮上してきがちです。案件の規模によっては静的型付け言語の採用を検討しないと後々で大変な目にあいかねません。

それならもうゼロから全部 Grunt で作れよ

Grunt

はい。JavaScript や CSS 周りのお世話なら Grunt だけで十分にまかなえるのですが、Middleman には HTML 周りの強力なテンプレート機能、ビルド時のミニファイ化やローカルデータの読み込みなどが標準で備わっているので、これらを手放すことは HTML コーデングの生産性を大幅に損なうことになります。仮にこれらと同等の機能を Grunt 環境上にゼロから作るとしたら、それはもう大変な労力を要するので絶対にやりたくないです。

typescript-logo

そんな訳で、HTML や CSS 周りやファイルのミニファイ化などはこれまで通り Middleman に任せ、TypeScript 周りだけを Grunt で管理するための環境を作ってみました。

環境構築手順 - 下準備

前提条件

以下の環境が既にあるという前提で話を進めていきます。

  • Mac OS X v10.9.2 - Mavericks
  • Command Line Tool がインストール済みであること
  • Ruby のバージョンが 2.0 以上であること
  • Bundler がインストール済みであること
  • Homebrew がインストール済みであること
  • Chrome がインストール済みであること

これらの環境構築については、以下のエントリにて詳しく紹介しています。

Homebrew で作るモダンなフロントエンド開発環境 (Git + zsh + apache + MySQL + Ruby)

Node.js をインストール

いきなりインストールする前に Homebrew をアップデートしましょう。もしかしたら Homebrew 本体が更新されていたり Fomula が追加/変更されているかもしれません。ターミナルを起動したらそのまま以下のコマンドを入力して実行します。

$ brew update

少し時間がかかりますが、そのまま待っていれば処理が終わります。次に Node.js をインストールします。そのまま以下のコマンドを入力して実行します。

$ brew install node

こちらはそれほど時間がかからずに処理が終わるかと思います。終わったら以下のコマンドを入力して無事にインストールできたかどうか確認します。

$ node -v
v0.10.26

バージョン情報が表示されたらインストール成功です。

Grunt は Node pacakge と呼ばれるものの一種であり、それら Package はnpm (Node Package Manager) というツールで管理するのが一般的です。この npm は Node.js と一緒にインストールされるので、すぐに使うことが出来ます。

Grunt 環境を構築

最初にgrunt-cli というパッケージをインストールします。Grunt コマンドでビルドを実行するためのものであり、これがないと何も始まりません。ターミナルから以下のコマンドを入力して実行します。

$npm install grunt-cli -g

-g はグローバルディレクトリにインストールするという意味のオプションです。このオプションをつけてインストールされた Package はどのディレクトリからでも呼び出すことが出来るようになります。オプションを付けない場合はコマンドを実行したディレクトリにインストールされ、その配下でしか呼び出せなくなります。デフォルトはこちらなのですが、grunt-cli はグローバルディレクトリにインストールしないと正常に機能しないので、必ず-gをつけてインストールします。

TypeScript をインストール

こちらも npm からインストールします。ターミナルに以下のコマンドを入力して実行します。

$ npm install typescript -g

-gをつけてグローバルディレクトリにインストールします。インストール処理が終わったら成功したか確認します。コマンド名はtscです。

$ tsc -v
Version 1.0.0.0

バージョン情報が表示されれば成功です。グローバルディレクトリへの構築はここまでです。次からはプロジェクト毎の環境構築となります。

Middleman 環境を構築

適当なサンプルプロジェクトを作成します。以下のコマンドを実行してディレクトリを作り、そこに移動します。

# プロジェクト名はお好みでどうぞ
$ mkdir middleman_and_typescript
$ cd middleman_and_typescript

bundler を使って Middleman をインストールします。以下のコマンドを実行してGemfileを生成します。

$ bundle init

生成された Gemfile をテキストエディタで開いて、以下の内容に書き換えます。

# If you have OpenSSL installed, we recommend updating
# the following line to use "https"
source 'http://rubygems.org'

gem "middleman", "~>3.3.2"

# Live-reloading plugin
gem "middleman-livereload", "~> 3.1.0"

# For faster file watcher updates on Windows:
gem "wdm", "~> 0.1.0", :platforms => [:mswin, :mingw]

# Windows does not come with time zone data
gem "tzinfo-data", platforms: [:mswin, :mingw]

保存して閉じたら、以下のコマンドを実行して Middleman をインストールします。

$ bundle install --path vendor/bundle

--path vendor/bundleオプションを付けることで、ローカルディレクトリ以下のvendor/bundleというディレクトリに Middleman をはじめとした関連 Gem がインストールされます。

Middleman がインストールされたら、以下のコマンドを実行して Middleman プロジェクトの初期化を行います。

$ bundle exec middleman init .

これで Middleman 関連の下準備が完了しました。こんな感じのディレクトリ構造になっていれば成功です。

middleman_and_typescript
├── Gemfile
├── Gemfile.lock
├── config.rb
├── source
│   ├── images
│   │   ├── background.png
│   │   └── middleman.png
│   ├── index.html.erb
│   ├── javascripts
│   │   └── all.js
│   ├── layouts
│   │   └── layout.erb
│   └── stylesheets
│       ├── all.css
│       └── normalize.css
└── vendor
    └── bundle

Node.js プロジェクトを作成

先ほど構築した Middleman 環境のディレクトリに Node.js プロジェクトを作成します。以下のコマンドを実行してpackage.jsonを生成します。

$ npm init

色々と聞いてきますが、特別な理由がない限りひたすらEnterを押して全部 Yes としておきます。これで現在の Node.js プロジェクトを管理するための JSON ファイルが生成されました。

package.json
{
  "name": "middleman_and_typescript",
  "version": "0.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

Chrome に LiveReload アプリを追加

img-livereload_cap

現在表示しているページのリロード処理を外部から呼び出すことが出来るアプリです。Chrome ウェブストアからインストールすることが出来ます。

img-livereload_icon

インストールするとこのようなアイコンが追加されます。

環境構築手順 - 実践

具体的には以下の様な構成にします。

Development - 開発時

  • Webサーバーは Middleman から起動してブラウザ上で確認する
  • 基本的には Middleman をメインとし、Haml や SCSS で記述したコードは Middleman の機能でコンパイルする
  • JavaScript は TypeScript で記述し、それを Grunt でコンパイルして Middleman プロジェクト内に出力する
  • TypeScript 編集後の自動リロードは、Grunt から行う

Production - ビルド時

  • Grunt からTypeScript コンパイルとMiddleman ビルドコマンドを順に実行する

必要な Node Package をインストール

今回必要な Package は以下の4つです。

以下のコマンドを順に実行して全てインストールします。

$ npm install grunt --save-dev
$ npm install grunt-contrib-watch --save-dev
$ npm install grunt-middleman --save-dev
$ npm install grunt-typescript --save-dev

--save-devオプションを付けることで、インストールした Package の情報が package.json に追記されます。最終的に以下のような内容になるはずです。

package.json
{
  "name": "middleman_and_typescript",
  "version": "0.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "grunt": "^0.4.4",
    "grunt-contrib-watch": "^0.6.1",
    "grunt-middleman": "^0.1.2",
    "grunt-typescript": "^0.3.4"
  }
}

Gruntfile を作成する

Grunt に何を実行させるかを定義するファイルです。JavaScript 形式、CoffeeScript 形式の何れかで記述することが出来ます。

Gruntfile.coffee
module.exports = (grunt)->

  pkg = grunt.file.readJSON 'package.json'

  for taskName of pkg.devDependencies
    if taskName.substring(0,6) == 'grunt-'
      grunt.loadNpmTasks taskName


  grunt.initConfig

    typescript:
      base:
        src: ['source_typescript/main.ts']
        dest: 'source/javascripts/main.js'
        options:
          soureMap: true

    middleman:
      options:
        useBundle: true
      build:
        options:
          command: 'build'

    watch:
      options:
        livereload: true
      typescript:
        files: ['source_typescript/*.ts']
        tasks: ['typescript']



  grunt.registerTask 'default', ['typescript','watch']
  grunt.registerTask 'build', ['typescript', 'middleman:build']

TypeScript ファイルはsource_typescriptというディレクトリに置きます。コンパイル先に Middleman プロジェクト内のsource/javascripts/というディレクトリにmain.jsという名前で出力します。出力された JavaScript ファイルは Middleman 側で普通に読み込まれ、Web ブラウザ上で実行されるという訳です。

また、TypeScript ファイルが更新されたらコンパイル処理を実行し、Web ブラウザをリロードするように Watch タスクを定義します。

Grunt とMiddleman を実行 - development

これでようやく準備完了です。ターミナルから Grunt と Middleman をそれぞれ起動します。

$ grunt
Running "typescript:base" (typescript) task
File /***/middleman_and_typescript/source/javascripts/main.js created.
js: 1 file, map: 0 files, declaration: 0 files (901ms)

Running "watch" task
Waiting...
$ bundle exec middleman server
== The Middleman is loading
== The Middleman is standing watch at http://0.0.0.0:4567
== Inspect your site configuration at http://0.0.0.0:4567/__middleman/

Chrome の LiveReload 機能を有効化して TypeScript ファイルを編集してみましょう。TypeScript のコンパイルが実行され、ブラウザがリロードされるはずです。

Grunt からビルド実行して静的ファイルを生成

先ほどと違ってビルドタスクは Grunt から一括して実行します。

$grunt build
Running "typescript:base" (typescript) task
File /***/middleman_and_typescript/source/javascripts/main.js created.
js: 1 file, map: 0 files, declaration: 0 files (843ms)

Running "middleman:build" (middleman) task
== Unknown Extension: relative_links
      create  build/stylesheets/all.css
      create  build/stylesheets/normalize.css
      create  build/images/background.png
      create  build/images/middleman.png
      create  build/javascripts/main.js
      create  build/javascripts/app.js
      create  build/index.html
>> Finished running middleman build

Done, without errors.

buildディレクトリが作られ、そこに静的ファイルが出力されました。ファイルのミニファイ化などは Middleman 側で行うので、config.rbという設定ファイルで制御します。

どうして development 時は Grunt と Middleman を別々に実行するの?

() 打開策を追記しました。

確かに Grunt の default タスクに追加することで、middleman serverタスクを Grunt から実行することは可能です。しかしそうすると Middleman タスクが起動している間は Grunt タスクが監視状態に入ってしまうため、それ以降のタスクを実行することができなくなります。試しにGruntfile.coffee を以下のように編集して Grunt タスクを実行してみます。

Gruntfile.coffee
grunt.initConfig

    typescript:
      base:
        src: ['source_typescript/main.ts']
        dest: 'source/javascripts/main.js'
        options:
          soureMap: true

    middleman:
      options:
        useBundle: true
      build:
        options:
          command: 'build'
      server:
        options:
          command: 'server'

    watch:
      options:
        livereload: true
      typescript:
        files: ['source_typescript/*.ts']
        tasks: ['typescript']

  grunt.registerTask 'default', ['typescript', 'middleman:server', 'watch']

watch タスクの前に middleman:server タスクを呼び出すようにしました。いざ Grunt を実行するとこのような状態になります。

$ grunt
Running "typescript:base" (typescript) task
File /***/middleman_and_typescript/source/javascripts/main.js created.
js: 1 file, map: 0 files, declaration: 0 files (832ms)

Running "middleman:server" (middleman) task
== The Middleman is loading
== The Middleman is standing watch at http://0.0.0.0:4567
== Inspect your site configuration at http://0.0.0.0:4567/__middleman/

Middleman server が起動している限り、Grunt タスクは先に進むことが出来ません。つまり TypeScript ファイルをいくら編集しても Watch タスクが動かないので、TypeScript の再コンパイルが実行されないのです。middleman:server タスクと watch タスクの呼び出し順序を入れ替えても同じですね。今度は watch タスクが起動している限り、middleman:server タスクはいつまで経っても実行されません。

そんな訳で苦肉の策ですが、Middleman server は Grunt からではなく別個に実行することでこのタスク詰まり問題を回避しています。ひとまず当初の目的は果たせましたが、もっと上手い方法があれば是非ともご教示いただけると嬉しいです。

( 追記)grunt-external-daemon で Middleman をバックグラウンドで動かす

当エントリ公開後に Node.js スジの方からアドバイスを頂きました。

grunt-external-daemon

平たく言うとバックグラウンドで別のタスクを走らせることが出来る、というものです。

middleman と watch はタスク実行に監視状態に入ることで後ろに続くタスクの流れをストップさせる性質がありますが、external-daemon はメインのタスクの流れと平行して別のタスクを走らせることが出来るという訳です。こいつぁクールだ。

早速インストールします。ターミナルから以下のコマンドを実行します。

$ npm install grunt-external-daemon --save-dev

Gruntfile.coffee を以下のように編集します。

Gruntfile.coffee
module.exports = (grunt)->

  pkg = grunt.file.readJSON 'package.json'

  for taskName of pkg.devDependencies
    if taskName.substring(0,6) == 'grunt-'
      grunt.loadNpmTasks taskName

  grunt.initConfig

    external_daemon:
      mid_serve:
        cmd: 'bundle'
        args: ['exec', 'middleman', 'server']
        options:
          verbose: true
          
    typescript:
      base:
        src: ['source_typescript/main.ts']
        dest: 'source/javascripts/main.js'

    middleman:
      options:
        useBundle: true
      build:
        options:
          command: 'build'

    watch:
      options:
        livereload: true
      typescript:
        files: ['source_typescript/*.ts']
        tasks: ['typescript']

  grunt.registerTask 'default', ['typescript','watch']
  grunt.registerTask 'build', ['typescript', 'middleman:build']
  grunt.registerTask 'serve', ['typescript', 'external_daemon:mid_serve', 'watch']

external-daemon のタスクを定義して watch タスクの前にそれを走らせるようにします。実行してましょう。

% grunt serve
Running "typescript:base" (typescript) task
File /***/try_grunt/middleman_and_typescript/source/javascripts/main.js created.
js: 1 file, map: 0 files, declaration: 0 files (835ms)

Running "external_daemon:mid_serve" (external_daemon) task
>> Started mid_serve

Running "watch" task
Waiting...
[bundle STDOUT] == The Middleman is loading

Middleman も無事に起動しました。TypeScript ファイルを編集すれば watch タスクが走ってコンパイルされます。

注意) Middleman を Bundler 経由でインストールした場合はbundleコマンドを external-daemon に指定すること

Middleman に限らず、Bundler 経由でインストールした Gem を実行するときはbundle execというコマンドのオプションに Gem を指定する必要があります。

# NG
$ middleman server

# OK
$ bundle exec middleman server

これに伴い、external-daemon のcmd プロパティには middleman ではなく bundle と指定し、args プロパティに exec, middleman, server と指定する必要があります。こちらの情報も追加でアドバイスを頂きました。

@kamiyam さん、本当にありがとうございます。これで勝つる。