Day 11|Axios 请求封装:把本地数组换成真实接口(含拦截器、重试、取消重复请求)

0 阅读7分钟

依旧先看今天效果图

昨天我们把数据收进了 Pinia,但那些商品、订单还躺在本地数组里。今天做一件「让项目真正变工程」的事:接上 HTTP。核心不是调通一个接口,而是把请求逻辑从组件里抽出来,封成一个可复用、可调试、可统一处理错误和加载态的层。文末的完整代码依然单文件、零构建,并且没后端也能跑(内置了 Mock 适配器,切一个开关就能接真实 API)。


模块一:为什么不用 fetch,要上 Axios?

fetch 够用,但不够「好用」——它有几个反直觉的设计:

问题fetchaxios
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

三个必须记的点:

  1. 数据在 response.data,不是 response。
  2. axios 返回的是 Promise,在 Vue 里配合 async/await 最舒服;模板中不能直接 await,要在 setup 或方法里取完赋给响应式变量。
  3. 错误在 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 则不会
3baseURL 末尾多一个 /去掉,或统一用工具函数拼接
4拦截器里忘了 return config / return Promise.reject(error)链路断裂,请求静默卡死
5在拦截器里 import store 造成循环依赖改成在拦截器内调用 useXxx()(Pinia 支持)或在挂载后注入
6POST 传参用了 params 而非 dataquery → 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 面板调试

✏️ 动手任务

  1. 把顶部的 BASE_URL 切成 dev,观察拦截器的错误提示是否正常工作(预期看到「网络异常」或 CORS 报错),再切回 mock 恢复。
  2. 给 api.getProducts 加上** AbortController 版**取消逻辑,对比「序号校验」和「abort」在 Network 面板中的区别(abort 的请求会显示 (canceled))。
  3. 新增一个 PUT /products/:id 的 Mock 分支,在商品卡片上加「库存 -1」按钮,体验 PUT 传参用 data 而非 params。
  4. (选做)把重试逻辑做成请求拦截器的 retryCount 配置项,实现「只在 config.retry > 0 时重试」。

📢 明天预告(Day 12)

组合式函数(Composables)抽离复用逻辑——今天我们学会了把请求抽成 api 模块,但「分页 + 加载态 + 错误态 + 防抖」这套模式在每个列表页还是复制粘贴了一遍。明天把它彻底抽成 usePagination(api.getProducts),顺带讲清 composable 与 store 的边界(什么时候该用哪个,这是面试高频题),并顺手把今天的商品列表重构成可复用的 useList。