前情提要:上一篇我們實作了加入購物車的 API,並且認識了 Alpine.js 的基本使用方式,接下來我們要深入購物車表格,讓它可以顯示、編輯以及刪除品項
在把購物車的內容顯示出來之前,我們先來重構與 Store API 最不可或缺的兩個方法:getJSON()
以及 reqJSON()
,前者是以 GET 方法請求 API,藉此取得相關資料,像是購物車的明細,後者是修改資料的方法,像是改變購物車商品數量、刪除品項等等,所有與 Store API 的溝通都需要用到這兩個方法。
回到 wp-content/plugins/woocommerce-cat/assets/src/template/cart.js
,先前的程式碼如下:
export default (homeUrl,nonce) => ({
nonce,
routeCart: homeUrl + '/wp-json/wc/store/v1/cart',
routeChecout: homeUrl + '/wp-json/wc/store/v1/checkout',
// 初始化
init() {
this.getJSON(this.routeCart);
},
// 取得資料請求
getJSON(route) {
fetch(route).then(resp => {
return resp.json()
}).then(data => {
return data
}).catch((error) => {
console.log(`Error: ${error}`);
})
},
// 修改資料請求
reqJSON(route, method, body) {
fetch(route, {
method,
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
'Nonce': this.nonce
},
body: JSON.stringify(body)
}).then(resp => {
return resp.json()
}).then(data => {
return data
}).catch((error) => {
console.log(`Error: ${error}`);
})
}
})
由於我們希望在進入結帳頁的時候,就能先從 Store API 取得目前購物車的內容,因此我們新增一個 cart
屬性,並且在 init()
初始化時使用 getJSON()
方法將取得的結果存到 cart
裡面,直覺上會寫成這樣:
export default (homeUrl,nonce) => ({
nonce,
cart:[], // 存放購物車資料
// 初始化
init() {
this.cart = this.getJSON(this.routeCart);
},
// 略
})
當這樣寫的時候會發現 cart
完全沒有寫入任何資料,但也沒有顯示任何錯誤訊息,這是怎麼一回事呢?
JavaScript 的非同步處理
原因出在每一次跟 Store API 請求的時候,必須要執行從 HTTP 請求到回傳資料的整個過程,在預設情況下 JS 是用「同步」的方式來執行程式,也就是讀完一行後接著處理下一行,它並不會等待前一行有取得資料的情況下才執行下一行,因此當我們使用 this.cart = this.getJSON(route)
來存放購物車資料時,getJSON()
根本還沒有跑完就完成初始化的動作,造成 cart
還沒有存到資料執行就結束了。
要改善這個問題必須採用「非同步」的作法,也就是要確保前一行的資料已經確實拿到並且存入後,才會繼續執行下一行。在早期的 JavaScript 只能使用回呼函式來確保當 A 函式觸發時再執行 B 函式,B 函示觸發後再執行 C 函示,但這樣的寫法會讓程式碼難以閱讀。
而在新版的 JS 裡面提供了 Promise 物件,可以用 then
串接的方式來確保前一行程式碼已經執行完畢,事實上我們已經在 getJSON()
方法裡面看過它了,JS 內建的 Fetch API 本身就是一種 Promise 物件,所以可以很直覺的寫成這樣:
getJSON(route) {
fetch(route).then(resp => {
return resp.json();
}).then(data => {
console.log(data)
}).catch((error) => {
console.log(`Error: ${error}`);
})
},
第一個 then
是將 API 的請求結果轉成 JSON 格式後,再交由第二個 then
來回傳,第三個 catch
是當有錯誤發生時的處理,透過 then
的串接可以很直覺的理解為第一件事做完後「然後」做第二件事,這樣就能確保這個流程是非同步的。
除了使用 then
的寫法外,Promise 物件還可以使用關鍵字 async/await
來指定該方法以非同步的方式執行,再拿 getJSON()
為例,我們可以改寫成這樣:
async getJSON(route) {
let response = await fetch(route);
let data = await response.json()
return data;
},
可以看到在方法名稱前面多增加了一個 async
的關鍵字,async 是非同步 asynchronous 的簡稱,多了這個關鍵字之後,我們就可以在這個方法裡面使用 await
來做到跟 then
一樣的效果,首先把 API 的請求結果存到變數 response
裡面,在 fetch
前面多加 await
關鍵字就代表會在取得回傳結果後才存入 response
,然後 JSON 化的動作也多加了 await
確保轉換完成後才存入變數 data
,最後就能放心的回傳 data
。
接下來就能在 init()
裡面使用 getJSON()
來取得購物車內容:
init() {
this.getJSON(this.routeCart).then(data => this.cart = data)
},
這邊需要注意的是加了 async
的 getJSON()
方法變成是 Promise 物件,如果要取得 Promise 物件的執行結果一定要使用 then
來取得,所以這邊會需要再一個 then
將 data
存到變數 cart
裡面,這樣就能確實在初始化時取得購物車的品項內容。
另外如果你比較少寫 JS 可能會對箭頭函示的寫法有點陌生,因為它有不少的簡化寫法讓我剛接觸的時候總是一頭霧水:
// 以往 function 的寫法
this.getJSON(this.routeCart).then( function( data ){
this.cart = data
} )
// 改用箭頭函示省略 function
this.getJSON(this.routeCart).then( ( data ) => {
this.cart = data
} )
// 如果函式只有一個參數,外面的小括弧可以省略
this.getJSON(this.routeCart).then( data => {
this.cart = data
} )
// 如果函式內容只有一行或是直接 return,外面的大括弧可以省略
this.getJSON(this.routeCart).then( data =>
this.cart = data
)
// 不換行的寫法
this.getJSON(this.routeCart).then( data => this.cart = data )
由於我希望可以各別處理 getJSON
與 reqJSON
的回傳結果,如果是像一開始直接把 then
寫在裡面就沒辦法自訂了,因此我把 reqJSON
也改成使用 async/await
的寫法,就能針對回傳的 data
做處理:
async reqJSON(route, method, body) {
let response = await fetch(route, {
method,
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
'Nonce': this.nonce
},
body: JSON.stringify(body)
});
return await response.json();
},
稍後會提到的刪除購物車就能這樣用:
removeItem(itemKey) {
this.reqJSON(`${this.routeCart}/remove-item`, 'POST', {
'key': itemKey,
}).then(data => this.cart = data)
},
更完整的 Promise、async/await 以及非同步的知識可以參考以下文章:
購物車物件
從 Store API 拿到購物車的資料後,我們就可以用 log 的方式來查看 cart
的內容,或是使用 Chrome 的 Alpine.js 擴充功能就能直接顯示所有的屬性與方法,並且還可以即時編輯屬性的值來確認顯示結果,個人很推薦使用:https://chrome.google.com/webstore/search/alpine.js?hl=zh-TW
回到我們的購物車,根據目前所設計的表格,我們會需要的欄位有商品圖片、商品名稱、單價、數量,至於小計則用單價 x 數量來計算,表格內容與對應欄位如下圖:
一、x-for 迴圈
接下來我們只要使用 Alpine.js 的 x-for
迴圈就可以非常方便的把商品內容顯示出來,在不考慮 CSS 樣式的情況下,基本寫法如下:
<ul>
<template x-for="item in cart.items" :key="item.id">
<li>...</li>
</template>
</ul>
Alpine.js 的迴圈基本公式要用 <template>
標籤把重複的內容包起來,然後屬性 x-for
的值為「元素名稱 in 陣列」,由於我們要的東西在 items
陣列裡面,因此會寫成 item in cart.items
,這樣就能用 item.xxx
來取得元素的屬性,至於 item
可以隨意命名。
至於 :key="item.id"
為每個元素的識別符號,需採用不重複的屬性來作為值,這邊使用商品 ID 來作為 key。
二、x-bind 綁定圖片網址
接下來取得商品圖片,圖片在 images
陣列裡面,我們直接拿第一張圖即可:
<ul>
<template x-for="item in cart.items" :key="item.id">
<li>
<!--商品圖片-->
<img :src="item.images[0].src" />
</li>
</template>
</ul>
:src
是 Alpine.js 綁定屬性 x-bind
的寫法,它可以讓原始的 HTML 屬性像是 src
、alt
、style
傳入 JS 參數,因此原本要顯示圖片的語法為 <img src="圖片網址">
,修改後變成 <img :src="JS參數">
,而商品第一張圖片的路徑放在 item.images[0].src
裡面,因此最後寫成 <img :src="item.images[0].src" />
。
x-bind
可以跟原始的 HTML 屬性並存,如果寫成 <img src="圖片網址" :src="JS參數">
也是沒問題,但在 src
上就顯得沒必要,實務上會並用的是 CSS class 屬性,這樣就能保有原始的 class 以及動態新增的 class。像這樣:<img class="w:100%" :class=" 5>3 ? 'h:500' : 'h:300' ">
,當 5>3 的話圖片高度就會是 500 像素高,後面會有更多實際案例。
三、x-text / x-html 輸出文字
下一個處理商品名稱:
<ul>
<template x-for="item in cart.items" :key="item.id">
<li>
<!--商品圖片-->
<img :src="item.images[0].src" />
<!--商品名稱-->
<h3 x-text="item.name"></h3>
</li>
</template>
</ul>
如果要輸出文字使用 x-text
屬性即可,假設 item.name
叫做筆記本的話,那麼這個寫法 <h3 x-text="item.name"></h3>
輸出後就會變成 <h3>筆記本</h3>
,另外輸出文字還可以使用 x-html
,跟 x-text
的差別在於後者會過濾掉 HTML 的標籤以確保輸出的資料只有純文字,用 x-html
的話則能顯示 HTML 標籤,通常都會用 x-text
輸出以避免資料來源被植入惡意程式碼。
然後商品單價也是使用 x-text
:
<ul>
<template x-for="item in cart.items" :key="item.id">
<li>
<!--商品圖片-->
<img :src="item.images[0].src" />
<!--商品名稱-->
<h3 x-text="item.name"></h3>
<!--單價-->
<p x-text="item.prices.currency_symbol+new Intl.NumberFormat().format(item.prices.price)"></p>
</li>
</template>
</ul>
可以看到 x-text
裡面跟平常的 JS 一樣可以做字串組合或是加減乘除,item.prices.currency_symbol
是 Store API 回傳的貨幣符號,這邊我們拿到的是「NT$」,然後 item.prices.price
是商品價格,這邊用了 NumberFormat()
物件來將商品增加三位逗點的格式,因此最後輸出的結果是 <p>NT$2,000</p>
。
小計的部分跟單價差不多,一樣是用 x-text
來輸出,只有輸出結果是用單價乘以數量:
<ul>
<template x-for="item in cart.items" :key="item.id">
<li>
<!--商品圖片-->
<img :src="item.images[0].src" />
<!--商品名稱-->
<h3 x-text="item.name"></h3>
<!--單價-->
<p x-text="item.prices.currency_symbol+new Intl.NumberFormat().format(item.prices.price)"></p>
<!--數量-->
<div>...</div>
<!--小計-->
<div class="float:right f:24 f:red-50 float:none@sm mt:10 mt:0@sm" x-text="item.prices.currency_symbol+new Intl.NumberFormat().format((item.prices.price*item.quantity))"></div>
<!--刪除-->
<div>...</div>
</li>
</template>
</ul>
至於數量跟刪除的部分會牽涉到點擊事件,以及 Alpine.js 超方便的表單綁定 x-model
功能,這部分我們留待下一篇繼續!