---
url: /courses/web-frontend-framework/axios-api-state/index.md
---
# 第6章：让页面可靠地连接接口

第 5 章的商品数据直接写在页面里，所以读取、添加和删除几乎马上完成。真实项目里的数据通常来自接口：请求可能很慢，可能返回空数组，也可能因为地址、服务或网络问题而失败。

本章把商品管理页的本地数组替换为接口数据。所有练习只使用课程提供的本地 Mock，重点掌握请求发出后页面状态的组织与反馈。

::: tip 学习说明
本章依次学习接口契约、请求分层、页面四态、错误传播和竞态处理，所有接口练习只连接本地 Mock。
:::

::: tip 配套资源
本章示例工程位于 `resources/ch06/classroom-demo/`，本地接口服务位于 `resources/ch06/mock-server.mjs`，章末任务起始工程位于 `resources/ch06/after-class-starter/`。无需修改 `mock-server.mjs`。
:::

::: tip 🎯 学习梯度指引（分层通关）

* **核心必学（保底通关 · 接口驱动业务）**：掌握 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 中找到 |

### 开工顺序

:::: steps

1. 复制 `resources/ch06/classroom-demo/` 作为课堂工程，执行 `npm install`。
2. 先执行 `npm run dev`，直接访问 `/mock/products.json`，完成“静态 JSON 的第一次 GET”。
3. 写接口契约，创建 `src/api/http.js` 和 `src/api/products.js`，让页面只调用 API 函数。
4. 进入动态 Mock 阶段时只切换一次数据地址，并打开两个终端：终端一执行 `npm run mock`，终端二执行 `npm run dev`。
5. 依次完成页面四态、响应拦截器和搜索竞态，再做本地新增、删除操作。
6. 每完成一步都打开 Network 面板，用 URL、method、status 和 response 验证，不凭页面外观猜结果。
   ::::

```json title="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
}
```

::: warning 本章只在一个位置切换数据源
前半段先请求 `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 }` |

可以先照下面的模板填写，再写请求代码：

```md title="接口契约模板"
接口名称：
method：
path：
query：
body：
成功响应：
错误响应：
```

::: tip 先分清 query 和 body
搜索词通常放在 query 中，例如 `/products?keyword=Vue`；新增商品的数据通常放在 body 中。两者不是同一个位置。
:::

**问题：** 如果只知道接口地址，不知道成功响应里数组叫 `items` 还是 `data`，页面能稳定取出列表吗？

**课堂演示**

### 步骤 1：先让静态数据能被访问

进入课堂工程：

```bash
cd resources/ch06/classroom-demo
npm install
npm run dev
```

在浏览器打开终端给出的本地地址，再访问：

```text
http://localhost:5173/mock/products.json
```

如果端口不是 `5173`，以终端实际显示的端口为准。

保存/运行后应该看到：浏览器显示带有 `items` 和 `total` 的 JSON，而不是 404 页面。

### 步骤 2：在 Network 面板找到证据

1. 按 `F12` 打开开发者工具。
2. 切到 `Network`。
3. 刷新 `/mock/products.json`。
4. 点开这条请求，依次看 `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` 或页面组件 | 何时请求、显示什么、用户怎样重试 | 不重复拼完整服务器地址 |

最小语法长这样：

```js
const http = axios.create({
  baseURL: '公共接口根地址',
  timeout: 8000,
})

http.get('/业务路径', {
  params: { keyword: '搜索词' },
})
```

`baseURL` 是公共前缀，`timeout` 是最多等待多久。最终地址由“公共前缀 + 业务路径”组成。

::: file-tree title="第6章请求分层" icon="colored"

* src/
  * api/
    * **http.js** # Axios 实例和公共规则
    * **products.js** # 商品接口函数
  * **App.vue** # 页面状态和用户操作
* public/
  * mock/
    * **products.json** # 第一次 GET 使用的静态数据
      :::

环境变量只保存“不同环境会变化的公开配置”：

```ini title=".env.development"
VITE_API_BASE_URL=
VITE_PRODUCTS_PATH=/mock/products.json
```

`VITE_` 开头的变量会进入浏览器代码，所以可以放接口地址，不能放密码、私钥或真正的访问令牌。生产环境如果换服务器，可以修改环境变量，不需要到每个页面找 URL。

自动附加登录令牌属于请求拦截器的常见用途，本章只知道这件事，不写请求拦截器代码。

**问题：** 如果三个页面都直接写 `http://127.0.0.1:3001/api`，以后端口变化时要改几个地方？

**课堂演示**

### 步骤 1：安装 Axios 并创建目录

```bash
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` 改成下面的最小页面：

```vue title="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 不会真的按关键词筛选，但请求参数已经正确发出。

::: tip 四点简单理解

* `http.js` 是大家共用的“请求工具”。
* `products.js` 是商品业务的“办事窗口”。
* 页面只说“我要哪些商品”，不背完整地址。
* `finally` 属于页面状态处理，下一节再加入。
  :::

### 步骤 5：唯一一次切换到动态 Mock

静态 JSON 只能做 GET，不能真正搜索、模拟 503、添加、编辑或删除。现在进行全文唯一一次数据源切换。

先把 `.env.development` 的两行改为：

```ini title=".env.development"
VITE_API_BASE_URL=http://127.0.0.1:3001/api
VITE_PRODUCTS_PATH=/products
```

具体变化：

1. 第 1 行从空地址改为 `http://127.0.0.1:3001/api`；
2. 第 2 行从 `/mock/products.json` 改为 `/products`；
3. `src/api/http.js` 和页面代码不改；
4. 修改环境变量后要重新启动 Vite。

::: warning 避坑警示：修改环境变量后必须重启 Vite 服务
在本地修改 `.env.development` 文件后，正在运行的 Vite 开发服务器**不会自动热更新环境变量**！必须先在终端二中按 `Ctrl + C` 停止服务，再重新执行 `npm run dev`，新配置的接口地址才会真正生效。
:::

打开两个终端：

```bash
# 终端一：启动课程提供的本地接口
npm run mock
```

```bash
# 终端二：启动 Vue 页面
npm run dev
```

先访问：

```text
http://127.0.0.1:3001/api/products
```

保存/运行后应该看到：浏览器返回 `{ items, total }`；页面搜索“Vue”时，列表只保留名称中含“Vue”的商品。

::: warning 从这里开始不要切回静态 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` 可能代表还没请求、正在加载、成功但为空，也可能代表请求失败。用户看到的下一步完全不同，所以要分开保存。

最常用的三个状态是：

```js
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` 完整替换为：

```vue title="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：用三个动作观察四态

1. **正常：** 保持 Mock 运行，搜索框留空。应该先加载，后显示商品。
2. **空数据：** 输入一个不存在的名称，例如“火星商品”。应该显示空状态，不显示错误。
3. **失败：** 在终端一按 `Ctrl + C` 停止 Mock，再点击“重新加载”。应该显示错误和重试按钮。
4. **恢复：** 重新执行 `npm run mock`，点击“重新加载”。页面应该恢复商品列表。

运行后应该看到：空数据和失败是两个不同画面；恢复 Mock 后不需要刷新整个浏览器。

### 步骤 4：认识状态机写法

当页面状态越来越多时，也可以只用一个 `state`：

```js
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'
  }
}
```

::: tip 🌟 进阶选学：状态机驱动设计（State Machine）
当页面异步状态越来越多时，使用多个分散的布尔变量（如 `loading`、`errorMessage`、`rows`）容易产生状态互斥漏洞（例如既在显示 loading 又渲染了错误）。进阶项目中常改用单一枚举变量：

```js
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 知识点四：响应拦截器统一处理但不吞错误

::: tip 🌟 进阶选学：响应拦截器统一拆包与错误传播链
响应拦截器是全站请求返回页面的“统一安检口”：

1. **自动拆包脱壳**：成功时统一返回 `response.data`，页面可直接消费核心业务数据；
2. **错误统一规范化**：从各种复杂响应中提取出标准的 `status`、`code` 和 `message`；
3. **绝不能吞掉失败**：错误分支**必须继续调用 `return Promise.reject(normalized)`**！如果误写成普通 return 或没有返回，Promise 会被判定为“成功”，导致页面不会进入 `catch`，失败提示和重试按钮就永远无法渲染。
   :::

::: danger 避坑警示：避免双层 .data 属性读取错误
如果在拦截器中配置了 `response => response.data` 统一脱壳，那么在后续的 API 模块和页面组件中接收到的就已经是真实的数据对象，**切勿再次写 `res.data.items`**（会变成 `undefined.items` 报错）。
:::

**学习目标**

掌握响应拦截器和 `Promise.reject` 的作用，能够整理错误对象并让页面 `catch` 继续收到失败。

**语法/概念**

响应拦截器像请求返回页面之前的一道统一检查。它适合做所有页面都需要的事情：

* 把 Axios 响应外壳统一拆开；
* 从不同错误响应里取出 status、code 和 message；
* 把整理后的错误继续交给调用页面。

不适合全部塞进拦截器的内容：

* 商品页专用的错误文案；
* 每次请求都弹同一句提示；
* 把失败改成空数组后继续当成功；
* 在源码中写真实令牌。

失败分支必须返回 rejected Promise：

```js
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`：

```js title="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：验证错误没有被吞掉

1. 停止 Mock；
2. 点击页面的“重新加载”；
3. 在页面错误区域查看 `error.message`；
4. 在 Console 临时打印 `error.status`；
5. 重新启动 Mock 并重试。

运行后应该看到：停服务时页面进入错误分支，网络错误的 status 通常是 `0`；恢复服务后页面重新显示列表。

::: warning 不要混用两种返回约定
既然响应拦截器统一返回了 `response.data`，所有业务 API 就按这个约定返回数据。不要一部分函数返回完整 response，另一部分函数只返回 data。
:::

**当堂练习（分层双轨制）**

* **【必做任务（基础通关）】**：修改响应拦截器的错误处理分支：统一给 `message` 添加前缀“请求失败：”，并确保保留 `status` 字段且继续调用 `return Promise.reject(normalized)` 抛出；停止 Mock 服务后点击页面重试，验证页面 `catch` 成功接收并展示该错误信息。
* **【选做挑战（🌟 自主拓展）】**：尝试在拦截器中根据不同的 HTTP 状态码进行错误文案定制（例如：`status === 404` 提示“接口地址不存在”，`status === 0` 提示“本地服务未启动或网络异常”），提升业务错误提示的精准度。

## 6.6 知识点五：搜索竞态与本地 CRUD

**学习目标**

掌握搜索竞态的原因和常用处理策略，能够用请求序号保护最新结果，并通过 API 模块完成本地列表、新增和删除。

**语法/概念**

::: tip 🌟 进阶选学：搜索竞态（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` |

最小语法模板如下，实际项目再把它们封装成有业务名字的函数：

```js
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` 改为：

```js title="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` 改为：

```vue title="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>
```

保存/运行后应该看到：

1. 页面能读取商品列表；
2. 填写名称、价格、库存和上架状态后能新增商品；
3. 删除前出现确认框，确认后列表重新请求；
4. Network 中能找到 GET、POST、DELETE；
5. 点击“演示连续搜索”会先发慢请求，再发快请求，页面最后保留“Vue”的结果。

### 步骤 3：验证竞态保护

打开 Network，点击“演示连续搜索”：

1. `keyword=V&delay=1200` 先发出；
2. `keyword=Vue&delay=100` 后发出，但更早返回；
3. 旧请求最后返回时，`requestId` 不等于最新编号，所以不能更新 `rows`；
4. 最终搜索框和页面结果都对应“Vue”。

运行后应该看到：结果不会从“Vue”闪回“V”。

::: details 进阶任务：只接收最后一次结果
请求序号的核心只有三步：

```js
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 本章小测

{{assessment:vue-axios-api-state-check}}

## 6.9 复盘检查点

{{reflection-checkpoint:vue-axios-api-state-checkpoint}}

## 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` |

::: danger 不记录敏感请求内容
排错日志可以记录 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，不连接真实生产服务，也不要求修改服务端代码。

### 基础通关要求（全员必做）

1. **API 模块三层分工**：在 `src/api/devices.js` 中封装设备列表查询（`listDevices`）、新增（`createDevice`）和删除（`deleteDevice`）三个核心业务函数。
2. **页面四态闭环**：页面必须清晰区分加载中（`loading`）、成功有数据（`rows`）、成功空数据（`rows.length === 0`）和请求失败（`errorMessage`）四态；失败时展示清晰原因并提供“重新加载”重试入口；无论成功失败，`loading` 必须在 `finally` 中恢复 `false`。
3. **新增与删除业务交互**：新增设备表单包含基础非空校验（设备名称、编号）；删除前调用确认框进行二次确认，确认后成功调用删除接口并刷新列表。
4. **Network 取证核对**：在浏览器开发者工具 Network 面板中能指认出列表 GET 请求、新增 POST 请求和删除 DELETE 请求的 URL、请求方法与状态码。

### 进阶拓展任务（自主选做 · 🌟 自主拓展）

1. **设备编辑业务闭环**：在 API 模块中补充 `updateDevice(id, payload)` 函数（使用 `PUT /devices/:id`），页面实现每行编辑交互，并在 Network 中查验证据。
2. **搜索竞态与时序保护**：使用递增的 `requestId` 处理快速搜索，通过人为构造不同延迟，验证旧请求返回时不会覆盖最新搜索结果。
3. **响应拦截器错误标准化**：在 `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 管理。
