自己紹介
じゅんじゅんと言うニックネームで、関西を拠点に活動しているフロントエンドエンジニアです。
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とは
モジュールバンドラのこと。
複数のモジュールを1つにまとめたファイルを出力するツールのこと
参考URL
Vue.jsとは
Vue (発音は / v j u ː / 、 view と同様)はユーザーインターフェイスを構築するためのプログレッシブフレームワークです。他の一枚板(モノリシック: monolithic)なフレームワークとは異なり、Vue は少しずつ適用していけるように設計されています。中核となるライブラリは view 層だけに焦点を当てています。そのため、使い始めるのも、他のライブラリや既存のプロジェクトに統合するのも、とても簡単です。また、モダンなツールやサポートライブラリと併用することで、洗練されたシングルページアプリケーションの開発も可能です。
JSのライブラリです。
Buefy
Lightweight UI components for Vue.js based on Bulma
BulmaというCSSライブラリのVueコンポーネントバージョンです。
vue-router
Vue.js と vue-router を使ったシングルページアプリケーションの構築は驚くほど簡単です。Vue.js のコンポーネントを使ってアプリケーションを既に構成しています。vue-router を混ぜ込むには、コンポーネントとルートをマッピングさせて vue-router にどこで描画するかを知らせるだけです。以下が基本的な例です。
VueのSPAのルーティングモジュール
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設定
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
で定めたファイルから作っていきます
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 },
]
})
mode
はhistory
というのを使うことで普段のブラウザのようなルーティングができるようになります。
routes
はpath
とcomponent
を指定します。
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
<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
で説明します。
<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を作るときに必要になるので、作成します。
<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
<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
メソッドを子のほうで受取り、実行します。
<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 にあります!