Vue.js + vue-routerでシングルページアプリケーション(SPA)を作ってみる

2017年2月19日

PROGRAMMING

Vue.js+vue-routerを使ったシングルページアプリケーション(SPA)を作ってみます。
タスクの追加・完了・削除だけでなく、タスクが完了済みかどうかの状態別に表示できる機能も備えたToDo管理アプリケーションを開発します。

完成版のソースコードはこちらに置いています。vue-todo

はじめに

Vue.jsとは

Vue.jsとはMVVMというMVCの派生アーキテクチャパターンを元にしたクライアントサイドJavaScriptフレームワークです。
より詳しい情報や使い方は公式ドキュメントがわかりやすく説明しているので是非ご覧ください。 Vue.js

vue-routerとは

vue-routerはVue.js用のルーターライブラリです。
このライブラリを使用することで、Vue.jsを使ったシングルページアプリケーションを実装することができます。
詳しい使い方はこちらから確認できます。vue-routerドキュメント

開発環境について

開発環境はDockerを使用し、Dockerコンテナ内にVue.jsの実行に必要なファイル群をvue-cliによって生成します。

【注意その1】Dockerについて詳しい説明は行わない
設定ファイルはコピペできるようにこの後記述するので、Docker for Mac等Docker実行環境の準備だけお願いします。
Docker for Macについてのみの内容ですが、過去に記事をアップしているので参考にしてください。Docker for Mac + Docker ComposeでWordPress開発環境を構築する!

【注意その2】Webpack, npmなどの説明は行わない
vue-cli(プロジェクトテンプレート: webpack)によって生成されるWebpackの設定ファイルの取り扱い方や、npmを使ってインストールするモジュールについて詳しい説明は行いません。

JavaScriptの記述について

JavaScriptの記述はすべてES6を使用します。

もくじ

1.開発環境の構築

まずは開発環境を構築しましょう。
今回のプロジェクト用のディレクトリを作成し移動してください。

$ mkdir vue-todo
$ cd vue-todo

次にvue-cliがインストールされたNodeイメージが必要なためDockerfileを作成します。

Dockerfile
FROM node:latest

RUN npm install -g vue-cli

RUN mkdir /srv/vue-todo
COPY . /srv/vue-todo

WORKDIR /srv/vue-todo

イメージのビルド、コンテナの作成と起動はdocker-composeコマンドを使用して行うので、docker-compose.ymlを以下の内容で作成してください。

docker-compose.yml
version: '2'
services:
  node:
    build: .
    command: npm run dev
    volumes:
      - .:/srv/vue-todo
    ports:
      - '8080:8080'

2つのファイルが作成できたら、イメージをビルドします。

$ docker-compose build

ビルドが完了したら、以下のコマンドを実行してVueのプロジェクトテンプレートを生成します。

$ docker-compose run --rm node vue init webpack .

コマンド実行によって聞かれる質問には以下のように回答してください。

  • Generate project in current directory?: Yes (Yを押してEnter)
  • Project name: vue-todo (そのままEnter)
  • Project description: A Vue.js project (そのままEnter)
  • Author: (そのままEnter)
  • Vue build: standalone (そのままEnter)
  • Install vue-router: Yes (Yを押してEnter)
  • Use ESLint to lint your code: No (Nを押してEnter)
  • Setup unit tests with Karma + Mocha?: No (Nを押してEnter)
  • Setup e2e tests with Nightwatch?: No (Nを押してEnter)

ここまで進めると、カレントディレクトリにvue-cliで生成されたファイル群が配置されます。

次にpackage.jsonに書かれているモジュールたちをインストールします。
今回の開発に使用するのでstylus, stylus-loaderを合わせてインストールして下さい。

$ docker-compose run --rm node npm install
$ docker-compose run --rm node npm install --save-dev stylus stylus-loader

インストールが完了したらDockerコンテナを起動してみましょう。

$ docker-compose up -d

起動コマンド実行からしばらくした後、localhost:8080にアクセスしてください。
以下の画面が表示されたら初期セットアップ完了です。

2.生成されたファイルを確認して表示の仕組みを理解する

localhost:8080に現在表示されている画面は、カレントディレクトリにあるindex.htmlによるものです。
ですがこのファイルには<body>タグ内に<div id="app"></div>と書かれているのみで、現在表示されているような内容は記述されていません。
ではどのファイルで記述されているのでしょうか?少し確認してみたいと思います。

src/main.js

このファイルの記述内容が元になってindex.htmlにVueコンポーネントがマウントされています。
ソースコードをチェックしてみましょう。

import Vue from 'vue'
import App from './App'
import router from './router'

new Vue({
  el: '#app',
  router,
  template: '<App/>',
  components: { App }
})

new Vue()で作成されたVueインスタンスは、IDがappのElementにAppコンポーネントを設置するよう指示されています。

routerオプションは、後ほど説明するファイルsrc/router/index.jsに記述されているルーティング設定を使用することを表しています。router: routerと記述するのを省略して書かれている点に注意してください。

src/App.vue

main.jsで作成されたVueインスタンスが、Appコンポーネントをマウントするよう指示されていることを先ほど確認したので、次はそのAppコンポーネントであるApp.vueファイルを確認してみましょう。

※ .vue拡張子のファイルはWebpack用のloaderであるvue-loaderが実行可能ファイルに変換します。

App.vueのソースコードは以下のようになっています。
このように.vue拡張子のファイルでは<template>, <script>, <style>の3つのブロックを1つのファイルに記述するのが基本です。

<template>
  <div id="app">
    <img src="./assets/logo.png">
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  name: 'app'
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

このファイルで特に注目すべき点は<router-view></router-view>の部分です。
このタグを設置することで、vue-routerのルーティングに合わせて設定されたコンポーネントが表示されます。

src/router/index.js

vue-routerのルーティングを設定するファイルです。
ソースコードを確認してみましょう。

import Vue from 'vue'
import Router from 'vue-router'
import Hello from 'components/Hello'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Hello',
      component: Hello
    }
  ]
})

new Router()でvue-routerのインスタンスを作成しています。
routesオプションにルーティングの設定を記述していくのですが、ここでは/にアクセスされたらHelloコンポーネント(src/components/Hello.vue)を表示するよう設定されていますね。
※ vue-routerの標準設定のせいでURLに#/が付与されてしまっていますが、これは後ほど設定を変更して取り除きます。


ここまでで、srcディレクトリ以下の各ファイルがどのような役割をしているのか何となくでも理解したかと思います。では、次に進みましょう。

3.Todoコンポーネントの作成

それではTodoコンポーネントの作成を行っていきます。

App.vueの編集

まずsrc/App.vueを以下に書き換えて下さい。

src/App.vue
<template>
  <div>
    <header>
      <h1>Vue ToDo</h1>
    </header>

    <router-view></router-view>

    <footer>
      Copyright <a href="https://maaarklog.com">Maaark</a>, All Rights Reserved.
    </footer>
  </div>
</template>

<script>
export default {
  name: 'app'
}
</script>

<style lang="stylus">
body
  margin 0
  padding 0
a
  color #6af
header, footer
  background-color #555
  color #fff
  padding 10px 0
  text-align center
  h1
    margin 0
    font-size 30px
  a
    color #fff
</style>

ヘッダーとフッターを追加して、スタイルを調整しています。
CSSは<style lang="stylus">でstylusを使用するよう指定しています。
環境構築時にstylusとstylus-loaderをインストールしたのはこのためでした。

Todo.vueの作成

次にTodo.vueを以下の内容で作成します。

src/components/Todo.vue
<template>
  <div class="todo">
    <input type="text" class="todo-input" placeholder="Todo"
           @change="changeKeyword" v-model="inputText" />
    <button @click="addTodo" class="submit-button">SUBMIT</button>
    <todo-list :todos="todos"></todo-list>
  </div>
</template>

<script>
import TodoList from './TodoList'
export default {
  data() {
    return {
      inputText: '',
      todos: [
        { text: 'todo', done: false }
      ]
    }
  },
  components: {
    TodoList
  },
  methods: {
    changeKeyword(e) {
      this.inputText = e.target.value
    },
    addTodo() {
      if (this.inputText) {
        this.todos.push({
          text: this.inputText, done: false
        })
        this.inputText = ''
      }
    }
  }
}
</script>

<style lang="stylus" scoped>
.todo-input, .submit-button
  display block
  width 300px
  box-sizing border-box
  font-size 20px
  margin 10px auto
  padding 10px
.todo-input
  border 1px solid #555
.submit-button
  background-color #6af
  border none
  border-radius 6px
  color #fff
  cursor pointer
</style>

templateブロック内の補足

<template>
  <div class="todo">
    <input type="text" class="todo-input" placeholder="Todo"
           @change="changeKeyword" v-model="inputText" />
    <button @click="addTodo" class="submit-button">SUBMIT</button>
    <todo-list :todos="todos"></todo-list>
  </div>
</template>

@change, @clickはVue.jsが用意しているv-onディレクティブの省略記法です。
指定したイベントが発生したら、指定した関数が実行されます。ここではinputタグの@changechangeKeyword()、buttonタグの@clickにはaddTodo()が指定されていますね。

v-modelディレクティブは指定した値との双方向バインディングを作成します。
ここではこのコンポーネントが持っている値(dataオプション)のinputTextがバインドされていて、このinputTextに何らかの操作で'abcde'という文字列がセットされた場合、inputタグのvalueも'abcde'に変更されるようになっています。

最後にこのTodoコンポーネントの子コンポーネントにあたるTodoListコンポーネントを<todo-list></todo-list>として呼び出し、todosを同名のpropsとして渡しています。

scriptブロック内の補足

<script>
import TodoList from './TodoList'

export default {
  data() {
    return {
      inputText: '',
      todos: [
        { text: 'todo', done: false }
      ]
    }
  },
  components: {
    TodoList
  },
  methods: {
    changeKeyword(e) {
      this.inputText = e.target.value
    },
    addTodo() {
      if (this.inputText) {
        this.todos.push({
          text: this.inputText, done: false
        })
        this.inputText = ''
      }
    }
  }
}
</script>

.vue拡張子でのVueコンポーネントのオプションはexport default {}内に記述するきまりとなっています。
各オプションの詳細はこちらを確認してください。

methodsオプション内にchangeKeyword()addTodo()が定義されています。これらの関数が先ほどの@change, @clickディレクティブに指定されている関数です。

dataオプションにはinputTexttodosを定義しています。それぞれの性質は以下の通り。

  • inputText
    • inputタグに入力された値とバンディングされている
    • changeKeyword()で値が変更される
    • addTodo()が実行されると、空文字が与えられる
  • todos
    • 入力されたTodoのObjectを入れる配列
    • TodoのObjectはaddTodo()で追加される

TodoList.vueの作成

TodoList.vueを以下の内容で作成してください。
このコンポーネントはTodoコンポーネントの子コンポーネントとして動作します。

src/components/TodoList.vue
<template>
  <div class="todo-list">
    <ul>
      <li v-for="(todo, index) in todos" @click="toggleTodo(index)">
        <span class="checkbox" :class="{'checked': todo.done}"></span>
        <span :class="{'checked': todo.done}">{{todo.text}}</span>
        <span @click="deleteTodo(index)" class="del">X</span>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  props: {
    todos: Array
  },
  methods: {
    toggleTodo(index) {
      const todo = this.todos[index]
      if (typeof(todo) === 'object' && todo !== {}) {
        todo.done = !todo.done
      }
    },
    deleteTodo(index) {
      if (this.todos[index]) {
        this.todos.splice(index, 1)
      }
    }
  }
}
</script>

<style lang="stylus" scoped>
.todo-list
  width 100%
  max-width 600px
  margin 0 auto
ul
  padding 0 5px
li
  position relative
  font-size 18px
  padding 8px 45px
  margin 5px 0
  list-style none
  cursor pointer
  transition .2s
  &:hover
    background-color #eaeaea
.checkbox
  position absolute
  top 8px
  left 8px
  width 25px
  height 25px
  border-radius 100%
  border 1px solid #555
  &.checked:after
    content ''
    position absolute
    top 40%
    left 8px
    display block
    margin-top -8px
    width 7px
    height 16px
    border-right 2px solid #6c5
    border-bottom 2px solid #6c5
    transform rotate(36deg)
.checked
  text-decoration line-through
  color #888
.del
  position absolute
  right 8px
  width 25px
  line-height 25px
  text-align center
  float right
  background-color #e65
  color #fff
  border-radius 100%
  z-index 99
  transition .3s
  &:hover
    background-color darken(#e65, 20%)
    transform rotate(180deg)
</style>

templateブロックの補足

<template>
  <div class="todo-list">
    <ul>
      <li v-for="(todo, index) in todos" @click="toggleTodo(index)">
        <span class="checkbox" :class="{'checked': todo.done}"></span>
        <span :class="{'checked': todo.done}">{{todo.text}}</span>
        <span @click="deleteTodo(index)" class="del">X</span>
      </li>
    </ul>
  </div>
</template>

v-forディレクティブtodospropsの配列の要素数だけ<li>タグが繰り返し描画されます。

:classv-bindディレクティブの省略記法で、v-for内展開されたtodoオブジェクトのdoneプロパティがtrueの場合、checkedクラスがセットされるよう記述してあります。

<li>タグには@click="toggleTodo(index)"が記述されているため、クリックされるとそのtodoオブジェクトのdoneプロパティがtrueもしくはfalseに変更されます。
同様に<span class="del">タグには@click="deleteTodo(index)"が記述されているので、クリックされるとそのtodoオブジェクトは削除されます。

scriptブロックの補足

<script>
export default {
  props: {
    todos: Array
  },
  methods: {
    toggleTodo(index) {
      const todo = this.todos[index]
      if (typeof(todo) === 'object' && todo !== {}) {
        todo.done = !todo.done
      }
    },
    deleteTodo(index) {
      if (this.todos[index]) {
        this.todos.splice(index, 1)
      }
    }
  }
}
</script>

propsオプションに関してはこちらを確認してください。

routerの編集

/にアクセスされた際<router-view></router-view>Todoコンポーネントが表示されるようルーティングの設定を変更します。

src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import Todo from 'components/Todo'

Vue.use(Router)

export default new Router({
  mode: 'history',
  routes: [
    { path: '/', component: Todo }
  ]
})

vue-routerの標準設定ではhashでURLをシミュレートするようになっており、URLに#/が含まれるようになっています。
これを取り除くためmode: 'history'オプションを追加しています。詳細はこちら

ここまでの記述が完了したら以下のコマンドを実行し、念のためDockerコンテナを再起動してください。

$ docker-compose stop
$ docker-compose up -d

ここでlocalhost:8080にアクセスすると以下のような画面が表示されるはずです。

試しにフォームに文字を入力し、SUBMITボタンをクリックしてみてください。
無事下のリストに入力したテキストのタスクが追加され、フォームの文字列がリセットされたでしょうか。
また、追加されたタスクをクリックするとチェックボックスにチェックが付き、右端にある☓ボタンをクリックしてタスクが削除されるかも確認してください。

4.画面遷移の実装

シングルページアプリケーションを作ると言いながら、まだリンクを選択して画面遷移する機能が実装できていませんね。
はじめに簡単な画面遷移を実装し、次にリンクを選択することでToDoの完了状態別にフィルタリングして表示する機能を実装してみます。

簡単な画面遷移

Helloコンポーネントの編集

まずHelloコンポーネントを以下に書き換えてください。

src/components/Hello.vue
<template>
  <div class="hello">
    <h2>{{message}}</h2>
    <router-link to="/">Back</router-link>
  </div>
</template>

<script>
export default {
  props: {
    str: String
  },
  computed: {
    message() {
      return 'Hello ' + (this.str || 'World') + '!!'
    }
  }
}
</script>

<style lang="stylus" scoped>
.hello
  text-align center
</style>

router-linkは、vue-routerによって追加された、リンクを作成するコンポーネントです。toプロパティにパスを入れて使用します。

また、このHelloコンポーネントはstrというpropsを受け取り、computedオプション内のmessage関数で使用されます。

routerの編集

/helloにアクセスされたらHelloコンポーネントを表示させたいので、src/router/index.jsを編集します。

src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import Todo from 'components/Todo'
import Hello from 'components/Hello' // 追加

Vue.use(Router)

export default new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      component: Todo,
    },
    // ↓ 追加
    {
      path: '/hello',
      component: Hello,
      props: { str: 'Vue' }
    }
  ]
})

ここでHelloコンポーネントにstrpropsを渡しています。

localhost:8080/hello にアクセスしてみましょう。

Backリンクをクリックすると、Todoコンポーネントに素早く切り替わったはずです。
簡単に作りましたが、これがvue-routerの機能を使用した画面遷移です。

タスクのフィルター表示機能

次はタスクのフィルター表示をvue-routerの機能を利用して実装してみましょう。
以下が実装後の画面です。

ALL, ACTIVE, COMPLETEDと3つのボタンが用意されており、それぞれクリックすると/all, /active, /completedに移動します。この3つのパスに対応して、「すべてのタスクを表示」「未完了のタスクのみ表示」「完了済みのタスクのみ表示」とタスクにフィルターを通して表示させます。

Todo.vueの編集

以下を追加します。コメントアウトしている箇所は削除してください。

  • ALL, ACTIVE, COMPLETEDボタンと、そのスタイルを追加
  • <router-view>コンポーネントを追加
src/components/Todo.vue
<template>
  <div class="todo">
    <div class="selector">
      <router-link to="/all">ALL</router-link>
      <router-link to="/active">ACTIVE</router-link>
      <router-link to="/completed">COMPLETED</router-link>
    </div>
    <input type="text" class="todo-input" placeholder="Todo"
           @change="changeKeyword" v-model="inputText" />
    <button @click="addTodo" class="submit-button">SUBMIT</button>
    <!-- <todo-list :todos="todos"></todo-list> -->
    <router-view :todos="todos"></router-view>
  </div>
</template>

<script>
// import TodoList from './TodoList'

export default {
  data() {
    return {
      inputText: '',
      todos: [
        { text: 'todo', done: false }
      ]
    }
  },
  // components: {
  //   TodoList
  // },
  methods: {
    changeKeyword(e) {
      this.inputText = e.target.value
    },
    addTodo() {
      if (this.inputText) {
        this.todos.push({
          text: this.inputText, done: false
        })
        this.inputText = ''
      }
    }
  }
}
</script>

<style lang="stylus" scoped>
.selector
  margin 10px 0
  text-align center
  a
    display inline-block
    border 1px solid #555
    border-radius 6px
    padding 5px 15px
    color #555
    text-decoration none
    &:hover
      background-color #eaeaea
    &.router-link-active
      border-color #6c5
      color #6c5
.
.
</style>

<router-link>コンポーネントは、toプロパティのパスが現在のパスと一致している場合router-link-activeというクラスが付与されるので、これを利用してリンクのスタイルを変更しています。

TodoList.vueの編集

追加、変更内容は以下です。

  • export default {}外にfiltersを追加
  • propsオプションにpath: Stringを追加
  • computedオプション +filteredTodosを追加
  • <li v-for= ~>in todosin filteredTodosに変更
src/components/TodoList.vue
<template>
  <div class="todo-list">
    <ul>
      <li v-for="(todo, index) in filteredTodos" @click="toggleTodo(index)"> <!-- `in todos` から `in filteredTodos` に変更 -->
        <span class="checkbox" :class="{'checked': todo.done}"></span>
        <span :class="{'checked': todo.done}">{{todo.text}}</span>
        <span @click="deleteTodo(index)" class="del">X</span>
      </li>
    </ul>
  </div>
</template>

<script>
const filters = { // 追加
  all: (todos) => { return todos },
  active: (todos) => { return todos.filter(todo => todo.done === false)},
  completed: (todos) => { return todos.filter(todo => todo.done === true)}
}

export default {
  props: {
    todos: Array,
    path: String  // 追加
  },
  computed: {     // 追加
    filteredTodos() {
      const visibility = this.path && this.path.match(/all|active|completed/) || 'all'
      return filters[visibility](this.todos)
    }
  },
  .
  .
</script>

pathpropsには 'all', 'active', 'completed'いずれかの文字列が与えられることを想定しています。それ以外もしくは空文字列だった場合は'all'がセットされているものとして処理します。

routerの編集

追加内容は以下。

  • TodoListのimportを追加
  • '/'childrenオプションを追加し3つのパスを定義
src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import Todo from 'components/Todo'
import TodoList from 'components/TodoList' // 追加
import Hello from 'components/Hello'

Vue.use(Router)

export default new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      component: Todo,
      children: [      // 追加
        {
          path: 'all',
          component: TodoList,
          props: { path: 'all' }
        },
        {
          path: 'active',
          component: TodoList,
          props: { path: 'active' }
        },
        {
          path: 'completed',
          component: TodoList,
          props: { path: 'completed' }
        }
      ]
    },
    {
      path: '/hello',
      component: Hello,
      props: { str: 'Vue' }
    }
  ]
})

これで/all, /active, /completedにアクセスできるようになりました。

localhost:8080にアクセスしてそれぞれのボタンを選択し、URLとタスクリストの表示の変更が行われているかを確認してください。
問題なければこれで完成となります。長くなりましたがお疲れ様でした。

さいごに

Vue.jsは学習コストが少ないことで有名なフレームワークです。
それを後押しするのはやはり公式ドキュメントのわかりやすさだと思っています。
実装の途中でわからないことのほとんどは公式ドキュメントとサンプルのソースコードを確認することでカバーできるので、今回のTodoアプリケーションのソースコードを元に、色々と遊んでみてください。

今後の予定として、今回実装したアプリケーションにVuexを導入する記事を作成予定です。