2015 年 1 月 3 日

Backbone.Marionette.Moduleとbrowserifyでモジュール管理 第1回

Backbone.Marionette.Moduleと、browserifyでモジュール管理 第1回

久々にBackboneを使う機会があり、それならとMarionetteでダンスさせることにしました。

MarionetteはBackboneの冗長になりがちなBoilerPlate部分を吸収してくれるフレームワークです。
Backboneを使ったことがある人は、アプリケーションの構造として、どうあるべきか悩んだことはないでしょうか。

Marionetteには、アプリケーションクラス、モジュールクラスというアプリケーションの構造を表現するクラスがあります。

しかし、Marionetteでアプリケーションを作成する標準的な方法に関する情報も少なく、
クラスの仕様を理解するだけでは、使いこなすことが難しそうです。

今回は、Marionette.Moduleを使ってアプリケーションをモジュール化し、なおかつ、CommonJS準拠のモジュール管理をBrowserアプリでも実現できるBrowserifyを利用して、アプリケーションの骨格を考えてみます。

【お知らせ 15.09.10】

Backbone.Marionetteのモジュール機能がv3(現在はv2.4)から廃止されるようなので、次回の予定はなくなりました。また、モジュールバンドラもbrowserifyからwebpackの利用が増えているようなので、次回の代わりとして、以下のQiitaの記事を公開いたしました。ご参考まで。

Backbone.MarionetteアプリをWebpackでバンドルする

目次

  1. CommonJSについて
  2. Backbone.Marionetteのモジュール
  3. サンプルアプリケーションビルド
    • package.json
    • Gruntfile.coffee
    • Browserifyタスク
    • Browserify:appタスク
    • Browserify:vendorタスク
    • Browserify:specタスク
  4. テスティング環境
  5. 次回へ

CommonJSについて

CommonJSの実装としては、Node.jsが有名です。Node.jsでは、Javascriptモジュールを読み込むのにrequireを利用します。

しかし、ブラウザで動作するJavascriptには、モジュールをロードする方法がなく、scriptタグで必要なJavascriptを読み込みます。

RequireJSを利用している方もいると思いますが、Browserifyは比較的簡単に利用できます。最近ではwebpackを使っている人もいるでしょう。

Backbone.Marionetteのモジュール

話を戻しますが、MarionetteのモジュールクラスはCommonJSに準拠したものではなく、あくまでMarionetteフレームワーク内のモジュールクラスです。

ちなみにMarionette.Moduleのドキュメントには以下のように紹介されています。

Marionette Modules allow you to create modular encapsulated logic.
They can be used to split apart large applications into multiple files,
and to build individual components of your app.

モジュールの役割としては、アプリケーションの分割(ファイル分割も含む)と隠蔽化ということだけで、どのように使うのは自由だという感じです。

たとえば、機能ごとにビユーとコントローラーをモジュールしましょうというような方針は特に見当たりません。結局、Backboneと同様に好きにしていいよという感じかなと思っています。

しかし、これでは進めようがないので、以下の書籍を参考にすることにしました。

ref. Backbone.Marionette.js: A Gentle Introduction by Devid Sulc

この書籍のチュートリアルを参考にしながら、Marionette.Moduleの使い方を探ってみたいと思います。
特に、この書籍の説明をするつもりはありませんので(全部、読んでないので…)、興味のある方は原書を読んでいただければと思います。

サンプルアプリケーションビルド

Browserifyの利用方法やテスティング環境の構築には、以下のエントリを参考にさせていただきました。Browserifyのバージョンが異なるためか解決できなかったこともありますが、大変参考になりました。

ref. browserify in Backbone.Marionette project
ref. Karma for JavaScript test runner

今回は、サンプルアプリケーションの構成の説明をかねて、Browserifyによるビルド方法をご紹介します。このエントリでは、Gruntを利用します。最初に必要なモジュールを準備します。

Browserifyのバンドルに関しては、qiitaのエントリにしましたので、こちらもあわせて参照してください。Gulpでもビルドしてみました。

Backbone.MarionetteアプリをBrowerifyでバンドルする(Grunt編)
Backbone.MarionetteアプリをBrowerifyでバンドルする(Gulp編)

package.json

利用するパッケージの内容と各バージョンは以下の通りです。
npmでインストールすると、バージョンが”^x.y.z”となりますが、あえて”~x.y.z”としています。

完全にバージョンを固定したい場合は、npm shrinkwrapを実行するとnpm-shrinkwrap.jsonファイルを作成してくれます。これはGemfile.lockやcomposer.lockと同様のもののようです。

なんとなく、browserify,grunt-browserifyのメジャーバージョンのあがり方が急激な気がしますが、Semantic Versioning(http://semver.org/)への準拠ということなのかもしれません。

{
  "name": "backbone.marionette",
  "version": "0.0.1",
  "description": "scaffold",
  "scripts": {
    "test": "karma start"
  },
  "author": "kan",
  "devDependencies": {
    "matchdep": "~0.3.0",
    "grunt": "~0.4.5",
    "grunt-browserify": "~3.2.1",
    "handlebars": "~2.0.0",
    "power-assert": "~0.10.0",
    "hbsfy": "~2.2.1",
    "espowerify": "~0.10.0",
    "normalize-css": "~2.3.1",
    "jquery": "~2.1.1",
    "backbone": "~1.1.2",
    "underscore": "~1.7.0",
    "backbone.marionette": "~2.3.0",
    "mocha": "~2.0.1",
    "karma": "~0.12.28",
    "karma-mocha": "~0.1.10",
    "karma-mocha-debug": "~0.1.2",
    "karma-safari-launcher": "~0.1.1",
    "karma-html2js-preprocessor": "~0.1.0"
  }
}

Backbone.Marionetteに関するパッケージは以下の通りです。
backbone.marionetteは、2.2.2から2.3.0へアップデートしました。主な変更点は最適化とリファクタリング、パフォーマンス向上しているそうです。

    "jquery": "~2.1.1",
    "backbone": "~1.1.2",
    "underscore": "~1.7.0",
    "backbone.marionette": "~2.3.0",
    "handlebars": "~2.0.0",

最近はJavascriptやCSSもノードパッケージから取得するようにして、bowerを使わなくなりました。その方が管理が簡単ですね。

Browserifyに関するパッケージは、以下の通りです。

    "grunt-browserify": "~3.2.1",
    "hbsfy": "~2.2.1",
    "espowerify": "~0.10.0",

テンプレートエンジンはHandelbarsを利用しています。hbsfyはHandlebarsの.hbsファイルのトランスフォームです。hbsファイルをプリコンパイルします。

espowerifyはpower-assertのためのBrowserifyトランスフォームです。これでテストコードをpower assert化します。BrowserifyはCoffeeScriptのソースもトランスパイルできますが、今回は利用していません。

テスティング関連のパッケージは、以下の通りです。

    "power-assert": "~0.10.0",
    "mocha": "~2.0.1",
    "karma": "~0.12.28",
    "karma-mocha": "~0.1.10",
    "karma-mocha-debug": "~0.1.2",
    "karma-safari-launcher": "~0.1.1",
    "karma-html2js-preprocessor": "~0.1.0"

テストランナーはKarma、テストフレームワークにはMochaを、アサーションにはpower-assertを使います。テストブラウザはSafariを使っていますが、ヘッドレスのPhantomJSでもいいでしょう。

Gruntfile.coffee

最近では、gulpを使う人も増えているようですが、gruntも健在ですよね。
いままではGruntfile.jsとしてJavascriptで記述していたのですが、せめてここだけでもCoffeeScript使おうということで、今回はCoffeeScriptで記述しています。

単に、js2coffeeコマンドで変換して、少し修正しただけですけど、js2coffee(https://www.npmjs.com/package/js2coffee)も便利ですね。

Gruntfileを見るだけで、プロジェクトのディレクトリ構成など、なんとなくつかめるだろうと思いますが、簡単に説明しておきます。

ディレクトリ構成

  package.json
  node_modules/
  Gruntfile.coffee
  assets/ # ソースコード
    js/
      /template # hbsファイル
    css/
  specs/ # テストコード
  karma.conf.js # karma initで生成します。
  public/ # 公開ディレクトリ
    assets/
      js/
      css/

Gruntfile

module.exports = (grunt) ->

  pkg = grunt.file.readJSON("package.json")
  remapify = require 'remapify'

  grunt.initConfig

    dir:
      lib: "./node_modules"
      src: "./assets"
      hbs: "./template"
      dst: "./public/assets"

    copy:
      csslib:
        files: [
          expand: true
          cwd: "<%= dir.lib %>/normalize-css/"
          src: ["normalize.css"]
          dest: "<%= dir.dst %>/css/"
        ]

    browserify:
      options:
        preBundleCB: (b) ->
          b.plugin remapify, [
            {
              cwd: "./assets/js"
              src: "*.js"
              expose: "nzkc"
            }
          ]

      app:
        files:
          "<%= dir.dst %>/js/app.js": [
            "<%= dir.src %>/js/*.js"
            "<%= dir.hbs %>/*.hbs"
          ]

        options:
          transform: ["hbsfy"]
          ignore: ["<%= dir.src %>/js/vendor.js"]
          external: [
            "jquery"
            "underscore"
            "backbone"
            "backbone.marionette"
          ]
          alias: [
            "<%= dir.hbs %>/template1.hbs:proj/template/template1"
            "<%= dir.hbs %>/template2.hbs:proj/template/template2"
            "<%= dir.hbs %>/template3.hbs:proj/template/template3"
          ]

      vendor:
        files:
          "<%= dir.dst %>/js/vendor.js": ["<%= dir.src %>/js/vendor.js"]

        options:
          alias: [
            "jquery"
            "underscore"
            "backbone"
            "backbone.marionette"
          ]

      spec:
        files:
          "./specs/spec.js": ["./specs/app-spec.js"]

        options:
          transform: ["hbsfy", "espowerify"]
          alias: [
            "<%= dir.hbs %>/template1.hbs:proj/template/template1"
            "<%= dir.hbs %>/template2.hbs:proj/template/template2"
            "<%= dir.hbs %>/template3.hbs:proj/template/template3"
          ]
          external: [
            "jquery"
            "underscore"
            "backbone"
            "backbone.marionette"
          ]

    watch:
      app:
        files: [
          "<%= dir.src %>/js/*.js"
          "<%= dir.src %>/template/*.hbs"
        ]
        tasks: ["browserify"]

      spec:
        files: ["specs/*.js"]
        tasks: ["browserify:spec"]

  require("matchdep").filterDev("grunt-*").forEach(grunt.loadNpmTasks)
  grunt.registerTask "default", ["watch"]

Browserifyタスク

Browserifyタスクで、vendor.js,app.js,spec.jsを生成します。
前述のエントリーで大変参考になったのは、 browserifyの出力ファイルを分割するところです。
いままで、ひとつのファイルにまとめるしかないのだろうと思っていました。

また、相対パスでrequireのパスを記述するのが面倒なので、マッピングとエイリアスを利用します。

grunt-browserifyの2.x以前ではaliasMappingが使えたのですが、今では(3.2.1)deprecatedされてしまいました。2.x以降で同様なことを行うには、remapifyを利用すればできるようですが、思ったように動作しませんでした。

ref. https://github.com/jmreidy/grunt-browserify

A note on alias mappings, which was a functionality pre 2.0
that was removed: its behavior can be reproduced with @joeybaker's remapify plugin,
as demonstrated in the code below:

具体的には、以下のようにbrowserifyタスクの共通optionsでremapifyにマッピング情報(.jsと.hbs)を設定したいのですが、引数のマッピング情報を二つ以上渡すと、Fatal error: Cannot read property '1' of nullとなります。

これも微妙でcwdで指定したパスにファイルが存在しなければ、とりあえずエラーは回避できるという状況です。

remapify = require 'remapify'

browserify:
  options:
    preBundleCB: (b) ->
      b.plugin remapify, [
        {
          cwd: "./assets/js"
          src: "*.js"
          expose: "app"
        }
        {
          cwd: "./template"
          src: "*.hbs"
          expose: "app/template"
        }
      ]

仕方なく、hbsファイルに関してはaliasを併用して、動作するようにしましたが、ここは改善したいところです。

browserify:
  options:
    preBundleCB: (b) ->
      b.plugin remapify, [
        {
          cwd: "./assets/js"
          src: "*.js"
          expose: "nzkc"
        }
      ]

  app:
    files:
      "<%= dir.dst %>/js/app.js": [
        "<%= dir.src %>/js/*.js"
      ]

    options:
      transform: ["hbsfy"]
      ignore: ["<%= dir.src %>/js/vendor.js"]
      external: [
        "jquery"
        "underscore"
        "backbone"
        "backbone.marionette"
      ]
      alias: [
        "<%= dir.hbs %>/template1.hbs:proj/template/template1"
        "<%= dir.hbs %>/template2.hbs:proj/template/template2"
        "<%= dir.hbs %>/template3.hbs:proj/template/template3"
      ]

  vendor:
    files:
      "<%= dir.dst %>/js/vendor.js": ["<%= dir.src %>/js/vendor.js"]

    options:
      transform: ["hbsfy"]
      alias: [
        "jquery"
        "underscore"
        "backbone"
        "backbone.marionette"
        "<%= dir.hbs %>/template1.hbs:proj/template/template1"
        "<%= dir.hbs %>/template2.hbs:proj/template/template2"
        "<%= dir.hbs %>/template3.hbs:proj/template/template3"
      ]

  spec:
    files:
      "./specs/spec.js": ["./specs/app-spec.js"]

    options:
      transform: ["hbsfy", "espowerify"]
      alias: [
        "<%= dir.hbs %>/template1.hbs:proj/template/template1"
        "<%= dir.hbs %>/template2.hbs:proj/template/template2"
        "<%= dir.hbs %>/template3.hbs:proj/template/template3"
      ]
      external: [
        "jquery"
        "underscore"
        "backbone"
        "backbone.marionette"
      ]

Browserify:appタスク

Browserifyタスクで生成するファイルは、vendor.js,app.js,spec.jsの3つです。
アプリケーションでは、vendor.jsとapp.jsを読み込みます。
vendorタスクでは、jquery,underscore,backbone,backbone.marionetteをエイリアスし、
appタスクでは、これをexternal宣言しておきます。

最初はfilesに<%= dir.hbs %>/*.hbs"を追加していましたが、ビルドは正常だが、実行時にテンプレートがrequireできなくなりました。aliasのみとすると正常に動作します。この辺の理由もよくわかっていないです。

Browserify:vendorタスク

vendorタスクで読み込むソース(vendor.js)は以下のような単純なものです。

var Backbone = require('backbone');
Backbone.$ = require('jquery');
require('backbone.marionette');

Backboneをロードした後、Backbone.$にjqueryを読み込んでおく必要があるようです。

Browserifyが出力するファイル(vendor.js)は以下のような形になります(一部)。

require=(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
var Backbone = require('backbone');
Backbone.$ = require('jquery');
require('backbone.marionette');

},{"backbone":"backbone","backbone.marionette":"backbone.marionette","jquery":"jquery"}],2:[function(require,module,exports){

(以下、省略)

先頭はbrowserifyのおまじないコードでrequireでモジュールをロードするファンクションです。

Browserifyを利用したことがある人にとっては、なじみのコードです。初めて見る人はなんだこれ?って感じになりますね。

Browserifyを知らずに、初めてこのコードを見たとき、CommonJS準拠のローダを実装しているようだが、トリッキーなコードを書くなと思って、一生懸命追いかけました(笑)。

Browserifyを使うとNode.jsのモジュールもブラウザで利用できるようになります。これも魅力的なところでしょう。

Browserify:specタスク

specタスクでも、同様にexternal宣言をしておき、アプリケーション
(app.js,entities.js,module.js,.hbsファイル)モジュールも取り込みたいのですが、ここも、いまいちうまくいっていないところで、前述したようにaliasで無理矢理読み込んでいます。app.jsとvendor.jsのように分離することも難しいようですが、せめてファイルが増えてもGruntfileを触らなくてもいいようにしたいですね。

テスティング環境

karmaをインストール後、karma initを実行して、対話形式でkarma.conf.jsを作成しておきます。

$ npm install karma
$ npm install -g karma-cli
$ karma init

karma.conf.jsの設定ポイントは、以下のとおりです。今回修正したのは以下3点だけです。
frameworkにmochaとmocha-debugを設定し、必要なJavascriptとテスト時に必要なDOMを生成するためのfixture.htmlを用意し、browserにSafariを指定します。

karma.conf.js

// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['mocha-debug', 'mocha'],

// list of files / patterns to load in the browser
files: [
  'public/assets/js/vendor.js',
  'specs/fixture.html',
  'specs/setup.js',
  'specs/spec.js'
],

// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['Safari'],

karmaを起動し、gruntでwatchタスクを起動して、後は好きなエディタでJavascriptソースファイルを修正していきます。

npm testとすると、package.jsonのscripts:testを実行できます。node_modules下のコマンドなら、フルパスを書く必要はありません。

  "scripts": {
    "test": "karma start"
  },
$ npm test
$ grunt watch

実際には、それぞれ別のターミナルで起動しています。
これでJavascrptファイルやhbsファイルに変更があれば、Browserifyタスクが自動的に実行され、Karmaのテストも自動的に再実行されるようになります。

次回へ

今回はサンプルアプリのJavaScriptソースファイルをBrowserifyでビルドし、テスティングするところまで説明しました。

次回は、本題であるBackbone.Marionetteのモジュールの簡単な説明と、Browerifyの利用を考慮したコーディング方法をご紹介します。