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

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

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

TODO-Module-Vue-eye-catch

今回はTODO-ModuleのSPA(シングルページアプリケーション)クライアントをVue.jsを使って作っていきます。

Vue.jsとはViewModelに特化したフレームワークです。AngularJSやKnockoutJSに影響を受けており、似ているところも多々あります。

AngularJSはフルスタックですが Vue.jsはそうではないので、ViewModel以外の部分は自作するか、他のライブラリを使う必要がります。 今回はVue.js以外にルーティングライブラリであるPage.jsと バリデーターのvue-validatorを使います。

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

動作環境

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

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

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

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

今回、Browsefiryを使って作っていきます。 Browsefiryとは簡単に言うと、複数のJavaScriptファイルを一つにまとめてくれるものです。

今まで作ってきたTODO-Moduleのクライアントは、各ファイルをscriptタグで読み込んでいました。 小規模ならそのスタイルでも問題無いですが、中・大規模になってくると大量のscriptタグを書かなければならず 管理も大変ですし、HTTPリクエストも増えてしまうのであまり良くありません。 また読み込む順序も重要なので、そこにも気を遣う必要があります。

ですがBrowsefiryを使うと、こういった問題が解決します。 Node.jsrequireがブラウザでも使えるようになるイメージです。

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

% mkdir todo-module-vue                                                                                                                                                          2.2.1
% cd todo-module-vue   

/package.json

{
  "private": true,
  "engines": {
    "node": ">=0.10.0"
  },
  "devDependencies": {
    "autoprefixer-core": "5.1.11",
    "browser-sync": "2.6.5",
    "browserify": "9.0.8",
    "coffee-script": "1.9.2",
    "coffeeify": "1.1.0",
    "connect-modrewrite": "0.8.1",
    "debowerify": "1.2.1",
    "del": "1.1.1",
    "envify": "3.4.0",
    "gulp": "3.8.11",
    "gulp-browserify": "0.5.1",
    "gulp-csso": "1.0.0",
    "gulp-if": "1.2.5",
    "gulp-load-plugins": "0.10.0",
    "gulp-minify-html": "1.0.2",
    "gulp-postcss": "5.1.3",
    "gulp-sass": "1.3.3",
    "gulp-size": "1.2.1",
    "gulp-sourcemaps": "1.5.2",
    "gulp-uglify": "1.2.0",
    "gulp-useref": "1.1.2",
    "insert-css": "0.2.0",
    "main-bower-files": "2.7.1",
    "node-notifier": "4.2.1",
    "vinyl-source-stream": "1.1.0",
    "vueify": "1.1.5"
  },
  "dependencies": {
    "page": "1.6.3"
  }
}
% npm install

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

/bower.json

{
  "name": "todo-module-vue",
  "private": true,
  "dependencies": {
    "vue": "0.11.8",
    "jquery": "2.1.3",
    "bootstrap": "3.3.4",
    "vue-validator": "1.0.2"
  }
}

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

% npm install bower gulp -g
% bower install

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

gulp 3.7からgulpfileCoffeeScriptで書けるようになりました。 gulpのバージョンが3.7未満の人は最新にアップデートしてください。

/gulpfile.coffee

gulp = require('gulp')
$ = require('gulp-load-plugins')()

browserSync = require('browser-sync')
reload = browserSync.reload
modRewrite = require('connect-modrewrite')

browserify = require('browserify')
envify = require('envify/custom')
source = require('vinyl-source-stream')

notifier = require('node-notifier')

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

gulp.task 'browserify', ->

  b = browserify(
    entries: ['./app/scripts/main.coffee']
    extensions: [".coffee", ".vue", ".js"]
    transform: ['coffeeify', 'debowerify', 'vueify']
    debug: nodeEnv == "development"
  )

  b.transform(envify({
    env: require("./app/scripts/config/#{nodeEnv}")
  }))

  b.bundle()
    .on("error", (err) ->
      console.error err.message
      notifier.notify
        message: err.message,
        title: "browserify error"
      @emit("end")
    )
    .pipe(source('bundle.js'))
    .pipe(gulp.dest('.tmp/scripts'))
  

gulp.task 'styles', ->

  gulp.src('app/styles/main.sass')
    .pipe($.sourcemaps.init())
    .pipe($.sass(
      outputStyle: 'nested'
      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 'html', ['browserify', 'styles'], ->

  assets = $.useref.assets({searchPath: ['.tmp', 'app', '.']})
  
  gulp.src('app/*.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 'fonts', ->
  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 'serve', ['browserify', 'styles'], ->

  browserSync
    port: 9003
    server:
      baseDir: ['.tmp', 'app']
      routes:
        '/bower_components': 'bower_components'
      middleware: [modRewrite(['!\\.\\w+$ /index.html [L]'])]

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

  gulp.watch 'app/styles/**/*.css', ['styles']
  gulp.watch 'app/scripts/**/*.coffee', ['browserify']
  gulp.watch 'app/scripts/**/*.vue', ['browserify']

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

gulp.task 'build', ['html', 'fonts'], ->
  gulp.src('dist/**/*')
    .pipe($.size(title: 'build', gzip: true))

gulp.task 'default', ['clean'], ->
  gulp.start 'build'

/app/index.html

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>TODO-Module Vue</title>

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

</head>

<body>
  <div id="app"></div>

  <!-- build:js /scripts/main.js -->
  <script src="/scripts/bundle.js"></script>
  <!-- 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

/app/scripts/main.coffee

Vue = require("vue")
Vue.use(require("vue-validator"))

window.$ = window.jQuery = require("jquery")
window.page = require("page")
window.App = {}

/app/scripts/config/development.coffee

module.exports =
  apiHost: "http://localhost:3000"

ここまでできたらサーバーを起動します

$ gulp serve

http://localhost:9003にアクセスすると、空白の画面が表示されます。 エラーが出ていないことを確認して下さい。

セッションサービスを作る

セッションを管理するサービスを作ります。セッション情報はlocalStorageに保存します。

/app/scripts/services/session.service.coffee

user = null

module.exports =

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

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

  setUser: (_user) ->
    localStorage.user = JSON.stringify(_user)
    user = _user

コンポーネント(.vueファイル)

Vue.jsの特徴で、コンポーネント単位で1つのファイルに記述できるというものがあります。 .vueファイルに「テンプレート(HTML)」「スタイル(CSS)」「スクリプト(JavaScript)」をまとめて記述することができます。 関連するコードが1つのファイルにまとまっているので、非常に見通しが良くなります。

実際は、vueifyというツールが.vueファイルを読み込んで変換してくれます。

今回は全体を管理するコンポーネントと、1ページ1コンポーネントを子コンポーネントとして作っていきます。

全体を管理するコンポーネントを作る

まずは全体を管理するコンポーネントを作っていきます。

/app/scripts/components/app.vue

<template>
  <header v-component="header"></header>

  <div class="container-fluid">

    <div v-if="flash" class="alert alert-dismissible fade in" v-class="flash.class">
      <button type="button" class="close" data-dismiss="alert"><span>&times;</span></button>
      <p>{{flash.message}}</p>
    </div>

    <div id="main" v-component="{{view}}"></div>
  </div>
</template>

<script lang="coffee" type="text/coffeescript">

module.exports = {

  el: "#app"

  data: ->
    view: ""
    flash: null

  components:
    header: require("./header")

  created: ->

    @$on("changeCurrentUser", =>
      @$broadcast("changeCurrentUser")
    )

    @$on("Layout:flash", (message) =>
      @flash = message
    )
}

</script>

#mainがコンテンツが入る領域になります。ページ遷移の際、このviewを切り替えてコンポーネントを描画します。

<div id="main" v-component="{{view}}"></div>

/app/scripts/components/header.vue

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

<script lang="coffee" type="text/coffeescript">

session = require("../services/session.service")

module.exports =

  replace: true

  data: ->
    currentUser: session.currentUser()

  created: ->
    @$on("changeCurrentUser", =>
      @currentUser = session.currentUser()
    )

</script>

/app/scripts/main.coffee

・
・

App.env = process.env.env

app = new Vue(require("./components/app"))
session = require("./services/session.service")

$(document).on('click', 'a[href]', (e) ->
  e.preventDefault()
  page($(this).attr('href'))
)

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

ヘッダーが表示されます TODO-Module-Vue トップ画面

ログインコンポーネントを作る

/app/scripts/components/index.vue

<template>

<h1>ログイン</h1>

<div class="well">
  <form v-on="submit: onSubmit($event)" novalidate="novalidate" class="simple_form new_user" id="new_user" accept-charset="UTF-8" method="post">

    <div v-class="has-error: validation.email.dirty && validation.email.invalid" class="form-group email required user_email">
      <label class="control-label" for="user_email">
        <abbr title="required">*</abbr>
        メールアドレス
      </label>
      <input v-model="email" v-validate="required, email" class="form-control" type="email" name="email" id="user_email" />
      <span v-if="validation.email.dirty && validation.email.invalid" class="help-block">
        <span v-if="validation.email.required">入力してください。</span>
        <span v-if="!validation.email.required && validation.email.email">無効なメールアドレスです。</span>
      </span>
    </div>

    <div v-class="has-error: validation.password.dirty && validation.password.invalid" class="form-group password required user_password">
      <label class="password required control-label" for="user_password"><abbr title="required">*</abbr> パスワード</label>
      <input v-model="password" v-validate="required, minLength: 8" class="password required form-control" type="password" name="user[password]" id="user_password">
      <span v-if="validation.password.dirty && validation.password.invalid" class="help-block">
        <span v-if="validation.password.required">入力してください。</span>
        <span v-if="validation.password.minLength">8文字以上で入力してください。</span>
      </span>
    </div>
    <input type="submit" name="commit" value="Login" class="btn btn-primary btn-block">
  </form>
</div>

</template>

<script lang="coffee" type="text/coffeescript">

session = require("../services/session.service")

module.exports =

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

      $.each(@validation, (i, attr) ->
        attr.dirty = true
      )

      if @valid
        $.ajax(
          url: "#{App.env.apiHost}/v1/sessions"
          method: "POST"
          data:
            email: @email
            password: @password
        ).done((data, textStatus, jqXHR) =>

          session.setUser(data)
          @$dispatch("changeCurrentUser")
          page("/tasks")
          @$dispatch('Layout:flash', {class: 'alert-info', message: 'ログインしました。'})

        ).fail((jqXHR, textStatus, errorThrown) ->
          alert "ログインに失敗しました"
        )

  validator:
    validates:
      email: (val) ->
        return /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(val)

</script>

/app/scripts/components/app.vue

・
・
module.exports = {
  ・
  ・
  components:
    header: require("./header")
    index: require("./index")
  ・
  ・

/app/scripts/main.coffee

・
・
requireLogin = (ctx, next) ->
  if session.currentUser()
    next()
  else
    page.redirect("/")

skipLogin = (ctx, next) ->
  if session.currentUser()
    page.redirect("/tasks")
  else
    next()

page("/", skipLogin, ->
  app.view = "index"
)

page("/logout", requireLogin, ->
  session.logout()
  app.$broadcast("changeCurrentUser")
  app.$emit('Layout:flash', {class: 'alert-info', message: 'ログアウトしました。'})
  page.redirect("/")
)

page()

ログイン画面が表示されました。 TODO-Module-Vue ログイン画面

新規登録コンポーネントを作る

/app/scripts/components/new_user.vue

<template>

<h1>新規登録</h1>

<div class="well">
  <form v-on="submit: onSubmit($event)" id="new_user" name="newUserForm" novalidate="novalidate">

    <div class="form-group email required user_email" v-class="has-error: (validation['user.email'].dirty && validation['user.email'].invalid)">
      <label class="control-label" for="user_email">
        <abbr title="required">*</abbr>
        メールアドレス
      </label>
      <input v-model="user.email" v-validate="required" id="user_email" class="form-control" type="email" name="email" />
      <span v-if="validation['user.email'].dirty" class="help-block">
        <span v-if="validation['user.email'].required">入力してください。</span>
        <span v-if="validation['user.email'].email">無効なメールアドレスです。</span>
      </span>
    </div>

    <div class="form-group password required user_password" v-class="has-error: (validation['user.password'].dirty && validation['user.password'].invalid)">
      <label class="control-label" for="user_password">
        <abbr title="required">*</abbr>
        パスワード
      </label>
      <input v-model="user.password" v-validate="required, minLength:8" id="user_password" class="form-control" type="password" name="password" />
      <span v-if="validation['user.password'].dirty" class="help-block">
        <span v-if="validation['user.password'].required">入力してください。</span>
        <span v-if="validation['user.password'].minLength">8文字以上で入力してください。</span>
      </span>
    </div>

    <div class="form-group password required user_password_confirmation" v-class="has-error: (validation['user.password_confirmation'].dirty && validation['user.password_confirmation'].invalid)">

      <label class="control-label" for="user_password_confirmation">
        <abbr title="required">*</abbr>
        パスワード(確認)
      </label>

      <input id="user_password_confirmation" class="form-control" type="password" name="password_confirmation"
        v-model="user.password_confirmation" v-validate="required, minLength:8, compareTo: user.password"/>

      <span v-if="validation['user.password_confirmation'].dirty" class="help-block">
        <span v-if="validation['user.password_confirmation'].required">入力してください。</span>
        <span v-if="validation['user.password_confirmation'].minLength">8文字以上で入力してください。</span>
        <span v-if="validation['user.password_confirmation'].compareTo">パスワードが一致しません。</span>
      </span>
    </div>

    <input type="submit" value="Save" class="btn btn-primary btn-block">
  </form>
</div>
  
</template>

<script lang="coffee" type="text/coffeescript">

session = require("../services/session.service")

module.exports =

  data: ->
    user: {}

  validator:
    validates:
      compareTo: (value, target) ->

        targetValue = @$parent.$data
        $.each(target.split("."), (i, attr) ->
          targetValue = targetValue[attr]
        )

        return value == targetValue

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

      $.each(@validation, (i, attr) ->
        attr.dirty = true
      )

      if @valid
        $.ajax(
          url: "#{App.env.apiHost}/v1/users"
          method: "POST"
          data: @$data
        ).done( (data) =>

          session.setUser(data)
          page("/tasks")
          @$dispatch("changeCurrentUser")

        ).fail( (jqXHR, textStatus, errorThrown) ->

          alert JSON.parse(jqXHR.responseText).join("\n")
        )

</script>

/app/scripts/components/app.vue

・
・
module.exports = {
  ・
  ・
  components:
    header: require("./header")
    index: require("./index")
    new_user: require("./new_user")
  ・
  ・

/app/scripts/main.coffee

・
・
page("/users/new", skipLogin, ->
  app.view = "new_user"
)
・
・
page()

新規登録画面が表示されました TODO-Module-Vue 新規登録画面

タスクコンポーネントを作る

次に、メインのタスクコンポーネントを作っていきます。

/app/scripts/components/tasks.vue

<template>

<div class="row">
  <div class="col-sm-2" id="sidebar">
    <h3>収集</h3>
    <ul class="list-unstyled">
      <li v-class="current: state == 'inbox'">
        <a class="clearfix" href="/tasks">
          <div class="pull-left">
            <i class="glyphicon glyphicon-inbox"></i>
            収集箱
          </div>
          <div class="pull-right inbox-count">{{task_count["inbox"]}}</div>
        </a>
      </li>
    </ul>
    <h3>終了</h3>
    <ul class="list-unstyled">
      <li v-class="current: state == '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">{{task_count["completed"]}}</div>
        </a>
      </li>
      <li v-class="current: state == 'deleted'">
        <a class="clearfix" href="/tasks/deleted">
          <div class="pull-left">
            <i class="glyphicon glyphicon-trash"></i>
            ゴミ箱
          </div>
          <div class="pull-right deleted-count">{{task_count["deleted"]}}</div>
        </a>
      </li>
    </ul>
  </div>
  <div class="col-sm-10">
    <form id="new_task" class="new_task" v-on="submit: onSubmit($event)">
      <div class="input-group">
        <input class="form-control" placeholder="タスク" type="text" id="task_title" v-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">
      <li class="task clearfix" v-repeat="tasks | filterBy state in aasm_state | orderBy 'id' -1">
        <div class="pull-left">
          <a href="/tasks/{{id}}/edit">{{title}}</a>
        </div>
        <div class="pull-right">
          <a v-on="click: action('done', this)" v-if="aasm_state == 'inbox'" class="task-complete btn btn-primary">完了</a>
          <a v-on="click: action('revert', this)" v-if="aasm_state != 'inbox'" class="task-revert btn btn-default">戻す</a>
          <a v-on="click: action('delete', this)" v-if="aasm_state != 'deleted'" class="task-delete btn btn-danger">削除</a>
        </div>
      </li>
    </ul>
  </div>
</div>
  
</template>

<script lang="coffee" type="text/coffeescript">

module.exports =

  data: ->
    tasks: []
    state: App.state

  computed:
    task_count: ->
      count = {"inbox": 0, "completed": 0, "deleted": 0}
      $.each(@tasks, -> count[this.aasm_state]++ )
      return count

  created: ->

    $.ajax(
      url: "#{App.env.apiHost}/v1/tasks"
    ).done( (data, textStatus, jqXHR) =>
      @tasks = data
    )

    @$on("changeState", =>
      @state = App.state
    )

  methods:
    action: (action, task) ->
      acts =
        done:
          url: "#{task.id}/complete"
          method: "PUT"
          flash: {class: 'alert-info', message: '完了にしました。'}
        delete:
          url: "#{task.id}"
          method: "DELETE"
          flash: {class: 'alert-info', message: 'ゴミ箱に入れました。'}
        revert:
          url: "#{task.id}/revert"
          method: "PUT"
          flash: {class: 'alert-info', message: '収集箱に戻しました。'}

      act = acts[action]
      $.ajax(
        url: "#{App.env.apiHost}/v1/tasks/#{act.url}"
        method: act.method
      ).done( (data) =>
        task.aasm_state = data.aasm_state
        @$dispatch('Layout:flash', act.flash)
      )

    onSubmit: (e) ->
      e.preventDefault()
      if @new_task_title?.trim()
        $.ajax(
          url: "#{App.env.apiHost}/v1/tasks"
          method: "POST"
          data:
            task:
              title: @new_task_title
        ).done( (data) =>
          @new_task_title = ""
          @tasks.push(data)
          @$dispatch('Layout:flash', {class: 'alert-info', message: '作成しました。'})
        )
</script>

/app/scripts/components/app.vue

・
・
module.exports = {
  ・
  ・
  components:
    header: require("./header")
    index: require("./index")
    new_user: require("./new_user")
    tasks: require("./tasks")
  ・
  ・

/app/scripts/main.coffee

・
・
page("/tasks", requireLogin, ->
  app.view = "tasks"
  App.state = "inbox"
  app.$broadcast("changeState")
)

page("/tasks/:state", requireLogin, (ctx) ->
  app.view = "tasks"
  App.state = ctx.params.state
  app.$broadcast("changeState")
)
・
・
page()

タスク画面が表示されました。登録や完了などひと通り動くことを確認して下さい。 TODO-Module-Vue タスク画面1 TODO-Module-Vue タスク画面2

タスク編集コンポーネントを作る。

最後に、タスク編集コンポーネントを作っていきます。

/app/scripts/components/task-edit.vue

<template>
<h2>タスク編集</h2>
<div class="well">
  <form class="edit_task" name="taskForm" v-on="submit: onSubmit($event)" novalidate="novalidate">

    <div class="form-group string required task_title" v-class="has-error: (validation['task.title'].dirty && validation['task.title'].invalid)">
      <label class="control-label" for="task_title">
        <abbr title="required">*</abbr>
        タイトル
      </label>
      <input v-model="task.title" v-validate="required" class="form-control" type="text" name="title" id="task_title">
      <span v-if="validation['task.title'].dirty" class="help-block">
        <span v-if="validation['task.title'].required">入力してください。</span>
      </span>
    </div>

    <div class="form-group text optional task_memo">
      <label class="control-label" for="task_memo">メモ</label>
      <textarea class="form-control" id="task_memo" v-model="task.memo"></textarea>
    </div>
    <input type="submit" name="commit" value="Save" class="btn btn-primary btn-block">
  </form>
</div>
</template>

<script lang="coffee" type="text/coffeescript">

module.exports =

  inherit: true

  data: ->
    task: {}

  created: ->
    $.ajax(
      url: "#{App.env.apiHost}/v1/tasks/#{@id}"
    ).done( (data) =>
      @task = data
    )

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

      $.each(@validation, (i, attr) ->
        attr.dirty = true
      )

      if @valid
        $.ajax(
          url: "#{App.env.apiHost}/v1/tasks/#{@id}"
          method: "PUT"
          data: {task: @task}
        ).done( =>
          page("/tasks")
          @$dispatch('Layout:flash', {class: 'alert-info', message: '更新しました。'})
        )
</script>

/app/scripts/components/app.vue

・
・
module.exports = {
  ・
  ・
  components:
    header: require("./header")
    index: require("./index")
    new_user: require("./new_user")
    tasks: require("./tasks")
    task_edit: require("./task_edit")
  ・
  ・

/app/scripts/main.coffee

・
・
page("/tasks/:id/edit", requireLogin, (ctx) ->
  app.id = ctx.params.id
  app.view = "task_edit"
)
・
・
page()

タスクの編集ができることを確認して下さい。 TODO-Module-Vue タスク編集画面

これで完成です!いかがだったでしょうか? ディレクティブなどAngularJSと似ているので、AngularJSを触ったことのある方はすんなり理解できたのではないでしょうか?

AngularJSは独自のルールが多いのでとっつきにくいところがあると思いますが、 Vue.jsは非常にシンプルなので、手っ取り早くデータバインディングだけしたいという場合には最適だと思います。

またVue.jsはViewModelの機能しか持っていないので、Backbone.jsやMarionette.jsと一緒に使うということもできます。

今回は使いませんでしたが、もちろんカスタムディレクティブを作ったり、アニメーションをつけたりもできます。 興味があったら使ってみてください。