今回は前回作ったTODO-Module-BackboneにMarionetteを導入していきます。
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が動く環境を用意してください。 CoffeeScriptとSassを使って書いていきます。 テンプレートエンジンは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"
}
}
gulpとbowerが入ってない人は以下のコマンドでインストール
% 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.coffeeとsetup.coffeeをMarionette仕様にします
/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にアクセスするとページが表示されます。 エラーが出てないことを確認して下さい。
ルーターを作る
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>
ログアウトボタンが消えたのが確認できましたね。
ログイン画面を作る
/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))
のようにmodelにlistenToしていましたが、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>
ユーザー登録画面を作る
/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>
タスク画面を作る
/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>
タスク編集画面を作る
/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クライアントが完成です!!
ビューを破棄する処理や、render処理をしなくて済むので、非常にコードの見通しが良くなります。
Marionette Inspectorという
Backbone DebuggerのMarionette版
Chrome拡張機能もあるので、開発時にはこれを使うと非常に便利です。
参考にした本
Backboneのことだけでなく、Marionetteのことも書かれていて非常にわかりやすいです。
オススメです!