在開始實作後端之前,我們先將原本都集中在 src/App.vue
的程式碼整理一下。這個處理也可以在開發的過程中逐步重構,可以根據現況調整。
商品資料
從之前的實作來看,我們大致上可以切分出 Cart
跟 Product
兩個元件,然而這兩個元件會互相使用到對方的資料,因此我們需要有一個保存共同資料的實作,這邊我們借助 Pinia 這個套件來處理這個問題。
1yarn add pinia
如果 Vue 版本低於 3.3.4 可能會發生問題,可以用
yarn upgrade vue
來更新到較新的版本。
再依照文件的 Getting Started 將 Pinia 設定好後,我們就可以加入 src/stores/product.ts
來維護我們的資料狀態。
1import { defineStore } from 'pinia'
2import { ref } from 'vue'
3import type { Ref } from 'vue'
4import Product from '../entities/product'
5
6export const useProductStore = defineStore('product', () => {
7 const items: Ref<Product[]> = ref([])
8 const refresh = (newItems: Product[]) => items.value = [...newItems]
9 const find = (id: number | string) => items.value.find(item => item.id === Number(id))
10
11 return {
12 items,
13 refresh,
14 find,
15 }
16})
對我們來說,需要一個 items
的參考紀錄目前顯示出來的商品資訊,同時還需要提供 refresh()
方法來更新商品列表,以及 find
來提供給 Cart
元件計算購物車內的總價。
因為使用 TypeScript 的關係,我們需要明確的定義 ref
對應的資料類型,因此另外加入了 src/entities/product.ts
來對商品資料進行定義。
1export default interface Product {
2 id: number
3 name: string
4 price: number
5}
定義好 Store 之後,我們就可以調整 src/App.vue
的實作,將商品資料獨立處理。
1<template>
2 <!-- 略 -->
3 <div>
4 <div v-for="product in product.items" :key="product.id" :data-testid="'product-' + props.id">
5 {{ product.name }}
6 <button @click="onAddToCart(product.id)">加入購物車</button>
7 <button @click="onRemoveFromCart(product.id)" v-if="isInCart(product.id)">移出購物車</button>
8 </div>
9 </div>
10</template>
11
12<script setup lang="ts">
13// ...
14import { useProductStore } from './stores/product'
15
16const products = useProductStore()
17fetch('/api/products')
18 .then(res => res.json())
19 .then(data => products.refresh(data))
20
21// ...
22</script>
接下來我們會依序的替換掉這些實作,直到將每個物件都分離清楚為止。
抓取資料的實作其實也可以實作在 Store 裡面,這樣就會構成一個類似於 Repository 的實現,然而這不在現階段的討論範圍,因此暫時不討論。
購物車資料
購物車的部分也採取相同的方式,我們在 src/stores/cart.ts
定義一個 Store 來保存狀態,並且把相關的行為封裝起來。
1import { defineStore } from 'pinia'
2import { ref, computed } from 'vue'
3import type { Ref } from 'vue'
4
5export const useCartStore = defineStore('cart', () => {
6 const items: Ref<Record<number, number>> = ref({})
7 const hasItem = (id: number) => items.value[id] && items.value[id] > 0
8 const add = (id: number) => {
9 items.value[id] = items.value[id] || 0
10 items.value[id] += 1
11 }
12 const remove = (id: number) => {
13 if(!hasItem(id)) {
14 return
15 }
16
17 items.value[id] -= 1
18 }
19 const clear = () => {
20 for(let id in items.value) {
21 delete items.value[Number(id)]
22 }
23 }
24 const totalItems = computed(() => Object.entries(items.value).reduce((sum, [_id, curr]) => sum + curr, 0))
25
26 return {
27 items,
28 hasItem,
29 add,
30 remove,
31 clear,
32 totalItems,
33 }
34})
基本上跟原本 src/App.ts
的實作沒有太大的差異,將大部分的實作移動到裡面後,還有一個 subtotal
是沒有放進來的。
這是因為 subtotal
同時參考了 Product
和 Cart
兩種來源,在處理上我們就需要思考一些問題。
- Cart 裡面放的是 Product 嗎?
- Cart 該是 Order + Order Item 的組合嗎?
在目前的設計上,購物車的資料是參考商品的(也比較合理)那麼我們只保存 id
也算合理,因為計算總價的時後是必須參考當下商品的金額為主。
在這樣的前提之下,我們應該是在元件中取出商品資料來計算,而不是統一由 Store 來處理這件事情,那麼 subtotal
的處理自然不會放在這裡。
基本上 Cart
相關的處理也都圍繞在這部分,我們接下來就可以拆分出 src/components/Cart.vue
來抽離原本實作在 src/App.vue
中的邏輯。
1<template>
2 <div class="cart">
3 <div v-if="hadCheckout">{{ checkoutResult }}</div>
4 <div data-testid="cart-amount">一共 {{ cart.totalItems }} 項商品</div>
5 <div data-testid="cart-subtotal">總金額為 ${{ subtotal }}</div>
6 <button @click="onCheckout">結帳</button>
7 </div>
8</template>
9
10<script setup lang="ts">
11import { ref, reactive, computed } from 'vue'
12import { useProductStore } from '../stores/product'
13import { useCartStore } from '../stores/cart'
14
15const products = useProductStore()
16const cart = useCartStore()
17
18const subtotal = computed(() => Object.entries(cart.items).reduce((prev, [id, count]) => prev + products.find(id)?.price * count, 0))
19
20const checkoutResult = ref(null)
21const onCheckout = () => fetch('/api/checkout', {
22 method: "POST",
23 headers: {
24 "Content-Type": "application/json",
25 },
26 body: JSON.stringify(cart)
27})
28 .then(res => {
29 if(res.ok) {
30 for(let id in cart) {
31 delete cart[id]
32 }
33 }
34
35 return res
36 })
37 .then(res => res.json())
38 .then(data => checkoutResult.value = data.text)
39const hadCheckout = computed(() => checkoutResult.value !== null)
40</script>
最後再到 src/App.vue
抽離掉購物車相關的實作,並且替換成元件即可。
1<template>
2 <Cart />
3 <div>
4 <div v-for="product in products.items" :key="product.id" :data-testid="'product-' + product.id">
5 {{ product.name }}
6 <button @click="cart.add(product.id)">加入購物車</button>
7 <button @click="cart.remove(product.id)" v-if="cart.hasItem(product.id)">移出購物車</button>
8 </div>
9 </div>
10</template>
11
12<script setup lang="ts">
13import { ref, reactive, computed } from 'vue'
14import { useProductStore } from './stores/product'
15import { useCartStore } from './stores/cart'
16import Cart from './components/Cart.vue'
17
18const products = useProductStore()
19const cart = useCartStore()
20
21fetch('/api/products')
22 .then(res => res.json())
23 .then(data => products.refresh(data))
24</script>
同樣的道理,因為商品所依賴的 Cart
也有對應的 Store 我們還可以繼續拆分成 src/components/Product.vue
1<template>
2 <div :data-testid="'product-' + props.id">
3 {{ props.name }}
4 <button @click="cart.add(props.id)">加入購物車</button>
5 <button @click="cart.remove(props.id)" v-if="cart.hasItem(props.id)">移出購物車</button>
6 </div>
7</template>
8
9<script setup lang="ts">
10import { useCartStore } from '../stores/cart'
11
12const props = defineProps<{
13 id: number,
14 name: string,
15}>()
16const cart = useCartStore()
17</script>
最後再次清理 src/App.vue
。
1<template>
2 <Cart />
3 <div>
4 <Product v-for="product in products.items" :key="product.id" :id="product.id" :name="product.name" />
5 </div>
6</template>
7
8<script setup lang="ts">
9import { ref, reactive, computed } from 'vue'
10import { useProductStore } from './stores/product'
11import { useCartStore } from './stores/cart'
12import Cart from './components/Cart.vue'
13import Product from './components/Product.vue'
14
15const products = useProductStore()
16
17fetch('/api/products')
18 .then(res => res.json())
19 .then(data => products.refresh(data))
20</script>
這樣就完成階段性的重構,讓原本混雜再一起的邏輯有了一定程度的拆分,在後續的擴充過程中再依照情況判斷需要做的修改即可。
API
在原本的實作中,我們呼叫 API 的處理是散落在不同並不好管理,因為搭配後端進行測試時可能會有需要另外指定不同的路徑,因此我們還需要統整一下 API 端點。
加入 src/api.ts
來統一定義 API 呼叫的實作,可以使用像是 axios 這類套件來處理,因為我們的實作相對簡單,就直接使用 fetch
來實作。
1export const fetchProducts = async () => await fetch('/api/products').then(res => res.json())
2export const checkout = async (items: Record<number, number>) => {
3 const res = await fetch('/api/checkout', {
4 method: "POST",
5 headers: {
6 "Content-Type": "application/json",
7 },
8 body: JSON.stringify(items)
9 })
10 const data = await res.json()
11
12 return [res.ok, data]
13}
我們把現有的兩個 API 呼叫都搬移到裡面,並且做了一些調整,讓回傳的內容是統一的。這樣在使用時就可以預期一定會拿到處理好的 JSON 物件而非字串。
將 src/App.vue
更新,改為採用統一定義的介面呼叫。
1<script setup lang="ts">
2import { useProductStore } from './stores/product'
3import { fetchProducts } from './api'
4import Cart from './components/Cart.vue'
5import Product from './components/Product.vue'
6
7const products = useProductStore()
8fetchProducts().then(data => products.refresh(data))
9</script>
最後針對 src/components/Cart.vue
也做相同的處理即可。
1<script setup lang="ts">
2// ...
3import { checkout } from '../api'
4
5// ...
6
7const checkoutResult = ref(null)
8const onCheckout = () =>
9 checkout(cart.items)
10 .then(([ok, data]) => {
11 if(ok) {
12 cart.clear()
13 }
14
15 return data
16 })
17 .then(data => checkoutResult.value = data.text)
18const hadCheckout = computed(() => checkoutResult.value !== null)
19</script>
到這邊我們的前端實作基本上就告一段落,接下來會在後端實作一個跟資料庫實際整合的版本,並且讓測試可以直接呼叫來進階成實際的整合測試。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 同時完成測試與文件 - Cucumber 的文件測試法
- 基本語法:功能描述 - Cucumber 的文件測試法
- 基本語法:驗證行為 - Cucumber 的文件測試法
- 基本語法:步驟定義 - Cucumber 的文件測試法
- 基本語法:輔助設定 - Cucumber 的文件測試法
- 前端環境:Vite 與 Cucumber - Cucumber 的文件測試法
- 商品列表與加入購物車 - Cucumber 的文件測試法
- 重構與移出購物車 - Cucumber 的文件測試法
- 商品資料與總價 - Cucumber 的文件測試法
- 結帳與結果 - Cucumber 的文件測試法
- 整理前端實作 - Cucumber 的文件測試法
- 初始化後端專案 - Cucumber 的文件測試法
- 商品資料 API - Cucumber 的文件測試法
- 更新購物車 API - Cucumber 的文件測試法
- 加入資料模型 - Cucumber 的文件測試法
- 持久化保存 - Cucumber 的文件測試法
- 結帳處理 - Cucumber 的文件測試法
- 在 Rails 的前後端分離 - Cucumber 的文件測試法
- 匯入前端實作 - Cucumber 的文件測試法
- 重現後端實作 - Cucumber 的文件測試法
- 累積價值 - Cucumber 的文件測試法