iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Vue.js

需求至上的 Vue 魔法之旅系列 第 16

Day 12 : 可靠的中央行政單位:Pinia Getters / Setters

  • 分享至 

  • xImage
  •  

前言

有時候使用者(祕書)會拿到一份大訂單,想直接覆蓋整個訂單列表,再繼續點其他飲料。

為了支援這種情境,我們把訂單的「衍生統計」與「批次匯入邏輯」集中到 Pinia 的 getters 與 setters,讓元件變得更乾淨、只處理 UI 與事件。


一、User Story(祕書匯入訂單)

  • 身為祕書,我想把手上的訂單清單(這邊我們假設拿到的是JSON 格式的清單)貼到系統,按一下就能覆蓋整份訂單名單,並且系統能即時驗證格式、顯示錯誤,成功後更新後端資料。

User Story 表(簡述)

角色 需求 目的
祕書 貼上 JSON 訂單並覆蓋整份清單 快速更新訂單、不必逐筆輸入
系統 立即驗證與顯示錯誤 避免髒資料寫入
後端 提供批次覆蓋 API 與前端狀態一致

User Story(Mermaid Story Map 近似)

https://ithelp.ithome.com.tw/upload/images/20250930/201210529zdwrGkOd2.png

時序圖(貼上 → 驗證 → 覆蓋)

https://ithelp.ithome.com.tw/upload/images/20250930/20121052Y2I1xBJWep.png


今天會用到的 Vue / Pinia 技術

  • Pinia 狀態集中:用 state 管訂單清單與錯誤、loading。
  • Getters:summaryRows 將 Day 5 的「統計結果」集中計算。
  • 可寫的 Getter(Computed with Setter):ordersJson,可直接綁定 <textarea> 做即時驗證與匯入。
  • Actions:replaceAllOrders 呼叫後端 PUT /api/orders/bulk,與後端資料一致化。
  • 拆分 Store:orderStore(訂單 + 統計 + 匯入)、menuStore(飲料選單 + 每飲料規則)。

二、程式實作

後端(day12/backend)

  • 新增菜單檔與 API(GET-only):
    • ordermenu.json:提供 drinks / sweetness / ice 與 per-drink 規則(例如:巧克力只能熱飲)。
    • GET /api/ordermenu:讀取 ordermenu.json 回傳。
  • 新增批次覆蓋 API:
    • PUT /api/orders/bulk:接收整份陣列、驗證必要欄位、正規化後覆蓋 order.json
    • 注意:路由順序放在 PUT /api/orders/:id 之前,避免 :id='bulk' 被誤攔截。

主要改動檔案:

  • server.js
    • 加入 readMenu()GET /api/ordermenu
    • 加入 PUT /api/orders/bulk(並上移到 :id 之前)
  • ordermenu.json
    • 新增 rules
      • 巧克力:allowedIce = ["熱飲"]allowedSweetness = ["正常甜","少糖"]

ordermenu.json我的設計是這樣的

  1. drinks sweetnessOptions iceOptions這些都是我們前面看過的
  2. rule我們可以想像以後有資料庫可以用的時候,會把這個做成rule table去 verify這些訂單的飲料是否正確
{
  "drinks": ["紅茶", "綠茶", "巧克力"],
  "sweetnessOptions": ["正常甜", "少糖", "去糖"],
  "iceOptions": ["正常冰", "去冰", "熱飲"],
  "rules": {
    "紅茶": {
      "allowedSweetness": ["正常甜", "去糖"],
      "allowedIce": ["正常冰", "去冰", "熱飲"]
    },
    "綠茶": {
      "allowedSweetness": ["正常甜", "去糖"],
      "allowedIce": ["正常冰", "去冰"]
    },
    "巧克力": {
      "allowedSweetness": ["正常甜", "少糖"],
      "allowedIce": ["熱飲"]
    }
  }
}



變更摘要(後端)

檔案 變更重點 說明
backend/server.js 新增 PUT /api/orders/bulk 批次覆蓋整份訂單,並加入 per-drink 業務規則驗證
backend/server.js 新增 GET /api/ordermenu 提供飲料/甜度/冰量與規則給前端
backend/server.js 路由順序調整 確保 /bulk/:id 之前,避免被參數路由攔截
backend/ordermenu.json 新增 rules 例如「巧克力只能熱飲」等限制

關鍵程式片段 (秘書更新整份清單的主要部分)

PUT /api/orders/bulk(含業務規則驗證)

// 🔁 PUT /api/orders/bulk - 批次覆蓋整份訂單清單(需放在 :id 路由之前)
app.put("/api/orders/bulk", async (req, res) => {
  try {
    const incoming = req.body
    if (!Array.isArray(incoming)) {
      return res.status(400).json({ error: "payload 應為陣列" })
    }

    // 讀取菜單規則以進行業務驗證
    const menu = await readMenu();
    const rules = menu?.rules || {};

    // 基本驗證 + 業務規則驗證 + 正規化
    const normalized = incoming.map((o) => {
      if (!o || typeof o !== 'object') {
        throw new Error('每筆應為物件')
      }
      const requiredOk = ['name','drink','sweetness','ice'].every((k) => typeof o[k] === 'string')
      if (!requiredOk) {
        throw new Error('缺少必要欄位或型別不正確')
      }

      // 規則驗證:若該飲料有規則則必須符合
      const rule = rules[o.drink];
      if (rule) {
        const allowSweet = Array.isArray(rule.allowedSweetness) ? rule.allowedSweetness : [];
        const allowIce = Array.isArray(rule.allowedIce) ? rule.allowedIce : [];
        if (allowSweet.length && !allowSweet.includes(o.sweetness)) {
          throw new Error(`飲料「${o.drink}」不允許甜度「${o.sweetness}」`);
        }
        if (allowIce.length && !allowIce.includes(o.ice)) {
          throw new Error(`飲料「${o.drink}」不允許冰量「${o.ice}」`);
        }
      }
      return {
        id: typeof o.id === 'string' ? o.id : Date.now().toString() + Math.random().toString(16).slice(2),
        name: o.name,
        note: typeof o.note === 'string' ? o.note : '',
        drink: o.drink,
        sweetness: o.sweetness,
        ice: o.ice,
        createdAt: o.createdAt || new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      }
    })

    const ok = await writeOrders(normalized)
    if (!ok) {
      return res.status(500).json({ error: '無法覆蓋訂單' })
    }

    res.json(normalized)
  } catch (error) {
    res.status(400).json({ error: error.message || "批次覆蓋失敗" })
  }
})

前端(day12/frontend)

  • 安裝與掛載 Pinia:
    • package.json 加入 pinia
    • src/main.js 使用 createPinia()
  • 新增 Stores:
    • src/stores/orderStore.js
      • state:orders, loading, error
      • getters:summaryRows, summaryMap, totalCount
      • 可寫 getter:ordersJson(JSON 雙向綁定 + 驗證)
      • actions:loadOrders, createOrder, updateOrder, removeOrder, replaceAllOrders
    • src/stores/menuStore.js
      • state:menu, loading, error
      • getters:drinks, sweetnessOptions, iceOptions, rules
      • action:loadMenu() 讀取 GET /api/ordermenu
  • Service 擴充:
    • src/services/orderService.js 新增 replaceAll(orders) 對應 PUT /api/orders/bulk
  • 元件整合:
    • src/App.vue
      • 初始化同時載入 ordersmenu
      • 新增匯入面板:<textarea v-model="orderStore.ordersJson">、按鈕呼叫 orderStore.replaceAllOrders(...)
    • src/components/OrderForm.vue
      • 以 props 接收 drinks/sweetnessOptions/iceOptions/menuRules
      • 依選擇的飲料套用規則收斂選項(如:巧克力只顯示熱飲)

變更摘要(前端)

檔案 變更重點 說明
src/main.js 掛載 Pinia 啟用中央狀態管理
src/stores/orderStore.js 新增 getters(統計)、setter(ordersJson)、replaceAllOrders 集中衍生資料與批次匯入邏輯
src/stores/menuStore.js 暴露 rules 提供前端依飲料收斂選項
src/services/orderService.js 新增 replaceAll 呼叫 PUT /api/orders/bulk
src/App.vue 新增匯入面板 textarea v-model ordersJson + 套用到後端
src/components/OrderForm.vue menuRules 動態限制 巧克力→只顯示「熱飲」

關鍵程式片段

orderStore 的可寫 getter 與批次覆蓋 action

// 可寫 getter:提供 JSON 字串的雙向綁定(便於匯入/匯出)
const ordersJson = computed({
  get() {
    return JSON.stringify(orders.value, null, 2)
  },
  set(txt) {
    try {
      const parsed = JSON.parse(txt)
      if (!Array.isArray(parsed)) throw new Error('JSON 根應為陣列')
      for (const o of parsed) {
        if (!o || typeof o !== 'object') throw new Error('每筆應為物件')
        const hasRequired = ['name','drink','sweetness','ice'].every(k => typeof o[k] === 'string')
        if (!hasRequired) throw new Error('缺少必要欄位或型別不正確')
      }
      orders.value = parsed
      error.value = ''
    } catch (e) {
      error.value = '匯入訂單 JSON 失敗: ' + e.message
    }
  }
})
// 批次覆蓋(祕書匯入json格式後,與後端同步)
async function replaceAllOrders(newOrders) {
  try {
    loading.value = true
    error.value = ''
    const data = await OrderService.replaceAll(newOrders)
    orders.value = data
  } catch (err) {
    error.value = '批次覆蓋失敗: ' + (err.response?.data?.error || err.message)
    console.error('批次覆蓋失敗:', err)
  } finally {
    loading.value = false
  }
}

menuStore 這邊就是我們可以載入menu的地方拉

export const useMenuStore = defineStore('menu', () => {
  const menu = ref({ drinks: [], sweetnessOptions: [], iceOptions: [], rules: {} })
  const loading = ref(false)
  const error = ref('')

  async function loadMenu() {
    try {
      loading.value = true
      error.value = ''
      const { data } = await http.get('/api/ordermenu')
      menu.value = data
    } catch (err) {
      error.value = '載入菜單失敗: ' + (err.response?.data?.error || err.message)
      console.error('載入菜單失敗:', err)
    } finally {
      loading.value = false
    }
  }

OrderService.replaceAll

async replaceAll(orders) {
  const { data } = await http.put('/api/orders/bulk', orders)
  return data
},

App.vue 的匯入面板

<!-- 祕書匯入(示範 Pinia 可寫 getter + 後端 bulk 覆蓋) -->
<section class="block" style="margin-top:16px">
  <h3>祕書匯入訂單(貼上 JSON 陣列)</h3>
  <textarea
    v-model="orderStore.ordersJson"
    style="width:100%; min-height:160px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;"
    placeholder='[ { "name": "王小美", "note": "少冰", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" } ]'
  ></textarea>
  <div class="actions" style="margin-top:8px">
    <button class="btn primary" @click="orderStore.replaceAllOrders(JSON.parse(orderStore.ordersJson))">套用到後端</button>
    <button class="btn" @click="orderStore.loadOrders">重新載入(後端)</button>
  </div>
</section>

OrderForm 依規則收斂選項

watch(drink, (d) => {
  if (!d) return
  const rule = props.menuRules[d]
  if (rule) {
    opt.sweetness = rule.allowedSweetness?.length ? rule.allowedSweetness : props.sweetnessOptions
    opt.ice = rule.allowedIce?.length ? rule.allowedIce : props.iceOptions
  } else {
    opt.sweetness = props.sweetnessOptions
    opt.ice = props.iceOptions
  }
  if (!opt.sweetness.includes(sweetness.value)) sweetness.value = ''
  if (!opt.ice.includes(ice.value)) ice.value = ''
})

三、設計解說與取捨(為什麼要這樣改)

  • 將統計集中到 getters(而非元件計算)
    • 目的:降低元件耦合與重複計算,任何頁面讀取 summaryRows 都能拿到一致的衍生結果。
    • 成本:如果統計很重,可在 action 分頁載入再計算;目前資料量小,直接在 getter 即時計算即可。
    • 對應程式:
const summaryMap = computed(() => {
  const m = new Map()
  for (const o of orders.value) {
    const key = `${o.drink}|${o.sweetness}|${o.ice}`
    m.set(key, (m.get(key) || 0) + 1)
  }
  return m
})

const summaryRows = computed(() =>
  Array.from(summaryMap.value.entries()).map(([key, count]) => {
    const [drink, sweetness, ice] = key.split('|')
    return { key, drink, sweetness, ice, count }
  })
)
  • 可寫 getter(computed setter)用在匯入:為什麼不是 action?
    • 目的:讓 <textarea v-model> 直接綁定 store,貼上同時就進行格式驗證與本地預覽;這是最直覺的 UX。
    • 若用 action,需要額外按鈕觸發驗證與塞值,操作步驟變多;setter 則能即時回饋錯誤訊息到 error
    • 對應程式:
const ordersJson = computed({
  get() {
    return JSON.stringify(orders.value, null, 2)
  },
  set(txt) {
    try {
      const parsed = JSON.parse(txt)
      if (!Array.isArray(parsed)) throw new Error('JSON 根應為陣列')
      for (const o of parsed) {
        if (!o || typeof o !== 'object') throw new Error('每筆應為物件')
        const hasRequired = ['name','drink','sweetness','ice'].every(k => typeof o[k] === 'string')
        if (!hasRequired) throw new Error('缺少必要欄位或型別不正確')
      }
      orders.value = parsed
      error.value = ''
    } catch (e) {
      error.value = '匯入訂單 JSON 失敗: ' + e.message
    }
  }
})
  • 仍然保留 action replaceAllOrders:為什麼?
    • 目的:把「與後端同步」這件事封裝在 store 內,UI 不需要知道 API 細節,避免重複呼叫程式碼。
    • 另外也能在 action 做 loading/錯誤統一處理。
    • 對應程式:
async function replaceAllOrders(newOrders) {
  try {
    loading.value = true
    error.value = ''
    const data = await OrderService.replaceAll(newOrders)
    orders.value = data
  } catch (err) {
    error.value = '批次覆蓋失敗: ' + (err.response?.data?.error || err.message)
    console.error('批次覆蓋失敗:', err)
  } finally {
    loading.value = false
  }
}
  • 規則存放在 menuStore 而不是元件:
    • 目的:規則屬於「主檔/字典表」的一部分,集中存放才能在任一頁一致使用與更新。
    • 對應程式:
export const useMenuStore = defineStore('menu', () => {
  const menu = ref({ drinks: [], sweetnessOptions: [], iceOptions: [], rules: {} })
  ...
})
  • 後端同步驗證規則:為什麼前端已驗證還要後端再驗?
    • 目的:防呆與資安,避免繞過前端直接打 API 的不合法資料;同時確保多前端/多來源一致性。
    • 對應程式:
const rule = rules[o.drink];
if (rule) {
  const allowSweet = Array.isArray(rule.allowedSweetness) ? rule.allowedSweetness : [];
  const allowIce = Array.isArray(rule.allowedIce) ? rule.allowedIce : [];
  if (allowSweet.length && !allowSweet.includes(o.sweetness)) throw new Error(...)
  if (allowIce.length && !allowIce.includes(o.ice)) throw new Error(...)
}
  • 調整路由順序:為什麼 /bulk 要放在 /:id 之前?
    • 目的:避免被動態參數攔截為 id=bulk,導致 404 或更新失敗。
    • 對應程式:
// 🔁 PUT /api/orders/bulk - 批次覆蓋整份訂單清單(需放在 :id 路由之前)

四、使用方式(祕書匯入操作)

  1. 啟動後端(day12/backend):npm i && npm run start
  2. 啟動前端(day12/frontend):npm i && npm run dev
  3. 進入頁面底部「祕書匯入訂單(貼上 JSON 陣列)」:
    • 在文字框貼上 JSON(例如下方範例)
    • 若有格式問題,頂部會顯示錯誤訊息
    • 按「套用到後端」→ 呼叫 PUT /api/orders/bulk 覆蓋整份清單

可貼上的 JSON 範例:

[
  { "name": "user1", "note": "少冰", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" },
  { "name": "corgi", "note": "", "drink": "綠茶", "sweetness": "正常甜", "ice": "正常冰" },
  { "name": "Roni", "note": "下午三點", "drink": "巧克力", "sweetness": "少糖", "ice": "熱飲" }
]

五、總結與重點

  • 把衍生資料(統計)集中到 Pinia getters,元件只負責呈現。
  • 使用可寫的 getter(computed setter)集中驗證與資料轉換,示範「祕書匯入」的最佳化 UX。
  • actions 統一處理 API 呼叫與錯誤;後端提供批次覆蓋 API,讓前後端資料一致。
  • 分離 orderStoremenuStore,降低耦合、提高維護性;menuStore 的 per-drink 規則能讓 UI 選項自動收斂。

為什麼 ordermenu 要放在 store?

  • 可熱插拔的菜單來源:未來祕書可能改點不同連鎖(例如「五X蘭」切換成「X新」),我們只要替換 GET /api/ordermenu 的資料或 JSON 檔,即可一次影響全站,不必逐頁調整。
  • 單一真相來源(SSOT):把菜單與規則集中在 menuStore,任何頁面或元件都從同一來源讀取,避免 App 規模變大後,由 App.vue 層層傳遞 props 造成「不同頁面看到的選項不一致」或「資料傳遞遺漏」的風險。
  • 更好的測試與維護:規則與選項屬於「字典表/主檔」型資料,抽到 store 後可以單獨 mock 與測試;若分散在各元件/頁面,重複邏輯難以控管。
  • 效能與可用性:菜單通常變動頻率低、被多處重用。以 store 快取可避免重複請求,也能在使用者切換頁面時秒級復用。
props 傳遞 vs store 對照
面向 App.vue 傳 props menuStore 提供
單一真相來源 可能分散在不同頁面重複宣告 集中於 store,任何頁面讀取一致
可維護性 更改清單需追蹤所有引用處 換品牌只需替換 ordermenu.json/API
跨頁共享 需層層傳遞或事件巴士 直接 useMenuStore() 取得
效能 每頁進入可能各自請求 store 可快取、避免重複拉取
測試 分散、難以 mock 單點 mock store 輕鬆測試
多頁共享情境圖(Mermaid)

https://ithelp.ithome.com.tw/upload/images/20250930/20121052kpH0mesjCg.png


六、錯誤與正確範例(測試遇到的範例)

下面是執行的結果
可以驗證我們寫的code或是rule有沒有被正確執行

  • 根節點必須是「陣列」
    • 錯誤
{ "name": "王小美", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }
  • 正確
[{ "name": "王小美", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }]
  • 為什麼:前端 setter 驗證根節點是否為陣列;否則丟出「JSON 根應為陣列」。
if (!Array.isArray(parsed)) throw new Error('JSON 根應為陣列')
  • 陣列元素必須是物件
    • 錯誤
["不是物件", 123]
  • 正確
[{ "name": "王小美", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }]
  • 為什麼:前端 setter 逐筆檢查型別是否為物件。
if (!o || typeof o !== 'object') throw new Error('每筆應為物件')
  • 必填欄位皆為字串:name/drink/sweetness/ice
    • 錯誤
[{ "name": "王小美", "drink": "紅茶", "ice": "去冰" }]
  • 正確
[{ "name": "王小美", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }]
  • 為什麼:前端 setter 檢查四個欄位是否都是字串。
const hasRequired = ['name','drink','sweetness','ice'].every(k => typeof o[k] === 'string')
  • 符合菜單規則(例如:巧克力只能熱飲)
    • 錯誤(會被後端擋住並回 400)
[{ "name": "小明", "drink": "巧克力", "sweetness": "少糖", "ice": "正常冰" }]
  • 正確
[{ "name": "小明", "drink": "巧克力", "sweetness": "少糖", "ice": "熱飲" }]
  • 為什麼:後端 PUT /api/orders/bulkordermenu.jsonrules 驗證。
const rule = rules[o.drink];
if (rule) {
  const allowSweet = Array.isArray(rule.allowedSweetness) ? rule.allowedSweetness : [];
  const allowIce = Array.isArray(rule.allowedIce) ? rule.allowedIce : [];
  if (allowSweet.length && !allowSweet.includes(o.sweetness)) throw new Error(...)
  if (allowIce.length && !allowIce.includes(o.ice)) throw new Error(...)
}
  • JSON 語法(逗號、引號)需正確
    • 錯誤
[
  { "name": "A", "drink": "紅茶" "sweetness": "去糖" }
]
  • 正確
[
  { "name": "A", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }
]
  • 為什麼:這類語法錯誤會被瀏覽器 JSON.parse 擋住,前端 setter 捕捉並顯示錯誤訊息。

https://ithelp.ithome.com.tw/upload/images/20250930/20121052We4eGXzIoN.png

day12 github code


上一篇
Day11:中央魔島書院 – 共享的 store (Pinia)
下一篇
Day : 12.5 Pinia Options API vs Composition API 差異比較
系列文
需求至上的 Vue 魔法之旅18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言