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

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

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

TODO-Module-Backbone-eye-catch

前回TODO-ModuleのクライアントをAngularJSで作成しましたが、 今回はBackbone.jsを使って作っていきます。 Backbone.jsAngularJSとくらべて薄いフレームワークで、シンプルな作りになっています。 そのため自由度が高いフレームワークになっています

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

前回同様このクライアントアプリの動作確認には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-backbone
% cd todo-module-backbone

/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-backbone",
  "private": true,
  "dependencies": {
    "bootstrap": "3.3.1",
    "backbone": "1.1.2",
    "handlebars": "3.0.0"
  }
}

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

% npm install bower gulp -g
% bower install

gulpfileを用意します。 gulp serveで立ち上がる開発サーバーのポート番号はtodo-module-angularとかぶらないよう9001にしてあります。

/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: 9001,
    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/index.html', ['injector:js']);
  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');
});

/app/index.html

<!doctype html>
<html lang="">
<head>
  <meta charset="utf-8">
  <meta name="description" content="">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>TODO-Module Backbone</title>
  <!-- Place favicon.ico and apple-touch-icon.png in the root directory -->

  <!-- build:css /styles/vendor.css -->
  <!-- bower:css -->
  <link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css" />
  <!-- endbower -->
  <!-- endbuild -->

  <!-- build:css /styles/main.css -->
  <link rel="stylesheet" media="all" href="/styles/main.css">
  <!-- endbuild -->
</head>

<body>
  <header>
    <nav class="navbar navbar-default" role="navigation">
      <div class="container-fluid">
        <div class="navbar-header">
          <a class="navbar-brand" href="/">TODO-Module Backbone</a>
        </div>
        <div class="collapse navbar-collapse">
          <ul class="is-not-login nav navbar-nav navbar-right">
            <li><a href="/">ログイン</a></li>
            <li><a href="/users/new">新規登録</a></li>
          </ul>
          <ul class="is-login nav navbar-nav navbar-right">
            <li><p class="email navbar-text"></p></li>
            <li><a id="logout" href="/logout">ログアウト</a></li>
          </ul>
        </div>
      </div>
    </nav>
  </header>

  <div class="container-fluid">
    <div class="alert alert-dismissible fade in" style="display: none;">
      <button type="button" class="close" data-dismiss="alert"><span>&times;</span></button>
      <p></p>
    </div>

    <div id="main"></div>
  </div>

  <!-- build:js /scripts/vendor.js -->
  <!-- bower:js -->
  <script src="/bower_components/jquery/dist/jquery.js"></script>
  <script src="/bower_components/bootstrap/dist/js/bootstrap.js"></script>
  <script src="/bower_components/underscore/underscore.js"></script>
  <script src="/bower_components/backbone/backbone.js"></script>
  <script src="/bower_components/handlebars/handlebars.js"></script>
  <!-- endbower -->
  <!-- endbuild -->

  <!-- build:js /scripts/main.js -->
  <!-- inject:js -->
  <!-- endinject -->
  <!-- endbuild -->
</body>
</html>

/app/styles/main.sass

a
  cursor: pointer

#sidebar
  height: 100%
  background-color: #fafafa
  border: 1px solid #e5e5e5
  padding-left: 0px
  margin-bottom: 20px
  h3
    color: #AAA
    font-size: 12px
    padding-left: 15px
  ul
    li
      line-height: 30px
      a
        padding-left: 25px
        display: block
      &.current
        border-left: 5px solid #819dc1
        a
          padding-left: 20px

#tasks
  li
    border-bottom: 1px solid #ededed
    line-height: 50px
    padding-left: 5px

.new-task-well
  padding: 10px
  .form-group
    margin-bottom: 0px
% gulp serve

http://localhost:9001にアクセスするとページが表示されます。

TODO-Module-Backbone トップ

main.coffeeを作る

一番最初に読み込まれるmain.coffeeから作っていきます。 pushStateを有効にするため、リンクがクリックされたらページ遷移はさせずに Backbone.history.navigateを呼ぶようにします。

また、グローバルにAppというオブジェクトを一つ作り、モデルやビューなどはこの中に入れていくことにします。

/app/scripts/main.coffee

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

@App = {}

/app/index.html

<html>
  ・
  ・
  <!-- build:js /scripts/main.js -->
  <script src="/scripts/main.js"></script>
  <!-- inject:js -->
  <!-- endinject -->
  <!-- endbuild -->
</html>

ユーザーモデルを作る

まずはユーザーモデルを作っていきましょう。

/app/scripts/models/User.coffee

class App.User extends Backbone.Model

  url: "#{App.env.apiHost}/v1/users"

  validate: ->

    errors = {}

    if @get('email') == ''
      errors.email =  '入力してください'
    else if !@get('email').match(/^[A-Za-z0-9]+[\w-]+@[\w\.-]+\.\w{2,}$/)
      errors.email =  '無効なメールアドレスです'

    if @get('password') == ''
      errors.password = '入力してください'
    else if @get('password').length < 8
      errors.password = '8文字以上で入力してください'

    if @get('password_confirmation') == ''
      errors.password_confirmation = '入力してください'
    else if @get('password') != @get('password_confirmation')
      errors.password_confirmation = 'パスワードが一致しません'

    return errors if !_.isEmpty(errors)

APIのURL指定と、バリデーションを行っています。

URLは開発環境や本番環境でURLを変える必要が出てくるので、 こちらもハードコーティングはせずに別の場所で定義します。

/app/config/development.js

App.env = {apiHost: "http://localhost:3000"};

gulpfile.jsには環境変数のNODE_ENVに応じて読み込まれる設定値が変わるようにタスクが定義されています。 デフォルトはdevelopmentになっているので、何も指定せずにgulp serveを実行すると

<!-- inject:js -->
<!-- endinject -->

この間にdevelpment.jsのscriptタグが挿入される仕組みです。

本番環境で実行したい場合は

% NODE_ENV=production gulp serve

と起動するとproduction.jsが読み込まれます。

セッションモデルを作る

次にセッションモデルを作ります。 ユーザー情報はローカルストレージに保存します。

/app/scripts/models/Session.coffee

class App.Session extends Backbone.Model

  url: "#{App.env.apiHost}/v1/sessions"

  validate: ->
    if @get('email') == ''
      return 'メールアドレスを入力してください'
    else if @get('password').length < 8
      return 'パスワードはを8文字以上で入力してください'

  parse: (data) ->
    @setUser(new App.User(data))
    return null

  currentUser: ->
    if localStorage.user
      @user ||= new App.User(JSON.parse(localStorage.user))
    else
      return null

  logout: ->
    localStorage.removeItem('user')
    @user = null
    @trigger('change')

  setUser: (user) ->
    localStorage.user = JSON.stringify(user.toJSON())
    @user = user
    @trigger('change')

/app/index.html

<html>
  ・
  ・
  <!-- build:js /scripts/main.js -->
  <script src="/scripts/main.js"></script>
  <!-- inject:js -->
  <!-- endinject -->
  <script src="/scripts/models/User.js"></script>
  <script src="/scripts/models/Session.js"></script>
  <!-- endbuild -->
</html>

ルーターを作る

全体を管理するルーターを作っていきます。

/app/scripts/Router.coffee

class @App.Router extends Backbone.Router

  initialize: ->
    App.session = new App.Session()

ルーター生成時にセッションオブジェクトを作ります。

/app/index.html

<html>
  ・
  ・
  <!-- build:js /scripts/main.js -->
  <script src="/scripts/main.js"></script>
  <!-- inject:js -->
  <!-- endinject -->
  <script src="/scripts/models/User.js"></script>
  <script src="/scripts/models/Session.js"></script>
  <script src="/scripts/Router.js"></script>
  <!-- endbuild -->
</html>

HeaderViewとFlashViewを作る

ヘッダー部分を管理するHeaderViewとFlashメッセージを管理するFlashViewを作ります。

/app/scripts/views/HeaderView.coffee

class App.HeaderView extends Backbone.View

  el: 'header'

  initialize: ->
    @listenTo(App.session, 'change', @change)
    @change()

  change: ->
    currentUser = App.session.currentUser()

    @$('.is-login').toggle(!!currentUser)
    @$('.is-not-login').toggle(!currentUser)

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

App.session.currentUserメソッドでログイン状態かそうでないかを判断し、リンクの出し分けを行っています。 またログイン時はユーザーのメールアドレスを表示しています。

/app/scripts/views/FlashView.coffee

class App.FlashView extends Backbone.View

  el: '.alert'

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

  showFlash: (data) ->

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

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

  hideFlash: ->
    @$el.hide()

Flashメッセージはイベントを通じて制御します。

Backbone.trigger('flash:show', {msg: 'メッセージ'})

のようにflash:showイベントを発生させるとFlashメッセージが表示され flash:hideイベントで消えます。

/app/index.html

<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
  ・
  ・
<script src="/scripts/views/FlashView.js"></script>
<script src="/scripts/views/HeaderView.js"></script>
<!-- endbuild -->

ではここまでできたらHeaderViewFlashViewを作って、Backboneを起動してみましょう。

/app/scripts/Router.coffee

class @App.Router extends Backbone.Router

  initialize: ->
    App.session = new App.Session()
    new App.HeaderView()
    new App.FlashView()

/app/scripts/setup.coffee

App.router = new App.Router()
Backbone.history.start({pushState: true})

setup.jsは必ず一番最後に読み込むようにしてください。

/app/index.html

<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
  ・
  ・
<script src="/scripts/setup.js"></script>
<!-- endbuild -->

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

TODO-Module-Backbone トップ2

ChromeのデベロッパーツールのConsoleでflash:showメッセージを発生させてみましょう

Backbone.trigger('flash:show', {msg: 'メッセージ'})

ちゃんと表示されていますね。

TODO-Module-Backbone Flashメッセージ

ログイン画面を作る

/app/templates/Foo.hbsとテンプレートを用意しておくと、JavaScriptからは App.templates.Fooのようにアクセスできるようgulpタスクが用意されています。

/app/templates/Index.hbs

<h1>ログイン</h1>

<div class="well">
  <form novalidate="novalidate" class="simple_form new_user" id="new_user"  accept-charset="UTF-8" method="post">
    <div class="form-group email required user_email">
      <label class="email required control-label" for="user_email"><abbr title="required">*</abbr> メールアドレス</label>
      <input class="string email required form-control" type="email" name="user[email]" id="user_email">
    </div>
    <div class="form-group password required user_password">
      <label class="password required control-label" for="user_password"><abbr title="required">*</abbr> パスワード</label>
      <input class="password required form-control" type="password" name="user[password]" id="user_password">
    </div>
    <input type="submit" name="commit" value="Login" class="btn btn-primary btn-block">
  </form>
</div>

/app/scripts/views/IndexView.coffee

class App.IndexView extends Backbone.View

  template: App.templates.Index

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

  events:
    'submit form#new_user': 'submit'

  render: ->
    @$el.html @template()
    return this

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

    @model.set('email', @$('#user_email').val())
    @model.set('password', @$('#user_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

/app/index.html

<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
  ・
  ・
<script src="/templates/Index.js"></script>
<script src="/scripts/views/Index.js"></script>
<script src="/scripts/setup.js"></script>
<!-- endbuild -->

ルーティングを定義する

/app/scripts/Rooter.coffee

class @App.Router extends Backbone.Router
  ・
  ・
  routes:
    '': 'index'

  index: ->
    @skipLogin =>
      @currentView.remove() if @currentView
      @currentView = new App.IndexView(model: App.session)
      $('#main').html(@currentView.render().el)
    
  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)

ルート(/)にアクセスされたときindexが呼ばれます。 このときskipLoginというメソッドを呼んでいますが、 これとrequireLoginは認証チェックのためのメソッドです。 認証が必要なページとそうでないページの2種類にわかれるので、必ずどちらかを呼ぶことになります。

ログイン画面が表示されましたね。

TODO-Module-Backbone ログイン画面

Railsアプリでユーザーを作成しておいて、そのアカウントでログインしてください。 ログインに成功するとまだ実装してないのでログインフォームは残ってしまいますが、 Flashメッセージが表示され、右上のメニューにメールアドレスとログアウトボタンが確認できるはずです。

TODO-Module-Backbone ログイン

ログアウトもできることを確認して下さい。

TODO-Module-Backbone ログアウト

ユーザー登録画面を作る

/app/templates/NewUser.hbs

<h1>新規登録</h1>

<div class="well">
  <form novalidate="novalidate" class="simple_form new_user" id="new_user" accept-charset="UTF-8" method="post">
    <div class="form-group email required user_email">
      <label class="email required control-label" for="user_email"><abbr title="required">*</abbr> メールアドレス</label>
      <input class="string email required form-control" type="email" name="user[email]" id="user_email">
      <span class="help-block"></span>
    </div>
    <div class="form-group password required user_password">
      <label class="password required control-label" for="user_password"><abbr title="required">*</abbr> パスワード</label>
      <input class="password required form-control" type="password" name="user[password]" id="user_password">
      <span class="help-block"></span>
    </div>
    <div class="form-group password required user_password_confirmation">
      <label class="password required control-label" for="user_password_confirmation"><abbr title="required">*</abbr> パスワード(確認)</label>
      <input class="password required form-control" type="password" name="user[password_confirmation]" id="user_password_confirmation">
      <span class="help-block"></span>
    </div>
    <input type="submit" name="commit" value="Save" class="btn btn-primary btn-block">
  </form>
</div>

/app/scripts/views/NewUserView.coffee

class App.NewUserView extends Backbone.View

  template: App.templates.NewUser

  initialize: ->
    @model = new App.User()
    @listenTo(@model, 'invalid', _.bind(@onInvalid, this))

  events:
    'submit form#new_user': 'submit'

  render: ->
    @$el.html @template()
    return this

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

    @model.set('email', @$('#user_email').val())
    @model.set('password', @$('#user_password').val())
    @model.set('password_confirmation', @$('#user_password_confirmation').val())

    @$('.form-group').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) ->
      @$(".form-group.user_#{key}")
        .addClass('has-error')
        .find('.help-block').html(value)
    )

/app/index.html

<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
  ・
  ・
<script src="/templates/NewUser.js"></script>
<script src="/scripts/views/NewUserView.js"></script>
<script src="/scripts/setup.js"></script>
<!-- endbuild -->

/app/scripts/Rooter.coffee

class @App.Router extends Backbone.Router
  ・
  ・
  routes:
    '': 'index'
    'logout': 'logout'
    'users/new': 'new_user'
  ・
  ・
  logout: ->
    @requireLogin ->
      App.session.logout()
      Backbone.history.navigate('', true)
      Backbone.trigger('flash:show', {msg: 'ログアウトしました。'})

  new_user: ->
    @skipLogin =>
      @currentView.remove() if @currentView
      @currentView = new App.NewUserView()
      $('#main').html(@currentView.render().el)
  ・
  ・

右上の新規登録をクリックすると登録フォームが表示されます。 正常に登録できることを確認しましょう。

TODO-Module-Backbone 新規登録

タスク画面を作る

まずはタスクの集合を扱うTasksコレクションを作ります。

/app/scripts/collections/Tasks.coffee

class App.TasksCollection extends Backbone.Collection

  model: App.Task
  url: "#{App.env.apiHost}/v1/tasks"

  comparator: (model) ->
    0 - model.get('id')
  
  inboxes: ->
    return @filtered('inbox')

  completed: ->
    return @filtered('completed')

  deleted: ->
    return @filtered('deleted')

  filtered: (state) ->
    _tasks = @filter((task) -> return task.get('aasm_state') == state)
    return _tasks

モデルとURLの指定に加えて、収集箱・完了・ゴミ箱のタスクを取得するメソッドを定義してます。

次にタスクモデルを作成します。

/app/scripts/models/Task.coffee

class App.Task extends Backbone.Model

  urlRoot: "#{App.env.apiHost}/v1/tasks"

  defaults:
    aasm_state: "inbox"
  
  complete: (callback) ->
    @_changeState(@url() + "/complete", "PUT", callback)

  delete: (callback) ->
    @_changeState(@url(), "DELETE", callback)

  revert: (callback) ->
    @_changeState(@url() + "/revert", "PUT", callback)
    
  _changeState: (url, type, callback) ->
    $.ajax(
      url: url
      type: type
    ).done((result) =>
      @set("aasm_state", result.aasm_state)
      callback.call()
    )

  validate: ->

    errors = {}

    if @get("title") == ""
      errors.title  = "入力してください"
    
    return errors if !_.isEmpty(errors)

タスクの状態を変更するメソッドを定義しています。

/app/templates/Tasks.hbs

<div class="row">
  <div class="col-md-2" id="sidebar">
    <h3>収集</h3>
    <ul class="list-unstyled">
      <li id="menu-inbox">
        <a class="clearfix" href="/tasks">
          <div class="pull-left">
            <i class="glyphicon glyphicon-inbox"></i>
            収集箱
          </div>
          <div class="pull-right inbox-count"></div>
        </a>
      </li>
    </ul>

    <h3>終了</h3>
    <ul class="list-unstyled">
      <li id="menu-completed">
        <a class="clearfix" href="/tasks/completed">
          <div class="pull-left">
            <i class="glyphicon glyphicon-ok-sign"></i>
            完了
          </div>
          <div class="pull-right completed-count"></div>
        </a>
      </li>
      <li id="menu-deleted">
        <a class="clearfix" href="/tasks/deleted">
          <div class="pull-left">
            <i class="glyphicon glyphicon-trash"></i>
            ゴミ箱
          </div>
          <div class="pull-right deleted-count"></div>
        </a>
      </li>
    </ul>
  </div>

  <div class="col-md-10">
    <form id="new_task" class="new_task">
      <div class="input-group">
        <input class="form-control" placeholder="タスク" type="text" id="task_title" ng-model="new_task_title">
        <span class="input-group-btn">
          <input type="submit" name="commit" value="登録" class="btn btn-success">
        </span>
      </div>
    </form>
    
    <ul class="list-unstyled" id="tasks"></ul>
  </div>
</div>

/app/templates/Tasks.hbs

<div class="pull-left">
  <a href="/tasks/{{id}}/edit">{{title}}</a>
</div>
<div class="pull-right task-actions">
  {{#if showComplete}}
    <a class="task-complete btn btn-primary" data-action="complete">完了</a>
  {{/if}}
  {{#if showRevert}}
    <a class="task-revert btn btn-default" data-action="revert">戻す</a>
  {{/if}}
  {{#if showDelete}}
    <a class="task-delete btn btn-danger" data-action="delete">削除</a>
  {{/if}}
</div>

/app/scripts/views/TasksView.coffee

class App.TasksView extends Backbone.View

  template: App.templates.Tasks
  childViews: []

  events:
    'submit #new_task': 'create'

  initialize: ->
    _.bindAll(this, 'render', 'renderTasks', 'renderOne', 'updateCount', 'toggleCurrent', 'removeChildViews')

    @listenTo(@collection, 'reset', @render)
    @listenTo(@collection, 'add', @renderOne)
    @listenTo(@collection, 'add change', @updateCount)
    @listenTo(Backbone, 'tasks:filter', @toggleCurrent)
    @listenTo(Backbone, 'tasks:filter', @renderTasks)

  render: ->
    @$el.html(@template())
    @renderTasks()
    @updateCount()
    @toggleCurrent()
    return this

  renderTasks: ->
    @removeChildViews()
    @collection.where({aasm_state: App.filter}).reverse().forEach((task) =>
      @listenTo(task, 'change:aasm_state', @updateCount)
      @renderOne(task)
    )

  renderOne: (task) ->
    childView = new App.TaskView(model: task)
    @childViews.push(childView)
    @$('#tasks').prepend(childView.render().el)

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

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

    return false

  updateCount: ->
    @$('.inbox-count').html(@collection.inboxes().length)
    @$('.completed-count').html(@collection.completed().length)
    @$('.deleted-count').html(@collection.deleted().length)
    
  toggleCurrent: ->
    @$('#menu-inbox').toggleClass('current', App.filter == 'inbox')
    @$('#menu-completed').toggleClass('current', App.filter == 'completed')
    @$('#menu-deleted').toggleClass('current', App.filter == 'deleted')

  remove: ->
    @removeChildViews()
    super()

  removeChildViews: ->
    _.invoke(@childViews, 'remove')
    @childViews = []

/app/scripts/view/TaskView.coffee

class App.TaskView extends Backbone.View

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

  initialize: ->
    @listenTo(@model, 'sync', @render)

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

  render: ->
    @$el.html(@template(_.extend(@model.toJSON(), {
      showComplete: @model.get("aasm_state") == "inbox"
      showDelete: @model.get("aasm_state") != "deleted"
      showRevert: @model.get("aasm_state") != "inbox"
    })))
    return this

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

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

最後のルーティングです。 tasks:filterイベントを発生させて、表示させるタスクを絞り込んでいます。

/app/scripts/Rooter.coffee

class @App.Router extends Backbone.Router
  ・
  ・
  routes:
    '': 'index'
    'logout': 'logout'
    'users/new': 'new_user'
    'tasks(/:filter)': 'tasks'
  ・
  ・
  tasks: (filter) ->
    @requireLogin =>

      App.filter = filter || 'inbox'
      Backbone.trigger('tasks:filter')

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

      unless @currentView instanceof App.TasksView
        @currentView.remove() if @currentView
        @currentView = new App.TasksView(collection: @tasksCollection)
        $('#main').html(@currentView.render().el)
  ・
  ・

/app/index.html

<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
  ・
  ・
<script src="/scripts/models/Task.js"></script>
<script src="/scripts/collections/Tasks.js"></script>
<script src="/templates/Task.js"></script>
<script src="/templates/Tasks.js"></script>
<script src="/scripts/views/TaskView.js"></script>
<script src="/scripts/views/TasksView.js"></script>
<script src="/scripts/setup.js"></script>
<!-- endbuild -->

最後に、タスクのAPIを呼ぶ際は認証トークンが必要なので、それを付けます。

/app/scripts/main.coffee

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

タスク画面ができました! タスクの登録・操作ができることを確認して下さい。

TODO-Module-Backbone タスク

タスク編集機能を作る

/app/templates/EditTask.hbs

<div class="container-fluid">
  <h2>タスク編集</h2>

  <form class="simple_form edit_task well" novalidate="novalidate">
    <div class="form-group string required task_title">
      <label class="string required control-label" for="task_title">
        <abbr title="required">*</abbr> タイトル
      </label>
      <input class="string required form-control" id="task_title" type="text" value="{{title}}">
      <span class="help-block"></span>
    </div>
    <div class="form-group text optional task_memo">
      <label class="text optional control-label" for="task_memo">メモ</label>
      <textarea class="text optional form-control" id="task_memo" >{{memo}}</textarea>
      <span class="help-block"></span>
    </div>
    <input class="btn btn-primary btn-block" name="commit" type="submit" value="Save">
  </form>
</div>

/app/scripts/views/EditTaskView.coffee

class App.EditTaskView extends Backbone.View

  template: App.templates.EditTask

  events:
    'submit': 'onSubmit'

  initialize: ->
    @listenTo(@model, 'invalid', @onInvalid)
    @render()

  render: ->
    @$el.html(@template(@model.toJSON()))
    return this

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

    @model.set('title', @$('#task_title').val())
    @model.set('memo', @$('#task_memo').val())

    @$('.form-group').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) ->
      @$(".form-group.task_#{key}")
        .addClass('has-error')
        .find('.help-block').html(value)
    )

/app/index.html

<!-- build:js /scripts/main.js -->
<!-- inject:js -->
<!-- endinject -->
  ・
  ・
<script src="/templates/EditTask.js"></script>
<script src="/scripts/views/EditTaskView.js"></script>
<script src="/scripts/setup.js"></script>
<!-- endbuild -->

/app/scripts/Rooter.coffee

class @App.Router extends Backbone.Router
  ・
  ・
  routes:
    '': 'index'
    'logout': 'logout'
    'users/new': 'new_user'
    'tasks(/:filter)': 'tasks'
    'tasks/:id/edit': 'edit_task'
  ・
  ・
  edit_task: (id) ->
    @requireLogin =>
      @currentView.remove() if @currentView

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

      $.when(defer).done =>
        @currentView = new App.EditTaskView(model: @tasksCollection.findWhere(id: parseInt(id)))
        $('#main').html(@currentView.render().el)
  ・
  ・

ここで注意点があります。 /tasksからこの編集画面に遷移してきた場合は、すでにTasksCollectionが作られているので問題ないのですが、 編集画面でリロードしたり直リンで来た場合、 tasksメソッドを通ってないのでTasksCollectionを作ってやる必要があります。 なので、Deferredを使ってTasksCollectionがある場合・ない場合どちらも対応できるようにしています。

TODO-Module-Backbone タスク編集

お疲れ様でした!これでTODO-ModuleのBackboneクライアントが完成です!!

データバインディング機能がBackboneには無いので、自分でやらないといけなかったりします。 ただstickitというプラグインを使うと 使えるようになるので、こういったプラグインを組み合わせていくと効率的に開発することができます。

ゾンビViewについて

Backboneを使う上で注意しないといけないことがあります。

ページ遷移して新たなViewを表示する際、古いViewをremoveしてやらないと いわゆるゾンビViewができてしまいメモリリークが起こってしまいます。 なので不要になったViewは必ずremoveして解放するようにしましょう。

参考にした本

最後に

Backbone Debugger というChromeの拡張機能を使うと、現在生成されているViewやModelなどが確認できるので非常に便利です。

こういった面倒なメモリ管理やrender処理などをやってくれるのが backbone.marionetteです。 次回はこれを使って作っていきたいと思います。

www.full-stack-engineer.com