Giao diện
useApi và fetcher
useApi() trả về một $fetch đã cấu hình sẵn cho framework:
baseURLlấy từruntimeConfig.public.apiBaseURL(mặc định/api) → mọi lời gọi đi qua BFF.- Chuyển tiếp cookie/header của request đang xử lý khi chạy trên server (
useRequestFetch). - Chuẩn hoá mọi lỗi về
AppError— kể cả lỗi mạng.
ts
const api = useApi()
const orders = await api<Paginated<Order>>('/orders', { query: { page: 1, pageSize: 20 } })
const order = await api<Order>('/orders/12')
const created = await api<Order>('/orders', { method: 'POST', body: payload })
await api(`/orders/${id}`, { method: 'DELETE' })Tham số thứ hai là tuỳ chọn của ofetch: method, query, body, headers, signal, timeout, responseType…
Đặt lời gọi vào một composable
Trang không gọi useApi trực tiếp. Mỗi nghiệp vụ có một composable đóng vai "repository": đổi đường dẫn, đổi định dạng hay thêm tham số chỉ sửa một chỗ, và trang thì test được bằng cách truyền fetcher giả.
ts
// app/composables/useOrdersApi.ts
import type { Order, OrderInput } from './orders.types'
import type { Paginated } from '@tasco/utils'
import type { TableQuery } from '@tasco/composables'
export function useOrdersApi() {
const api = useApi()
const fetchOrders = (query: TableQuery) =>
api<Paginated<Order>>('/orders', { query: toOrdersQuery(query) })
const fetchOrder = (id: string) => api<Order>(`/orders/${id}`)
const createOrder = (payload: OrderInput) =>
api<Order>('/orders', { method: 'POST', body: payload })
const updateOrder = (id: string, payload: Partial<OrderInput>) =>
api<Order>(`/orders/${id}`, { method: 'PATCH', body: payload })
const removeOrder = (id: string) => api<void>(`/orders/${id}`, { method: 'DELETE' })
return { fetchOrders, fetchOrder, createOrder, updateOrder, removeOrder }
}Quy ước tên hàm: fetch* cho lời gọi HTTP, load* cho việc nạp dữ liệu vào state, map* cho hàm đổi dữ liệu ngoài sang kiểu nội bộ, to* cho chuyển đổi thuần.
Khi backend lệch hợp đồng
Backend nội bộ không phải lúc nào cũng theo hợp đồng dữ liệu của framework. Quy đổi ở fetcher — không sửa ở proxy, và cũng đừng để định dạng lạ lan vào component.
ts
/** Backend nhận `sort`/`order`, và bộ lọc ở cấp cao nhất. */
function toOrdersQuery(query: TableQuery) {
return {
page: query.page,
pageSize: query.pageSize,
sort: query.sortField,
order: query.sortOrder === 'descend' ? 'desc' : query.sortOrder ? 'asc' : undefined,
...query.filters,
}
}
/** Backend trả envelope `{ data: { list, totalCount } }` với field snake_case. */
interface OrderListResponse {
data: { list: Array<{ id: number, order_code: string, created_at: string }>, totalCount: number }
}
async function fetchOrders(query: TableQuery): Promise<Paginated<Order>> {
const res = await api<OrderListResponse>('/orders', { query: toOrdersQuery(query) })
return {
items: res.data.list.map(mapOrder),
total: res.data.totalCount,
}
}
function mapOrder(raw: OrderListResponse['data']['list'][number]): Order {
return { id: raw.id, code: raw.order_code, createdAt: raw.created_at }
}Kiểu snake_case chỉ tồn tại trong file này và được đặt tên riêng (OrderListResponse); phần còn lại của app chỉ thấy Order với field camelCase.
Dùng với useTable
useTable nhận đúng một fetcher (query: TableQuery) => Promise<Paginated<T>>:
ts
const { fetchOrders } = useOrdersApi()
const { dataSource, loading, pagination, onChange } = useTable<Order>(fetchOrders, { pageSize: 20 })Xem Trang danh sách.
Dùng với useAsyncData
Cho dữ liệu tải một lần theo route (trang chi tiết, dashboard):
ts
const route = useRoute()
const { fetchOrder } = useOrdersApi()
const { data: order, status, error, refresh } = await useAsyncData(
() => `order-${route.params.id}`, // key đổi theo id → đổi route là tải lại
() => fetchOrder(String(route.params.id)),
)Key phải chứa mọi tham số ảnh hưởng tới kết quả. Key cố định trong khi tham số đổi là nguyên nhân kinh điển của "trang hiện dữ liệu của bản ghi trước".
Huỷ và timeout
ts
const controller = new AbortController()
onBeforeUnmount(() => controller.abort())
const data = await api('/orders/search', {
method: 'POST',
body: keyword,
signal: controller.signal,
timeout: 15_000, // mặc định không giới hạn ở phía client
})Ô tìm kiếm gõ liên tục nên huỷ lời gọi trước khi bắn lời gọi mới, tránh kết quả cũ về sau đè kết quả mới.
Tải tệp lên
ts
const form = new FormData()
form.append('file', file)
await api('/orders/import', { method: 'POST', body: form })Không tự đặt Content-Type — trình duyệt cần tự sinh boundary.
Những điều không nên làm
| Đừng | Thay bằng |
|---|---|
$fetch('https://gateway.../orders') từ trang | useApi() để đi qua BFF |
Tự gắn Authorization ở client | BFF gắn — client không có token |
Lưu token vào localStorage/store | Cookie httpOnly do BFF quản lý |
try/catch rồi nuốt lỗi im lặng | Để lỗi nổi lên hoặc gọi $tascoError(e) — xem Xử lý lỗi |
| Gọi API ngay trong component con | Nhận dữ liệu qua props; lời gọi thuộc về trang |
Liên quan
- Hợp đồng dữ liệu — hình dạng chuẩn của request/response.
- Xử lý lỗi —
AppError, phân loại, hiển thị. - @tasco/composables — chữ ký đầy đủ của composable.