外观
第6章:让页面可靠地连接接口
约 9128 字大约 30 分钟
AxiosHTTP异步状态接口封装
2026-07-28
第 5 章的商品数据直接写在页面里,所以读取、添加和删除几乎马上完成。真实项目里的数据通常来自接口:请求可能很慢,可能返回空数组,也可能因为地址、服务或网络问题而失败。
本章把商品管理页的本地数组替换为接口数据。所有练习只使用课程提供的本地 Mock,重点掌握请求发出后页面状态的组织与反馈。
学习说明
本章依次学习接口契约、请求分层、页面四态、错误传播和竞态处理,所有接口练习只连接本地 Mock。
配套资源
本章示例工程位于 resources/ch06/classroom-demo/,本地接口服务位于 resources/ch06/mock-server.mjs,章末任务起始工程位于 resources/ch06/after-class-starter/。无需修改 mock-server.mjs。
🎯 学习梯度指引(分层通关)
- 核心必学(保底通关 · 接口驱动业务):掌握 6.1、6.2(接口契约与 Network 取证)、6.3(Axios 实例与 API 分层)、6.4(页面四态处理)、6.6(基础 CRUD:GET 查、POST 增、DELETE 删)。能够独立跑通网络接口的请求与交互反馈闭环。
- 进阶选学(🌟 自主拓展 · 异步时序与工程拦截):6.4(状态机驱动模式)、6.5(响应拦截器统一拆包与错误标准化传播)、6.6(搜索竞态与请求序号保护)。供学有余力或有进阶工程需求的学生自主探索,不作为基础达标强制考核。
6.1 本章任务清单
本章工程主线是:先看懂一次请求的约定,再把请求代码分层,接着补齐页面四态,最后处理错误传播、搜索竞态和本地 CRUD。
| 任务 | 具体怎么做 | 完成后应该看到 |
|---|---|---|
| 写清接口契约 | 写出 method、path、query、body、成功、错误六要素,并用 Network 面板核对 | 能说清请求发到哪里、带了什么、返回了什么 |
| 建立请求分层 | 创建 Axios 实例、商品 API 模块和页面调用,给搜索词传 query | 页面不拼完整 URL;Network 参数和输入一致 |
| 完成页面四态 | 用 loading、rows、errorMessage 控制加载、成功、空数据和失败 | 正常、空数组、停 Mock 三种操作会出现不同画面 |
| 规范错误传播 | 用响应拦截器整理错误,并继续 Promise.reject | 接口失败仍会进入页面 catch,重试入口能够出现 |
| 处理竞态与 CRUD | 用请求序号保护最后一次搜索,完成列表、新增和删除 | 快速搜索不闪回旧结果;GET、POST、DELETE 都能在 Network 中找到 |
开工顺序
- 复制
resources/ch06/classroom-demo/作为课堂工程,执行npm install。 - 先执行
npm run dev,直接访问/mock/products.json,完成“静态 JSON 的第一次 GET”。 - 写接口契约,创建
src/api/http.js和src/api/products.js,让页面只调用 API 函数。 - 进入动态 Mock 阶段时只切换一次数据地址,并打开两个终端:终端一执行
npm run mock,终端二执行npm run dev。 - 依次完成页面四态、响应拦截器和搜索竞态,再做本地新增、删除操作。
- 每完成一步都打开 Network 面板,用 URL、method、status 和 response 验证,不凭页面外观猜结果。
public/mock/products.json
{
"items": [
{
"id": 101,
"name": "Vue 组件化学习卡",
"price": 29.9,
"stock": 20,
"enabled": true
},
{
"id": 102,
"name": "前端排错记录本",
"price": 16,
"stock": 4,
"enabled": false
}
],
"total": 2
}本章只在一个位置切换数据源
前半段先请求 public/mock/products.json,目的是降低第一次请求的难度;从 6.3 的“切换到动态 Mock”开始,后面统一请求 http://127.0.0.1:3001/api/products。不要在两个地址之间来回切换。
6.2 知识点一:先写接口契约
学习目标
掌握接口契约的六个要素,能够在写 Axios 代码前说明一次请求的输入、输出和错误。
语法/概念
接口可以理解为前端和数据服务之间的一张“取货单”。取货单没写清楚,前端就只能一边猜一边改。
一张最小接口契约要写六项:
| 要素 | 简单说明 | 商品列表例子 |
|---|---|---|
| method | 这次要读、加、改还是删 | GET |
| path | 请求送到哪个路径 | /products |
| query | 跟在地址后面的筛选条件 | keyword=Vue |
| body | 放在请求体里的新数据 | GET 列表没有 body |
| 成功 | 成功后数据长什么样 | { items, total } |
| 错误 | 失败后怎样说明原因 | { message, code } |
可以先照下面的模板填写,再写请求代码:
接口契约模板
接口名称:
method:
path:
query:
body:
成功响应:
错误响应:先分清 query 和 body
搜索词通常放在 query 中,例如 /products?keyword=Vue;新增商品的数据通常放在 body 中。两者不是同一个位置。
问题: 如果只知道接口地址,不知道成功响应里数组叫 items 还是 data,页面能稳定取出列表吗?
课堂演示
步骤 1:先让静态数据能被访问
进入课堂工程:
cd resources/ch06/classroom-demo
npm install
npm run dev在浏览器打开终端给出的本地地址,再访问:
http://localhost:5173/mock/products.json如果端口不是 5173,以终端实际显示的端口为准。
保存/运行后应该看到:浏览器显示带有 items 和 total 的 JSON,而不是 404 页面。
步骤 2:在 Network 面板找到证据
- 按
F12打开开发者工具。 - 切到
Network。 - 刷新
/mock/products.json。 - 点开这条请求,依次看
Request URL、Request Method、Status Code和Response。
运行后应该看到:method 是 GET,状态码是 200,响应中有商品数组。
步骤 3:把证据写回契约表
| 要素 | 本次实际值 |
|---|---|
| method | GET |
| path | /mock/products.json |
| query | 暂无 |
| body | 无 |
| 成功 | { items: 商品数组, total: 数量 } |
| 错误 | 静态文件不存在时返回 404 |
保存后应该看到:这张表里的值都能在 Network 面板中找到依据,不是凭记忆填写。
当堂练习(分层双轨制)
- 【必做任务(基础通关)】:把“商品列表”换成“实训室设备列表”写一张接口契约表。写全 method(
GET)、path(/devices)、query(keyword)、body(无)、成功响应与错误响应六项;在 Network 面板中核对至少两项实际值(例如 Request Method 和 Status Code),并在下方附上一句“我核对的证据是……”。 - 【选做挑战(🌟 自主拓展)】:为“新增设备”编写一份包含 Request Body 的接口契约表,明确指出新增时的请求方法(
POST)、JSON 数据体字段以及新增成功后的预期状态码(如201或200)。
6.3 知识点二:Axios 实例、API 模块与三层边界
学习目标
掌握 Axios 实例、业务 API 模块和页面三层分工,能够让搜索词通过 API 函数进入请求参数。
语法/概念
把请求代码分层,不是为了多建文件,而是让每层只管一类问题:
| 层 | 负责什么 | 不负责什么 |
|---|---|---|
http.js | 公共地址、超时、以后统一的响应处理 | 不写“商品列表怎么显示” |
products.js | 商品有哪些接口、路径和参数 | 不控制页面加载画面 |
App.vue 或页面组件 | 何时请求、显示什么、用户怎样重试 | 不重复拼完整服务器地址 |
最小语法长这样:
const http = axios.create({
baseURL: '公共接口根地址',
timeout: 8000,
})
http.get('/业务路径', {
params: { keyword: '搜索词' },
})baseURL 是公共前缀,timeout 是最多等待多久。最终地址由“公共前缀 + 业务路径”组成。
第6章请求分层
src
api
http.js# Axios 实例和公共规则
products.js# 商品接口函数
App.vue# 页面状态和用户操作
public
mock
products.json# 第一次 GET 使用的静态数据
环境变量只保存“不同环境会变化的公开配置”:
.env.development
VITE_API_BASE_URL=
VITE_PRODUCTS_PATH=/mock/products.jsonVITE_ 开头的变量会进入浏览器代码,所以可以放接口地址,不能放密码、私钥或真正的访问令牌。生产环境如果换服务器,可以修改环境变量,不需要到每个页面找 URL。
自动附加登录令牌属于请求拦截器的常见用途,本章只知道这件事,不写请求拦截器代码。
问题: 如果三个页面都直接写 http://127.0.0.1:3001/api,以后端口变化时要改几个地方?
课堂演示
步骤 1:安装 Axios 并创建目录
npm install axios在 src 下新建 api 文件夹,再创建 http.js 和 products.js。
保存后应该看到:项目依赖中出现 Axios,目录中有两个 API 文件,页面文件仍保留在 src/App.vue。
步骤 2:建立唯一一份 Axios 实例
这里要记住的关键词是 baseURL,它表示每个请求共同使用的接口根地址。
保存后应该看到:http.js 只有公共设置,products.js 只表达“获取商品”这个业务动作。
步骤 3:让页面调用 API 函数
把 src/App.vue 改成下面的最小页面:
src/App.vue
<script setup>
import { onMounted, ref } from 'vue'
import { fetchProducts } from './api/products'
const keyword = ref('')
const currentKeyword = ref('全部商品')
const rows = ref([])
async function loadProducts() {
currentKeyword.value = keyword.value.trim() || '全部商品'
const data = await fetchProducts(keyword.value)
rows.value = Array.isArray(data.items) ? data.items : []
}
onMounted(loadProducts)
</script>
<template>
<main class="page">
<h1>接口商品列表</h1>
<div class="search">
<input v-model="keyword" placeholder="输入商品名称" />
<button type="button" @click="loadProducts">搜索</button>
</div>
<p>当前搜索词:{{ currentKeyword }}</p>
<ul>
<li v-for="row in rows" :key="row.id">
{{ row.name }} · ¥{{ row.price }} · 库存 {{ row.stock }}
</li>
</ul>
</main>
</template>
<style scoped>
.page {
width: min(720px, calc(100% - 32px));
margin: 48px auto;
font: 16px/1.7 system-ui, sans-serif;
}
.search {
display: flex;
gap: 8px;
}
input,
button {
padding: 10px 12px;
font: inherit;
}
input {
flex: 1;
}
li {
margin: 8px 0;
}
</style>保存/运行后应该看到:页面列出静态 JSON 中的商品,点击搜索后页面会显示当前搜索词。
步骤 4:检查搜索参数
输入“Vue”,点击“搜索”,然后在 Network 中点开 products.json 请求。
运行后应该看到:Request URL 中出现 keyword=Vue;静态 JSON 不会真的按关键词筛选,但请求参数已经正确发出。
四点简单理解
http.js是大家共用的“请求工具”。products.js是商品业务的“办事窗口”。- 页面只说“我要哪些商品”,不背完整地址。
finally属于页面状态处理,下一节再加入。
步骤 5:唯一一次切换到动态 Mock
静态 JSON 只能做 GET,不能真正搜索、模拟 503、添加、编辑或删除。现在进行全文唯一一次数据源切换。
先把 .env.development 的两行改为:
.env.development
VITE_API_BASE_URL=http://127.0.0.1:3001/api
VITE_PRODUCTS_PATH=/products具体变化:
- 第 1 行从空地址改为
http://127.0.0.1:3001/api; - 第 2 行从
/mock/products.json改为/products; src/api/http.js和页面代码不改;- 修改环境变量后要重新启动 Vite。
避坑警示:修改环境变量后必须重启 Vite 服务
在本地修改 .env.development 文件后,正在运行的 Vite 开发服务器不会自动热更新环境变量!必须先在终端二中按 Ctrl + C 停止服务,再重新执行 npm run dev,新配置的接口地址才会真正生效。
打开两个终端:
# 终端一:启动课程提供的本地接口
npm run mock# 终端二:启动 Vue 页面
npm run dev先访问:
http://127.0.0.1:3001/api/products保存/运行后应该看到:浏览器返回 { items, total };页面搜索“Vue”时,列表只保留名称中含“Vue”的商品。
从这里开始不要切回静态 JSON
6.4 及后续内容统一使用 mock-server.mjs。课程已经提供该本地接口,只需启动服务,无需阅读或编写服务端代码。
当堂练习(分层双轨制)
- 【必做任务(基础通关)】:在当前页面上完成“名称搜索”:API 函数
fetchProducts接收keyword并通过params发送;页面点击搜索后展示“当前搜索词:xxx”;输入“Vue”后在 Network 面板确认 Request URL 中带有keyword=Vue;清空输入再次搜索能恢复展示全部商品。 - 【选做挑战(🌟 自主拓展)】:为搜索输入框绑定
@keyup.enter="loadProducts"支持键盘回车快捷搜索;并在输入框为空时自动触发一次无参查询,提升搜索交互体验。
6.4 知识点三:页面四态
学习目标
掌握加载、成功、空数据和失败四态,能够用固定判断顺序给用户明确反馈和重试入口。
语法/概念
接口页面不能只问“数组有没有内容”。rows.length === 0 可能代表还没请求、正在加载、成功但为空,也可能代表请求失败。用户看到的下一步完全不同,所以要分开保存。
交互演示
接口页面的四态模型
页面不是只在“成功返回数据”时才存在。
- 1加载中loading
- 2成功有数据success
- 3成功为空empty
- 4请求失败error
当前发生
请求发出后显示加载反馈,并避免重复提交。
观察证据
慢网速下用户仍知道系统在工作。
最常用的三个状态是:
const rows = ref([])
const loading = ref(false)
const errorMessage = ref('')模板按这个顺序判断:
| 顺序 | 条件 | 页面应该显示 |
|---|---|---|
| 1 | loading | 正在加载或骨架屏 |
| 2 | errorMessage | 错误原因和重试按钮 |
| 3 | rows.length === 0 | 请求成功,但当前没有数据 |
| 4 | 其他情况 | 商品列表 |
finally 可以理解为“无论成功还是失败,最后都要收尾”。加载状态最适合在这里恢复。
问题: 请求失败后,如果没有执行 loading.value = false,用户会看到什么错误画面?
课堂演示
步骤 1:先写请求状态的固定骨架
这里要记住的状态名是 loading,它表示当前请求是否还没有结束。
这段代码中,600 会让本地 Mock 延迟 600 毫秒,方便肉眼看到“正在加载”。
保存/运行后应该看到:请求开始时 loading 变为 true,请求结束后由 finally 恢复为 false,成功和失败都不会一直停在加载画面。
步骤 2:替换为可直接运行的四态页面
把 src/App.vue 完整替换为:
src/App.vue
<script setup>
import { onMounted, ref } from 'vue'
import { fetchProducts } from './api/products'
const keyword = ref('')
const rows = ref([])
const loading = ref(false)
const errorMessage = ref('')
async function loadProducts() {
loading.value = true
errorMessage.value = ''
try {
const data = await fetchProducts(keyword.value, 600)
rows.value = Array.isArray(data.items) ? data.items : []
} catch (error) {
rows.value = []
errorMessage.value =
error?.message || '商品加载失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(loadProducts)
</script>
<template>
<main class="page">
<h1>商品请求四态</h1>
<form class="search" @submit.prevent="loadProducts">
<input v-model="keyword" placeholder="输入商品名称" />
<button type="submit" :disabled="loading">搜索</button>
</form>
<p v-if="loading" class="state loading">
正在加载商品……
</p>
<div v-else-if="errorMessage" class="state error">
<p>{{ errorMessage }}</p>
<button type="button" @click="loadProducts">重新加载</button>
</div>
<p v-else-if="rows.length === 0" class="state empty">
接口请求成功,但当前没有商品。
</p>
<ul v-else class="products">
<li v-for="row in rows" :key="row.id">
<strong>{{ row.name }}</strong>
<span>
¥{{ row.price }} · 库存 {{ row.stock }} ·
{{ row.enabled ? '上架' : '下架' }}
</span>
</li>
</ul>
</main>
</template>
<style scoped>
.page {
width: min(760px, calc(100% - 32px));
margin: 48px auto;
font: 16px/1.7 system-ui, sans-serif;
color: #172033;
}
.search {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
input,
button {
padding: 10px 12px;
font: inherit;
}
input {
flex: 1;
}
.state,
.products {
padding: 20px;
border-radius: 14px;
background: #f4f7fb;
}
.loading {
color: #2563eb;
}
.error {
color: #a61b2b;
background: #fff1f2;
}
.empty {
color: #64748b;
}
.products {
list-style: none;
}
.products li {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid #dbe3ef;
}
.products li:last-child {
border-bottom: 0;
}
</style>保存/运行后应该看到:页面先出现“正在加载商品”,大约 600 毫秒后显示商品列表。
步骤 3:用三个动作观察四态
- 正常: 保持 Mock 运行,搜索框留空。应该先加载,后显示商品。
- 空数据: 输入一个不存在的名称,例如“火星商品”。应该显示空状态,不显示错误。
- 失败: 在终端一按
Ctrl + C停止 Mock,再点击“重新加载”。应该显示错误和重试按钮。 - 恢复: 重新执行
npm run mock,点击“重新加载”。页面应该恢复商品列表。
运行后应该看到:空数据和失败是两个不同画面;恢复 Mock 后不需要刷新整个浏览器。
步骤 4:认识状态机写法
当页面状态越来越多时,也可以只用一个 state:
const state = ref('idle')
async function loadProducts() {
state.value = 'loading'
try {
const data = await fetchProducts()
rows.value = data.items
state.value = rows.value.length ? 'success' : 'empty'
} catch (error) {
state.value = 'error'
}
}🌟 进阶选学:状态机驱动设计(State Machine)
当页面异步状态越来越多时,使用多个分散的布尔变量(如 loading、errorMessage、rows)容易产生状态互斥漏洞(例如既在显示 loading 又渲染了错误)。进阶项目中常改用单一枚举变量:
const state = ref('idle') // 取值:'idle' | 'loading' | 'success' | 'empty' | 'error'这样能确保同一时刻页面绝对只处于唯一的一种状态,排除状态重叠矛盾。
| 写法 | 适合情况 |
|---|---|
loading + rows + errorMessage | 初学、状态数量少,读起来直接 |
idle/loading/success/empty/error | 状态较多,希望每次只处在一种状态 |
本章课堂代码继续使用三个独立状态。状态机写法用于对比理解;如果以后多个页面都要读取同一个请求状态,第 7 章再学习全局状态,这里不引入 Pinia 代码。
保存/运行后应该看到:改用状态机时,同一时刻只会命中 loading、success、empty、error 中的一种页面状态。
当堂练习(分层双轨制)
- 【必做任务(基础通关)】:在四态页面中增加
lastError:初始值为空字符串;当进入catch失败分支时保存“上一次失败时间:时:分:秒”并在错误画面中展示;当点击“重新加载”且请求成功后清空lastError;验证停止 Mock 能看到失败时间和原因,恢复 Mock 重试后错误文字消失。 - 【选做挑战(🌟 自主拓展)】:为搜索按钮添加
:disabled="loading",在请求进行中禁用按钮并呈现加载文字,防止在慢速网络下多次狂点触发重复请求。
6.5 知识点四:响应拦截器统一处理但不吞错误
🌟 进阶选学:响应拦截器统一拆包与错误传播链
响应拦截器是全站请求返回页面的“统一安检口”:
- 自动拆包脱壳:成功时统一返回
response.data,页面可直接消费核心业务数据; - 错误统一规范化:从各种复杂响应中提取出标准的
status、code和message; - 绝不能吞掉失败:错误分支必须继续调用
return Promise.reject(normalized)!如果误写成普通 return 或没有返回,Promise 会被判定为“成功”,导致页面不会进入catch,失败提示和重试按钮就永远无法渲染。
避坑警示:避免双层 .data 属性读取错误
如果在拦截器中配置了 response => response.data 统一脱壳,那么在后续的 API 模块和页面组件中接收到的就已经是真实的数据对象,切勿再次写 res.data.items(会变成 undefined.items 报错)。
学习目标
掌握响应拦截器和 Promise.reject 的作用,能够整理错误对象并让页面 catch 继续收到失败。
语法/概念
响应拦截器像请求返回页面之前的一道统一检查。它适合做所有页面都需要的事情:
- 把 Axios 响应外壳统一拆开;
- 从不同错误响应里取出 status、code 和 message;
- 把整理后的错误继续交给调用页面。
交互演示
Axios 实例与拦截器的请求链
统一配置可以减少重复,但必须保持错误继续传播。
- 1调用实例api module
- 2请求处理request interceptor
- 3发送请求adapter
- 4响应处理response interceptor
当前发生
业务模块调用预先配置的 Axios 实例。
观察证据
组件中不重复写 baseURL。
不适合全部塞进拦截器的内容:
- 商品页专用的错误文案;
- 每次请求都弹同一句提示;
- 把失败改成空数组后继续当成功;
- 在源码中写真实令牌。
失败分支必须返回 rejected Promise:
return Promise.reject(normalizedError)如果返回普通对象,Promise 会变成成功状态,页面就会走 try 后半段,而不是进入 catch。
问题: 错误分支最后应该返回 fulfilled Promise,还是 rejected Promise?
课堂演示
步骤 1:在现有实例下面加入响应拦截器
不要再创建第二个 Axios 实例。在 src/api/http.js 的 axios.create 下方追加:
这里要记住的错误传播动作是 reject,它会让调用页面继续进入失败分支。
保存后应该看到:请求成功时,API 函数直接得到业务数据;请求失败时,页面仍能进入 catch。
步骤 2:同步调整商品 API 模块
拦截器已经返回 response.data,所以 products.js 不要再读取第二次 .data:
src/api/products.js
import http from './http'
export function fetchProducts(keyword = '', delay = 0) {
const path =
import.meta.env.VITE_PRODUCTS_PATH || '/products'
return http.get(path, {
params: {
keyword: keyword.trim() || undefined,
delay: delay || undefined,
},
})
}保存/运行后应该看到:商品列表仍然正常,说明“响应拆包”只移动到了拦截器,页面拿到的数据结构没有变化。
步骤 3:验证错误没有被吞掉
- 停止 Mock;
- 点击页面的“重新加载”;
- 在页面错误区域查看
error.message; - 在 Console 临时打印
error.status; - 重新启动 Mock 并重试。
运行后应该看到:停服务时页面进入错误分支,网络错误的 status 通常是 0;恢复服务后页面重新显示列表。
不要混用两种返回约定
既然响应拦截器统一返回了 response.data,所有业务 API 就按这个约定返回数据。不要一部分函数返回完整 response,另一部分函数只返回 data。
当堂练习(分层双轨制)
- 【必做任务(基础通关)】:修改响应拦截器的错误处理分支:统一给
message添加前缀“请求失败:”,并确保保留status字段且继续调用return Promise.reject(normalized)抛出;停止 Mock 服务后点击页面重试,验证页面catch成功接收并展示该错误信息。 - 【选做挑战(🌟 自主拓展)】:尝试在拦截器中根据不同的 HTTP 状态码进行错误文案定制(例如:
status === 404提示“接口地址不存在”,status === 0提示“本地服务未启动或网络异常”),提升业务错误提示的精准度。
6.6 知识点五:搜索竞态与本地 CRUD
学习目标
掌握搜索竞态的原因和常用处理策略,能够用请求序号保护最新结果,并通过 API 模块完成本地列表、新增和删除。
语法/概念
🌟 进阶选学:搜索竞态(Race Condition)与请求序号保护
当用户在输入框快速键入时(如连续输入“V”、“Vu”、“Vue”),浏览器会发出多次异步请求。由于网络波动或服务器响应耗时差异,先发出的慢请求可能反而后返回并覆盖新数据,导致页面从新结果“闪回”旧结果(即搜索竞态)。 常见防护策略包括:
- 请求序号机制:用递增编号(如
latestRequestId)进行版本匹配,只有与当前最新序号相等的响应才能更新界面; - 取消机制:利用
AbortController在发起新请求时主动中止上一条旧请求。 本节保底目标是掌握常规 CRUD 接口联调;竞态防护供学有余力时自主探究。
用户快速输入“V”“Vu”“Vue”时,会发出多次请求。后发请求不一定后返回。如果旧请求最后回来并覆盖 rows,页面会从新结果“闪回”旧结果,这就是搜索竞态。
| 策略 | 简单说明 | 本章是否演示 |
|---|---|---|
| 防抖 | 等用户停一下再请求 | 只讲概念 |
| 请求序号 | 只让最后一个号码更新页面 | 课堂演示 |
| 取消旧请求 | 新请求开始时取消旧请求 | AbortController 一句话带过 |
| 服务端分页 | 让关键词、页码和结果保持同一组 | 不展开 |
CRUD 是四类常见动作:
| 动作 | method | 本地 Mock 路径 |
|---|---|---|
| 查列表 | GET | /products |
| 新增 | POST | /products |
| 修改 | PUT | /products/:id |
| 删除 | DELETE | /products/:id |
最小语法模板如下,实际项目再把它们封装成有业务名字的函数:
http.get('/products')
http.post('/products', newProduct)
http.put('/products/' + id, changedProduct)
http.delete('/products/' + id)页面按钮不直接写 Axios。它只调用 listProducts、createProduct、deleteProduct 这类业务函数。
问题: 第一次搜索比第二次搜索更晚返回时,应该让哪一次响应修改 rows?
课堂演示
步骤 1:给商品 API 模块增加 CRUD 函数
把 src/api/products.js 改为:
src/api/products.js
import http from './http'
const path =
import.meta.env.VITE_PRODUCTS_PATH || '/products'
export function listProducts(keyword = '', delay = 0) {
return http.get(path, {
params: {
keyword: keyword.trim() || undefined,
delay: delay || undefined,
},
})
}
export function createProduct(payload) {
return http.post(path, payload)
}
export function deleteProduct(id) {
return http.delete(path + '/' + id)
}保存后应该看到:一个文件集中表达商品列表、新增和删除,页面不需要知道完整 URL。
步骤 2:完成可运行的列表、新增和删除页面
把 src/App.vue 改为:
src/App.vue
<script setup>
import { onMounted, reactive, ref } from 'vue'
import {
createProduct,
deleteProduct,
listProducts,
} from './api/products'
const keyword = ref('')
const rows = ref([])
const loading = ref(false)
const errorMessage = ref('')
const draft = reactive({
name: '',
price: 0,
stock: 0,
enabled: true,
})
let latestRequestId = 0
async function loadProducts(delay = 0) {
const requestId = ++latestRequestId
loading.value = true
errorMessage.value = ''
try {
const data = await listProducts(keyword.value, delay)
if (requestId !== latestRequestId) return
rows.value = Array.isArray(data.items)
? data.items
: []
} catch (error) {
if (requestId !== latestRequestId) return
rows.value = []
errorMessage.value =
error.message || '请求失败,请重试'
} finally {
if (requestId === latestRequestId) {
loading.value = false
}
}
}
async function addProduct() {
if (!draft.name.trim()) {
window.alert('请先填写商品名称')
return
}
await createProduct({
name: draft.name.trim(),
price: Number(draft.price),
stock: Number(draft.stock),
enabled: draft.enabled,
})
draft.name = ''
draft.price = 0
draft.stock = 0
draft.enabled = true
await loadProducts()
}
async function removeProduct(row) {
const confirmed = window.confirm(
'确定删除“' + row.name + '”吗?',
)
if (!confirmed) return
await deleteProduct(row.id)
await loadProducts()
}
function demonstrateRace() {
keyword.value = 'V'
loadProducts(1200)
window.setTimeout(() => {
keyword.value = 'Vue'
loadProducts(100)
}, 50)
}
onMounted(loadProducts)
</script>
<template>
<main class="page">
<h1>本地 Mock 商品管理</h1>
<form class="toolbar" @submit.prevent="loadProducts()">
<input v-model="keyword" placeholder="搜索商品名称" />
<button type="submit">搜索</button>
<button type="button" @click="demonstrateRace">
演示连续搜索
</button>
</form>
<form class="create-form" @submit.prevent="addProduct">
<input v-model="draft.name" placeholder="商品名称" />
<input
v-model.number="draft.price"
type="number"
min="0"
step="0.1"
placeholder="价格"
/>
<input
v-model.number="draft.stock"
type="number"
min="0"
placeholder="库存"
/>
<label>
<input v-model="draft.enabled" type="checkbox" />
上架
</label>
<button type="submit">新增商品</button>
</form>
<p v-if="loading" class="state">正在请求……</p>
<div v-else-if="errorMessage" class="state error">
<p>{{ errorMessage }}</p>
<button type="button" @click="loadProducts()">
重试
</button>
</div>
<p v-else-if="rows.length === 0" class="state">
当前没有商品。
</p>
<ul v-else class="products">
<li v-for="row in rows" :key="row.id">
<span>
<strong>{{ row.name }}</strong>
· ¥{{ row.price }}
· 库存 {{ row.stock }}
· {{ row.enabled ? '上架' : '下架' }}
</span>
<button type="button" @click="removeProduct(row)">
删除
</button>
</li>
</ul>
</main>
</template>
<style scoped>
.page {
width: min(900px, calc(100% - 32px));
margin: 40px auto;
font: 16px/1.7 system-ui, sans-serif;
color: #172033;
}
.toolbar,
.create-form {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0;
padding: 16px;
border-radius: 12px;
background: #f4f7fb;
}
input,
button {
padding: 9px 11px;
font: inherit;
}
.toolbar input {
flex: 1 1 240px;
}
.state,
.products {
padding: 18px;
border-radius: 12px;
background: #f4f7fb;
}
.error {
color: #a61b2b;
background: #fff1f2;
}
.products {
list-style: none;
}
.products li {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid #dbe3ef;
}
.products li:last-child {
border-bottom: 0;
}
</style>保存/运行后应该看到:
- 页面能读取商品列表;
- 填写名称、价格、库存和上架状态后能新增商品;
- 删除前出现确认框,确认后列表重新请求;
- Network 中能找到 GET、POST、DELETE;
- 点击“演示连续搜索”会先发慢请求,再发快请求,页面最后保留“Vue”的结果。
步骤 3:验证竞态保护
打开 Network,点击“演示连续搜索”:
keyword=V&delay=1200先发出;keyword=Vue&delay=100后发出,但更早返回;- 旧请求最后返回时,
requestId不等于最新编号,所以不能更新rows; - 最终搜索框和页面结果都对应“Vue”。
运行后应该看到:结果不会从“Vue”闪回“V”。
进阶任务:只接收最后一次结果
请求序号的核心只有三步:
let latestRequestId = 0
async function search() {
const requestId = ++latestRequestId
const data = await listProducts(keyword.value)
if (requestId !== latestRequestId) return
rows.value = data.items
}requestId 像取号牌:只有最新号码能改页面。AbortController 还可以直接取消旧请求,本章只知道它能做这件事,不展开代码。
当堂练习(分层双轨制)
- 【必做任务(基础通关)】:在当前页面上完成商品“新增(POST)”与“删除(DELETE)”的基础闭环:在 API 模块中封装
createProduct和deleteProduct;页面能输入名称、价格新增商品并刷新列表;点击某行删除按钮弹出确认框,确认后成功调用删除接口并刷新列表;在 Network 面板中指出 POST 和 DELETE 请求。 - 【选做挑战(🌟 自主拓展)】:在 API 模块中补齐
updateProduct(id, payload)(使用PUT /products/:id),为表格行增加编辑交互;或通过点击“演示连续搜索”观察控制台与 Network,理解请求序号requestId是如何阻止慢响应覆盖新响应的。
6.7 本章串联验收
按下面顺序完整走一遍,不要只演示“成功列表”:
| 操作 | 页面证据 | Network 证据 |
|---|---|---|
| 正常加载 | 加载后出现商品 | GET 200,响应含 items |
| 搜索不存在名称 | 出现空状态 | GET 200,items 为空 |
| 停止 Mock | 出现错误与重试 | 请求失败或 status 为 0 |
| 恢复 Mock 后重试 | 列表恢复 | 新 GET 请求成功 |
| 新增商品 | 新记录出现 | POST 201 |
| 编辑商品 | 指定名称变化 | PUT 200 |
| 删除商品 | 指定记录消失 | DELETE 200 |
| 连续搜索 | 最后条件决定结果 | 两条请求顺序和延迟可见 |
6.8 本章小测
章节测评
3 题第6章小测:接口与异步状态
检查页面四态、错误传播和 Network 证据。
6.9 复盘检查点
学习自检
停一下:失败路径完整吗
检查接口契约、页面四态、错误传播和调试证据。
6.10 排错矩阵
| 现象 | Network 证据 | 修复方向 |
|---|---|---|
| 请求未出现 | 没有记录 | 事件未触发、条件提前 return、组件未挂载 |
| 404 | URL 与路径 | base URL 或接口 path 拼接错误 |
| 401 / 403 | 状态码与响应体 | 会话失效或权限不足,不是“网络断开” |
| CORS | Console 与响应头 | 由服务端或开发代理解决,不用 no-cors 掩盖 |
| 一直 loading | 请求已结束 | finally 未恢复状态 |
| 失败却显示空数据 | Promise 链 | 拦截器吞掉错误或 catch 后当成功返回 |
| 结果闪回旧数据 | 请求时序 | 搜索竞态未处理 |
npm run mock 报找不到脚本 | 终端命令与当前目录 | 没有在 classroom-demo 或配套起始项目目录执行 |
| 修改环境变量后仍走旧地址 | Request URL 还是静态路径 | 修改 .env.development 后没有重启 Vite |
成功后 data.items 报错 | Response 有数据但页面结构不符 | 拦截器已拆 response.data,API 模块又重复读取 .data |
不记录敏感请求内容
排错日志可以记录 method、路径、状态码和追踪标识,但不要打印密码、完整令牌、Cookie、身份证号或真实用户数据。
6.11 本章知识自检
| 我能做到 | 自检方法 |
|---|---|
| 写接口契约 | 不看代码写出六要素,并用 Network 核对 |
| 分清三层职责 | 指出 http.js、products.js、页面各管什么 |
| 演示四态 | 正常、空数据、停 Mock、恢复重试都走一遍 |
| 解释错误传播 | 说明为什么必须 Promise.reject |
| 识别搜索竞态 | 说明旧响应为什么不能覆盖新结果 |
| 完成 CRUD | 在 Network 中找到 GET、POST、PUT、DELETE |
| 使用本地 Mock | 能独立打开两个终端并说明每个终端在运行什么 |
全部做到后,再开始章末课后任务。
6.12 课后任务:实训室设备 API 管理页
任务目标
实训室需要通过课程提供的本地 Mock 管理设备记录。你要把 resources/ch06/after-class-starter/ 补成一个接口驱动页面,让设备列表、搜索、新增、编辑和删除都通过 API 模块完成,并让加载、空数据和请求失败都有明确反馈。
接口服务使用 resources/ch06/mock-server.mjs,设备资源路径为 /devices。所有请求只访问本地 Mock,不连接真实生产服务,也不要求修改服务端代码。
基础通关要求(全员必做)
- API 模块三层分工:在
src/api/devices.js中封装设备列表查询(listDevices)、新增(createDevice)和删除(deleteDevice)三个核心业务函数。 - 页面四态闭环:页面必须清晰区分加载中(
loading)、成功有数据(rows)、成功空数据(rows.length === 0)和请求失败(errorMessage)四态;失败时展示清晰原因并提供“重新加载”重试入口;无论成功失败,loading必须在finally中恢复false。 - 新增与删除业务交互:新增设备表单包含基础非空校验(设备名称、编号);删除前调用确认框进行二次确认,确认后成功调用删除接口并刷新列表。
- Network 取证核对:在浏览器开发者工具 Network 面板中能指认出列表 GET 请求、新增 POST 请求和删除 DELETE 请求的 URL、请求方法与状态码。
进阶拓展任务(自主选做 · 🌟 自主拓展)
- 设备编辑业务闭环:在 API 模块中补充
updateDevice(id, payload)函数(使用PUT /devices/:id),页面实现每行编辑交互,并在 Network 中查验证据。 - 搜索竞态与时序保护:使用递增的
requestId处理快速搜索,通过人为构造不同延迟,验证旧请求返回时不会覆盖最新搜索结果。 - 响应拦截器错误标准化:在
http.js中配置响应拦截器统一拆包response.data,错误分支统一包装结构并使用return Promise.reject(normalized)确保页面 catch 生效。
必须使用的知识
- 基础核心:Axios 实例与 API 模块分层、页面四态模型(
loading/rows/errorMessage)与finally状态复位、Network 面板抓包取证、课程本地 Mock 服务联动。 - 进阶拓展(🌟 自主拓展):PUT 修改接口封装、请求序号处理搜索竞态、响应拦截器统一拆包与错误抛出链。
完成效果
- 基础效果:页面能稳定读取设备列表、搜索过滤、新增设备与删除设备;请求期间显示加载反馈;空数组显示空状态;停止 Mock 后显示错误与重试按钮;重新启动 Mock 后点击重试恢复列表。
- 拓展效果(🌟 自主拓展):支持设备编辑修改;快速连续输入多个搜索词时最终列表始终对应最后一次输入;错误拦截具备统一格式。
验收步骤
| 顺序 | 操作 | 达标结果 |
|---|---|---|
| 1 | 打开终端一执行 npm run mock,终端二执行 npm run dev | 页面和本地接口都能正常访问 |
| 2 | 打开设备页并观察首次加载过程 | 能区分加载中与正常数据展示 |
| 3 | 搜索不存在的设备关键词 | 正常显示空状态,不误判为失败 |
| 4 | 完成新增与删除一条设备操作 | 页面变化与 Network 中的 POST、DELETE 请求一致 |
| 5 | 停止 Mock 后点击页面重试 | 出现错误提示与重试按钮,不误判为空数据 |
| 6 | 恢复 Mock 后再次点击重试 | 列表恢复正常,不需要刷新整个浏览器 |
| 7 | 执行 npm run build | 构建成功,无 Console 报错 |
| 8 | (🌟 自主拓展验收) | 验证设备编辑 PUT 请求,或快速连续搜索演示竞态防护 |
提交内容
- 项目源码和 Mock 种子数据,不包含
node_modules/和dist/; - 正常列表、空状态、失败重试三张效果图;
- GET、POST、DELETE 各一条请求的 Network 记录(包含 URL、method、status);
- 一张
npm run build成功终端截图; - (🌟 自主拓展选交):PUT 编辑请求截图或竞态复现修复说明。
本章小结
- 接口开发先写契约,再写请求代码。
http.js管公共规则,products.js管商品接口,页面管用户看到的状态。- 接口页面至少要区分加载、成功有数据、成功空数据和失败。
- 响应拦截器可以整理错误,但不能吞掉失败。
- 搜索时后发请求不一定后返回,要阻止旧响应覆盖新结果。
- 本章只使用本地 Mock;下一章再判断哪些状态需要跨页面共享,并用 Pinia 管理。
