Edited at

Vue.jsで簡単なメモアプリを実装する

More than 1 year has passed since last update.


概要

以前に投稿した「サーバーサイドJavaエンジニアがVue.jsの基本を学習したときのメモ」に続く記事です。

Vue.jsの基本と周辺技術を学びながら簡単なメモアプリを実装してみました。

実装にあたってVue.jsに加えてルーティングにvue-router、ストア(状態管理)にVuex、CSSフレームワークにbootstrap-vueを利用しました。

ソースコードはrubytomato/exercise-vueにあります。(2018年6月6日現在、この記事と若干変わっていますがご了承ください)

環境


  • Windows 10 Professional

  • Node.js 8.11.1

  • Vue.js 2.5.16


    • vue-router 3.0.1

    • Vuex 3.0.1

    • bootstrap-vue 2.0.0-rc.11

    • vue-moment 4.0.0

    • vue-cli 2.9.3



  • Visual Studio Code

参考


メモアプリについて

実装したメモアプリの機能はメモを登録、一覧表示し、メモのIDをクリックすると詳細を表示するというシンプルなものです。


メモ一覧

Vuexで管理するメモデータをカード形式で一覧表示します。また一覧の末尾にメモの登録フォームを表示するようにしていて、メモが一件も無いときは登録フォームだけ表示されます。


メモ詳細

メモ一覧で選択したメモの詳細情報を表示します。この画面ではメモの一部の属性(プラットフォームやミリオンかどうか)をクリックで更新できるようにしています。


扱うメモの属性

メモの属性は以下の通りです。

id
name
type

id
id
Number

title
メモのタイトル
String

description
メモの説明
String

platforms
プラットフォーム
Array

million
ミリオン
Boolean

releasedAt
リリース日
Date


動作とソースコード

vue initコマンドでテンプレートにwebpackを使用するプロジェクトのひな型を生成し、それを拡張する方法でメモアプリを実装していきました。

> vue init webpack exercise-vue

メモアプリのコンポーネント(ページやページの部品)は全て単一ファイルコンポーネント(拡張子がvue)というVue.js独自のファイルで実装しています。単一ファイルコンポーネントにするとコンポーネントのhtml(template)、振る舞い(script)、CSS(style)を1つのファイルにまとめることができるのですが、リリースするにはwebpackでバンドルする必要が出てきます。

ただ開発時は意識する必要はないので特に支障はありませんでした。


依存するモジュールのインストール


  • vue-router

  • Vuex

  • bootstrap-vue

  • vue-moment

> npm install vue-router Vuex bootstrap-vue vue-moment --save


  • vue-momentはVue.jsから簡単にmomentが利用できるようになるプラグインです。


プロジェクトの構造

exercise-vue

|
+--- /src
| |
| +--- /components
| | |
| | +--- MemoListCard.vue
| | |
| | +--- MemoListForm.vue
| |
| +--- /constants
| | |
| | +--- index.js
| |
| +--- /pages
| | |
| | +--- MemoList.vue
| | |
| | +--- MemoDetails.vue
| |
| +--- /router
| | |
| | +--- index.js
| |
| +--- /store
| | |
| | +--- index.js
| |
| +--- App.vue
| |
| +--- main.js
|
+--- index.html
|
+--- package.json


ストア

Vuexを利用したストアの実装はstore/index.jsです。storeをルートのVueインスタンス生成時のコンストラクタに渡すと、ほかのコンポーネントからthis.$storeという方法でストアにアクセスできます。


main.js

import store from './store'

// ルートのVueインスタンスの生成
new Vue({
el: '#app',
'router': router,
'store': store, // ← これ
components: { App },
template: '<App/>'
})



ストアのコアコンセプト

リファレンスのコアコンセプトで挙げられている5つのコンセプト(ステート、ゲッター、ミューテーション、アクション、モジュール)のうち、このメモアプリではステート、ゲッター、ミューテーションを使用しています。


store/index.js

import Vue from 'vue'

import Vuex from 'vuex'

Vue.use(Vuex)

const store = new Vuex.Store({
state: {
// ここにステートを記述
},
getters: {
// ここにゲッターを記述
},
mutations: {
// ここにミューテーションを記述
},
actions: {
// ここにアクションを記述
},
modules: {
// ここにモジュールを記述
}
}

export default store



ステート (state)

ストアで管理するデータはstateというプロパティに定義します。

本来なら、バックエンドのAPIサーバーからメモデータを取得するべきですが、そこまで実装できていないのでメモデータはstateに直接定義しています。

state: {

memos: [
{ id: 1, title: '...', description: '...', platforms: ['...'], million: false, releasedAt: new Date ()},
{ id: 2, title: '...', description: '...', platforms: ['...'], million: false, releasedAt: new Date ()},
{ id: 3, title: '...', description: '...', platforms: ['...'], million: false, releasedAt: new Date ()},
// ...省略 ...
{ id: 14, title: '...', description: '...', platforms: ['...'], million: false, releasedAt: new Date ()}
],
nextId: 15
}


ゲッター (getters)

stateに定義したデータを参照するメソッドはgettersというプロパティに定義します。ここで定義するメソッドは第1引数にstateというオブジェクトを受け取り、メソッドはstateオブジェクトを通してデータにアクセスします。

このメモアプリではメモ一覧画面で使用するメモ全件を取得するメソッド、メモ詳細画面で使用するメモ1件を取得するメソッドの2つのゲッターを実装しています。

getters: {

// 全件を返す
memos (state) {
return state.memos
},
// idで指定した1件を返す
memoById (state) {
return function (_id) {
var memo = state.memos.find(memo => memo.id === _id)
if (memo) {
return memo
}
console.error('memo not found')
return CONSTANTS.ERROR_MEMO
}
}
}


ミューテーション (mutations)

stateに定義したデータを更新するメソッドはmutationsというプロパティに定義します。gettersと同様に第1引数にstateというオブジェクトを受け取り、更新するデータは第2引数のpayloadというオブジェクトで受け取るように実装しています。

このメモアプリでは、メモの追加・削除を行うメソッド、メモの属性を更新するメソッドの計4つのミューテーションを実装しています。

mutations: {

// メモを1件追加
addMemo (state, payload) {
payload.id = state.nextId
state.memos.push(payload)
state.nextId++
},
// idで指定したメモを削除
removeMemo (state, payload) {
var index = state.memos.findIndex(memo => memo.id === payload.id)
if (index !== -1) {
state.memos.splice(index, 1)
}
},
// idで指定したメモのミリオンを反転させる
toggleMillion (state, payload) {
var index = state.memos.findIndex(memo => memo.id === payload.id)
if (index !== -1) {
state.memos[index].million = !state.memos[index].million
}
},
// idで指定したメモのプラットフォーム配列を操作する、同じ要素があれば削除、なければ追加
togglePlatform (state, payload) {
var index = state.memos.findIndex(memo => memo.id === payload.id)
if (index !== -1) {
var platforms = state.memos[index].platforms
if (platforms.includes(payload.platform)) {
platforms.splice(platforms.indexOf(payload.platform), 1)
} else {
platforms.push(payload.platform)
}
}
}
}


ルーティング

vue-routerを利用したルーティングの実装はrouter/index.jsです。

ストアと同じようにVueインスタンス生成時のコンストラクタにrouterを渡すと、ほかのコンポーネントからthis.$routerthis.$routeという方法でルーターや選択したルート(パスやパラメータ)の情報にアクセスできます。


main.js

import router from './router'

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



ルート

ルートとコンポーネントの紐づけは、routesプロパティで定義します。

path
component
vue file

/memo-list
MemoList
/src/pages/MemoList.vue

/memo/:id
MemoDetails
/src/pages/MemoDetails.vue


router/index.js

import Vue from 'vue'

import Router from 'vue-router'
import MemoList from '@/pages/MemoList'
import MemoDetails from '@/pages/MemoDetails'

Vue.use(Router)

const router = new Router({
routes: [
{
path: '/memo-list',
name: 'MemoList',
component: MemoList,
meta: {
title: 'memo list'
}
},
{
path: '/memo/:id',
name: 'MemoDetails',
component: MemoDetails,
props: true, // ← これ
meta: {
title: 'details of memo'
}
}
]
})



動的セグメントとprops

pathの中で:(コロン)で始まる部分を動的セグメントと言います。動的セグメントの値の取得方法には、コンポーネントの中でthis.$route.paramsからアクセスする方法と、routeのpropsプロパティをtrueにしてコンポーネントのpropsにバインドさせる方法があります。

たとえば:idという動的セグメントの値を取得する場合、this.$route.paramsでは

this.$route.params.id

propsでは、下記のようにコンポーネントのpropsにidを定義することで:idをバインドさせることができます。


MemoDetails.vue

<script>

export default {
name: 'MemoDetails',
// routeの動的セグメント
props: ['id'],

// ...省略...
}
</script>



アドレスバーで動的セグメントを直接書き換える

ブラウザのアドレスバーで動的セグメントを直接書き換えると、画面の描画が変わらないという現象がありました。

動的セグメントが変わるとbeforeRouteUpdateプロパティに設定したコールバック関数が実行されるので、ここでidが変わったことを通知する処理を実装することで対応しました。

具体的な対応は以下の通りです。


  1. Vueインスタンスの監視の対象とするidをdataプロパティにtargetIdとして定義します。

  2. targetIdはpropsプロパティのid(動的セグメントの:idがバインドされる)で更新します。

  3. 動的セグメントが変わった場合は、コールバック関数でtargetIdを更新します。

  4. targetIdは監視対象なので状態が変化すると、自動的にストアからデータを取得する処理が実行されます。


  • beforeRouteUpdateの関数内で直接memoメソッドを呼び出すという方法もあります。


MemoDetails.vue

<script>

export default {
name: 'MemoDetails',
data () {
return {
targetId: this.id // 1. 2.
}
},
// routeの動的セグメント
props: ['id'],
// pathの:idを直接書き換えたときの対応
beforeRouteUpdate (to, from, next) {
this.targetId = to.params.id // 3.
next()
},
computed: {
memo () {
if (!this.targetId) {
console.error('invalid id')
return CONSTANTS.ERROR_MEMO
}
return this.$store.getters.memoById(parseInt(this.targetId, 10)) // 4.
}
},
// ...省略...
}
</script>


スクロールの位置

vue-routerではリファレンスのスクロールの振る舞いで説明されているように、ブラウザバックで前の画面に戻るときにスクロールバーの位置を遷移前の位置に設定できる機能があります。

このメモアプリでもメモ詳細からメモ一覧へブラウザバックしたときに元の画面位置に戻れるように対応しています。

scrollBehavior: (to, from, savedPosition) => {

return savedPosition || { x: 0, y: 0 }
}

ただし、画面遷移時のアニメーションを有効にしていると、ブラウザバックしたときに期待する位置に画面が表示されないという現象がありましたのでアニメーションは利用しないようにしました。

(アニメーションと併用する方法もあるようですがコードが煩雑になるので止めました)


その他のプラグインの有効化

Bootstrap-vueとvue-momentを有効化します。


main.js

import moment from 'vue-moment'

import BootstrapVue from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

Vue.use(moment)
Vue.use(BootstrapVue)


Bootstrap-vueでは、特定のコンポーネントだけ使いたい場合、個別にインポートすることができるようです。

例えばドロップダウンリストを使いたいときは下記のように個別にインポートして有効化します。

import { Dropdown } from 'bootstrap-vue/es/components';

Vue.use(Dropdown);

vue-momentを有効にすると、コンポーネントからthis.$momentという方法で利用できるようになります。


メモ一覧

メモ一覧ではページ自体を扱うコンポーネント、メモ一件を表示するメモカードコンポーネント、メモを登録するフォームのメモフォームコンポーネントで構成しています。


pages/MemoList.vue

<script>

import MemoListCard from '@/components/MemoListCard'
import MemoListForm from '@/components/MemoListForm'

export default {
name: 'MemoList',
components: {
'memo-list-card': MemoListCard,
'memo-list-form': MemoListForm
},

//...省略...
}
</script>



メモ一覧のコンポーネント


  • pages/MemoList.vue

m5.png

1. 一覧

メモ1件を表示するメモカードコンポーネントを、メモデータの件数分ループして表示します。

<memo-list-card

v-for="(memo, index) in memos" <!-- 1-1 -->
v-bind:key="index"
v-bind:memo="memo"/>

1-1. ストアのゲッターに実装したmemosメソッドを使ってメモデータを取得します。

computed: {

memos () {
return this.$store.getters.memos
}
}

2. フォーム

メモ登録フォームは、メモフォームコンポーネントで表示します。

<memo-list-form/>


動作確認用のボタン

デバッグを兼ねて画面下にメモの状態を更新するボタンを設置しています。

このボタンを押すとメモの状態が更新されるので、自動的にメモ一覧の表示が変わることが確認できます。

上の黒いボタンはメモの削除ボタンで、下の白いボタンはミリオン属性を更新するボタンです。

3. Remove Button

<button class="btn-sm btn-dark m-1"

v-for="(memo, index) in memos"
v-bind:key="index"
v-on:click="removeMemo(memo.id)">
{{ memo.id }}
</button>

methods: {

removeMemo (_id) {
this.$store.commit('removeMemo', {id: parseInt(_id, 10)})
}
}

4. Million Toggle Button

<button class="btn-sm btn-light m-1"

v-for="(memo, index) in memos"
v-bind:key="index"
v-on:click="toggleMillion(memo.id)">
{{ memo.id }}
</button>

methods: {

toggleMillion (_id) {
this.$store.commit('toggleMillion', {id: parseInt(_id, 10)})
}
}


メモ1件を表示するメモカードコンポーネント


  • components/MemoListCard.vue

m2.png

1. IDのリンク

IDはメモ詳細画面へのリンクです。router-linkコンポーネントでリンクを作成します。

このコンポーネントのname属性にはルーターの実装で付けたnameを指定します。また、動的セグメントは下記のようにparams属性で設定できます。


router/index.js

{

path: '/memo/:id',
name: 'MemoDetails', // ← この名前をv-bind:toのnameに指定
component: MemoDetails,
props: true,
meta: {
title: 'details of memo'
}
}

<router-link

v-bind:to="{name: 'MemoDetails', params:{ id: memo.id }}"
v-bind:class="{ 'btn-sm btn-primary': !memo.million, 'btn-sm btn-success': memo.million }">
{{ memo.id }}
</router-link>

2. タイトル

表示するタイトルは16文字で切り、末尾に"…"を付加するように加工しています。説明文もおなじように加工しています。

<span class="card-title">{{ formatedTitle }}</span>

computed: {

formatedTitle () {
if (!this.memo || !this.memo.title) {
return ''
}
return this.getOmissionAndPlusMidpoint(this.memo.title, 16)
}
},
methods: {
getOmissionAndPlusMidpoint (str, limit) {
if (str.length < limit) {
return str
}
return str.substr(0, limit) + ''
}
}

3. プラットフォーム

メモのプラットフォーム属性の要素数分表示します。なおプラットフォームの要素が0件のときはタグ自体表示されません。

<span class="badge badge-info" style="margin-left: 2px;"

v-for="(platform, index) in memo.platforms"
v-bind:key="index">
{{ platform }}
</span>

4. リリース日からの経過時間

メモデータのリリース日をmomentのfromNowメソッドを使って"a few seconds ago"というような文字列に加工して表示しています。

この部分を1分毎に更新して表示したいので、mountedというライフサイクルイベントにsetIntervalを使って更新処理を実装しています。これによりブラウザをリロードしなくても登録直後は"a few seconds ago"と表示され、1分経つ毎に"a minute ago"、"2 minute ago"のように表示されます。

m1.png

ちなみに

ハイドライド・スペシャルの発売日は昭和61年3月18日です。


<small class="text-muted">released at</small>

<span>{{ releasedAtFromNow }}.</span>

data () {

return {
releasedAtFromNow: this.getReleasedAtFromNow()
}
},
mounted () {
// releasedAtFromNowを1分ごとに更新する
window.setInterval(() => {
this.releasedAtFromNow = this.getReleasedAtFromNow()
}, 1000 * 60)
},
methods: {
getReleasedAtFromNow () {
if (!this.memo || !this.memo.releasedAt) {
return ''
}
return this.$moment(this.memo.releasedAt).fromNow()
}
}

5. million

ミリオンのときにだけ表示するようにv-showディレクティブを利用します。

<span class="badge badge-success" v-show="memo.million">Million</span>


メモ登録フォームを表示するコンポーネント


  • components/MemoDetails.vue

m3.png

1, 2. 入力欄

コンポーネントのdataプロパティに定義するmemoオブジェクトとinputタグをv-modelディレクティブでバインドします。

<div class="card-header text-left">

<input type="text" class="form-control" placeholder="title"
v-model.trim="memo.title">
</div>
<div class="card-body text-left">
<textarea class="form-control" placeholder="description"
v-model.trim="memo.description"/>
</div>

data () {

return {
memo: this.emptyMemo()
}
},
methods: {
emptyMemo () {
return CONSTANTS.NEW_EMPTY_MEMO()
}
}

空のメモオブジェクトは、定数クラスに以下のように実装しています。

{

NEW_EMPTY_MEMO () {
return {
id: 0,
title: '',
description: '',
platforms: [],
million: false,
releasedAt: null
}
}
}

3. Add Button

入力したタイトルと説明でメモを1件登録します。v-on:clickのpreventでbuttonのクリックイベントが実行されないようにしています。

<button class="btn-sm btn-secondary" type="submit"

v-on:click.prevent="addMemo"> <!-- 3-1 -->
add
</button>

3-1. ボタンをクリックしたときにストアのミューテーションに実装したaddMemoメソッドを実行します。

実行後にフォームの登録内容をクリアするため空のオブジェクトでdataプロパティのmemoを初期化します。

methods: {

addMemo () {
if (!this.memo.title || !this.memo.description) {
return
}
this.memo.platforms = []
this.memo.million = false
this.memo.releasedAt = new Date()
this.$store.commit('addMemo', this.memo)
this.memo = this.emptyMemo()
}
}


メモ詳細

メモ詳細は1コンポーネントだけで構成しています。


メモ詳細のコンポーネント


  • pages/MemoDetails.vue

m4.png

メモ詳細で表示するメモデータは、ストアのゲッターに実装したmemoメソッドで取得します。

computed: {

memo () {
if (!this.targetId) {
console.error('invalid id')
return CONSTANTS.ERROR_MEMO
}
return this.$store.getters.memoById(parseInt(this.targetId, 10))
}
}

1. Platform

定数に定義したプラットフォームの一覧を表示し、メモのプラットフォーム属性が一致する場合はclassの内容を変更して表示します。

また、リンクをクリックしたときにメモのプラットフォーム属性を更新します。

<h6 class="card-subtitle text-muted">Platform:

<a href="#" class="badge" style="margin-left:4px;"
v-for="(platform, index) in platforms" <!-- 1-2 -->
v-bind:key="index"
v-bind:class="getTargetPlatformClass(platform)" <!-- 1-3 -->
v-on:click.prevent="togglePlatform(platform)"> <!-- 1-4 -->
{{ platform }}
</a>
</h6>

1-2. プラットフォームの種類は定数に定義しています。

computed: {

platforms () {
return CONSTANTS.PLATFORMS
}
}


constants/index.js

{

PLATFORMS: ['FC', 'SFC', 'GB', '64', 'GC', 'DS', 'Wii', '3DS', 'Wii U', 'Switch']
}

1-3. classの切り替え

methods: {

getTargetPlatformClass (_platform) {
if (this.memo.platforms.length === 0) {
return 'badge-dark'
}
return this.memo.platforms.includes(_platform) ? 'badge-info' : 'badge-dark'
}
}

1-4. リンクをクリックしたときにストアのミューテーションに実装したtogglePlatformメソッドを実行します。

commitメソッドの第2引数に指定するオブジェクトがミューテーションのtogglePlatformメソッドの第2引数に渡されます。

methods: {

togglePlatform (_platform) {
this.$store.commit('togglePlatform', {id: this.memo.id, platform: _platform})
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// このオブジェクトがpayloadになる
}
}

2. Description

メモの説明文属性に特定のキーワードがあればhtmlタグに置換して表示しています。

ただしそのまま表示するとエスケープされてしまうので、エスケープをしないようにv-htmlディレクティブを使用します。


エスケープされる

<p class="card-text">{{ formatedDescription }} </p>



v-htmlでエスケープせずに表示する

<p class="card-text" v-html="formatedDescription"/>


computed: {

formatedDescription () {
if (!this.memo.description) {
return ''
}
return this.memo.description
.replace('', '<span class="badge-lg badge-pill badge-success p-1">')
.replace('', '</span>')
}
}

3. Release Date

メモのリリース日属性をmomentで"YYYY/MM/DD"にフォーマットして表示しています。

<small>Release Date. {{ formatedReleasedAt }}</small>

computed: {

formatedReleasedAt () {
if (!this.memo.releasedAt) {
return ''
}
return this.$moment(this.memo.releasedAt).format('YYYY/MM/DD')
}
}

4. Million Button

メモのミリオン属性を更新するボタンです。ミリオン属性の状態でclassとボタンラベルの内容を変えています。

<button class="btn"

v-bind:class="{'btn-primary': memo.million, 'btn-success': !memo.million}"
v-on:click="toggleMillion"> <!-- 4-1 -->
{{ millionButtonLabel }} <!-- 4-2 -->
</button>

4-1. ボタンをクリックしたときにストアのミューテーションに実装したtoggleMillionメソッドを実行します。

methods: {

toggleMillion () {
this.$store.commit('toggleMillion', {id: this.memo.id})
}
}

4-2. メモのミリオン属性によってボタンラベルに表示を切り替え

computed: {

millionButtonLabel () {
return !this.memo.million ? 'is Million ?' : 'is not Million ?'
}
}

5. Back Button

this.$router.back()でブラウザの戻るボタンと同じ動きを実装しています。backの他にgoやforwardといったメソッドがあります。

<button class="btn btn-primary"

v-on:click="historyBack">
back
</button>

methods: {

historyBack () {
this.$router.back()
}
}


操作している様子

操作している様子をgifにしました。

(登録したメモのIDが16と1つ飛んでいるのは直前に1件登録して削除しているためです)

xxx22.gif