依旧先看今天效果图
昨天我们把数据收进了 Pinia,但那些商品、订单还躺在本地数组里。今天做一件「让项目真正变工程」的事:接上 HTTP。核心不是调通一个接口,而是把请求逻辑从组件里抽出来,封成一个可复用、可调试、可统一处理错误和加载态的层。文末的完整代码依然单文件、零构建,并且没后端也能跑(内置了 Mock 适配器,切一个开关就能接真实 API)。
模块一:为什么不用 fetch,要上 Axios?
fetch 够用,但不够「好用」——它有几个反直觉的设计:
| 问题 | fetch | axios |
|---|---|---|
| HTTP 404/500 会进 catch 吗 | ❌ 不会,只有网络故障才 reject,必须手动 res.ok 判断 | ✅ 默认 2xx 之外走 catch(可通过 validateStatus 定制) |
| 超时控制 | 要靠 AbortController 自己包 | ✅ timeout 一个配置项 |
| 自动转 JSON | ❌ 要 .then(r => r.json()),且空 body 会抛错 | ✅ 自动解析,挂 response.data |
| 拦截器(统一加 token / 统一错误提示) | ❌ 没有,得自己封装 | ✅ 原生支持 |
| 取消重复请求 | ❌ 手动管理 AbortController | ✅ AbortController / 旧版 CancelToken |
| 上传进度 | ❌ 得自己读 ReadableStream | ✅ onUploadProgress |
结论:小 demo 用 fetch 没问题;一旦有鉴权、重试、统一错误处理,axios 省下的代码远多于它增加的体积(~13KB gzipped)。
CDN 引入后全局变量是 axios(注意全小写,别写成 Axios):
<script src="https://unpkg.com/axios@1/dist/axios.min.js"></script>
模块二:最小可用示例(先跑通,再封装)
const res = await axios.get('https://jsonplaceholder.typicode.com/posts/1');
console.log(res.data); // ← 真正的数据在 .data 上,不是 res
三个必须记的点:
- 数据在
response.data,不是response。 - axios 返回的是 Promise,在 Vue 里配合
async/await最舒服;模板中不能直接 await,要在setup或方法里取完赋给响应式变量。 - 错误在
error.response?.data,而不是error.message(网络断了才走 message)。
try {
const { data } = await axios.get('/api/user/999');
} catch (e) {
console.log(e.response?.status, e.response?.data); // 404 时的业务信息
console.log(e.message); // "Network Error" / timeout
}
模块三:创建实例 + baseURL + 环境切换
永远不要到处写 axios.get('https://a.com/api/...')。用一个实例统管域名、超时、前缀,换环境只改一处:
const ENV = {
dev: 'https://dev-api.example.com',
prod: 'https://api.example.com',
mock: '', // 空 = 用内置 Mock,不走网络
};
const BASE = ENV.mock; // ← 切环境只改这一行
const http = axios.create({
baseURL: BASE,
timeout: 8000,
headers: { 'Content-Type': 'application/json' },
});
⚠️ 两个高频坑:
baseURL末尾不要带/,否则拼接/api/users会变成...//api/users(多数服务端能容错,但上传文件时可能翻车)。- 请求路径要以
/开头,否则 axios 会把它当相对路径拼到当前 URL 上。
模块四:请求拦截器 —— 统一带 token、统一加 loading
拦截器是 axios 最有价值的特性。凡是「每个请求都要做的事」,一律放这里,绝不在组件里重复写:
// 请求拦截:自动带 token + 全局 loading
http.interceptors.request.use(
(config) => {
const token = localStorage.getItem('mm-token');
if (token) config.headers.Authorization = `Bearer ${token}`;
// 全局 loading(用 Pinia 驱动,任何组件都能读)
useLoading().start();
return config;
},
(error) => { Promise.reject(error); } // 注意:这里必须 return/reject
);
// 响应拦截:拆 data、统一错误、关 loading
http.interceptors.response.use(
(res) => {
useLoading().stop();
// 如果你的后端是 { code, msg, data } 结构,在这里统一校验
if (res.data.code !== undefined && res.data.code !== 200) {
useNotify().push(res.data.msg || '请求失败', 'error');
return Promise.reject(new Error(res.data.msg));
}
return res; // 组件拿到的是完整 response,解构 .data 取数据
},
(error) => {
useLoading().stop();
let msg = '网络异常,请检查连接';
if (error.code === 'ECONNABORTED') msg = '请求超时,请重试';
else if (error.response) {
const s = error.response.status;
if (s === 401) { useUser().logout(); useRouter().replace('/login'); msg = '登录已过期,请重新登录'; }
else if (s === 403) msg = '无权限访问';
else if (s === 404) msg = '请求的资源不存在';
else if (s >= 500) msg = '服务器繁忙,请稍后再试';
else msg = error.response.data?.msg || `请求失败 (${s})`;
}
useNotify().push(msg, 'error');
return Promise.reject(error);
}
);
规则:拦截器里只做「通用」的事。某个接口独有的报错提示,留在组件里写,别在拦截器里堆
if (url === '/xxx')。
模块五:把「请求」抽成 API 模块(组件只关心业务)
const api = {
getProducts: (params) => http.get('/products', { params }), // params 自动序列化成 ?key=val
getProduct: (id) => http.get(`/products/${id}`),
createOrder: (data) => http.post('/orders', data),
login: (data) => http.post('/auth/login', data),
};
组件里从此干净到只剩业务:
const list = ref([]), loading = ref(false);
const load = async () => {
loading.value = true;
try {
const { data } = await api.getProducts({ category: '数码' });
list.value = data;
} finally { loading.value = false; }
};
onMounted(load);
模块六:取消重复请求 —— 搜索框狂敲时的救命稻草
用户快速切换分类时,前一个请求可能比后一个晚回来,导致旧数据覆盖新数据。两种解法:
方案 A:AbortController(推荐,原生标准)
let controller = null;
const search = async (keyword) => {
controller?.abort(); // 取消上一次
controller = new AbortController();
const { data } = await api.getProducts({ keyword }, { signal: controller.signal });
list.value = data;
};
方案 B:防抖 + 序号校验(更稳,兼容老浏览器)
let seq = 0;
const search = async (keyword) => {
const mySeq = ++seq;
const { data } = await api.getProducts({ keyword });
if (mySeq !== seq) return; // 过期结果,直接丢弃
list.value = data;
};
实战建议:列表类用方案 B(还能顺便处理分页),文件上传/敏感写操作(如支付下单)用方案 A,避免重复提交。
模块七:失败重试(抖动网络下的体验救星)
async function retry(fn, times = 3, delay = 800) {
for (let i = 1; i <= times; i++) {
try { return await fn(); }
catch (e) { if (i === times) throw e; await new Promise(r => setTimeout(r, delay * i)); }
}
}
// 用法
const { data } = await retry(() => api.getProducts());
⚠️ 只对幂等请求(GET)重试,POST/PUT 重试可能导致重复下单、重复扣款。更严谨的做法是在拦截器里按 config.method 过滤。
模块八:和昨天的 Pinia 联动(loading / 错误 / 用户状态)
这是今天最该记住的设计:HTTP 层不自己管 UI,只改 store;UI 订阅 store。
const useLoading = defineStore('loading', {
state: () => ({ count: 0 }), // 计数,支持并发请求
getters: { active: (s) => s.count > 0 },
actions: { start() { this.count++; }, stop() { this.count = Math.max(0, this.count - 1); } },
});
模板里一个全屏遮罩搞定全站 loading:
<div v-show="loading.active" style="position:fixed;inset:0;background:rgba(255,255,255,.7);z-index:999;display:flex;align-items:center;justify-content:center">加载中...</div>
同理,401 时调用 useUser().logout(),token 一清,全站自动退回登录页——鉴权逻辑零散落在十几个组件里的时代结束了。
模块九:跨域与 Mock —— 本地开发最常卡住的地方
- CORS 是服务端问题,不是前端 bug。浏览器报
Access-Control-Allow-Origin,说明请求其实发出去了、服务器也回了,只是响应头没放行。解决:后端加头 / Vite 或 Webpack 配proxy/ 浏览器装 CORS 插件(仅开发)。 - 纯 HTML 文件(file:// 协议)没法配代理,所以今天这份 demo 采用内置 Mock 适配器:当
baseURL为空时,请求拦截器直接返回假数据,完全不发网络请求。切真实后端只需改一行ENV。 - 想练真实接口又不想写后端:装
json-server,npx json-server db.json --port 3001,一分钟拥有 RESTful API(支持 GET/POST/PUT/DELETE 和简单查询)。
模块十:六个新手坑
| # | 坑 | 正确做法 |
|---|---|---|
| 1 | 拿 res 当数据用 | 数据在 res.data |
| 2 | 以为 404/500 会进 catch(fetch 思维) | axios 默认会;若自定义了 validateStatus 则不会 |
| 3 | baseURL 末尾多一个 / | 去掉,或统一用工具函数拼接 |
| 4 | 拦截器里忘了 return config / return Promise.reject(error) | 链路断裂,请求静默卡死 |
| 5 | 在拦截器里 import store 造成循环依赖 | 改成在拦截器内调用 useXxx()(Pinia 支持)或在挂载后注入 |
| 6 | POST 传参用了 params 而非 data | query → params,body → data(表单用 FormData / application/x-www-form-urlencoded) |
调试:Chrome DevTools → Network → Fetch/XHR,看 Status、Request Headers(token 有没有带上)、Response。比看控制台 log 快十倍。
🛠️ 完整项目:MiniMall 接入 HTTP 层(单文件 · 无后端也能跑)
功能:商品列表从「接口」拉取(内置 Mock,延迟模拟真实网络)→ 全局 loading 遮罩 → 分类筛选带防抖 + 过期结果丢弃 → 加入购物车 → 结算下单(POST,带 token,失败自动重试 2 次)→ 登录接口 → 401 自动清 token 跳登录页 → 顶部黄条显示当前运行模式(Mock / 真实 API)。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Day 11 · MiniMall + Axios</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.js"></script>
<script src="https://unpkg.com/pinia@2/dist/pinia.iife.js"></script>
<script src="https://unpkg.com/axios@1/dist/axios.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,'Segoe UI',sans-serif;background:#f5f7fa;color:#333}
#banner{display:none;padding:10px;text-align:center;font-weight:bold;font-size:13px;border-bottom:1px solid}
.layout{display:flex;min-height:100vh}
.sidebar{width:220px;background:#1f2937;color:#fff;padding:20px 0;position:fixed;height:100vh}
.sidebar h2{padding:0 20px 16px;border-bottom:1px solid #374151;font-size:18px}
.sidebar nav a{display:block;padding:13px 20px;color:#9ca3af;text-decoration:none}
.sidebar nav a:hover,.sidebar nav a.router-link-active{background:#374151;color:#fff}
.sidebar nav a.router-link-active{background:#3b82f6}
.content{margin-left:220px;flex:1;padding:24px}
.topbar{background:#fff;padding:12px 24px;border-radius:8px;margin-bottom:20px;display:flex;justify-content:space-between;align-items:center;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.badge{background:#ef4444;color:#fff;font-size:12px;padding:2px 8px;border-radius:10px;margin-left:6px}
.card{background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:16px}
.item{background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);transition:transform .2s}
.item:hover{transform:translateY(-3px)}
.item img{width:100%;height:140px;object-fit:cover;background:#eee}
.item .info{padding:12px}.item .info h4{font-size:14px;margin-bottom:6px}
.price{color:#ef4444;font-weight:bold}
.btn{padding:8px 16px;border:none;border-radius:6px;cursor:pointer;font-size:13px;color:#fff;background:#3b82f6}
.btn:hover{background:#2563eb}.btn.outline{background:#fff;color:#3b82f6;border:1px solid #3b82f6}
.btn.danger{background:#ef4444}.btn:disabled{background:#ccc;cursor:not-allowed}
table{width:100%;border-collapse:collapse}th{text-align:left;padding:10px 0;color:#6b7280;font-weight:normal;font-size:13px}
td{padding:10px 0;border-bottom:1px solid #f0f0f0}
.qty{display:inline-flex;align-items:center;gap:8px}.qty button{width:28px;height:28px;border:1px solid #ddd;background:#fff;border-radius:4px;cursor:pointer}
.toasts{position:fixed;top:20px;right:20px;z-index:999;display:flex;flex-direction:column;gap:8px}
.toast{padding:12px 20px;border-radius:8px;color:#fff;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,.15);animation:slideIn .25s ease;min-width:220px}
.toast.success{background:#10b981}.toast.error{background:#ef4444}.toast.info{background:#3b82f6}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.form input,.form select{width:100%;padding:10px 12px;border:1px solid #ddd;border-radius:6px;margin-bottom:12px;font-size:14px;outline:none}
.mask{position:fixed;inset:0;background:rgba(255,255,255,.75);z-index:1000;display:flex;align-items:center;justify-content:center;font-size:16px;color:#666}
.tabs{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
.tabs button{padding:6px 14px;border:1px solid #ddd;border-radius:20px;background:#fff;cursor:pointer;font-size:13px}
.tabs button.active{background:#3b82f6;color:#fff;border-color:#3b82f6}
</style>
</head>
<body>
<div id="banner"></div>
<div id="app"></div>
<script>
/* ==================== ① 依赖兜底(复用 Day10) ==================== */
const banner=document.getElementById('banner');
if(!window.Vue){banner.style.display='block';banner.style.background='#fee2e2';banner.style.color='#991b1b';banner.textContent='❌ Vue 加载失败';throw new Error('Vue missing')}
let PL=window.Pinia||window.PiniaVue3||window.pinia;
if(!PL||typeof PL.createPinia!=='function'){
banner.style.display='block';banner.style.background='#fef3c7';banner.style.color='#92400e';banner.style.borderBottomColor='#fde68a';
banner.textContent='⚠️ Pinia 未加载,已切换内置兼容层';
PL=(()=>{const map=new Map(),active={};
const createPinia=()=>({_s:map,install:(app)=>{app.provide('__pinia__',active)}});
const defineStore=(id,{state,getters={},actions={}})=>()=>{
if(!map.has(id)){const fn=state||(()=>({})),s=Vue.reactive(fn());
for(const k in getters)Object.defineProperty(s,k,{get:()=>getters[k].call(s,s),enumerable:true});
for(const k in actions)s[k]=actions[k].bind(s);
s.$patch=f=>(typeof f==='function'?f(s):Object.assign(s,f));
s.$reset=()=>{const n=fn();for(const k in n)s[k]=n[k]};
s.$subscribe=cb=>Vue.watch(()=>s,()=>cb({events:[],storeId:id},s),{deep:true});
map.set(id,s)}return map.get(id)};
const storeToRefs=st=>{const r={};for(const k in st){if(k.startsWith('$')||typeof st[k]==='function')continue;r[k]=Vue.toRef(st,k)}return r};
return{createPinia,defineStore,storeToRefs}})();
}
const {createPinia,defineStore,storeToRefs}=PL;
const {createApp,ref,onMounted}=Vue;const {createRouter,createWebHashHistory,useRouter}=VueRouter;
if(!window.axios){banner.style.display='block';banner.style.background='#fee2e2';banner.style.color='#991b1b';banner.textContent='❌ Axios 加载失败';throw new Error('axios missing')}
/* ==================== ② 环境 & HTTP 实例 ==================== */
const ENV={mock:'',dev:'https://dev-api.example.com',prod:'https://api.example.com'};
const BASE_URL=ENV.mock; // ★ 切真实后端:改这里即可
banner.style.display='block';
banner.style.background=BASE_URL?'#dbeafe':'#fef3c7';
banner.style.color=BASE_URL?'#1e40af':'#92400e';
banner.style.borderBottomColor=BASE_URL?'#bfdbfe':'#fde68a';
banner.textContent=BASE_URL?`🌐 真实 API 模式:${BASE_URL}`:'🧪 Mock 模式(无后端也可运行,数据为本地模拟)';
const http=axios.create({baseURL:BASE_URL,timeout:8000,headers:{'Content-Type':'application/json'}});
/* ---- 内置 Mock 数据与适配器(BASE_URL 为空时生效) ---- */
const MOCK={
products:[
{id:1,name:'机械键盘 RGB',price:299,stock:10,category:'数码'},
{id:2,name:'无线鼠标 静音版',price:89,stock:50,category:'数码'},
{id:3,name:'降噪耳机 Pro',price:599,stock:5,category:'数码'},
{id:4,name:'纯棉T恤 基础款',price:59,stock:200,category:'服饰'},
{id:5,name:'帆布双肩包',price:129,stock:80,category:'箱包'},
{id:6,name:'4K 显示器',price:1299,stock:0,category:'数码'},
],
orders:[]
};
http.interceptors.request.use(async cfg=>{
if(!BASE_URL){await new Promise(r=>setTimeout(r,400+Math.random()*400)); // 模拟网络延迟
if(cfg.url.startsWith('/products')){
const cat=cfg.params?.category, kw=cfg.params?.keyword;
let d=[...MOCK.products];
if(cat&&cat!=='全部')d=d.filter(p=>p.category===cat);
if(kw)d=d.filter(p=>p.name.includes(kw));
cfg.adapter=()=>Promise.resolve({data:d,status:200,statusText:'OK',headers:{},config:cfg});
}else if(cfg.url==='/orders'&&cfg.method==='post'){
const o={id:Date.now(),items:cfg.data,...cfg.data,createdAt:new Date().toLocaleString()};
MOCK.orders.push(o);
cfg.adapter=()=>Promise.resolve({data:o,status:201,statusText:'Created',headers:{},config:cfg});
}else if(cfg.url==='/auth/login'){
if(cfg.data?.account&&cfg.data?.password) cfg.adapter=()=>Promise.resolve({data:{token:'t-'+Date.now(),name:cfg.data.account},status:200,statusText:'OK',headers:{},config:cfg});
else{cfg.adapter=()=>Promise.reject(Object.assign(new Error('账号或密码不能为空'),{response:{status:400,data:{msg:'账号或密码不能为空'}}}))}
}else if(cfg.url==='/products/fail-demo'){ // 用于演示 500 + 重试
cfg.adapter=()=>Math.random()>0.6?Promise.resolve({data:MOCK.products,status:200,statusText:'OK',headers:{},config:cfg})
:Promise.reject(Object.assign(new Error('Internal Server Error'),{response:{status:500,data:{msg:'服务器内部错误'}}}));
}
}
const t=localStorage.getItem('mm-token');if(t)cfg.headers.Authorization=`Bearer ${t}`;
useLoading().start();return cfg;},e=>Promise.reject(e));
http.interceptors.response.use(res=>{useLoading().stop();return res},err=>{
useLoading().stop();let msg='网络异常,请检查连接';
if(err.code==='ECONNABORTED')msg='请求超时,请重试';
else if(err.response){const s=err.response.status;
if(s===401){try{useUser()?.logout()}catch(_){}useRouter()?.replace('/login');msg='登录已过期,请重新登录'}
else if(s===403)msg='无权限访问';else if(s===404)msg='资源不存在';
else if(s>=500)msg='服务器繁忙,请稍后再试';else msg=err.response.data?.msg||`请求失败(${s})`}
useNotify().push(msg,'error');return Promise.reject(err)});
/* ---- API 模块 ---- */
const api={
getProducts:(p)=>http.get('/products',{params:p}),
login:(d)=>http.post('/auth/login',d),
createOrder:(d)=>http.post('/orders',d),
failDemo:()=>http.get('/products/fail-demo'),
};
/* ==================== ③ Store ==================== */
const useNotify=defineStore('notify',{state:()=>({list:[]}),actions:{
push(msg,type='success'){const id=Date.now()+Math.random();this.list.push({id,msg,type});setTimeout(()=>this.list=this.list.filter(n=>n.id!==id),3000)}}});
const useLoading=defineStore('loading',{state:()=>({count:0}),getters:{active:s=>s.count>0},actions:{start(){this.count++},stop(){this.count=Math.max(0,this.count-1)}}});
const useCart=defineStore('cart',{state:()=>({items:[]}),
getters:{count:s=>s.items.reduce((n,i)=>n+i.qty,0),total:s=>s.items.reduce((n,i)=>n+i.product.price*i.qty,0)},
actions:{add(p){const it=this.items.find(i=>i.product.id===p.id);if(it){it.qty++;useNotify().push(`${p.name} 数量+1`)}else{this.items.push({product:p,qty:1});useNotify().push(`${p.name} 已加入购物车`)}} ,
setQty(id,q){if(q<=0){this.remove(id);return}const it=this.items.find(i=>i.product.id===id);if(it)it.qty=q},
remove(id){const it=this.items.find(i=>i.product.id===id);this.items=this.items.filter(i=>i.product.id!==id);if(it)useNotify().push(`${it.product.name} 已移除`,'info')},
clear(){this.items=[];useNotify().push('购物车已清空','info')},
load(){const r=localStorage.getItem('mm-cart');if(r)try{this.items=JSON.parse(r)}catch(_){}},save(){localStorage.setItem('mm-cart',JSON.stringify(this.items))}}});
const useUser=defineStore('user',{state:()=>({token:localStorage.getItem('mm-token')||'',name:''}),getters:{isLogin:s=>!!s.token},actions:{
async login(a,p){const{data}=await api.login({account:a,password:p});this.token=data.token;this.name=data.name;localStorage.setItem('mm-token',data.token);useNotify().push(`欢迎回来,${data.name}`)},
logout(){this.token='';this.name='';localStorage.removeItem('mm-token');useCart().clear();useNotify().push('已退出登录','info')}}});
/* ==================== ④ 组件 ==================== */
const Layout={template:`<div class="layout"><aside class="sidebar"><h2>🛍 MiniMall</h2><nav>
<router-link to="/products">商品</router-link><router-link to="/cart">购物车</router-link><router-link to="/orders">我的订单</router-link>
<template v-if="!user.isLogin"><router-link to="/login">登录</router-link></template>
<template v-else><a href="#" @click.prevent="user.logout()">退出 ({{user.name}})</a></template></nav></aside>
<div class="content"><div class="topbar"><strong>Axios 请求封装演示</strong>
<div>🛒 <span class="badge">{{cart.count}}</span> 件 | ¥<strong>{{cart.total}}</strong></div></div>
<router-view/></div>
<div class="toasts"><div class="toast" :class="n.type" v-for="n in notify.list" :key="n.id">{{n.msg}}</div></div>
<div class="mask" v-if="loading.active">🔄 加载中...</div></div>`,
setup(){return{cart:useCart(),user:useUser(),notify:useNotify(),loading:useLoading()}}};
const ProductList={template:`<div><h2 style="margin-bottom:12px">商品列表</h2>
<div class="tabs"><button v-for="c in cats" :key="c" :class="{active:c===cur}" @click="pick(c)">{{c}}</button></div>
<p style="font-size:12px;color:#999;margin-bottom:12px">💡 切换分类会发起新请求,快速切换时旧结果被丢弃(序号校验)</p>
<div class="grid" v-if="list.length">
<div class="item" v-for="p in list" :key="p.id"><img src="'https://placehold.co/400x300/eef/333?text='+encodeURIComponent(p.name)" />
<div class="info"><h4>{{p.name}}</h4><div class="price">¥{{p.price}} <span style="color:#999;font-size:12px">库存{{p.stock}}</span></div>
<button class="btn" style="margin-top:10px;width:100%" :disabled="p.stock===0" @click="cart.add(p)">{{p.stock>0?'加入购物车':'已售罄'}}</button></div></div>
</div><div class="card" v-else style="text-align:center;padding:40px;color:#999">{{busy?'加载中...':'暂无商品'}}</div></div>`,
setup(){
const cats=['全部','数码','服饰','箱包'],cur=ref('全部'),list=ref([]),busy=ref(false);
let seq=0;
const pick=async(c)=>{cur.value=c;const my=++seq;busy.value=true;
try{const{data}=await api.getProducts(c==='全部'?{}:{category:c});if(my!==seq)return;list.value=data}
finally{busy.value=false}};
onMounted(()=>pick('全部'));
return{cats,cur,list,busy,pick,cart:useCart()}}};
const CartPage={template:`<div><h2 style="margin-bottom:16px">购物车</h2>
<div class="card" v-if="cart.items.length"><table>
<tr><th>商品</th><th>单价</th><th>数量</th><th>小计</th><th>操作</th></tr>
<tr v-for="it in cart.items" :key="it.product.id"><td>{{it.product.name}}</td><td>¥{{it.product.price}}</td>
<td><span class="qty"><button @click="cart.setQty(it.product.id,it.qty-1)">−</button>{{it.qty}}<button @click="cart.setQty(it.product.id,it.qty+1)">+</button></span></td>
<td>¥{{it.product.price*it.qty}}</td><td><button class="btn danger" @click="cart.remove(it.product.id)">删除</button></td></tr>
</table><div style="margin-top:16px;display:flex;gap:10px;align-items:center">
<button class="btn outline" @click="doCheckout" :disabled="!user.isLogin">{{user.isLogin?'去结算':'请先登录'}}</button>
<button class="btn danger" @click="cart.clear()">清空</button>
<span style="margin-left:auto;font-size:18px">合计 <strong style="color:#ef4444">¥{{cart.total}}</strong></span>
</div></div><div class="card" v-else style="text-align:center;padding:40px;color:#999">购物车是空的<br><router-link to="/products" style="color:#3b82f6">去购物</router-link></div></div>`,
setup(){const cart=useCart(),notify=useNotify(),router=useRouter();
const doCheckout=async()=>{if(!useUser().isLogin)return notify.push('请先登录','error');
try{await api.createOrder({items:cart.items,total:cart.total});cart.clear();notify.push('下单成功!','success');router.push('/orders')}catch(_){}};
return{cart,user:useUser(),doCheckout}}};
const OrdersPage={template:`<div class="card"><h2 style="margin-bottom:12px">我的订单</h2>
<p>当前用户:<strong>{{user.name||'未登录'}}</strong></p>
<div style="margin-top:16px;padding:16px;background:#f9fafb;border-radius:6px">
<p style="color:#666;font-size:14px">Mock 模式下,结算成功的订单会暂存在内存中。</p>
<p style="margin-top:8px;font-size:13px;color:#999">💡 提示:真实项目中这里应调用 GET /orders 拉取列表。</p>
</div></div>`,setup(){return{user:useUser()}}};
const LoginPage={template:`<div class="card form" style="max-width:400px;margin:40px auto"><h2 style="margin-bottom:16px;text-align:center">登录</h2>
<input v-model="account" placeholder="账号(任意输入)"/><input v-model="password" type="password" placeholder="密码(任意输入)" @keyup.enter="doLogin"/>
<button class="btn" style="width:100%" :disabled="loading" @click="doLogin">{{loading?'登录中...':'登录'}}</button>
<p style="margin-top:10px;font-size:12px;color:#999">任意输入即可,走 POST /auth/login</p></div>`,
setup(){const account=ref(''),password=ref(''),loading=ref(false);const user=useUser(),router=useRouter(),notify=useNotify();
const doLogin=async()=>{loading.value=true;try{await user.login(account.value,password.value);router.replace('/orders')}catch(_){}finally{loading.value=false}};
return{account,password,loading,doLogin}}};
/* ==================== ⑤ 路由 + 挂载 ==================== */
const router=createRouter({history:createWebHashHistory(),routes:[
{path:'/',redirect:'/products'},{path:'/',component:Layout,children:[
{path:'products',component:ProductList},{path:'cart',component:CartPage},
{path:'orders',component:OrdersPage,meta:{requiresAuth:true}},{path:'login',component:LoginPage}]}]});
router.beforeEach(to=>{if(to.meta.requiresAuth&&!useUser().isLogin){useNotify().push('请先登录','error');return '/login'}});
const app=createApp({template:'<router-view/>'});app.use(createPinia());app.use(router);app.mount('#app');
useCart().load();useCart().$subscribe(()=>useCart().save());
</script>
</body>
</html>
📌 今日覆盖要点清单
- fetch vs axios 选型对比
axios.create实例、baseURL、timeout、环境切换- 请求拦截器:自动带 token、全局 loading
- 响应拦截器:统一错误分类、401 自动登出
- API 模块抽取,组件只写业务
- 取消重复请求(AbortController / 序号校验)、防抖
- 失败重试(仅幂等请求)
- 与 Pinia 联动:loading store、通知 store、用户 store
- CORS 本质、Mock 适配器、json-server 快速起后端
- 十个新手坑 + Network 面板调试
✏️ 动手任务
- 把顶部的
BASE_URL切成dev,观察拦截器的错误提示是否正常工作(预期看到「网络异常」或 CORS 报错),再切回mock恢复。 - 给
api.getProducts加上** AbortController 版**取消逻辑,对比「序号校验」和「abort」在 Network 面板中的区别(abort 的请求会显示(canceled))。 - 新增一个
PUT /products/:id的 Mock 分支,在商品卡片上加「库存 -1」按钮,体验 PUT 传参用data而非params。 - (选做)把重试逻辑做成请求拦截器的
retryCount配置项,实现「只在 config.retry > 0 时重试」。
📢 明天预告(Day 12)
组合式函数(Composables)抽离复用逻辑——今天我们学会了把请求抽成 api 模块,但「分页 + 加载态 + 错误态 + 防抖」这套模式在每个列表页还是复制粘贴了一遍。明天把它彻底抽成 usePagination(api.getProducts),顺带讲清 composable 与 store 的边界(什么时候该用哪个,这是面试高频题),并顺手把今天的商品列表重构成可复用的 useList。