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

# 自己紹介

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

HAL大阪3回生です。（2018/03/11現在）

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

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

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

気になる方は[こちら](https://qiita.com/konojunya/items/728357afcab505441724)をご覧ください。

クライアント以外には

- [サーバーサイド編](https://qiita.com/konojunya/items/3dee37d1e9f45ccf474c)
- [インフラ編](https://qiita.com/konojunya/items/5fa65c219227cdf9a737)

があります。

## 環境

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

```
- 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](https://webpack.js.org/)

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

参考URL

- [webpack 入門 （v3系 対応）](https://qiita.com/soarflat/items/28bf799f7e0335b68186)
- [はじめてのWebpack](https://qiita.com/cheez921/items/266b1b4534b6111a9686)

### Vue.jsとは

[Vue.js](https://jp.vuejs.org/index.html)

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

JSのライブラリです。

#### Buefy

[Buefy](buefy.github.io)

>Lightweight UI components for Vue.js based on Bulma

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

#### vue-router

[vue-router](https://router.vuejs.org/ja/essentials/getting-started.html)

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

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

### axiosとは

[axios](https://github.com/axios/axios)

>Promise based HTTP client for the browser and node.js

[Promise](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise)ベースのHTTPクライアントです。おおかたjquery.Ajaxのjquery依存してないやつみたいな認識してれば大丈夫です（実際は少し違いますが初心者向けの記事のためあえて上記のような書き方をしておきます）

### **npm install**

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

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

まず`package.json`をつくります

```shell
$ npm init -y
```

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

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

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

```shell
$ 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設定**

```js: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
    })
  ])
}
```

重要なのは

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

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

### **Vue**

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

```js: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してルーターを作っていきます。

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

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

`routes`は`path`と`component`を指定します。

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

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

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

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

#### **Component**

コンポーネントは、**[Atomic Design](https://postd.cc/the-unicorn-workflow-design-to-code-with-atomic-design-principles-and-sketch/)**を意識しました。
ただ今回はAtomic Designの記事ではないのと、完結にするべく

- pages
- modules

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

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

まずpagesから

##### **pages**

```vue: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/products`に`GET`でアクセスして一覧を取得します。
それを`items`へいれるんですが、このときに、今回自分自身への投票を別のやり方でやりたかったので自分のIDである1（seedで最初にDBにいれているのでauto incrementで1になります。実際はもう少しちゃんとしたやり方で自分を特定して抜くべきです）の要素以外を`items`へいれています。

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

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

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

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

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

```vue: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を作るときに必要になるので、作成します。

```vue: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**

```vue: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`メソッドを子のほうで受取り、実行します。

```vue: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](https://twitter.com/konojunya)

ソースコードは [konojunya/HEW2018](https://github.com/konojunya/HEW2018) にあります！