VuexのGitHubに掲載されているShopping Cart Exampleを元にショッピングカートを実装することでVuexの理解を深めるだけではなくvue.jsを使ったより実践的なアプリケーションの作り方を学ぶことができます。他のアプリケーションを構築する際の助けになるヒントがあると思うので少し長いですが、一つ一つ理解しながら読みすすめてください。

本文書を読みすすめるには、Vuexのstate, mutations, actions, gettersやヘルパー関数mapXXXの基本を理解しておく必要があります。

Vueの環境構築

vue-cliによるプロジェクトの作成

vueコマンドを利用してプロジェクトの作成を行います。ここではプロジェクトの名前はshopping-cartとします。

マニュアルのインストールを選択して、Vuexを忘れずに選択してください。


$ vue create shopping-cart

インストールが完了したら以下のコマンドを実行してください。


 $ cd shopping-cart
 $ npm run serve

Shoppoing Cartを作成するための準備は完了です。

ショッピングカードの構築

商品一覧の取得

今回の環境ではPHPのLaravel, node.jsのExpressなどのバックエンド側のサーバ構築を行わないためショッピングカードに入れる商品一覧はファイルから取得します。

vueのプロジェクトディレクトリにあるsrcの下にapiディレクトリを作成し、shop.jsファイルを作成してください。3つの商品情報と今後使用する2つの関数getProductsとbuyProductsを追加します。商品情報にはidと商品名のtitle、金額のprice, 在庫数のinventoryの4つのプロパティから構成されています。

getProductsは商品情報を取得する際に利用し、buyProductsは購入を実行した時に処理が成功したか失敗したかをランダムに制御する関数です。
fukidashi

/**
 * Mocking client-server processing
 */
const _products = [
  {"id": 1, "title": "iPad 4 Mini", "price": 500.01, "inventory": 2},
  {"id": 2, "title": "H&M T-Shirt White", "price": 10.99, "inventory": 10},
  {"id": 3, "title": "Charli XCX - Sucker CD", "price": 19.99, "inventory": 5}
]

export default {
  getProducts (cb) {
    setTimeout(() => cb(_products), 100)
  },

  buyProducts (products, cb, errorCb) {
    setTimeout(() => {
      // simulate random checkout failure.
      (Math.random() > 0.5 || navigator.userAgent.indexOf('PhantomJS') > -1)
        ? cb()
        : errorCb()
    }, 100)
  }
}
cbはcallbackの略でcallback関数が渡されます
fukidashi

App.vueファイルにはデフォルトでもコードが記述されていますが、一度削除して次の内容に書き換えます。


<template>
  <div id="app">
    <h1>Shopping Cart Example</h1>
    <hr>
    <h2>Products</h2>
    <ProductList/>
  </div>
</template>

<script>
import ProductList from './ProductList.vue'

export default {
  components: { ProductList }
}
</script>

App.vueファイル内で商品一覧を表示するProductList.vueファイルをimportしているのでcomponentsディレクトリの下にProductList.vueファイルを作成します。

ProductList.vueファイルの中では商品一覧を表示させるためにv-forディレクティブを追加し、データプロパティproductsを展開します。


<template>
	<ul>
		<li v-for="product in products" v-bind:key="product.id">
			{{ product.title }} - {{ product.price }}
		</li>
	</ul>
</template>

<script>
	export default{
		data(){
			return {
				products : [],
			}
		},
		created(){
			
		}
	}
</script>

ProductListコンポーネントの初期化の中で自動で商品情報の取得を行うためにライフサイクルフックcreatedの中に商品一覧を取得するコードを記述します。

商品一覧はapi¥shop.jsファイルに保存されているので、このファイルをimportする必要があります。import後、shop.jsのgetProductsメソッドを使って商品情報を取得します。


<script>
	import shop from '@/api/shop.js'
	export default{
		data(){
			return {
				products : [],
			}
		},
		created(){
			shop.getProducts( products => this.products = products);
		}
	}
</script>

ブラウザで確認すると商品一覧が表示されていることを確認することができます。

商品情報一覧表示
商品情報一覧表示

shop.jsファイルから商品情報を取得して商品一覧を取得しブラウザ上に表示することができました。しかしVuexを利用していないので次はVuexを利用したコードへの書き換えを行います。

Vuexを利用して商品一覧を取得

Vuexはプロジェクト作成時にインストール済みなのですぐに利用することができます。Vuexに関する記述はstore¥index.jsファイルで行います。

Vuexでデータを集中管理を行うためにProductListコンポーネントで行っていた商品情報の取得をVuexの中で行います。shop.jsファイルのimportをProductListコンポーネントからVuexの/store/index.jsに変更します。

商品情報の入ったデータプロパティproductsはVuexの中で管理を行うのでstateに設定します。ライフサイクルフックcreatedで直接行っていた商品情報の取得は、ActionsにgetAllProductsを追加し、commitでsetProductsのmutationsを実行することで行います。


import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

import shop from '@/api/shop.js'

export default new Vuex.Store({
  state: {
	products : [],
  },
  mutations: {
	setProducts(state, products){
		state.products = products;
	},
  },
  actions: {
	getAllProducts(context){
		shop.getProducts(products => {
			context.commit('setProducts', products);
		})
	},
  },
  modules: {
  }
})

context.commitについてはcontextのcommitメソッドだけ利用する場合は、下記のように書き換えることが可能です。


  actions: {
	getAllProducts({commit}){
		shop.getProducts(products => {
			commit('setProducts', products);
		})
	},
  },

ProductListコンポーネントでは商品一覧情報の管理を行いので(Vuexで管理するため)データプロパティproductsを削除し、ライフサイクルフックcreatedではdispatchメソッドを使ってVuexのActionsを実行することで商品情報を取得します。v-forで展開するproductsはcomputedプロパティを使ってstate.productsから取得しています。


export default{
	computed:{
		products(){
			return this.$store.state.products;
		}
	},
	created(){
		this.$store.dispatch('getAllProducts')
	}
}

ブラウザで確認するとVuexを設定する前と同じように商品一覧が表示されます。

商品情報一覧表示
商品情報一覧表示

mapStateへの変更

computedプロパティのproductsについては、mapStateを使うことでコードを短縮化することができます。mapStateなどのヘルパー関数を利用する場合は、mapStateのimportを忘れるとエラーが発生するので注意してください。


import { mapState } from 'vuex'

export default{
	computed: mapState(['products']),
	created(){
		this.$store.dispatch('getAllProducts')

mapStateに変更してもブラウザに表示される内容は変わりません。

ショッピングカートへの商品の移動

商品一覧の各行にボタンをつけて、ボタンをクリックするとショッピングカートに商品が移動する処理を実装します。

追加するボタンにはクリックイベントaddProductToCartを設定します。


<template>
	<ul>
		<li v-for="product in products" v-bind:key="product.id">
			{{ product.title }} - {{ product.price }}<br>
			<button @click="addProductToCart(product)">
				Add to cart
			</button>
		</li>
	</ul>
</template>

ブラウザで確認するとボタンは追加されますがカートへの追加処理であるaddProductToCart関数の設定を行っていないためボタンをクリックしても何も起こりません。

ボタンの追加
ボタンの追加

カートへの処理を追加する前にカートに移動した商品情報の保管場所を設定します。商品一覧と同様にカート内の商品一覧もVuexで管理を行うためstateにitemsを追加してそこに保存します。ユーザがボタンをクリックするとaddProductToCartが実行され商品情報がitemsに追加されていくことになります。

Vuexのindex.jsファイルのActoionsにaddProductToCartを追加し、mutationsのpushProductToCartでitemsの中にクリックを押した商品情報を入れます。itemsに保存する情報は商品が識別できるidと数量qutantityです。productのidは重複しないようにシステム内で一意に設定しておく必要があります。


state: {
	products : [],
	items : [],
},
mutations: {
	setProducts(state, products){
		state.products = products;
	},
	pushProductToCart(state, product){
		state.items.push({
			id: product.id,
			quantity: 1
		})
	},
},
actions: {
	getAllProducts({commit}){
		shop.getProducts(products => {
			commit('setProducts', products);
		})
	},
	addProductToCart({commit},product){
		commit('pushProductToCart', product)
	}
},

ここまでの設定でボタンをクリックするとstateのitemsに商品情報が登録されます。itemsの中にはidとquantityを持つオブジェクトとして登録します。

ショッピングカートの中身を表示させるsrc¥componentsの下にShoppingCart.vueファイルの作成を行います。


<template>
  <div class="cart">
    <h2>Your Cart</h2>
    <p v-show="!cartProducts.length"><i>Please add some products to cart.</i></p>
    <ul>
      <li
        v-for="product in cartProducts"
        :key="product.id">
        {{ product.title }} - {{ product.price }} x {{ product.quantity }}
      </li>
    </ul>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  computed: mapGetters(['cartProducts']),
}
</script>

カートに表示させる商品はGettersのcartProducts(これから作成)を利用して行います。v-showではカートの中に商品が入っていればカートの中身をv-forディレクティブで見れるようにv-showによって制御を行っています。

GettersのcartProductsはstore¥index.jsに追加します。itemsにはidとquantityしか入っていないためstate.productsに入った商品一覧情報を使ってtitleとpriceを取得しています。


getters: {
  cartProducts: state =>{
    return state.items.map(item =>{
      const product = state.products.find(product => product.id===item.id)
      return {
        title: product.title,
        price: product.price,
        quantity: item.quantity
      }
    })
  }
},

ここまでの設定でボタンをクリックした商品をショッピングカートに入れることができます。

ブラウザで確認しましょう。Your Cartの部分が追加したShoppingCart.vueで表示しているカートの中身です。アクセス直後では、カートには何も商品が入っていません。

カートが空の状態
カートが空の状態

Add to Cartボタンをクリックするとボタンを押した行の商品がカートに入っていきます。

カートには入りましたがAdd to cartを繰り返し押すと同じ商品も別の行としてカートに追加されて行きます。原因はカートに入れる際に同じ商品がカートに存在するかのチェックを行っていないためです。

商品がカートに入った状態
商品がカートに入った状態

カートに入る商品を制御する

カートに同じ商品を入れた場合は商品がカートにすでに存在しているかチェックを行い、存在している場合は数量が増えるようにコードの更新を行います。

ActionsのaddProductToCartを更新します。itemsの中に同じidを持つproductがあるかチェックを行い、ない場合はitemsに追加し、ある場合はmutationsのincrementItemQuantityを実行して数量を増やします。

Actinosの中でstateの値を変更するのではなく必ずmutationsを使うことを忘れないでください。
fukidashi

addProductToCart({state,commit},product){
  const cartItem = state.items.find(item => item.id === product.id)
  if(!cartItem){
    commit('pushProductToCart', product)
  }else{
    commit('incrementItemQuantity', cartItem);
  }
}

store¥index.jsのmutationsにincrementItemQuantityを追加します。処理の内容はitemsの中から同じidを持つitemを見つけて、そのitemのquantityを1つ増やします。


incrementItemQuantity(state,{ id }){
	const cartItem=state.items.find(item => item.id === id);
	cartItem.quantity++;
}
Actionsからcommitを実行した際にpayload(commitの第2引数で値を渡せる)にCartItemを渡していましたが、その中のidのみを利用する場合は{ id }と記述することが可能です。
fukidashi

コード更新後、ボタンをクリックすると同じ商品の場合場合は、ショッピングカードの行が増えるのではなく数量が増えていきます。

ボタンを押すと数量が増える
ボタンを押すと数量が増える

商品の在庫管理を制御する

冒頭で説明したようにshop.jsで読み込む商品情報には在庫数inventoryの情報も含まれています。ショッピングカートに入れる数量がinventoryを超えないように在庫数の管理も行っていきます。

以下の2点の処理を加えます。

  • カートに入れた後に商品情報一覧の在庫数を減らす
  • 在庫がなくなったらAdd to cartボタンを無効にする

カートに入れた後の商品情報の在庫数を減らす処理を追加します。

store¥index.jsに商品の在庫数を減らすmutationsのdecrementProductIventoryを追加します。


decrementProductIventory(state, { id }){
  const product = state.products.find(product => product.id === id)
  product.inventory--;
}

ActionsのaddProductToCartの中でカートを更新した後に追加したdecrementProductInventoryを実行します。


addProductToCart({state,commit},product){
const cartItem = state.items.find(item => item.id === product.id)
if(!cartItem){
	commit('pushProductToCart', product)
}else{
	commit('incrementItemQuantity', cartItem);
}
commit('decrementProductIventory',product);
}

在庫数を減らす処理が完了したら、在庫数をチェックしてボタンを無効にする処理を追加します。product.inventryが0になるとボタンが無効になります。


<ul>
	<li v-for="product in products" v-bind:key="product.id">
		{{ product.title }} - {{ product.price }}<br>
		<button @click="addProductToCart(product)" :disabled="!product.inventory">
			Add to cart
		</button>
	</li>
</ul>

iPad 4 Miniは2つしか在庫がないので2つカートに入れるとAdd to cartボタンがクリックできなくなります。

在庫数のチェックを行いボタンが無効
在庫数のチェックを行いボタンが無効

合計金額を表示

ショッピングカートであれば合計金額が表示される機能が必要になります。カートに入れた商品の合計金額を計算するコードをVuexのGettersにcartTotalPrice追加します。reduceメソッドを使ってカートに入っている商品の合計金額を計算しています。


getters: {
	cartProducts: state =>{
//中略
	},
  cartTotalPrice: (state, getters) => {
    return getters.cartProducts.reduce((total, product) => {
      return total + product.price * product.quantity
    }, 0)
  }
},

ショッピングカートの表示画面に合計金額を表示させるため、ShoppingCart.vueのmapGettersにcartTotalPriceを追加して表示を行います。


<template>
  <div class="cart">
    <h2>Your Cart</h2>
    <p v-show="!cartProducts.length"><i>Please add some products to cart.</i></p>
    <ul>
      <li
        v-for="product in cartProducts"
        :key="product.id">
        {{ product.title }} - {{ product.price }} x {{ product.quantity }}
      </li>
    </ul>
    <p>Total: {{ cartTotalPrice }}</p>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  computed: mapGetters(['cartProducts','cartTotalPrice']),
}
</script>

ブラウザで確認するとカートの中に商品が入っているとカートの中に金額が表示されるようになります。

カート内の合計金額が表示
カート内の合計金額が表示

購入処理を行う

ここまでの設定でカートへの商品の登録、合計金額の表示ができるようになりました。最後にカートに入った商品の購入処理を実装します。

購入処理の内容な購入処理が正常に行われれば画面には”successful”が表示され、失敗すればエラーメッセージfailedが表示されるといったものです。クレジットカードなどの決済エラーなどを想像してください。

購入ボタンを合計金額の表示の下に追加します。商品がカートに入っていない時は購入ボタンは無効にしています。checkoutメソッドはこれから作成します。


<p>Total: {{ cartTotalPrice }}</p>
<p><button :disabled="!cartProducts.length" @click="checkout(cartProducts)">Checkout</button></p>

ブラウザで確認するとCheckOutボタンは表示されていますが、無効なのでボタンをクリックすることはできません。

カートに商品がないと購入ボタンは押せない
カートに商品がないと購入ボタンは押せない

商品を一つカートの中に入れるとボタンはクリック可能になります。ボタンを押した後の処理は何も設定していないのでボタンを押しても何も起こりません。

カートに商品が入るとボタンが押せる
カートに商品が入るとボタンが押せる

checkoutボタンを押した後の制御を行っていきます。

まず、購入処理が成功したかどうかのステータスを持つcheckoutStatusをstore¥index.jsのstateに追加します。


state: {
  products : [],
  items : [],
  checkoutStatus : null
},

購入処理のステータスを表示させるhtmlタグをshopping-cart.vueファイルのcheckoutボタンの下に追加します。


<p><button :disabled="!cartProducts.length" @click="checkout(cartProducts)">Checkout</button></p>
<p v-show="checkoutStatus">Checkout {{ checkoutStatus }}.</p>

checkoutStatusはmapstateを利用して、shoppongCart.vueファイルからアクセスできるように設定します。

次にcheckoutボタンをクリックすると実行されるのcheckoutメソッドをshoppingCart.vueファイルに追加します。


export default {
  import { mapGetters,mapState } from 'vuex'

export default {
  computed: {
    ...mapGetters(['cartProducts','cartTotalPrice']),
    ...mapState(['checkoutStatus']),
  },
  methods: {
    checkout (products) {
      this.$store.dispatch('checkout',products)
    }
}

dispatchにより、Actionsのcheckoutを実行するので、store¥index.jsファイルにcheckoutを追加します。

Actionsのcheckoutでは、購入した商品の情報をsavedCartItemsに保存します。動作確認のためにmutationsのsetCheckoutStatusを使ってメッセージを入れます。またsetCartItemsのmutationsを使ってカートの中身を空にします。


checkout ({ state }, products) {
  const savedCartItems = state.items

  commit('setCheckoutStatus', 'before checkout')
  // empty cart
  commit('setCartItems', { items: [] })
  },
},

commitで実行しているmutationsのsetCheckoutStatusとsetCartItemsも追加します。


setCartItems (state, { items }) {
  state.items = items
},
setCheckoutStatus (state, status) {
  state.checkoutStatus = status
}

ブラウザを開いて商品を1つカートに入れます。

カートに商品が入るとボタンが押せる
カートに商品が入るとボタンが押せる

checkoutボタンをクリックするとカートの中身が空になり、Checkout before checkoutのメッセージが表示されれば設定どおりの動作になります。

chekoutボタンをクリック
chekoutボタンをクリック

shop.jsに最初に追加したbuyProductsを使って、購入処理が成功、失敗を制御します。


buyProducts (products, cb, errorCb) {
  setTimeout(() => {
    // simulate random checkout failure.
    (Math.random() > 0.5 || navigator.userAgent.indexOf('PhantomJS') > -1)
      ? cb()
      : errorCb()
  }, 100)
}

checkoutのActionsを下記のように書き換えます。buyProductsを実行し、成功した場合はcheckoutStatusのメッセージにはsuccesfulが入ります、失敗した場合はfailedが入り、一度空にしたカートの中身をsavedCartItemsで保存していた商品情報で再度カートを購入処理前の状態に戻しています。


checkout ({ state,commit }, products) {

const savedCartItems = state.items

commit('setCheckoutStatus', 'before checkout')
// empty cart
  commit('setCartItems', { items: [] })

  shop.buyProducts(
    products,
    () => commit('setCheckoutStatus', 'successful'),
    () => {
      commit('setCheckoutStatus', 'failed')
      // rollback to the cart saved before sending the request
      commit('setCartItems', { items: savedCartItems })
    }
  )
},

購入処理の機能が実装できたので、ブラウザを使って動作確認します。

購入処理に失敗した場合は、Checkoutのメッセージにfailedが表示され、カートの中身を処理前の状態に戻ります。

購入処理に失敗した場合
購入処理に失敗した場合

購入処理に成功した場合は、Checkoutのメッセージにsuccessfulが表示され、カートの中身は空になります。

購入処理に成功した場合
購入処理に成功した場合

vue.jsのVuexを使って在庫管理とショッピングカートへの商品の移動と購入処理の流れを実装することができました。

ECサイトを構築するためにはカート以外にもユーザ管理、商品管理、購入処理等の重要な処理が残されています。今後機会があればそのような機能についてのVue.jsでの構築方法について説明を行いたいと思っています。