フルスタックエンジニアに

フルスタックエンジニアを目指すブログです。主にRuby on RailsやJavaScriptについて書いていきたいと思います。

JavaScriptフレームワーク比較用サンプルアプリ 「TODO-Module-Marionette」を作る

TODO-Module-Marionette-eye-catch

今回は前回作ったTODO-Module-BackboneMarionetteを導入していきます。
Backboneは薄いライブラリなので、オレオレ実装になってしまうことが多いです。
Marionetteを導入するとBackboneのベストプラクティスに乗っかれるので、大規模アプリには非常に有用です。

例えば

  • 自動で描画をしてくれるので、自分でrenderメソッドを定義する必要がない
  • ビューの管理をしてくれるので、破棄し忘れてゾンビViewが生まれる危険性が減る
  • ヘッダー領域・メイン領域などを管理するRegion機能がある

などさまざまなメリットがあります。

前回の記事 www.full-stack-engineer.com

デモアプリとソースコードはこちらに公開しています。

前回同様このクライアントアプリの動作確認にはAPIが必要なので、 前回までの記事を参考にローカルで動作させておくかか、 デモアプリのAPIを利用してください。

www.full-stack-engineer.com www.full-stack-engineer.com

動作環境

  • OS X Yosemite
  • node v0.10.37
  • npm 2.7.3

前回 同様gulpを使っていくのでNode.jsが動く環境を用意してください。 CoffeeScriptSassを使って書いていきます。 テンプレートエンジンはHandlebarsを使います。

アプリケーションを作成する

% mkdir todo-module-marionette
% cd todo-module-marionette

/package.json

{
  "private": true,
  "engines": {
    "node": ">=0.10.0"
  },
  "devDependencies": {
    "autoprefixer-core": "4.0.2",
    "browser-sync": "1.8.2",
    "connect-modrewrite": "0.7.11",
    "del": "1.1.1",
    "gulp": "3.6.0",
    "gulp-cache": "0.2.2",
    "gulp-coffee": "2.3.1",
    "gulp-csso": "0.2.6",
    "gulp-declare": "0.3.0",
    "gulp-define-module": "0.1.1",
    "gulp-handlebars": "4.0.0",
    "gulp-if": "1.2.1",
    "gulp-imagemin": "2.0.0",
    "gulp-inject": "1.2.0",
    "gulp-jshint": "1.5.3",
    "gulp-load-plugins": "0.8.0",
    "gulp-minify-html": "0.1.6",
    "gulp-postcss": "3.0.0",
    "gulp-sass": "1.3.3",
    "gulp-size": "1.1.0",
    "gulp-sourcemaps": "1.3.0",
    "gulp-uglify": "1.0.1",
    "gulp-useref": "1.0.2",
    "jshint-stylish": "1.0.0",
    "main-bower-files": "2.5.0",
    "opn": "1.0.0",
    "wiredep": "2.0.0"
  }
}
% npm install

/node_modulesにnpmモジュールがインストールされます。

/bower.json

{
  "name": "todo-module-marionette",
  "private": true,
  "dependencies": {
    "bootstrap": "3.3.1",
    "backbone": "1.1.2",
    "handlebars": "3.0.0",
    "marionette": "2.4.1"
  }
}

gulpbowerが入ってない人は以下のコマンドでインストール

% npm install bower gulp -g
% bower install

gulpfileを用意します。 gulp serveで立ち上がる開発サーバーのポート番号は9002にしてあります。

/gulpfile.js

/*global -$ */
'use strict';
var gulp = require('gulp');
var $ = require('gulp-load-plugins')();
var browserSync = require('browser-sync');
var reload = browserSync.reload;
var modRewrite = require('connect-modrewrite');

var nodeEnv = process.env.NODE_ENV || "development";

gulp.task('styles', function () {
  return gulp.src('app/styles/main.sass')
    .pipe($.sourcemaps.init())
    .pipe($.sass({
      outputStyle: 'nested', // libsass doesn't support expanded yet
      precision: 10,
      includePaths: ['.'],
      indentedSyntax: true,
      onError: console.error.bind(console, 'Sass error:')
    }))
    .pipe($.postcss([
      require('autoprefixer-core')({browsers: ['last 1 version']})
    ]))
    .pipe($.sourcemaps.write())
    .pipe(gulp.dest('.tmp/styles'))
    .pipe(reload({stream: true}));
});

gulp.task('scripts', function () {
  return gulp.src('app/scripts/**/*.coffee')
    .pipe($.coffee())
    .pipe(gulp.dest('.tmp/scripts'));
});

gulp.task('templates', function () {
  return gulp.src('app/templates/**/*.hbs')
    .pipe($.handlebars())
    .pipe($.defineModule('plain'))
    .pipe($.declare({
      namespace: 'App.templates' // change this to whatever you want
    }))
    .pipe(gulp.dest('.tmp/templates'));
});

gulp.task('html', ['injector:js', 'styles', 'scripts', 'templates'], function () {
  var assets = $.useref.assets({searchPath: ['.tmp', 'app', '.']});

  return gulp.src('.tmp/*.html')
    .pipe(assets)
    .pipe($.if('*.js', $.uglify()))
    .pipe($.if('*.css', $.csso()))
    .pipe(assets.restore())
    .pipe($.useref())
    .pipe($.if('*.html', $.minifyHtml({conditionals: true, loose: true})))
    .pipe(gulp.dest('dist'));
});

gulp.task('images', function () {
  return gulp.src('app/images/**/*')
    .pipe($.cache($.imagemin({
      progressive: true,
      interlaced: true,
      // don't remove IDs from SVGs, they are often used
      // as hooks for embedding and styling
      svgoPlugins: [{cleanupIDs: false}]
    })))
    .pipe(gulp.dest('dist/images'));
});

gulp.task('fonts', function () {
  return gulp.src(require('main-bower-files')({
    filter: '**/*.{eot,svg,ttf,woff,woff2}'
  }).concat('app/fonts/**/*'))
    .pipe(gulp.dest('.tmp/fonts'))
    .pipe(gulp.dest('dist/fonts'));
});

gulp.task('extras', function () {
  return gulp.src([
    'app/*.*',
    '!app/*.html'
  ], {
    dot: true
  }).pipe(gulp.dest('dist'));
});

gulp.task('injector:js', function(){
    return gulp.src(['app/index.html'])
        .pipe($.inject(
            gulp.src(['app/config/' + nodeEnv + '.js']),
            {ignorePath: ['app', '.tmp']}
        ))
        .pipe(gulp.dest(".tmp"))
});


gulp.task('clean', require('del').bind(null, ['.tmp', 'dist']));

gulp.task('serve', ['styles', 'scripts', 'injector:js', 'templates', 'fonts'], function () {
  browserSync({
    notify: false,
    port: 9002,
    server: {
      baseDir: ['.tmp', 'app'],
      routes: {
        '/bower_components': 'bower_components'
            },
            middleware: [
                modRewrite([
                    '!\\.\\w+$ /index.html [L]' 
                ])
            ]
    }
  });

  // watch for changes
  gulp.watch([
    'app/*.html',
    'app/scripts/**/*.js',
    '.tmp/scripts/**/*.js',
    '.tmp/templates/**/*.js',
    'app/images/**/*',
    '.tmp/fonts/**/*'
  ]).on('change', reload);

  gulp.watch('app/styles/**/*.css', ['styles']);
  gulp.watch('app/scripts/**/*.coffee', ['scripts']);
  gulp.watch('app/templates/**/*.hbs', ['templates']);
  gulp.watch('app/fonts/**/*', ['fonts']);
  gulp.watch('bower.json', ['wiredep', 'fonts']);
});

// inject bower components
gulp.task('wiredep', function () {
  var wiredep = require('wiredep').stream;

  gulp.src('app/*.html')
    .pipe(wiredep({
      ignorePath: /^(\.\.\/)*\.\./
    }))
    .pipe(gulp.dest('app'));
});

gulp.task('build', ['html', 'images', 'fonts', 'extras'], function () {
  return gulp.src('dist/**/*').pipe($.size({title: 'build', gzip: true}));
});

gulp.task('default', ['clean'], function () {
  gulp.start('build');
});

todo-module-backboneのソースを流用できるので、appフォルダをまるっとコピーして 必要ないソースだけ消します。

rm -fr app/scripts/views app/scripts/Router.coffee

main.coffeesetup.coffeeMarionette仕様にします

/app/scripts/main.coffee

$(document).on("click", "a[href]", (e) ->
  e.preventDefault()
  Backbone.history.navigate($(this).attr("href").substr(1), true)
)

$.ajaxSetup({
  dataType: "json"
  beforeSend: (xhr) ->
    if currentUser = App.session.currentUser()
      xhr.setRequestHeader("Authorization", currentUser.get("authentication_token"))
})

App = new Mn.Application()
App.on("start", ->
  Backbone.history.start({pushState: true})
)

/app/scripts/setup.coffee

App.start()
% gulp serve

http://localhost:9002にアクセスするとページが表示されます。 エラーが出てないことを確認して下さい。

TODO-Module-Marionette トップ1

ルーターを作る

MarionetteのルーターはBackbone.RouterではなくBackbone.Marionette.AppRouterを使います。

/app/scripts/Router.coffee

class App.Router extends Backbone.Marionette.AppRouter

  appRoutes:
    "": "index"
    "users/new": "new_user"
    "tasks(/:filter)": "tasks"
    "tasks/:id/edit": "edit_task"
    "logout": "logout"

RootViewとHeaderViewを作る

/app/scripts/view/RootView.coffee

class App.RootView extends Backbone.Marionette.LayoutView

  template: false
  el: "body"

  regions:
    header: "header"
    main: "#main"

  ui:
    flash: ".alert"

  initialize: ->
    @listenTo(Backbone, "flash:show", @showFlash)
    @listenTo(Backbone, "flash:hide", @hideFlash)

  showFlash: (data) ->

    data = $.extend({type: "info"}, data)

    @ui.flash.removeClass((index, css) ->
      return css.match(/alert-\S+/)
    ).addClass("alert-#{data.type}").show().find("p").html(data.msg)

  hideFlash: ->
    @ui.flash.hide()

/app/scripts/views/HeaderView.coffee

class App.HeaderView extends Backbone.Marionette.ItemView

  template: false
  el: "header"

  ui:
    isLogin: ".is-login"
    isNotLogin: ".is-not-login"

  modelEvents:
    "change": "change"

  onRender: ->
    @change()

  change: ->
    currentUser = @model.currentUser()

    @ui.isLogin.toggle(!!currentUser)
    @ui.isNotLogin.toggle(!currentUser)

    if currentUser
      @$(".email").html(currentUser.get("email"))

main.coffeeを作る

/app/scripts/main.coffee

  ・
  ・
@App = new Mn.Application()
@App.on("start", ->
  new App.Router(controller: new App.Controller())
  Backbone.history.start({pushState: true})
)

コントローラーを作る

コントローラーはBackboneには無い機能です。 Backboneではルーターがコントローラーの役割もすることが多かったのですが、 Marionetteでは明確に分けられています。

/app/scripts/Controller.coffee

class App.Controller extends Backbone.Marionette.Controller

  initialize: ->

    App.session = new App.Session()

    App.rootView = new App.RootView()
    App.rootView.render()
    new App.HeaderView(model: App.session).render()

  index: ->

  new_user: ->

  tasks: (filter) ->

  edit_task: (id) ->
    
  logout: ->
    @requireLogin ->
      App.session.logout()
      Backbone.history.navigate("", true)
      Backbone.trigger("flash:show", {msg: "ログアウトしました。"})
    
  skipLogin: (callback) ->
    unless App.session.currentUser()
      callback.call(this)
    else
      Backbone.history.navigate("/tasks", true)

  requireLogin: (callback) ->
    if App.session.currentUser()
      callback.call(this)
    else
      Backbone.history.navigate("/", true)
    

スクリプトを読み込む

/app/index.html

<html>
  ・
  ・
  <!-- build:js /scripts/main.js -->
  ・
  ・
  <script src="/scripts/views/RootView.js"></script>
  <script src="/scripts/views/HeaderView.js"></script>
  <script src="/scripts/Controller.js"></script>
  <script src="/scripts/Router.js"></script>
  <script src="/scripts/setup.js"></script>
  <!-- endbuild -->
</html>

ログアウトボタンが消えたのが確認できましたね。

TODO-Module-Marionette トップ2

ログイン画面を作る

/app/scripts/views/IndexView.coffee

class App.IndexView extends Backbone.Marionette.ItemView

  template: App.templates.Index
  
  events:
    "submit form#new_user": "submit"

  ui:
    email: "#user_email"
    password: "#user_password"

  modelEvents:
    "invalid": "onInvalid"

  submit: (e) ->
    e.preventDefault()

    @model.set("email", @ui.email.val())
    @model.set("password", @ui.password.val())

    if @model.isValid()
      @model.save().done( ->
        Backbone.history.navigate("/tasks", true)
        Backbone.trigger("flash:show", {msg: "ログインしました。"})
      ).fail( ->
        alert "ログインに失敗しました"
      )

  onInvalid: (model, error) ->
    alert error

Backboneでは

initialize: ->
  @listenTo(@model, 'invalid', _.bind(@onInvalid))

のようにmodellistenToしていましたが、Marionetteでは

modelEvents:
  "invalid": "onInvalid"

で同様のことが行われます。また

ui:
  email: "#user_email"
  password: "#user_password"

と指定するとjQueryオブジェクトがキャッシュされます。 uiを使うとこのViewで扱うDOMが一目でわかるようになります。

/app/scripts/Controller.coffee

  ・
  ・
index: ->
  @skipLogin ->
    App.rootView.main.show(new App.IndexView(model: App.session))
  ・
  ・

/app/index.html

<html>
  ・
  ・
  <!-- build:js /scripts/main.js -->
  ・
  ・
  <script src="/scripts/views/HeaderView.js"></script>
  <script src="/scripts/views/IndexView.js"></script>
  ・
  ・
  <!-- endbuild -->
</html>

TODO-Module-Marionette ログイン画面

ユーザー登録画面を作る

/app/scripts/views/NewUserView.coffee

class App.NewUserView extends Backbone.Marionette.ItemView

  template: App.templates.NewUser

  initialize: ->
    @model = new App.User()

  ui:
    email: "#user_email"
    password: "#user_password"
    passwordConfirmation: "#user_password_confirmation"
    formGroup: ".form-group"

  modelEvents:
    "invalid": "onInvalid"

  events:
    "submit form#new_user": "submit"

  submit: (e) ->
    e.preventDefault()

    @model.set("email", @ui.email.val())
    @model.set("password", @ui.password.val())
    @model.set("password_confirmation", @ui.passwordConfirmation.val())

    @ui.formGroup.removeClass("has-error").find(".help-block").html("")

    if @model.isValid()
      @model.save().done =>
        App.session.setUser(@model)
        Backbone.history.navigate("/tasks", true)

  onInvalid: (model, error) ->
    _.each(error, (value, key) ->
      @ui.formGroup.filter(".user_#{key}")
        .addClass("has-error")
        .find(".help-block").html(value)
    , this)

/app/scripts/Controller.coffee

  ・
  ・
new_user: ->
  @skipLogin ->
    App.rootView.main.show(new App.NewUserView())
  ・
  ・

/app/index.html

<html>
  ・
  ・
  <!-- build:js /scripts/main.js -->
  ・
  ・
  <script src="/scripts/views/IndexView.js"></script>
  <script src="/scripts/views/NewUserView.js"></script>
  ・
  ・
  <!-- endbuild -->
</html>

TODO-Module-Marionette 新規登録画面

タスク画面を作る

/app/scripts/views/TasksView.coffee

class App.TasksView extends Backbone.Marionette.CompositeView

  template: App.templates.Tasks
  childView: App.TaskView
  childViewContainer: "#tasks"

  events:
    "submit #new_task": "create"

  ui:
    title: "#task_title"
    inboxCount: ".inbox-count"
    completedCount: ".completed-count"
    deletedCount: ".deleted-count"
    menuInbox: "#menu-inbox"
    menuCompleted: "#menu-completed"
    menuDeleted: "#menu-deleted"

  collectionEvents:
    "add change sync": "updateCount"

  onRender: ->
    @updateCount()
    @toggleCurrent()

  filter: (child, index, collection) ->
    child.get("aasm_state") == App.filter

  create: (e) ->
    e.preventDefault()

    task = new App.Task(title: @ui.title.val())
    if task.isValid()
      task.save().done( =>
        @collection.add(task)
        Backbone.history.navigate('/tasks', true)
        Backbone.trigger("flash:show", {msg: "作成しました。"})
      )
      @ui.title.val("")

    return false

  updateCount: ->
    @ui.inboxCount.html(@collection.inboxes().length)
    @ui.completedCount.html(@collection.completed().length)
    @ui.deletedCount.html(@collection.deleted().length)
    
  toggleCurrent: ->
    @ui.menuInbox.toggleClass("current", App.filter == "inbox")
    @ui.menuCompleted.toggleClass("current", App.filter == "completed")
    @ui.menuDeleted.toggleClass("current", App.filter == "deleted")

/app/scripts/views/TaskView.coffee

class App.TaskView extends Backbone.Marionette.ItemView

  tagName: "li"
  className: "task clearfix"
  template: App.templates.Task

  ui:
    taskComplete: ".task-complete"
    taskDelete: ".task-delete"
    taskRevert: ".task-revert"

  events:
    "click .task-actions a": "taskAction"

  modelEvents:
    "sync": "render"

  onRender: ->
    @ui.taskComplete.toggle(@model.get("aasm_state") == "inbox")
    @ui.taskDelete.toggle(@model.get("aasm_state") != "deleted")
    @ui.taskRevert.toggle(@model.get("aasm_state") != "inbox")

  flashMessages:
    complete: "完了にしました。"
    delete: "ゴミ箱に入れました。"
    revert: "収集箱に戻しました。"

  taskAction: (event) ->
    action = $(event.currentTarget).data("action")
    @model[action]( =>
      this.remove()
      Backbone.trigger("flash:show", {msg: @flashMessages[action]})
    )

/app/scripts/Controller.coffee

  ・
  ・
tasks: (filter) ->
  @requireLogin =>

    App.filter = filter || "inbox"

    unless @tasksCollection
      @tasksCollection = new App.TasksCollection()
      @tasksCollection.fetch({reset: true})

    if App.rootView.main.currentView instanceof App.TasksView
      App.rootView.main.currentView.render()
    else
      App.rootView.main.show(new App.TasksView(collection: @tasksCollection))
  ・
  ・

/app/index.html

<html>
  ・
  ・
  <!-- build:js /scripts/main.js -->
  ・
  ・
  <script src="/scripts/views/NewUserView.js"></script>
  <script src="/scripts/views/TaskView.js"></script>
  <script src="/scripts/views/TasksView.js"></script>
  ・
  ・
  <!-- endbuild -->
</html>

TODO-Module-Marionette タスク画面

タスク編集画面を作る

/app/scripts/views/EditTaskView.coffee

class App.EditTaskView extends Backbone.Marionette.ItemView

  template: App.templates.EditTask

  ui:
    title: "#task_title"
    memo: "#task_memo"
    formGroup: ".form-group"

  events:
    "submit": "onSubmit"

  modelEvents:
    "invalid": "onInvalid"

  onSubmit: (e) ->
    e.preventDefault()

    @model.set("title", @ui.title.val())
    @model.set("memo", @ui.memo.val())

    @ui.formGroup.removeClass("has-error").find(".help-block").html("")

    if @model.isValid()
      @model.save().done(->
        Backbone.history.navigate("/tasks", true)
        Backbone.trigger("flash:show", {msg: "更新しました。"})
      )

    return false

  onInvalid: (model, error) ->
    _.each(error, (value, key) ->
      @ui.formGroup.filter(".task_#{key}")
        .addClass("has-error")
        .find(".help-block").html(value)
    , this)

/app/scripts/Controller.coffee

  ・
  ・
edit_task: (id) ->
  @requireLogin =>

    defer = []
    unless @tasksCollection
      @tasksCollection = new App.TasksCollection()
      defer = @tasksCollection.fetch({reset: true})

    $.when(defer).done =>
      App.rootView.main.show(new App.EditTaskView(model: @tasksCollection.findWhere(id: parseInt(id))))
  ・
  ・

/app/index.html

<html>
  ・
  ・
  <!-- build:js /scripts/main.js -->
  ・
  ・
  <script src="/scripts/views/TasksView.js"></script>
  <script src="/scripts/views/EditTaskView.js"></script>
  ・
  ・
  <!-- endbuild -->
</html>

TODO-Module-Marionette タスク編集画面

お疲れ様でした!これでTODO-ModuleのMarionetteクライアントが完成です!!
ビューを破棄する処理や、render処理をしなくて済むので、非常にコードの見通しが良くなります。

Marionette Inspectorという Backbone DebuggerMarionette版 Chrome拡張機能もあるので、開発時にはこれを使うと非常に便利です。

参考にした本

Backboneのことだけでなく、Marionetteのことも書かれていて非常にわかりやすいです。
オススメです!