Vue.js + Vuexで開発をしてみよう!

こんにちは、フロントエンドエンジニアの蔀です。

ここ数年のフロントエンド開発の潮流の変化は急激で、雨後の筍のように色々なフレームワークが出てきていますね。

8月末には、Mediumでこんな記事が人気になりました。

The State Of JavaScript: Front-End Frameworks と銘打たれたこの記事は、React/Angular/Angular2/Ember/Vue/Backbone といった、近年流行しているJavaScriptフレームワークに関する興味、満足度、知名度などを調査して比較してくれています。

注目していただきたいのが、「Satisfaction(使ってみて満足したかどうか)」の項目です。 Satisfaction

近年流行しているReact.jsと肩を並べ、Vue.jsが非常に高い満足度であることがわかります。

Vue.jsについては日本語ドキュメントも充実していますが、「実際に作ってみた」系記事が少ないのが実情です。 今日はこのVue.jsで簡単なアプリケーションを作ってみましょう!

環境

  • Max OSX - 10.10.5以上
  • node.js - v5.0.0以上
  • npm - v3.0.0以上

node.jsやnpmの準備がまだの方はこちらからインストールしておいてください。

事前準備

以下の記事では、ES2015という新しいJavaScriptの文法を特に断りなく使用していきます。 全く最近のJavaScriptに触れてないよ!どうしよう!という方は、まずES2015の紹介を読んでおくとよいでしょう。
また、Vue.jsのチュートリアルをやってみてから、一通りVue.jsのドキュメントを読んでおくと、本記事の理解が進むと思います。

作るものの要件

GIF検索用のシンプルなWebアプリケーションを作ることにします。

  • GIFの検索ができる(GIPHYのAPIを使用)
  • 検索結果のGIFを並べて表示してくれる

ワイヤーフレームも書いてみました。

ワイヤーフレーム

開発環境を整える

プロジェクトのひな形を作る

vue-cliをインストールしましょう。
vue-cliはVue.jsのプロジェクトに必要な構成を簡単にやってくれるツールです。
ローカルサーバーやビルドツールの設定を代わりにやってくれるのが最高です。
プロジェクト名はgiphy-vueにしておきましょう。

1
2
3
4
5
6
7
8
9
# vue-cli のインストール(グローバル)
$ npm install -g vue-cli
# "webpack" を使ったプロジェクトのひな形を作ってくれます
# いくつかの質問が出てきますが、分からなければ全部Enterを押しても大丈夫です
$ vue init webpack gipfy-vue
# 依存関係をインストールしてgo!
$ cd giphy-vue
# 少し時間がかかるので、コーヒーなどを飲みながら待ちます
$ npm install

プロジェクトの中身の確認

さて、npm installが終了したら、どんなものが入ったのかを確認しましょう。 srcファイルの中身を見ると、なにやら見慣れない拡張子がありますね。App.vueHello.vueというファイルが見えます。
この.vueファイルには、実現したいひとまとまりの機能を記述していき(これを コンポーネント と呼びます)、コンポーネントを組み合わせてアプリケーションを作っていきます。

作ってみよう

ヘッダーの作成

早速、ヘッダーを作ってみましょう。
src/componentsに、Header.vueというファイルを作ってください。
ファイルの中に、次のように記述していきます。

src/components/Header.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
  <div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">
    <header class="mdl-layout__header">
      <div class="mdl-layout__header-row">
        <i class="material-icons">dehaze</i>
        <span class="mdl-layout-title">GIPHY-VUE</span>
      </div>
    </header>
  </div>
</template>
<style scoped>
.mdl-layout__header-row {
  padding: 0 16px;
}
i {
  padding-right: 16px
}
</style>

次に、このヘッダーコンポーネントを呼び出すため、src/App.vueを編集していきます。 App.vueは、このアプリケーション全体を包み込むコンポーネント、 トップレベルコンポーネント となります。 このコンポーネントに、色々な子コンポーネントを記述していくことでアプリケーションを作っていきます。

デフォルトで作られているApp.vueの中身を空にしてから、次のように記述してください。

src/App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
  <div id="app">
    <!-- ここに先ほど作ったヘッダーを配置 -->
    <vue-header></vue-header>
  </div>
</template>
<script>
import VueHeader from './components/Header'
export default {
  components: {
    VueHeader
  }
}
</script>

<script>タグの中で、Header.vueを呼び出しているのがわかると思います。
その下にあるのは普通のJavaScriptオブジェクトですね。このオブジェクトのcomponentsにコンポーネントを登録すると、<vue-header>のように、HTMLの中でタグとして呼び出せるようになります。
W3C標準に従うため、キャメルケースのコンポーネントのタグ名は必ずvue-component-nameのように、ケバブケースで書きましょう。

App.vueを保存したら、npm run devと入力してローカルサーバを立ち上げます。
http://localhost:8080を見ると、次のようにヘッダーが表示されているはずです。

Headr

リロードや再ビルドしなくても変更が反映されるように、vue-cliがローカルサーバーを設定してくれています。便利ですね。 (CSSはMaterial Design Liteを使っています)

必要なコンポーネントを用意する

同じような感じで、アプリケーションに必要なパーツを作っていきましょう。
作成するのは、

  • Card.vue - gifを表示するカード
  • Search.vue - 検索フォーム
  • TimeLine.vue - Card.vueを並べて表示する部分

といったところでしょう。

ディレクトリ構成はこのような感じになります。

Dir

src/page1/Card.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<template>
  <div class="gif mdl-card mdl-shadow--2dp">
    <div class="mdl-card__title mdl-card--expand"></div>
    <div class="mdl-card__actions">
      <span class="filename">Title</span>
    </div>
  </div>
</template>
<style>
.gif {
  margin: 0 auto;
  width: 60%;
  display: flex;
  justify-content:space-between;
  margin-bottom: 60px;
}
.gif.mdl-card {
  width: 256px;
  height: 256px;
}
.gif> .mdl-card__actions {
  height: 52px;
  padding: 16px;
  background: rgba(0, 0, 0, 0.2);
}
.filename {
  color: #fff;
  font-size: 14px;
  font-weight: 500;
}
</style>

src/page1/Search.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
  <div class="mdl-textfield mdl-js-textfield">
    <input class="mdl-textfield__input" type="text" id="sample1">
    <i class="material-icons">search</i>
  </div>
</template>
<style media="screen">
  .mdl-textfield {
    padding: 0;
    background-color: #f6f6f6;
    margin: 30px auto;
    width: 60%;
    display: flex;
    margin-bottom: 60px;
  }
</style>

src/page1/TimeLine.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
  <main class="mdl-layout__content">
    <div class="page-content">
      <search></search>
      <card v-for="n in 10"></card>
    </div>
  </main>
</template>
<script>
import Search from './Search'
import Card from './Card'
export default {
  components: {
    Search,
    Card
  }
}
</script>
<style>
  main {
    background-color: #f6f6f6;
    width: 100%;
    margin: 0 auto;
  }
  .page-content {
    display: flex;
    flex-direction: column;
  }
</style>

これでコンポーネントの準備は完了です。
これらのコンポーネントを、App.vueで呼び出します。

src/App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
  <div id="app">
    <vue-header></vue-header>
    <time-line></time-line>
  </div>
</template>
<script>
import VueHeader from './components/Header'
import TimeLine from './components/page1/TimeLine'
export default {
  components: {
    VueHeader,
    TimeLine
  }
}
</script>

Vuexを使ってみる

アプリケーションのデータフロー

さて、うまくいけば、アプリケーションの見た目は以下のようになっているはずです。

制作過程1

まだ普通の静的なページですので、JavaScriptのロジックを追加し、GIPHYのAPIを呼び、返ってきたデータを表示できるようにしたいと思います。
この状態から、

  • GIFの検索ができる
  • 検索結果のGIFを並べて表示してくれる

ためには、どうすればよいでしょうか?

  • 検索ボックスに入力した文字列をAPIに渡す
  • APIで返ってきた値の数だけ、GIFをCard.vueに渡して表示する(v-forでループさせる)

おそらくこういったことをしないといけませんが、ちょっとめんどくさそうです。
そもそも、上記3項目の「やりたいこと」を実現するために、

  • 検索キーワード
  • 検索結果

といった「データ」を、コンポーネント間でやり取りし、色々と処理しなければなりません。
その処理についていちいち「あれをこうして、これをこうして……」とバトンリレーのように書いていくのは大変ですよね。
そこで登場するのがVuexです。

VuexはFluxの実装の1つ(正確にはReduxに似た実装)になります。
Fluxについては漫画で説明するFluxが分かりやすいでしょう。

Vuexは開発しながら理解していきましょう。まずはVuexのセットアップをしてみます。

Vuexのセットアップ

Vuexをインストールしましょう。
Vuexは現在2.0系がrc版で公開されています。
1.0系とは一部書き方が異なりますが、今後は2.0系が主流となると思われますので、こちらを使用してみます。
(今後、Vuexを使って開発をする場合は、リリースノートを見て最新版をインストールするようにしてください!)

1
$ npm i --save vuex@^2.0.0-rc.5

インストールしたら、src/main.jsを次のように編集します。

src/main.js

1
2
3
4
5
6
7
8
import store from './vuex/store'
/* eslint-disable no-new */
new Vue({
  store,
  el: 'body',
  components: { App }
})

Vueインスタンスにstoreを渡していますね。
まだ./vuex/storeは存在していないので、この時点ではエラーとなります。

新たにsrc/vuexというディレクトリをつくり、store.jsというファイルをつくりましょう。

src/vuex/store.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Vuex from 'vuex'
import Vue from 'vue'
Vue.use(Vuex)
const state = {}
const actions = {}
const getters = {}
const mutations = {}
export default new Vuex.Store({
  state,
  getters,
  actions,
  mutations
})

Vuexとは?

vuex

公式Documentは、Vuexを上の図で説明していますね。

  • Vue Components(アプリケーションの実体)
  • Actions(ユーザの操作/APIとのやりとり)
  • Mutations(状態への変更処理)
  • State(状態)

という要素が矢印で繋がれ、データがその間を循環しているのがわかります。

大事なのはこれが一方向の循環だということです。
一方向の循環とすることで、データの流れが簡潔になり、開発しやすくなります。
開発者同士で会話する際に、お互いいつも同じ「循環のイメージ」が思い浮かびますから、コミュニケーションコストが下がります。
(その他にも、Pure functionによる副作用の削減やSingle source of truthの原則、Time travel機能など、色々な論点があるのですが、Vuex固有の話というより、Flux実装にまつわる話題なので割愛させていただきます。興味がある方はこちらを読むと良いでしょう。)

そして、先ほど作ったstore.js内のstate, actions, getters, mutationsという変数は、この循環に対応しています。

このVuexが描くサイクルを実装していくと、わかりやすく拡張しやすいアプリケーションが開発できるというわけです。
さっそくこのサイクルに沿って、GIPHY-VUEを作っていきましょう。

Vuexを装着する

stateを決める

まずは基本となるStateの定義からです。
Stateは、アプリケーション全体の情報を管理する場所です。 アプリケーションは、このStateの情報を「唯一の情報源: Single source of truth」として利用し、見た目の構築やサーバとの通信などに利用します。
GIPHY-VUEでは、検索キーワードと、検索結果を状態(State)として保存しておきたいですね。

src/vuex/store.js

1
2
3
4
const state = {
  keyword: '',
  gifs: []
}

mutation typeを作る

次に、アプリケーション内でユーザーが行なうであろう操作について記述していきます。
いきなりActionsを作る前に、新しく、src/vuex/mutation-types.jsというファイルを作りましょう。
名前が示す通り、stateをmutate(変更)する操作のタイプを記述します。

ユーザーがする操作は、

  • 検索ワードを書き換える
  • 検索する

といったところでしょう。

src/vuex/mutation-types.js

1
2
export const CHANGE_KEYWORD = 'CHANGE_KEYWORD'
export const SEARCH = 'SEARCH'

わざわざString型を同じ文字列の変数に代入して、さらにexportしていますね。
ユーザーの操作のタイプは、アプリケーション内のいたるところで参照するので、
変数にしておいたほうが使い勝手が良かったり、lintチェックの補助を受けられたりという利点があります。

これを、store.js内で呼び出します。

src/vuex/store.js

1
2
3
4
import {
  CHANGE_KEYWORD,
  SEARCH
} from './mutation-types'

actionsを作る

アプリケーションの状態を変更するようなユーザからの入力や外部APIの呼び出しは、Actionsと呼ばれます。
store.jsactionsを次のように変更します。

src/vuex/store.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function getGIFs (query) {
  const params = encodeURIComponent(query).replace(/%20/g, '+')
  return fetch('http://api.giphy.com/v1/gifs/search?q=' + params + '&api_key=dc6zaTOxFJmzC')
          .then(res => res.json())
}
const actions = {
  [CHANGE_KEYWORD] ({ commit }, keyword) {
    commit(CHANGE_KEYWORD, keyword)
  },
  [SEARCH] ({ commit, state }) {
    getGIFs(state.keyword)
      .then(data => {
        commit(SEARCH, data)
      })
  }
}

ES2015の書き方が使われていますので注意してください。
先ほど記述したCHANGE_KEYWORDが早速登場しています。メソッド名に使われていますね。
第2引数にはkeywordという変数が登場しています。何らかの方法でこのメソッドにkeywordを渡してやる必要がありそうです。
また、第1引数には、commit()という謎のメソッドが分割代入(ES2015)されていますね。
これは「変更をアプリケーションの状態にcommitする」、つまり、先ほど書いたstateを書き換えることを示しています。
つまり、Mutationsのことですね。

SEARCHというActionsの中では、GIPHYのAPIを呼び、検索結果をdataという変数にしてcommit()に渡しています。
getGIFs()という関数内ではGIPHYの開発者用公開APIキーを利用しています。

mutationsを作る

同じように、store.jsの中に次のように記述します。

src/vuex/store.js

1
2
3
4
5
6
7
8
const mutations = {
  [CHANGE_KEYWORD] (state, keyword) {
    state.keyword = keyword
  },
  [SEARCH] (state, gifs) {
    state.gifs = gifs.data
  }
}

3行目を見ると、state.keyword = keyword という形で、Stateを変更していることがわかります。

Vue Componentsの編集

あとは、Vuexの循環の最後の要素、Vue Componentsの編集をしていきます。

検索フォームの編集

src/components/page1/Search.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
  <div class="mdl-textfield mdl-js-textfield">
    <input @change="CHANGE_KEYWORD($event.target.value)" class="mdl-textfield__input" type="text" id="sample1">
    <i @click="SEARCH" class="material-icons">search</i>
  </div>
</template>
<script>
  import { mapActions } from 'vuex'
  import { CHANGE_KEYWORD, SEARCH } from '../../vuex/mutation-types'
  export default {
    methods: {
      ...mapActions([
        CHANGE_KEYWORD, SEARCH
      ])
    }
  }
</script>

2行目と4行目を見ると、@change, @clickというVue.jsのイベントハンドラにCHANGE_KEYWORD, SEARCHというメソッドが登録されています。mutation-types.jsで定義した変数ですね。
この変数は、同時に、Actionsのメソッド名にもなっていたことを思い出してください。
14行目を見ると、mapActions()というメソッドでCHANGE_KEYWORD, SEARCHを使っているのがわかります。
これは、store.js内で定義したActionsをコンポーネントで呼び出せるようにしてくれるメソッドです。
つまり、@change="CHANGE_KEYWORD($event.target.value)"は、「changeイベントが発行されると、CHANGE_KEYWORD()というActionsに、検索フォームの中身を渡す」という意味になります。

CHANGE_KEYWORD()の内容は以下のとおりでしたね。

src/vuex/store.js

1
2
3
4
const actions = {
  [CHANGE_KEYWORD] ({ commit }, keyword) {
    commit(CHANGE_KEYWORD, keyword)
  }

ここで、Actionsは、Mutationsを次に呼んでいたことを思い出してください。Actionsの引数として渡された$event.target.valueは、同じメソッド名(CHANGE_KEYWORD)のMutationsによって、state.keywordに代入されることになります。

src/vuex/store.js

1
2
3
4
const mutations = {
  [CHANGE_KEYWORD] (state, keyword) {
    state.keyword = keyword
  },

こうして、「ユーザーの操作の結果」を、Stateに保存することが出来ました。

あとは、検索ボタン(虫眼鏡のマーク)をクリックすると、SEARCHというActionsが呼ばれ、GIPHYのAPIから得られた検索結果がMutationsによって、同様にStateに保存されることになります。

すべてVuexのサイクルに沿った同じ流れなのでわかりやすいですね。

検索結果の表示

さて、Stateに保存した検索結果をアプリケーション側に表示する必要がありますね。
Stateの値をコンポーネントが取得するためには、Gettersというものを使います。
store.jsにGettersを付け加えましょう。

src/vuex/store.js

1
2
3
const getters = {
  gifs: state => state.gifs
}

ここではgifsというメソッドを定義しています。state.gifsを返してくれます。

では、このGettersはどこで呼ぶのでしょうか?

src/components/TimeLine.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
  <main class="mdl-layout__content">
    <div class="page-content">
      <search></search>
      <card
        v-for="gif in gifs"
        :gif="gif">
      </card>
    </div>
  </main>
</template>
<script>
import Search from './Search'
import Card from './Card'
import { mapGetters } from 'vuex'
export default {
  computed: {
    ...mapGetters(['gifs'])
  },
  components: {
    Search,
    Card
  }
}
</script>

19行目に注目してください。computedというプロパティの中に、mapGettes()が登場しています。
先ほどのmapActions()と同じように、gettersをこのコンポーネントから使えるようにしてくれています。
6行目を見てください。v-forの中でgifsが使われています。これが先ほど定義したGettersですね。
gifsは検索結果の配列を返すので、forループで配列の要素をひとつずつpropsにしてCard.vueに渡しているということになります。

つまり、Card.vueでは、propsで受け取ったgifの情報を表示してやるだけ、ということですね!

src/components/Card.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
  <div class="gif mdl-card mdl-shadow--2dp">
    <div class="mdl-card__title mdl-card--expand">
      <img :src="gif.images.fixed_height.url" alt="" />
    </div>
    <div class="mdl-card__actions">
      <span class="filename">{{gif.slug}}</span>
      <i class="material-icons">favorite</i>
    </div>
  </div>
</template>
<script>
  export default {
    props: {
      gif: Object
    }
  }
</script>

Card.vueの15行目を見ると、propsで、gifという値を受け取っています。これを4行目のimgタグのsrc要素で使っています。
GIPHYのデータ構造通りにプロパティを指定し、表示したいGIFのURLを渡してやりましょう。

完成

finish

うまく行けば、このように検索ができるようになっているはずです! 見たいGIFをガンガン検索しましょう。

今回作ったアプリケーションは検索と閲覧機能だけの非常にシンプルな作りですが、

  • ページのルーティング機能をつけてSPA(Single Page Application)にする
  • お気に入り登録できるようにする
  • 別ページでお気に入りのGIFを閲覧できるようにする

などといったことも、Vue.js + Vuexを使えば非常に簡単に実現できます! 基本的に頭のなかに思い描くことは、Vuexのあの「循環」だけで大丈夫です。

例えばお気に入り登録とその閲覧であれば、

  1. ハートをユーザがクリック
  2. Actionsがコンポーネントから呼ばれる
  3. Mutationsがstate.favoritesにgifを追加
  4. 別ページのコンポーネントのGettersでstate.favoritesを取得して表示

という流れになります。先ほどの検索の流れとだいたい同じですね? アプリケーションが大規模になればなるほど、Vuexの恩恵を感じることでしょう!

是非、Vuexにトライしてみてください!