iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Vue.js

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

Day 9.5 — 讓通訊更好用的魔法:用 Axios 封裝飲料訂單 CRUD

  • 分享至 

  • xImage
  •  

前言

昨天(Day 9)我們用最簡單的 fetch 打通了 GET / POST,正式踏出「能跟世界溝通」的第一步。
今天把這條資料管線升級:導入 AxiosService 層,實作 完整 CRUD(新增、讀取、更新、刪除),並處理 loading / error 狀態,讓呼叫更一致、可維護。

同時我們也要訓練「工程分工的直覺」:
哪些功能必須交給後端 API?哪些其實在前端就能高效完成
例如:OrderStats.vue 的「統計表」其實前端拿到 order list 後再計算即可(你的情境計算簡單、無巨量資料),這樣可減少後端壓力與溝通次數。反之,需要持久化、跨裝置共享、權限驗證的功能,就交給後端( server.js 已提供完整 CRUD)。


這一篇會做什麼(重點!!)

  • Axios 取代 fetch,建立共用客戶端
  • 建立 Service 層(OrderService):前端不直接碰 URL,把 API 細節集中管理。
  • 在 Vue 元件中呼叫 OrderService 完成 CRUDloading/error 管理。

一、CRUD User Stories(沿用飲料系統)

1.使用者需求面

  1. 讀取:「作為使用者,我想看到目前所有訂單。」→ GET /api/orders
  2. 新增:「作為使用者,我想送出一筆新的訂單。」→ POST /api/orders
  3. 更新:「作為使用者,我想修正已存在的訂單資訊。」→ PUT /api/orders/:id
  4. 刪除:「作為使用者,我想移除一筆訂單。」→ DELETE /api/orders/:id

後端 server.js 已支援上述四個端點,資料持久化到 order.json


2. 工程師的user sotry觀點參考

下面把「前端工程師 / 後端工程師」視角可能會有點不一樣,這邊我幫大家補充一下。

有時候可以多站在別人的角度思考一下,這個功能:

是否有完成我的目的-> user
UI是否有流程的體驗->前端
資料邏輯跟api設計是否正確-> 後端

這也是開發一個專案要注意的地方~!!

2.1) 讀取訂單(GET /api/orders)

前端工程師(FE)

  • 作為使用者,我想在進入頁面時看到目前所有訂單。

  • 驗收標準

    • 進入頁面 onMounted 觸發讀取;列表渲染完成。
    • 讀取中顯示 loading、錯誤顯示 error
    • 成功後畫面列表與 order.json 資料一致。
  • FE 任務

    • OrderService.list()(axios.get)
    • 管理 loading/error
    • 把資料傳給 <OrderList /> 呈現

後端工程師(BE)

  • 作為 API,我要提供目前的所有訂單資料。

  • 驗收標準

    • GET /api/orders 回傳 200application/json
    • 資料來源為 order.json,順序/欄位正確
  • BE 任務

    • 讀檔 order.json,回傳陣列
    • 錯誤時回 500 + { error }

2.2) 新增訂單(POST /api/orders)

前端工程師(FE)

  • 作為使用者,我想送出一筆新的飲料訂單。

  • 驗收標準

    • 表單必填完成才能送出(姓名、飲料…)。
    • 送出中禁用按鈕;成功後清空欄位並重新載入列表。
  • FE 任務

    • <AddOrder /> 收集資料 → OrderService.create(payload)
    • 成功後 await loadOrders()

後端工程師(BE)

  • 作為 API,我要把收到的訂單寫入 order.json

  • 驗收標準

    • POST /api/orders201 與新訂單 JSON(含 id/createdAt
    • 檔案成功持久化
  • BE 任務

    • 讀檔→push 新物件→寫檔
    • 產生 idcreatedAt

2.3) 更新訂單(PUT /api/orders/:id)

前端工程師(FE)

  • 作為使用者,我想修改既有訂單(例如更正名字或飲料)。

  • 驗收標準

    • 選定一筆→編輯→送出→列表中的該筆更新。
  • FE 任務

    • <EditOrder /> 選單帶入值
    • OrderService.update(id, patch) → 成功後重刷

後端工程師(BE)

  • 作為 API,我要按 id 找到目標並更新欄位。

  • 驗收標準

    • PUT /api/orders/:id200 + 更新後物件
    • 找不到回 404
  • BE 任務

    • 讀檔→findIndex→merge→寫檔→回傳

2.4) 刪除訂單(DELETE /api/orders/:id)

前端工程師(FE)

  • 作為使用者,我想移除一筆訂單。

  • 驗收標準

    • 按刪除→該筆從列表消失;失敗有錯誤提示。
  • FE 任務

    • <OrderList /> 觸發 @remove(id)
    • OrderService.remove(id) → 成功後重刷

後端工程師(BE)

  • 作為 API,我要根據 id 刪除訂單並回傳結果。

  • 驗收標準

    • DELETE /api/orders/:id200 + { message }
    • 找不到回 404
  • BE 任務

    • 讀檔→splice→寫檔→回傳

REST 端點一覽(對齊)

動作 Method & Path 說明
讀取 GET /api/orders 取全列表
新增 POST /api/orders 建立一筆
更新 PUT /api/orders/:id 覆寫/更新欄位
刪除 DELETE /api/orders/:id 依 id 刪除

3. 前後端分工的直覺(前後端工程師各自需要的sense)

  • 後端(要 API):持久化(新增/修改/刪除)、跨裝置查看、權限/驗證、需要可靠資料來源。
  • 前端(可以自己做):列表分頁、排序、簡單聚合統計(如飲料 × 甜度 × 冰量的 count)、暫存表單、即時互動 UI。

什麼時候需要開立api、計算什麼時候需要在後端做?

這個就需要工程師的判斷

比如說金融或是線上的交易,應該都是要給代號或是序號在後端計算,金額從前端送出去會有被串改的風險!!

(很常會遇到新聞說: 網路XX駭客自由改動金額,董前後端的工程師看了就會/images/emoticon/emoticon39.gif)


4. 時序圖

我們可以根據這些情境

來畫畫看前後端不同事件的時序圖

https://ithelp.ithome.com.tw/upload/images/20250927/2012105252DeEiSlQ3.png

二、實作程式

1 後端部分

後端根據這些設計我們可以產生出後端的code如下

import express from "express";
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import cors from "cors";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();
const PORT = 3000;
const DATA_FILE = path.join(__dirname, "order.json");

// ✅ 啟用 CORS(允許所有來源)
app.use(cors());
app.use(express.json());

// 輔助函數:讀取訂單資料
async function readOrders() {
  try {
    const txt = await fs.readFile(DATA_FILE, "utf8");
    return JSON.parse(txt);
  } catch (error) {
    console.error("讀取訂單資料失敗:", error);
    return [];
  }
}

// 輔助函數:寫入訂單資料
async function writeOrders(orders) {
  try {
    await fs.writeFile(DATA_FILE, JSON.stringify(orders, null, 2), "utf8");
    return true;
  } catch (error) {
    console.error("寫入訂單資料失敗:", error);
    return false;
  }
}

// 🔍 GET /api/orders - 取得所有訂單
app.get("/api/orders", async (_req, res) => {
  try {
    const orders = await readOrders();
    res.json(orders);
  } catch (error) {
    res.status(500).json({ error: "無法取得訂單資料" });
  }
});


// ➕ POST /api/orders - 新增一筆訂單
app.post("/api/orders", async (req, res) => {
  try {
    const orders = await readOrders();
    const newOrder = {
      id: Date.now().toString(),
      name: req.body.name || '',
      note: req.body.note || '',
      drink: req.body.drink || '',
      sweetness: req.body.sweetness || '',
      ice: req.body.ice || '',
      createdAt: new Date().toISOString(),
    };
    
    orders.push(newOrder);
    
    const success = await writeOrders(orders);
    if (!success) {
      return res.status(500).json({ error: "無法儲存訂單" });
    }
    
    res.status(201).json(newOrder);
  } catch (error) {
    res.status(500).json({ error: "新增訂單失敗" });
  }
});

// ✏️ PUT /api/orders/:id - 更新指定訂單
app.put("/api/orders/:id", async (req, res) => {
  try {
    const orders = await readOrders();
    const index = orders.findIndex(o => o.id === req.params.id);
    
    if (index === -1) {
      return res.status(404).json({ error: "找不到指定的訂單" });
    }
    
    // 保留原始的 id 和 createdAt
    const updatedOrder = {
      ...orders[index],
      name: req.body.name || orders[index].name,
      note: req.body.note || orders[index].note,
      drink: req.body.drink || orders[index].drink,
      sweetness: req.body.sweetness || orders[index].sweetness,
      ice: req.body.ice || orders[index].ice,
      updatedAt: new Date().toISOString(),
    };
    
    orders[index] = updatedOrder;
    
    const success = await writeOrders(orders);
    if (!success) {
      return res.status(500).json({ error: "無法更新訂單" });
    }
    
    res.json(updatedOrder);
  } catch (error) {
    res.status(500).json({ error: "更新訂單失敗" });
  }
});

// 🗑️ DELETE /api/orders/:id - 刪除指定訂單
app.delete("/api/orders/:id", async (req, res) => {
  try {
    const orders = await readOrders();
    const index = orders.findIndex(o => o.id === req.params.id);
    
    if (index === -1) {
      return res.status(404).json({ error: "找不到指定的訂單" });
    }
    
    const deletedOrder = orders[index];
    orders.splice(index, 1);
    
    const success = await writeOrders(orders);
    if (!success) {
      return res.status(500).json({ error: "無法刪除訂單" });
    }
    
    res.json({ message: "訂單已刪除", order: deletedOrder });
  } catch (error) {
    res.status(500).json({ error: "刪除訂單失敗" });
  }
});

// 錯誤處理中間件
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: "伺服器內部錯誤" });
});

// 404 處理
app.use((req, res) => {
  res.status(404).json({ error: "找不到指定的路由" });
});

app.listen(PORT, () => {
  console.log(`🚀 飲料點單 API 伺服器啟動成功!`);
  console.log(`📍 伺服器位址: http://localhost:${PORT}`);
});

https://ithelp.ithome.com.tw/upload/images/20250927/20121052kKquv6yiiE.png

有這份code run起來後

我們可以使用postman看看是否正確

https://www.postman.com/

如果後端資訊需要json物件(例如新增訂單) 記得要在postman的raw ->type選json喔!!

然後可以把這整串json丟進去測試

{
  "name": "測試用戶",
  "note": "請加珍珠",
  "drink": "紅茶",
  "sweetness": "正常甜",
  "ice": "去冰"
}

2. 前端部分

今天會講一下,最近寫專案喜歡的寫法分享
會多兩隻檔案:

  1. src/services/http.js
  2. src/services/orderService.js

安裝 Axios

axios 是我們在前端call api很好用的一套library他原生支援async function的處理

npm i axios

建立共用客戶端(src/services/http.js) axios初始機器

職責:建立並輸出一個共用 HTTP 客戶端(axios.create),集中設定 baseURL / timeout / headers,以及請求/回應攔截器(統一列印、攔截錯誤、加 token…)。

為什麼要獨立?

任何 API 呼叫都經過這個實例,同一處就能改全局行為(例如:加上 Authorization、全域錯誤訊息格式化)。

日後換網域加超時、改重試策略,只動這一個檔案。

因為這篇是有預設有經驗的js工程師

所以你應該知道: 如果以後要改url的設定或是讀取邏輯會 超。級。麻。煩!!!

/images/emoticon/emoticon05.gif

所以這隻會當作我default 初始化axios呼叫器的東西

Ps port跟url通常還是會放到env file或是CICD注入參數

// src/services/http.js
import axios from 'axios'

// 建立 axios 實例
export const http = axios.create({
  baseURL: 'http://localhost:3000',
  timeout: 5000,
  headers: {
    'Content-Type': 'application/json',
  },
})

// 請求攔截器
http.interceptors.request.use(
  (config) => {
    // 可以在這裡加入 loading 狀態或 token
    console.log('發送請求:', config.method?.toUpperCase(), config.url)
    return config
  },
  (error) => {
    console.error('請求錯誤:', error)
    return Promise.reject(error)
  }
)

// 回應攔截器
http.interceptors.response.use(
  (response) => {
    console.log('收到回應:', response.status, response.config.url)
    return response
  },
  (error) => {
    console.error('回應錯誤:', error.response?.status, error.message)
    
    // 統一處理錯誤
    if (error.response) {
      // 伺服器回應了錯誤狀態碼
      const { status, data } = error.response
      switch (status) {
        case 404:
          console.error('資源不存在:', data.error)
          break
        case 500:
          console.error('伺服器錯誤:', data.error)
          break
        default:
          console.error('API 錯誤:', data.error || error.message)
      }
    } else if (error.request) {
      // 請求已發送但沒有收到回應
      console.error('網路錯誤: 無法連接到伺服器')
    } else {
      // 其他錯誤
      console.error('未知錯誤:', error.message)
    }
    
    return Promise.reject(error)
  }
)


Order Service(src/services/orderService.js

這邊可以叫做orderService.js也可以叫做api service

主要目的就是管理你api function的地方

職責:把「業務語意」的 API 封裝成乾淨的函式:list / create / update / remove。
重點:元件不直接碰 URL 與 axios,而是呼叫 OrderService.create(payload) 這樣的語意方法。

為什麼要獨立?

抽掉重複樣板:URL、HTTP 方法、錯誤格式化都集中這裡。

強化可測性:單元測試可以直接 mock 這層,而非改動元件。

未來可演進:要加快取、重試、佇列、離線同步,都在 Service 層做。

https.js向是axios魔法的初始設定,有點像是我們使用魔法前需要設定的魔仗跟知識心法
orderService.js就是api的套裝概念,這個就像是我們的咒語跟動作,也就是身體運行的部分

// src/services/orderService.js
import { http } from './http'

export const OrderService = {
  /**
   * 取得所有訂單
   * @returns {Promise<Array>} 訂單陣列
   */
  async list() {
    const { data } = await http.get('/api/orders')
    return data
  },

  /**
   * 新增訂單
   * @param {Object} payload - 訂單資料
   * @param {string} payload.name - 姓名
   * @param {string} payload.note - 備註
   * @param {string} payload.drink - 飲料
   * @param {string} payload.sweetness - 甜度
   * @param {string} payload.ice - 冰量
   * @returns {Promise<Object>} 新建的訂單
   */
  async create(payload) {
    const { data } = await http.post('/api/orders', payload)
    return data
  },

  /**
   * 更新訂單
   * @param {string} id - 訂單 ID
   * @param {Object} patch - 要更新的欄位
   * @returns {Promise<Object>} 更新後的訂單
   */
  async update(id, patch) {
    const { data } = await http.put(`/api/orders/${id}`, patch)
    return data
  },

  /**
   * 刪除訂單
   * @param {string} id - 訂單 ID
   * @returns {Promise<Object>} 刪除結果
   */
  async remove(id) {
    const { data } = await http.delete(`/api/orders/${id}`)
    return data
  },
}

JSDoc 是 JavaScript 的文檔標準,用來為函數、類別、變數等加上說明文件。

通常會寫起來方便前後端工程師在做的時候

給自己或是夥伴們看的註記


在 App 裡串 Service(最小示例)

src/App.vue

<script setup>
import { ref, onMounted } from 'vue'
import { OrderService } from './services/orderService'
import OrderList from './components/OrderList.vue'
import AddOrder from './components/AddOrder.vue'
import EditOrder from './components/EditOrder.vue'

const orders = ref([])
const loading = ref(false)
const error = ref('')

// 讀取
async function loadOrders() {
  loading.value = true
  error.value = ''
  try {
    orders.value = await OrderService.list()
  } catch (e) {
    error.value = e?.message || '載入失敗'
  } finally {
    loading.value = false
  }
}

// 新增
async function handleCreate(order) {
  await OrderService.create(order)
  await loadOrders()
}

// 更新
async function handleUpdate({ id, patch }) {
  await OrderService.update(id, patch)
  await loadOrders()
}

// 刪除
async function handleRemove(id) {
  await OrderService.remove(id)
  await loadOrders()
}

onMounted(loadOrders)
</script>

<template>
  <main style="padding:12px">
    <h1>Day 9.5 — Axios + Service:飲料訂單 CRUD</h1>

    <AddOrder @submit="handleCreate" />

    <section style="margin:12px 0">
      <button @click="loadOrders">重新載入</button>
      <span v-if="loading" style="margin-left:8px">Loading...</span>
      <span v-if="error" style="margin-left:8px; color:#c00">{{ error }}</span>
    </section>

    <OrderList
      :orders="orders"
      @remove="handleRemove"
    />
    <EditOrder
      v-if="orders.length"
      :orders="orders"
      @update="handleUpdate"
    />
  </main>
</template>

職責:做為頁面容器 / 單一資料來源(Single Source of Truth):

持有 orders / loading / error

呼叫 OrderService 完成 CRUD

把資料與事件用 props / emits傳遞給子元件(, , )

為什麼把資料放在 App?

  • 這是本頁的最小共享範圍:表單、清單、統計都要用到同一份 orders。

  • 放在 App 能確保資料只修改一次,子元件只負責顯示/觸發事件。

今日的app.vue改動部分分析

1. 狀態管理改動

// 原有:靜態資料
const orders = reactive([
{ name: 'alice', note: '', drink: '紅茶', sweetness: '正常甜', ice: '正常冰' },
// ...
])

// 改為:動態資料 + 狀態管理
const orders = reactive([]) // 空陣列,從 API 載入
const loading = ref(false) // 載入狀態
const error = ref('') // 錯誤訊息

2. 新增處理與api交互應用的函數

  1. handleSubmit
  2. handleEdit
  3. handleRemove
  4. loadOrders

3.css改動

有時候api取得失敗或是資料錯誤 , 我們會希望使用顏色呈現,不一樣的css讓user知道 feedback


今天的重點

  • Axios 客戶端 + Service 層:把 URL、錯誤處理、攜帶 headers 等集中管理,元件只思考「要做什麼」,不管「怎麼叫 API」。
  • CRUD 範式:讀取 → 新增 → 更新 → 刪除 → 重刷資料。
  • 分工直覺:簡單統計放前端;需要一致性與持久化的資料交由後端。

潛在限制(以及簡短對策)

  • App.vue 可能變得複雜

    • 對策:把 CRUD 方法抽到 Composable(例如 useOrders())或用 Pinia 管理共享狀態。
  • 深層組件 props 傳遞冗長

    • 對策:用 provide/inject 傳遞只讀資料或動作;或改為 Pinia(全域可取用)。
  • Service 層需要維護(端點變動要同步改)

    • 對策:這其實是好事,因為唯一出口改一次全域生效;可搭配型別(TS)降低心智負擔。

Ps 一句話收斂

http.js 管通道、orderService.js 管語意、App.vue 管狀態與流向。
通道統一、語意清晰、資料單源、UI 純粹 —— 這就是可維護的前端。

程式碼大家可以參考~

day9.5 程式碼github


上一篇
Day 9 : 連接世界的魔法:API 溝通術
系列文
需求至上的 Vue 魔法之旅13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言