前情提要:上一篇文章我們把開發環境的建立流程走過了一遍,也介紹了 ValetPress、Wpackio、Master CSS 以及 Alpine.js,接下來進入實際開發的環節。
之前我們規劃會用以下三個短碼來設計不同需求的購物流程:
[one_page_checkout]
– 一頁式結帳頁[one_page_product]
– 一頁式商品列表 + 商品說明頁[one_page_cart]
– 一頁式購物車
因為考量到與既有網站的相容性,我先從影響層面比較小卻是最關鍵的一頁式購物車這個短碼進行開發,這個短碼會在同一頁面中顯示購物車表格、會員登入註冊、結帳資訊、付款與運送資訊等區塊,最終完成的畫面可以參考下圖:
這個頁面會包含許多資料取得以及判斷的邏輯,它雖然只有一個畫面看起來很單純,但背後需要處理龐大的程式碼,所以我們先從它來下手,搞定結帳頁後剩下的就相對單純許多。第一步我們先來處理購物車表格,預計會完成的功能如下:
- 設計一個短碼名為
[one_page_cart]
- 短碼內顯示購物車表格以及臨時的加入購物車按鈕
- 使用 Store API 把商品加入購物車
- 從 Store API 取得目前購物車內的商品
- 點擊完成結帳後輸出要傳送的購買資料
本篇會牽涉到許多 JavaScript 的部分,如果你對它不熟的話就先看過有印象就好,把重心放在程式碼的邏輯以及最後實現的效果即可,讓我們開始吧!
一、設計短碼
建立 WordPress 的短碼很簡單,只要使用 add_shortcode()
帶入短碼名稱以及輸出內容的函式即可,該函式可以帶入一些設定參數,但因為之後我希望都是走 REST API 來取得設定值,因此這邊我就不帶參數,另一方面我想要好維護所以用 require_once()
的方式來把 HTML 抽出來獨立成一個 PHP 檔,程式碼如下:
add_shortcode( 'woocat_one_page_cart', 'woocat_one_page_cart_shortcode' );
function woocat_one_page_cart_shortcode() {
ob_start();
require_once WOOCAT_PLUGIN_DIR . 'template/Checkout.php';
return ob_get_clean();
}
接下來新開一個頁面放入短碼 [woocat_one_page_cart]
就能顯示 Checkout.php
裡面的內容,PHP 的檔案結構如下:
woocommerce-cat/
├── composer.json
├── composer.lock
├── package-lock.json
├── package.json
├── postcss.config.js
├── src
│ ├── Shortcode.php
│ └── StoreApi
├── template
│ └── Checkout.php
├── vendor
├── wocommerce-cat.php
├── wpackio.project.js
└── wpackio.server.js
前端檔案結構
前一篇文章中提到我們使用 Wpackio 來處理前端程式的打包流程,只要把進入點設定好,其他的 JS 或 CSS 檔只要在進入點裡面匯入即可,最後再使用 Wpackio 的 PHP 套件來引入這個進入點,就能無痛搞定打包流程。
以下是我規劃的前端檔案結構:
woocommerce-cat/assets
├── dist
└── src
├── main.js
├── main.scss
├── template
│ ├── archiveProduct.js
│ ├── cart.js
│ ├── checkout.js
│ └── singleProduct.js
└── vendor
└── alpine.js
我把前端的檔案都放在 /assets
底下,/assets/dist
是編譯後的 JS 檔,/assets/src
是實際開發時的原始檔,在裡面可以看到 main.js
,而它就是進入點,所有需要載入的檔案都要透過它來匯入,/assets/src/template
是我為了方便管理所以把頁面中的元件拆分成獨立的檔案, /assets/src/vendor
則是放一些不需要讓 Wpackio 編譯打包的工具。
main.js
程式碼如下:
import '@master/css';
import './main.scss';
import Cart from './template/cart';
我引入了 Master CSS 以及我自己的 SCSS,還有稍後要實作的購物車,接下來開啟 Wpackio 的設定檔 wpckio.project.js
,把 entry 的路徑指到 main.js
:
files: [
// If this has length === 1, then single compiler
{
name: 'app',
entry: {
// mention each non-interdependent files as entry points
// The keys of the object will be used to generate filenames
// The values can be string or Array of strings (string|string[])
// But unlike webpack itself, it can not be anything else
// <https://webpack.js.org/concepts/#entry>
// You do not need to worry about file-size, because we would do
// code splitting automatically. When using ES6 modules, forget
// global namespace pollutions 😉
main: './assets/src/main.js', // Could be a string
//main: ['./src/mobile/index.js'], // Or an array of string (string[])
},
...
name 的部分是為了給 wp_enqueue_script()
辨識用的,可以取自己喜歡的名稱,設定好後就能 Wpackio 的物件來載入 JavaScript:
add_action(
'wp_enqueue_scripts',
function() {
$enqueue = new \WPackio\Enqueue( 'woocommerceCat', 'assets/dist', '1.0.0', 'plugin', __FILE__ );
$enqueue->enqueue( 'app', 'main', array( 'in_footer' => false ) );
},
);
這邊在實作時踩到一個雷,如果直接用 import 的方式來引入 Alpine.js 的話會噴錯,原因是它需要等 DOM 已經渲染完成之後才執行,因此要用 <script defer src="...">
的寫法。
但 Wpackio 並沒有提供 defer 的設定,如果直接把 Alpine.js 用 import 的方式在進入點引入,會抓不到 Alpine 物件,因此最後的解決辦法是另外引入而不透過 Wpackio 來處理:
add_action(
'wp_enqueue_scripts',
function() {
wp_enqueue_script( 'alpine', WOOCAT_PLUGIN_URL . 'assets/src/vendor/alpine.js', array(), '3.1.0', false );
$enqueue = new \WPackio\Enqueue( 'woocommerceCat', 'assets/dist', '1.0.0', 'plugin', __FILE__ );
$enqueue->enqueue( 'app', 'main', array( 'in_footer' => false ) );
},
);
然後 wp_enqueue_script()
也沒有提供 defer
參數的設定,因此需要透過另外一個勾點 script_loader_tag
來加入:
add_filter(
'script_loader_tag',
function ( $tag, $handle ) {
if ( 'alpine' !== $handle ) {
return $tag;
}
return str_replace( ' src', ' defer src', $tag );
},
10,
2
);
這樣就能正確抓到 Alpine.js 的變數了,關於 JS 載入機制的解釋可以參考這篇文章的詳盡圖解。
Master CSS
接下來處理介面的部分,這邊示範 Master CSS 是如何大幅減少開發時間,以購物車的表格為例,為了更方便處理 RWD 的問題,我使用 Grid 來設計表格,呈現的效果如下:
表格標頭 HTML 摘錄如下:
<div class="w:100% border:1px|solid|#ddd box-shadow:0|0|10px|#efefef r:5">
<div class="
{grid;grid-template-columns:2fr|1fr|160|1fr|50;p:0}>div@sm
{p:20;f:16;color:#666;as:center}>div>div@sm
{p:10|20;bg:#f0f1f3;f:14;f:gray-60}>div:first-child>div@sm
{p:20;rel;bb:1px|solid|fade-ㄨ88}>div">
<div class="d:block@sm d:none">
<div>商品明細</div>
<div>單價</div>
<div>數量</div>
<div>小計</div>
<div> </div>
</div>
Master CSS 我覺得最方便的地方是可以在 class 裡面寫簡化過後的 CSS,像是 w:100%
等於 width:100%
、r:5
等於 border-radius:5px
,如果不知道簡寫該如何寫還是可以用原始的寫法,像是 box-shadow:0|0|10px|#efefef
,屬性如果有多個參數值用 |
分隔即可。
另外除了控制標籤自己的樣式外,還可以使用選擇器來指到子元素,像是 {p:20;rel;bb:1px|solid|fade-88}>div
就能指到標籤本身裡面第一層的 <div>
,而外圍的大括號可以把給第一層 <div>
的屬性群組化,就像在寫原生 CSS 的 div{...}
一樣。
然後最精彩的是媒體查詢,在指定的標籤名稱後面用 @xx
來指定,像是 {grid;grid-template-columns:2fr|1fr|160|1fr|50;p:0}>div@sm
,這代表當視窗尺寸大於 small mobile 時才會套用 grid 屬性,如果你需要更精準的控制斷點,譬如說當螢幕尺寸大於等於 801 像素,還可以寫成這樣 @>=801
,用 Master CSS 可以省下很多時間,如果還是習慣寫 CSS 就可以寫在 main.scss
裡面。
Alpine.js
接下來是最核心的 JS 部分,它的用法很直覺,直接寫在 HTML 標籤的屬性裡面,當一個 <div>
帶有 x-data
的時候,則該 <div>
底下的所有標籤都是 Alpine.js 的守備範圍,x-data
傳入的是物件,因此可以擁有屬性與方法,具體寫法如下:
<div x-data="{ open: false, toggle() { this.open = ! this.open } }">
<button @click="toggle()">顯示內文</button>
<div x-show="open">
內文
</div>
</div>
在這個 <div>
之中,x-data
傳入了一個屬性 open
且預設值為 false
,以及一個 toggle()
方法,這方法主要作用是切換 open
的值,然後在 <button>
中使用 @click="toggle()"
,就能在點擊按鈕時觸發 toggle()
方法,最後使用 x-show
傳入 open
這個布林值,來控制內文的顯示與否,這就是 Alpine.js 最基本的用法。
但當專案內容比較複雜的時候,把所有屬性與方法寫在 x-data
裡面會不好管理,因此把它拆分成獨立的檔案來宣告會是比較實際的作法,拆分的步驟是先把 x-data
傳入的參數改為物件名稱,而這個物件會使用到 Alpine.js 的初始化事件來進行註冊。
修改後 HTML 的部分不變,差別在 x-data
傳入的是一個物件 dropdown()
,如果沒有帶參數後面的括弧就不用寫:
<div x-data="dropdown">
<button @click="toggle">Toggle Content</button>
<div x-show="open">
Content...
</div>
</div>
然後拆分出來的 JS 去監聽 Alpine.js 的 init 初始化事件,也就是當它初始化完成後,去宣告 dropdown 物件的屬性與方法:
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
toggle() {
this.open = ! this.open
},
}))
})
更進一步我們還可以將物件內容拆分出來,最後再用 import 的方式進行匯入:
import Dropdown from './dropdown';
document.addEventListener('alpine:init',() => {
Alpine.data('dropdown', Dropdown);
})
然後 dropdown.js
裡面用 export 來匯出內容:
export default () => ({
open: false,
toggle(){
this.open = !this.open
}
})
回到我們的場景,我用了一個 cart
物件並帶入兩個參數,一個是首頁網址、另一個是請求 Store API 需要的 nonce,直接把 PHP 帶入 x-data
裡面作為 cart()
物件的參數:
<div x-data="cart(
'<?php echo esc_attr( home_url() ); ?>',
'<?php echo esc_attr( wp_create_nonce( 'wc_store_api' ) ) ?>')
">
...
</div>
然後在 cart.js
裡面取得這兩個參數作為稍後請求 API 所用:
export default (homeUrl,nonce) => ({
homeUrl: homeUrl,
nonce: nonce,
routeCart: homeUrl + '/wp-json/wc/store/v1/cart',
routeChecout: homeUrl + '/wp-json/wc/store/v1/checkout',
})
然後在 JS 裡面如果屬性名稱與值相同的話,中間就不用冒號了,改寫如下:
export default (homeUrl,nonce) => ({
nonce,
routeCart: homeUrl + '/wp-json/wc/store/v1/cart',
routeChecout: homeUrl + '/wp-json/wc/store/v1/checkout',
})
最後在 main.js
匯入 cart.js
:
import '@master/css';
import './main.scss';
import Cart from './template/cart';
document.addEventListener('alpine:init',() => {
Alpine.data('cart', Cart);
})
這樣我們就可以專心在 cart.js
裡面來處理各項功能。
二、使用 Store API 把商品加入購物車
要取得購物車內容裡面必須要有商品(廢話),這邊我本來以為使用 WooCommerce 原生的加入購物車,再用 Store API 就能拿到購物車明細,但想不到原生的歸原生,Store API 歸 Store API,兩邊的購物車內容是不相通的,因此需要設計一個臨時的加入購物車按鈕來處理 Store API 的購物車資料,至於不相通的問題之後要再來處理了。
首先,因為要處理大量的 API 請求,我在 cart.js
這邊設計了兩個方法,一個叫做 getJSON()
,專門用來取得從 Store API 的回傳內容,帶有一個 API 請求路徑的參數,這邊使用瀏覽器內建的 Fetch API:
getJSON(route) {
fetch(route).then(resp => {
return resp.json();
}).then(data => {
console.log(data)
}).catch((error) => {
console.log(`Error: ${error}`);
})
},
另一個是 reqJSON()
方法,用來處理 GET 以外的 API 請求,帶有三個參數,分別是請求路徑、請求方法以及傳送內容:
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 => {
this.qty = data.items_count
}).catch((error) => {
console.log(`Error: ${error}`);
})
}
當進入結帳頁的時候,我們要先取得購物車裡面的資料,在 Alpine.js 有一個固定的方法叫做 init()
,也就是當 Alpine.js 初始化完成後要做的事就放在這邊,所以我們要先用 getJSON()
以 GET 方式請求路徑 /wp-json/wc/store/v1/cart
來取得購物車內容:
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 => {
console.log(data)
}).catch((error) => {
console.log(`Error: ${error}`);
})
},
})
理論上我們要把取得的內容存在一個變數之中,這留待之後實作。其次是加入購物車的功能,我們新增一個方法叫做 addToCart(productId)
,帶入一個商品 ID 的參數:
export default (homeUrl,nonce) => ({
nonce,
routeCart: homeUrl + '/wp-json/wc/store/v1/cart',
routeChecout: homeUrl + '/wp-json/wc/store/v1/checkout',
//...
addToCart(productId) {
this.reqJSON(`${this.routeCart}/add-item`,'POST',{
'id': productId,
'quantity': 1
})
},
//...
})
加入購物車的請求路徑為 /wp-json/wc/store/v1/cart/add-cart
,請求方法為 POST,帶入兩個參數,分別為商品 ID 以及數量,然後回到 HTML 的按鈕,使用 @click
來觸發 addToCart()
方法:
<div x-data="cart(
'<?php echo esc_attr( home_url() ); ?>',
'<?php echo esc_attr( wp_create_nonce( 'wc_store_api' ) ) ?>')
">
<button @click="addToCart(20)">加入購物車</button>
</div>
商品 ID 20 我先寫死,之後會帶入動態的資料,這樣就完成了加入購物車以及取得購物車的內容,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);
},
// 加入購物車
addToCart(productId) {
this.reqJSON(`${this.routeCart}/add-item`,'POST',{
'id': productId,
'quantity': 1
})
},
// 取得 API 回傳資料
getJSON(route) {
fetch(route).then(resp => {
return resp.json();
}).then(data => {
this.qty = data.items_count
console.log(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 => {
this.qty = data.items_count
}).catch((error) => {
console.log(`Error: ${error}`);
})
}
})
小結
從 Store API 取得回傳資料後,接下來我們就要用 Alpine.js 的迴圈語法 x-for
將資料進行輸出,同時還會用到 x-model
做雙向資料綁定來修改購物車的商品數量,該專案同步開源於 Github 上面,只是現在還亂亂的可能不太好閱讀,我會逐步來完善它。
不知道這樣的分享你覺得如何?有任何想法歡迎跟我交流,我們下一篇繼續!