前回 はTODO-ModuleのクライアントをAngularJSで作成しましたが、 今回はBackbone.jsを使って作っていきます。 Backbone.jsはAngularJSとくらべて薄いフレームワークで、シンプルな作りになっています。 そのため自由度が高いフレームワークになっています
デモアプリとソースコードはこちらに公開しています。
前回同様このクライアントアプリの動作確認には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が動く環境を用意してください。 CoffeeScriptとSassを使って書いていきます。 テンプレートエンジンは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" } }
gulp
とbower
が入ってない人は以下のコマンドでインストール
% 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>×</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
にアクセスするとページが表示されます。
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 -->
ではここまでできたらHeaderView
とFlashView
を作って、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 -->
ログアウトボタンが消えたのが確認できましたね。
ChromeのデベロッパーツールのConsoleでflash:show
メッセージを発生させてみましょう
Backbone.trigger('flash:show', {msg: 'メッセージ'})
ちゃんと表示されていますね。
ログイン画面を作る
/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種類にわかれるので、必ずどちらかを呼ぶことになります。
ログイン画面が表示されましたね。
Railsアプリでユーザーを作成しておいて、そのアカウントでログインしてください。 ログインに成功するとまだ実装してないのでログインフォームは残ってしまいますが、 Flashメッセージが表示され、右上のメニューにメールアドレスとログアウトボタンが確認できるはずです。
ログアウトもできることを確認して下さい。
ユーザー登録画面を作る
/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) ・ ・
右上の新規登録をクリックすると登録フォームが表示されます。 正常に登録できることを確認しましょう。
タスク画面を作る
まずはタスクの集合を扱う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')) })
タスク画面ができました! タスクの登録・操作ができることを確認して下さい。
タスク編集機能を作る
/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クライアントが完成です!!
データバインディング機能がBackboneには無いので、自分でやらないといけなかったりします。 ただstickitというプラグインを使うと 使えるようになるので、こういったプラグインを組み合わせていくと効率的に開発することができます。
ゾンビViewについて
Backboneを使う上で注意しないといけないことがあります。
ページ遷移して新たなViewを表示する際、古いViewをremove
してやらないと
いわゆるゾンビViewができてしまいメモリリークが起こってしまいます。
なので不要になったViewは必ずremove
して解放するようにしましょう。
Backbone Debugger というChromeの拡張機能を使うと、現在生成されているViewやModelなどが確認できるので非常に便利です。
こういった面倒なメモリ管理やrender
処理などをやってくれるのが
backbone.marionetteです。
次回はこれを使って作っていきたいと思います。