JavaScript
vue.js
webpack
babel

HEW2018 モダンなWebサービスの作成(クライアント編)

自己紹介

じゅんじゅんと言うニックネームで、関西を拠点に活動しているフロントエンドエンジニアです。

HAL大阪3回生です。(2018/03/11現在)

イベントや、勉強会に参加してるので是非お会いした際はお声掛けください!

HEW2018 モダンなWebサービスの作成(クライアント編)とは

これは僕の学校のイベントの発表をQiitaの記事としてあげておくためのエントリーです。

気になる方はこちらをご覧ください。

クライアント以外には

があります。

環境

クライアントですが構成は以下のようにしました

- view/
    - index.html
- src/
    - app.js
    - config/
        - ranking-bgcolor.json
    - components/
        - modules/
            - card.vue
            - rankingItem.vue
        - pages/
            - index.vue
            - ranking.vue
            - notfound.vue
- package.json
- webpack.config.js

開発

Webpackとは

webpack

モジュールバンドラのこと。
複数のモジュールを1つにまとめたファイルを出力するツールのこと

参考URL

Vue.jsとは

Vue.js

Vue (発音は / v j u ː / 、 view と同様)はユーザーインターフェイスを構築するためのプログレッシブフレームワークです。他の一枚板(モノリシック: monolithic)なフレームワークとは異なり、Vue は少しずつ適用していけるように設計されています。中核となるライブラリは view 層だけに焦点を当てています。そのため、使い始めるのも、他のライブラリや既存のプロジェクトに統合するのも、とても簡単です。また、モダンなツールやサポートライブラリと併用することで、洗練されたシングルページアプリケーションの開発も可能です。

JSのライブラリです。

Buefy

Buefy

Lightweight UI components for Vue.js based on Bulma

BulmaというCSSライブラリのVueコンポーネントバージョンです。

vue-router

vue-router

Vue.js と vue-router を使ったシングルページアプリケーションの構築は驚くほど簡単です。Vue.js のコンポーネントを使ってアプリケーションを既に構成しています。vue-router を混ぜ込むには、コンポーネントとルートをマッピングさせて vue-router にどこで描画するかを知らせるだけです。以下が基本的な例です。

VueのSPAのルーティングモジュール

axiosとは

axios

Promise based HTTP client for the browser and node.js

PromiseベースのHTTPクライアントです。おおかたjquery.Ajaxのjquery依存してないやつみたいな認識してれば大丈夫です(実際は少し違いますが初心者向けの記事のためあえて上記のような書き方をしておきます)

npm install

順番に設定周りからやっていきます

必要なモジュールをインストールします。

まずpackage.jsonをつくります

$ npm init -y

次にサービスに必要なものをインストールします

$ npm install --save axios buefy vue vue-router

次に開発に必要なものをインストールします

$ npm install --save-dev babel-core babel-loader babel-preset-env cross-env css-loader file-loader node-sass sass-loader style-loader vue-loader vue-template-compiler webpack

webpack設定

webpack.config.js
var path = require('path')
var webpack = require('webpack')

module.exports = {
  entry: "./src/app.js",
  output: {
    path: path.resolve(__dirname, './public/js/'),
    filename: 'bundle.js'
  },
  module: {
    rules: [{
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          loaders: {
            'scss': 'vue-style-loader!css-loader!sass-loader',
            'sass': 'vue-style-loader!css-loader!sass-loader?indentedSyntax'
          }
        }
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.(png|jpg|gif|svg)$/,
        loader: 'file-loader',
        options: {
          name: '[name].[ext]?[hash]'
        }
      },
      {
        test: /\.css$/,
        loader: 'style-loader!css-loader'
      }
    ]
  },
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js'
    }
  },
  devServer: {
    historyApiFallback: true,
    noInfo: true,
    overlay: true
  },
  performance: {
    hints: false
  },
  devtool: '#eval-source-map'
}

if (process.env.NODE_ENV === 'production') {
  module.exports.devtool = '#source-map'
  module.exports.plugins = (module.exports.plugins || []).concat([
    new webpack.DefinePlugin({
      'process.env': {
        NODE_ENV: '"production"'
      }
    }),
    new webpack.LoaderOptionsPlugin({
      minimize: true
    })
  ])
}

重要なのは

entry: "./src/app.js",
output: {
  path: path.resolve(__dirname, './public/js/'),
  filename: 'bundle.js'
},

このあたりです。webpackの詳しい説明はしませんが、entryでそこを基準にバンドルするかを決めます。outputは出力先を表しています。

Vue

まず最初にentryで定めたファイルから作っていきます

src/app.js
import Vue from 'vue'
import Buefy from 'buefy'
import 'buefy/lib/buefy.css'
import VueRouter from 'vue-router'

Vue.use(VueRouter)
Vue.use(Buefy)

// components
import Index from './components/pages/index.vue'
import Ranking from './components/pages/ranking.vue'
import NotFound from './components/pages/notfound.vue'

const router = new VueRouter({
  mode: 'history',
  routes: [
    { path: "/", component: Index },
    { path: "/ranking", component: Ranking },
    { path: "*", component: NotFound },
  ]
})

const app = new Vue({
  router
}).$mount("#app")

今回はページを3ページ作ります。

  • 一覧表示画面
  • ランキング画面
  • NotFound

です。全てのコンポーネントをimportしてルーターを作っていきます。

const router = new VueRouter({
  mode: 'history',
  routes: [
    { path: "/", component: Index },
    { path: "/ranking", component: Ranking },
    { path: "*", component: NotFound },
  ]
})

modehistoryというのを使うことで普段のブラウザのようなルーティングができるようになります。

routespathcomponentを指定します。

pathはその名の通り、URLを指します。
componentはそのURLに来たときにどのコンポーネントをマウントするかを決めています。

path*がはいっているのは、上記で設定されていないルーティングに来た場合にはいります。主にNotFoundのページなどをだすのがよさそうです。

const app = new Vue({
  router
}).$mount("#app")

ここでid=appのDOMに対してマウントする設定をします。

Component

コンポーネントは、Atomic Designを意識しました。
ただ今回はAtomic Designの記事ではないのと、完結にするべく

  • pages
  • modules

の2つに分けています。(ただのコンポーネント思考です)

pagesは1ページのtemplateを定義しています。
modulesは例えば、liなどループで複数回使うものなどをファイルを切り出して書いています。

まずpagesから

pages
src/components/pages/index.vue
<template>
  <section>
    <div class="ranking">
      <router-link class="ranking-text" to="/ranking">ランキングをみてみる</router-link>
    </div>
    <div class="wrapper">
      <card v-for="item in items" :key="item.id" :item="item" :voteme="voteme" />
    </div>
    <b-loading :active.sync="isLoading"></b-loading>
  </section>
</template>

<script>
import card from '../modules/card.vue'
import axios from 'axios'

const MY_ID = 1

export default {
  components: {
    card
  },
  data() {
    return {
      items: [],
      isLoading: true
    }
  },
  created() {
    axios.get("/api/products").then((res) => {
      this.isLoading = false

      let items = []
      for(let item of res.data) {
        if(item.id != MY_ID) {
          items.push(item)
        }
      }
      this.items = this.shuffle(items)
    })
  },
  methods: {
    shuffle(array) {
      for(let i = array.length - 1; i > 0; i--) {
        const r = Math.floor(Math.random() * (i + 1))
        const tmp = array[i]
        array[i] = array[r]
        array[r] = tmp
      }
      return array
    },
    voteme() {
      axios.post(`/api/products/${MY_ID}/vote`).then((res) => {
        this.$toast.open({
          message: `ありがとうございます!`,
          type: 'is-success'
        })
      })
    }
  }
}
</script>

b-<xxx>のようになっているHTMLはBuefyのコンポーネントです。

router-linkと書いているのは、vue-routerが提供するコンポーネントです。ルーティングされたURLへブラウザのロードなくページ遷移します。

cardはmodulesで定義しているコンポーネントです。のちほど説明します。

まずライフサイクルを理解しないといけません。

今回はcreatedというライフサイクルを使っています。これは、Vueがマウントされる前に呼ばれるメソッドです。ここで/api/productsGETでアクセスして一覧を取得します。
それをitemsへいれるんですが、このときに、今回自分自身への投票を別のやり方でやりたかったので自分のIDである1(seedで最初にDBにいれているのでauto incrementで1になります。実際はもう少しちゃんとしたやり方で自分を特定して抜くべきです)の要素以外をitemsへいれています。

itemsに入れるときにshuffleというメソッドを読んでいますが、これは表示するときに配列をshuffleして表示するためです。同じ人が1面目にずっとあると投票されやすくなってしまって不公平であるためそうしました。

Vueのメソッドはmethodsで定義します。

votemeは、僕自身に表を入れるメソッドです。

今回は、localStorageを使って1ブラウザで1回しか投票できないようにしました。が、もう一回別の人にも投票したい場合、僕のサービスをよく使っているユーザーとして、僕に投票してくれた人はもう一度できるようにしました。

この実装はmodules/cardで説明します。

src/components/pages/ranking.vue
<template>
  <section>
    <div class="ranking">
      <router-link class="ranking-text" to="/">一覧に戻る</router-link>
    </div>
    <div class="wrapper" v-if="items.length >= 1">
      <ranking-item :item="item" :key="item.id" :index="index" :max="max" v-for="(item, index) in items"></ranking-item>
      <b-loading :active.sync="isLoading"></b-loading>
    </div>
    <div class="noresult" v-else>
      <h1>まだランキングが集計できません。</h1>
    </div>
  </section>
</template>

<script>
import axios from 'axios'
import rankingItem from '../modules/rankingItem.vue'

export default {
  components: {
    rankingItem
  },
  data() {
    return {
      items: [],
      max: 0,
      isLoading: true
    }
  },
  created() {
    axios.get("/api/ranking").then((res) => {
      this.isLoading = false
      if(res.data) {
        for(let item of res.data) {
          if(item.votes > this.max) {
            this.max = item.votes
          }
        }
        this.items = res.data
      }
    })
  }
}
</script>

ランキングのページは、API側でランキング済みのユーザー配列が返ってくるのでそれを表示しているだけです。

noresultというのは、例えばまだ誰も投票してない場合にAPIからは要素数0の配列が返ってくるのでまだ集計できてないよというメッセージを出しています。

createdの中でAPIにアクセスして、itemsに代入しています。このときに、投票数の最大をとっています。
これは、ランキングのUIを作るときに必要になるので、作成します。

src/components/pages/notfound.vue
<template>
  <h1>not found...</h1>
</template>

<style lang="scss" scoped>
h1 {
  width: 100vw;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 5rem;
  font-weight: 600;
  color: white;
}
@media (max-width: 640px) {
  h1 {
    font-size: 3rem;
  }
}
</style>

notfoundページは、特になにもしていません。HTMLとCSSだけでつくっています。

modules
src/components/modules/card.vue
<template>
  <div class="card">
    <b-modal :active.sync="isImageModalActive" :width="400" scroll="keep">
      <p class="img">
        <img :src="item.thumbnail">
      </p>
      <div class="button-wrapper">
        <button class="button" @click="vote">投票する</button>
      </div>
    </b-modal>
    <div class="card-image" @click="isImageModalActive = true">
      <figure class="img">
        <img :src="item.thumbnail" alt="thumbnail">
      </figure>
    </div>
  </div>
</template>

<script>
import axios from 'axios'

export default {
  props: ["item", "voteme"],
  data() {
    return {
      isImageModalActive: false
    }
  },
  methods: {
    vote() {
      if(this.canVote()) {
        axios.post(`/api/products/${this.item.id}/vote`).then((res) => {
          this.$toast.open({
            message: `『${this.item.title}』に投票しました!`,
            type: 'is-success'
          })
          this.setLocalStorage()
          this.isImageModalActive = false
        })
      } else {
        this.$dialog.confirm({
          message: "1人、1票までしか投票できません。<br/>この投票サイトにも投票するともう1度、投票することができます。",
          cancelText: 'やめておく',
          confirmText: '投票する',
          type: 'is-danger',
          onConfirm: () => {
            this.removeLocalStorage()
            this.voteme()
          }
        })
      }
    },
    removeLocalStorage() {
      localStorage.removeItem("hew2018-vote")
    },
    setLocalStorage() {
      localStorage.setItem("hew2018-vote", this.item.id)
    },
    canVote() {
      return !localStorage.getItem("hew2018-vote")
    }
  }
}
</script>

今回、投票するボタンを押された時に投票する実装をしないといけないので、voteというメソッドをつくっています。

/api/products/1/voteなどにPOSTでアクセスすると投票が行えるので、axiosでアクセスして、そのときにsetLocalStorageを呼びます。

setLocalStorageでは、hew2018-voteというキーでその作品のidをlocalStorageに格納しています。

canVoteは、投票が行えるかを判断します。localStorage.getItem("hew2018-vote")でlocalStorageからキーhew2018-voteの値を取得できますが、取得できた場合はid、できなかった場合はnullがかえってきます。

値がある -> true -> 投票できない
値がない -> null = false -> 投票できる

なので、条件を!で反転させてできるかできないかを判断します。

僕自身に投票した場合はremoveLocalStorageを呼びます。これでlocalStorageからhew2018-voteを削除するので、次回は投票できるようになります。

僕自身に投票する場合のthis.voteme()ですが、これはpages/index.vueにあったものです。

親から子へ、値を流すにはpropsというものを使います。これで、親のvotemeメソッドを子のほうで受取り、実行します。

src/components/modules/rankingItem.vue
<template>
  <div class="wrapper">
    <div class="content">
      <p class="name">{{item.author}}</p>
      <p class="point">{{item.votes}}</p>
    </div>
    <div class="bg" :style="style"></div>
  </div>
</template>

<script>
import bgColor from '../../config/ranking-bgcolor.json'

export default {
  props: ["item", "max", "index"],
  data() {
    return {
      style: {
        width: '0%',
        backgroundColor: "transparent"
      }
    }
  },
  created() {
    const width = this.item.votes / parseInt(this.max) * 100
    this.style.width = `${width}%`
    this.style.backgroundColor = bgColor[this.index]
  }
}
</script>

ランキングのほうは、予めjsonファイルで色を指定しておいて親からもらったmaxを元に幅を計算します。

これで少しおしゃれなViewをつくることができました!

あとがき

Twitterしています!ぜひフォローください。 @konojunya

ソースコードは konojunya/HEW2018 にあります!