この記事では、私がVue.jsの練習がてらつくったかんたんなアプリについて、コードの紹介を中心にまとめます。
前置きなど
私がこのアプリを作る上で確認したかったことは、
- Vuetify できれいなデザインのアプリがつくれること
- Vuetify でのサイドメニュー、ヘッダーなどのつくりかた
- Vuex で管理したデータを複数ページからアクセスすること
- Atomic Design などの設計原則を Vue.js でためしてみること
などです。
私は普段 Laravel をメインでつかうのですが、フロント側をつくっていて感じることのひとつに、「JavaScriptできれいにコードかくのむずかしくない?」ということがありました。
バックエンドをつくるときには、業務に関する情報はドメインに、DBに関する情報はリポジトリに集めようといった方針でつくることが多いですが、JavaScript ではどういう単位で、どういう指針で分割すればいいんだろう、とおもっていました。
そこで知ったのが、UI設計にもレイヤーごとに役割を分割する一般的な設計指針があるとのことでした。
そこでそれをちょっと試してみよう、ということでサンプルをつくった次第です。
出来栄え
サンプルアプリではありますが、かんたんに作ったものの概要をまとめます。
画面説明
まずひとつめの画面です。
画面のデザインは Vuetify をほぼテンプレのまま利用しています。
Vuexでデータを管理できることの確認がしたかったため、テキトーではありますがここではなんらかの在庫を確認するアプリを想定しています。
「商品名」のプルダウンから商品を選び、「個数」を入力してボタンを押すと、その個数が在庫数として下のテーブルに反映されます。
テーブルの内容は Vuex で管理しています。
次にふたつめの画面です。
管理する商品自体を追加することはこちらの画面からおこないます。
使ってみる
実際に商品名を追加したり、在庫を増やしてみます。
まず、商品管理の画面より、管理する商品を追加してみます。
追加できたので、在庫管理のページにも反映されているか確認します。
プルダウンメニューと下のテーブル両方にデータが反映されているので、在庫を増やしてみます。
在庫も増やすことができました。
アプリの概要は以上です。
コンポーネントごとに設計
設計に関しては、粒度ごとにコンポーネントとしてわけてみました。
UI設計の世界では Atomic Design という言葉があるそうですが、なんとなくそれにならい、ちいさい粒度から順に Atoms、Molecules、Organisms としています。
Organisms
今回いちばん大きな単位は Organisms としていますが、以下の図のようなパーツが該当します。
- サイドバー
<template>
<v-navigation-drawer
:value="value"
@input="$emit('input', $event)"
app
>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="text-h5">Items</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
<v-list>
<v-list-item
v-for="item in items"
:key="item.title"
:to="item.path"
link
>
<v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-navigation-drawer>
</template>
<script>
export default {
data () {
return {
items: [
{ title: 'Dashboard', icon: 'mdi-view-dashboard', path: '/dashboard' },
{ title: 'Products', icon: 'mdi-image', path: '/products' },
{ title: 'About', icon: 'mdi-help-box', path: '/about' }
]
}
},
props: {
value: {
type: Boolean,
default: false
}
}
}
</script>
サイドバー部分はほとんど Vuetify のテンプレですが、バーを開くか閉じるかという情報を親からもらうようにしています(これでいいのか...?)。
コンポーネントごとに役割を区別するためには、それぞれのコンポーネントはどこまでを責任範囲とし、どんな情報を持つのか、あるいは親からもらうのか、といったことを考える必要がありますね。
- ヘッダー
<template>
<v-app-bar color="primary" app>
<v-app-bar-nav-icon v-on:click="handle"></v-app-bar-nav-icon>
<v-toolbar-title>Application</v-toolbar-title>
</v-app-bar>
</template>
<script>
export default {
methods: {
handle () {
this.$emit('change')
}
}
}
</script>
こちらはヘッダーです。
ヘッダーには左側にボタンがあり、それを押すことでサイドバーを開閉させます。
そこで、ここでは状態をもたず、ボタンがおされたイベントを親に通知するだけとしています。
ここも最初は親から props をもらって、それをどうにかして変更してその値を親に返す...といったことを考えていましたが、このほうがシンプルだとおもいました。
どのレイヤーで状態をもつか、また、処理はどこでするのか、といったことは最初にしっかりきめておくとよさそうです。
- ダッシュボード
<template>
<div class="dashboard ma-5">
<v-card class="ma-2" max-width="1000">
<v-card-title><h3>在庫管理</h3></v-card-title>
<v-container>
<v-row>
<v-col cols="6">
<ProductSelectSample
v-bind:items="this.$store.state.products"
v-bind:selectedProduct="selectedProduct"
v-on:input="selectedProduct = $event"
></ProductSelectSample>
</v-col>
<v-col cols="2">
<ProductNumberSample v-model.number="productCount"></ProductNumberSample>
</v-col>
<v-col cols="2">
<ButtonSample @click="addProductCount">Enter</ButtonSample>
</v-col>
</v-row>
<v-data-table
:headers=this.$store.state.headers
:items=this.$store.state.products
></v-data-table>
</v-container>
</v-card>
</div>
</template>
<script>
import ButtonSample from '@/components/atoms/ButtonSample.vue'
import ProductSelectSample from '@/components/molecules/ProductSelectSample.vue'
import ProductNumberSample from '@/components/molecules/ProductNumberSample.vue'
export default {
name: 'DashboardSample',
components: {
ButtonSample,
ProductSelectSample,
ProductNumberSample
},
data () {
return {
productCount: null,
selectedProduct: { id: null, name: null, number: 0 }
}
},
methods: {
addProductCount () {
this.$store.commit('addProductCount', {
productCount: this.productCount,
selectedProduct: this.selectedProduct
})
}
}
}
</script>
ここには入力フォーム、ボタン、テーブルと要素がいろいろあります。
フォームとボタンはそれぞれ molecules をつくったのでそれをよんでいます。
プルダウンには、選択肢を Vuex から取得したものをわたしています。
ボタンは、押下したときのイベントをうけてここで処理をするようにしています。
テーブルは Vuex にアクセスして値を取得しています。
- プロダクト
「Products」タブをおすとこちらの画面に切り替わります。
<template>
<div class="ma-5">
<v-card class="ma-2" max-width="1000">
<v-card-title><h3>商品管理</h3></v-card-title>
<v-container>
<v-row>
<v-col cols="6">
<NewProductNameSample
v-bind:value="newProductName"
v-on:input="newProductName = $event"></NewProductNameSample>
</v-col>
<v-col cols="2">
<ButtonSample @click="addNewProduct">Enter</ButtonSample>
</v-col>
</v-row>
<v-simple-table>
<template v-slot:default>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr v-for="item in products" :key="item.name">
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-container>
</v-card>
</div>
</template>
<script>
import NewProductNameSample from '@/components/molecules/NewProductNameSample.vue'
import ButtonSample from '@/components/atoms/ButtonSample.vue'
export default {
name: 'ProductsSample',
components: {
NewProductNameSample,
ButtonSample
},
computed: {
products: function () {
return this.$store.state.products
}
},
data () {
return {
newProductName: null
}
},
methods: {
addNewProduct () {
this.$store.commit('addNewProduct', {
name: this.newProductName
})
}
}
}
</script>
商品名を追加する場合は Vuex 上のデータを修正するようにしています。
Molecules
次におおきな単位は Molecules です。
- 商品名
<template>
<div>
<h4>商品名</h4>
<v-select
:items="items"
item-text="name"
v-bind:value="selectedProduct"
v-on:input="$emit('input', $event)"
return-object
></v-select>
</div>
</template>
<script>
export default {
name: 'ProductSelectSample',
props: ['items', 'selectedProduct']
}
</script>
「商品名」というラベルとプルダウンを表現します。
ここではこれらをさらに分割はせず、セットで Molecules としています。
Vuetify の v-select を利用しています。
- 個数
<template>
<div>
<h4>個数</h4>
<v-text-field
v-bind:value="value"
v-on:input="$emit('input', $event)"
></v-text-field>
</div>
</template>
<script>
export default {
name: 'ProductNumberSample',
props: ['value']
}
</script>
「個数」のラベルとフォームを表現しています。
Vuetify の v-text-field を利用しています。
- 新商品
<template>
<div>
<h4>新しい商品名</h4>
<v-text-field
v-bind:value="value"
v-on:input="$emit('input', $event)"
></v-text-field>
</div>
</template>
<script>
export default {
name: 'NewProductNameSample',
props: ['value']
}
</script>
こちらは商品管理画面でつかう、新しい商品名を入力するフォームです。
Atoms
一番小さく、それ以上分割できない単位が Atoms です。
今回は Atoms はボタンしか作りませんでしたが、通常は、ラベルなりフォームなり、さまざまなパーツを
ここにつくっていくことになるとおもいます。
- ボタン
<template>
<v-btn @click="handleClick"><slot></slot></v-btn>
</template>
<script>
export default {
name: 'ButtonSample',
methods: {
handleClick (event) {
this.$emit('click', event)
}
}
}
</script>
ボタンは Vuetify の v-btn を使っています。
ボタンが押下されたときどういった処理をするのかは、ボタンを呼び出す側で定義してもらったほうが汎用性がききますので、clickイベントを通知するだけとしています。
Vuex
Vuex ではテーブルのデータを管理し、データや行の追加、更新をおこないます。
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
headers: [
{ text: 'ID', value: 'id' },
{ text: '名前', value: 'name' },
{ text: '個数', value: 'number' }
],
products: [
{ id: '1', name: '牛乳', number: 2 },
{ id: '2', name: 'りんご', number: 3 }
]
},
mutations: {
addProductCount (state, payload) {
state.products.map(item => {
if (item.id === payload.selectedProduct.id) {
item.number += payload.productCount
}
})
},
addNewProduct (state, payload) {
const newId = state.products.length + 1
state.products.push({ id: newId, name: payload.name, number: 0 })
}
},
actions: {
}
})
感想
私は普段仕事で Vue.js は使うものの Vuetify、Vuex は初めて使いましたが、これらを使った開発は非常に強力で、高い自由度を保ちながらきれいなコードがかけると感じました。
Vuex を使うことで、データ管理と処理の記述を分離させることができ、またデータの一元管理ができるという大きなメリットがあります。
また、レイヤーごとにコンポーネントを分割し、親子関係を表現することで時間がたってもコードが何人にも理解不能な古文書となることから救ってくれそうなきがします。
一方で、レイヤーが増えてくると、どのレイヤーでどの情報を持ち、処理をするのかといったことは正解が一通りでなく、悩むこともありそうな気がします。
基本的には、自分のレイヤーで保持する data は、それがなんのためにある data であるのかパッと見てわかりやすくするためには、処理も自分のレイヤーで完結するのがいいのでしょうが、処理した結果を親に送るのがいいのか、親にはイベント通知だけして親側で処理するのか、みたいなところで悩むことはありそうだな。。。と感じました。
そのあたりはやっていくうちに慣れていくものかもしれません。