码上问学问代码之理,学编程之道

第 9 章 · axios 与跨域 重点

前面八章你学会了画界面、管状态、规划路由——但页面上的作品还是假的。这一章把前端和课程二的 Flask 后端真正接上:用 axios 封装一个只写一次的请求层,自动带 token、自动拆 {code, msg, data}、自动处理 401;再讲清楚「跨域」到底是什么,用 Vite 代理 在开发期解决它。学完你能独立完成前后端联调,这是整个课程三最关键的一章。

为什么用 axios 而不是 fetch

两者都能发请求,差别在「省掉多少重复」
先讲为什么浏览器本来就自带 fetch,能发请求,为什么还要额外装一个库?
原因不在「能不能」,而在「要写多少重复代码」。fetch 只负责「把请求发出去」,剩下的活全得你自己干:判断 HTTP 状态码、手动解析 JSON、判断业务状态码、拼错误信息——而且每发一个请求就要重写一遍。项目里有几十个接口,就是几十份复制粘贴。
axios 的价值在于:它提供了拦截器,能让你把「所有请求都要做的事」(带 token、统一报错)只写一次。
动手做先把同一个需求用两种方式各写一遍——取作品列表第一页。看完你就明白差别在哪。
fetch 版:四步走js
// 取「古典文学网」作品列表第一页
async function loadArticles() {
  const res = await fetch('/api/article/list?page=1&page_size=10')

  if (!res.ok) {                        // ① 先判断 HTTP 状态码
    throw new Error(`HTTP ${res.status}`)
  }

  const json = await res.json()         // ② 再手动解析 JSON(还要再 await 一次)

  if (json.code !== 200) {              // ③ 再判断业务状态码
    throw new Error(json.msg)
  }

  return json.data                      // ④ 最后才拿到真正的数据
}
页面效果浏览器运行结果
运行结果: fetch 的返回值不是数据,而是一个 Response 对象: res.ok → true res.status → 200 res.json() → 还要再 await 一次,才拿到 { code: 200, msg: "success", data: {...} } 所以「取一条列表」这件事,fetch 要写四步;而这四步里, 第 ①②③ 步对每个接口都一模一样——复制粘贴几十遍的活。
axios 版:封装之后一行js
// request 是第 3~5 节封装出来的实例,全项目共用
async function loadArticles() {
  const data = await request.get('/article/list', { params: { page: 1, page_size: 10 } })
  return data                            // 拿到的直接就是 data,四步全被拦在了拦截器里
}
页面效果浏览器运行结果
运行结果: 同样的两行调用,返回的是同一份数据: { "list": [ { "id": 3, "title": "念奴娇·赤壁怀古", "author": "苏轼", "views": 412 }, ... ], "total": 6, "page": 1, "page_size": 10 } (判断状态码、解析 JSON、弹错误提示,全都在 utils/request.js 里统一做了)
讲透:axios 到底比 fetch 多了什么
  • 核心是「可插入的分层」:axios 让请求流程有了 请求拦截器 → 真正发请求 → 响应拦截器 三段,你可以在两端插入公共逻辑。fetch 没有,只能自己写一个函数包起来。
  • 三件「所有接口都要做」的事:带上 Authorization 请求头、判断业务 code、失败时统一提示——这三件正好是本章第 4、5 节要写进拦截器的内容。
  • 它不改变 HTTP 本身:底层还是标准的 XHR / HTTP。理解这一点,你才不会把「跨域」和「axios 的锅」搞混——跨域是浏览器的规矩,换什么库都一样。
  • 本项目端到端对接课程二的接口:基础地址、统一响应格式、鉴权头、分页参数,全部以 docs/API接口文档.md 为准,前端不自己发明约定。

安装与最简用法

先跑通一次,再谈封装
动手做装 axios 1.x,然后在组件里发第一个请求。
终端bash
npm install axios
终端输出命令行执行结果
运行结果: $ npm install axios added 1 package in 1.1s (写入 package.json 的 dependencies: "axios": "^1.7.7")
src/views/ArticleList.vue(先看最朴素的写法)vue
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'   // 先直接用 axios,还没封装

const list = ref([])

onMounted(() => {
  axios.get('/api/article/list', { params: { page: 1, page_size: 10 } })
    .then((res) => {
      // 注意两层:res.data 是后端返回的 { code, msg, data }
      if (res.data.code === 200) {
        list.value = res.data.data.list
      }
    })
    .catch((err) => {
      console.log('请求失败:', err.message)
    })
})
</script>
页面效果组件渲染结果
运行结果: 控制台打印 res.data: { code: 200, msg: "success", data: { list: (6) [{…}, {…}, {…}, {…}, {…}, {…}], total: 6, page: 1, page_size: 10 } } 展开 data 看分页字段: total 6 page 1 page_size 10 问题也看出来了:每个组件都要写 res.data.code === 200 和 res.data.data, 写十次就重复十次——所以才需要下面的封装。
讲透:两个 data 别搞混
  • res.data 是 axios 给的第一层:HTTP 响应体被解析后的对象。
  • res.data.data 才是后端业务数据:接口文档约定的 { code, msg, data } 里那个 data。
  • 所以最原始的写法里全是 res.data.data.list 这种东西,可读性差还容易漏判 code。第 5 节的响应拦截器会把第一层剥掉,让业务里只剩一个 data。
  • params 和 data 不是一回事:params 拼在 URL 后面(GET 查询参数),data 是请求体(POST / PUT 的 JSON)。用错了后端就收不到值。
  • 本项目里所有接口路径都不带 /api 前缀(baseURL 负责),写成 '/article/list' 即可;上面这段是演示最原始的写法才写全。

封装 axios 实例

baseURL 与 timeout
先讲为什么如果每个组件都写 'http://127.0.0.1:5000/api/article/list',会发生什么?上线那天后端域名一变,全项目几十个文件都要改。
正确做法是把「和服务器地址有关的东西」集中到一处:axios.create() 创建一个属于本项目的实例,约定好 baseURL(接口前缀)和 timeout(超时时间)。以后所有请求都从这个实例发出去。
动手做新建 src/utils/request.js,创建实例。注意 baseURL 只写 '/api'——不写 host、不写端口。
src/utils/request.jsjs
import axios from 'axios'

// 创建属于本项目的 axios 实例:以后全项目都用它,不再直接用 axios
const request = axios.create({
  // 接口文档约定:本地开发 http://127.0.0.1:5000/api,线上 https://你的域名/api
  // 这里只写 /api,让「谁来补上主机名」交给 Vite(开发)或 Nginx(生产)决定
  baseURL: '/api',
  timeout: 10000   // 10 秒没响应就当失败(后端挂了、断网都会走到错误分支)
})

export default request
页面效果浏览器运行结果
运行结果: request.get('/article/list', { params: { page: 1 } }) 实际发出的请求: GET http://localhost:5173/api/article/list?page=1 说明: · '/article/list' 被拼到 baseURL '/api' 后面,成为 /api/article/list · 主机名是当前页面的 localhost:5173,不是 5000 端口 · Network 面板:api/article/list?page=1 GET 200 18ms
讲透:为什么 baseURL 要写相对路径
  • 相对路径 = 「和当前页面同一个源」,浏览器不会拦它(第 6 节会讲清楚这个「源」)。
  • 写绝对地址 http://127.0.0.1:5000/api 就变成跨域请求,开发环境立刻报 CORS 错误——这是新手最常踩的第一个坑。
  • 一个实例管所有接口:将来后端换域名、换端口,只改这一处;如果确实是多后端(比如还接了第三方接口),就再 create 一个实例,各用各的。
  • timeout 不是可选项。不设超时的后果是后端挂了的时候,页面会一直转圈等下去,用户以为是自己网卡了。10 秒是个务实的默认值。
避坑两个高频错误:① baseURL 写成 'http://127.0.0.1:5000/api'——开发期就跨域了,而且打包上线后这个地址必然失效;② 调接口时又手贱带上 /api:request.get('/api/article/list'),结果拼成 /api/api/article/list,后端返回 404。记住:baseURL 管前缀,业务里只写路径。

请求拦截器:自动带 token

Authorization: Bearer <token>
先讲为什么接口文档里写得很清楚:需要登录的接口必须带上 Authorization: Bearer <token>。但真正需要登录的接口有十几个——点赞、收藏、发书评、改资料、头像上传……
如果每个调用处都写一遍「取 token → 拼请求头」,只要有一处忘了,那个功能就会莫名其妙提示「请先登录」。这种「每个请求都必须做」的事,就该写进请求拦截器,让它在所有请求发出前自动执行。
动手做在 request.js 里加请求拦截器。token 从第 8 章的 Pinia store 里取。
src/utils/request.js(追加请求拦截器)js
import { useUserStore } from '@/stores/user'

// 请求拦截器:每个请求「发出之前」都会先跑这里
request.interceptors.request.use(
  (config) => {
    const userStore = useUserStore()

    // 有 token 就按接口文档的约定带上鉴权头:Authorization: Bearer <token>
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }

    // 必须返回 config,否则这个请求就断在半路了
    return config
  },
  (error) => Promise.reject(error)
)
页面效果浏览器运行结果
运行结果: 【未登录】token 是空字符串,不加鉴权头 GET /api/article/list?page=1 Content-Type: application/json 【登录后】token = "eyJhbGciOiJIUzI1NiJ9.demo" GET /api/user/profile Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.demo Content-Type: application/json (点赞、收藏、发书评……所有请求都自动带上,业务代码里一行 token 都没写)
讲透
  • config.headers 就是这次请求的配置,往上面挂 Authorization,等于给请求加了个头。
  • 为什么在拦截器里调用 useUserStore() 是安全的:拦截器函数在真正发请求时才执行,那时候 Pinia 早就装好了(第 8 章讲过这个顺序问题)。
  • 为什么不直接从 localStorage 读 token:也能用,但 store 才是「当前登录状态」的唯一真相。用户点了退出,store 里的 token 立刻没了,后续请求自然不带——不用等 localStorage 和 store 同步。
  • 两个回调:第一个处理成功的情况,第二个处理「请求配置阶段就出错」的情况。第二个千万别删,删掉可能让错误静默消失。

响应拦截器:统一处理 code

本章最重要的一段代码
封装后的 axios:请求经过两层拦截器,代码里只剩「调接口、拿数据」页面组件api/article.jsgetArticleList()请求拦截器· 自动加 baseURL /api· 自动带 token 头 Authorization: Bearer …后端 Flask校验 token → 业务处理返回 {code, msg, data}响应拦截器code===200 → 拆壳还 data401 → 清登录态踢去登录页面拿到:纯 data不用每个页面拆壳判错拦截器是「一次配置、处处生效」:token、拆壳、错误提示都在这一处收口
先讲为什么接口文档里有一条关键约定:所有接口 HTTP 状态码统一返回 200,业务成败看 code 字段。
这意味着——登录失败、token 过期、参数错误,HTTP 都是 200。axios 看到 200 会认为一切正常,直接走成功分支。所以「400 参数错误」在页面上会静默发生,用户点半天没反应。
必须由响应拦截器把关:拆开 {code, msg, data},成功的把 data 递下去,失败的统一提示,401 的清登录态跳登录页。
动手做这是本课程里最该逐行读懂的一段代码,写完它,你的整个前端就只有一套错误处理逻辑。
src/utils/request.js(完整版)js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'

const request = axios.create({
  baseURL: '/api',
  timeout: 10000
})

/* ---------------- 请求拦截器:自动带 token ---------------- */
request.interceptors.request.use(
  (config) => {
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

/* ---------------- 响应拦截器:统一拆 {code, msg, data} ---------------- */
request.interceptors.response.use(
  (response) => {
    const res = response.data

    // ① 业务成功:直接把 data 交给页面,业务里不用再写 res.data.data
    if (res.code === 200) {
      return res.data
    }

    // ② 未登录 / token 失效:清干净本地状态,送去登录页
    if (res.code === 401) {
      const userStore = useUserStore()
      userStore.logout()
      ElMessage.error(res.msg || '登录已过期,请重新登录')
      router.push({
        name: 'login',
        query: { redirect: router.currentRoute.value.fullPath }
      })
      return Promise.reject(new Error(res.msg || '未登录'))
    }

    // ③ 其它业务错误(400 / 403 / 404 / 500):统一弹 msg
    ElMessage.error(res.msg || '请求失败')
    return Promise.reject(new Error(res.msg || '请求失败'))
  },
  (error) => {
    // ④ 走到这里说明请求压根没拿到响应:超时、断网、后端没启动
    const msg = error.response
      ? `请求失败(HTTP ${error.response.status})`
      : '网络异常,请检查后端服务是否已启动'
    ElMessage.error(msg)
    return Promise.reject(error)
  }
)

export default request
页面效果浏览器运行结果
运行结果: ① 成功 —— GET /api/article/list?page=1&page_size=10 后端返回 { code: 200, msg: "success", data: {...} } 拦截器 return res.data,组件里 await 拿到的直接是: { list: [...], total: 6, page: 1, page_size: 10 } ② token 过期 —— GET /api/user/profile 后端返回 { "code": 401, "msg": "登录已过期,请重新登录", "data": null } → userStore.logout() 清空 token 与用户信息 → 顶部弹出红色提示:登录已过期,请重新登录 → 地址跳到 /login?redirect=/user/profile ③ 参数错误 —— POST /api/comment/add 后端返回 { "code": 400, "msg": "缺少参数:article_id", "data": null } → 弹出提示:缺少参数:article_id → 组件里的 await 会抛错进 catch,需要的话可以自己再处理 ④ 后端没启动 请求直接失败,error.response 是 undefined → 弹出提示:网络异常,请检查后端服务是否已启动
讲透:为什么返回 res.data 而不是整个 res
  • 把「协议细节」挡在业务之外。拦截器返回什么,业务里的 await 就拿到什么。返回 data,业务里就只剩业务数据,看着干净。
  • 这就是「一次封装,处处受益」:全项目只有一个地方判断 code、只有一个地方弹提示。50 个接口,0 行重复代码。
  • 成功返回 res.data,失败 Promise.reject——一边让正常流程顺畅,一边让异常能被 try/catch 或 .catch 接住,两种写法都不别扭。
  • HTTP 200 不等于业务成功,这是本项目最重要的一个认知。所以不要用 .then().catch() 去判断「登录成功了没」,要看 code——而这件事拦截器已经替你做了。
  • 401 时为什么要 logout():本地 token 已经失效了,留着它只会让后续每个请求都失败一次。清掉之后第 7 章的守卫也会正常工作。
避坑401 处理里千万别写 window.location.href = '/login'。它会让浏览器整页重新加载,Pinia 里的状态全丢,而且如果登录页还有别的接口报 401,就会来回跳、反复刷新。用 router.push 走前端路由;更稳妥的做法是先判断 router.currentRoute.value.name !== 'login' 再跳,避免在登录页里又跳登录页。

跨域是怎么回事

接口明明通了,页面却拿不到数据
先讲为什么你封装好了 axios,兴冲冲把 baseURL 改成后端的真实地址,结果控制台一片红:blocked by CORS policy。更气人的是,打开 Network 面板一看,请求状态码是 200,后端日志里也明明打印了这次访问——数据就是拿不到。
这不是后端写错了,也不是 axios 的 bug,而是浏览器的一条安全规矩:同源策略。
动手做把 baseURL 改成后端的绝对地址,复现一次跨域,亲眼看一下那个奇怪的「200 却报错」。
故意触发跨域(改完记得改回去)js
// 把请求直接打到后端的完整地址上:5173 → 5000,端口不同 = 跨域
import axios from 'axios'

axios.get('http://127.0.0.1:5000/api/article/list', { params: { page: 1 } })
  .then((res) => console.log(res.data))
  .catch((err) => console.log('请求失败:', err.message))
页面效果浏览器运行结果
运行结果: 浏览器控制台(红色报错): Access to XMLHttpRequest at 'http://127.0.0.1:5000/api/article/list?page=1' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Network 面板(看起来完全正常): Name Method Status Type Time article/list?page=1 GET 200 xhr 21ms 页面上的表现: 控制台打印「请求失败:Network Error」,list 依然是空数组 (后端日志里能看到这次访问记录——请求真的到了,只是响应被浏览器扣下了)
讲透:为什么会「接口能调通但页面拿不到数据」
  • 请求是简单请求时,浏览器会「先发再说,再检查」:请求照发、后端照答,但浏览器检查响应头里有没有 Access-Control-Allow-Origin。没有,就把响应扔掉。
  • 所以「后端日志有记录」和「前端拿到数据」是两件事。这解释了为什么你会觉得「接口明明是好的」。
  • 三种解法:① 后端加 CORS 响应头(课程二可以配);② 让请求不跨域——开发期用 Vite 代理、生产期用 Nginx 反代(本课程采用,前端代码最干净);③ 让浏览器别管(只在调试时禁用安全策略,绝不能这样上线)。
  • 注意 localhost 和 127.0.0.1 也算不同源,别以为都是本机就没事——这是排查跨域时最容易忽略的一点。
  • 还有个高频细节:带 Authorization 头的请求会触发「预检」(浏览器先发一个 OPTIONS 问能不能这么干),后端得能正确响应 OPTIONS,否则一样跨域失败。

开发期解法:Vite 代理

server.proxy 把 /api 转发给 Flask
开发期跨域解法:Vite 代理 —— 浏览器以为同源,其实是 Vite 帮你转发浏览器localhost:5173请求 /api/article/listVite 开发服务器server.proxy:见 /api 开头 → 转发转发到 localhost:5000Flask 后端 :5000同源策略不限制服务器对服务器关键认知:跨域是「浏览器」的安全策略,不是 HTTP 的限制Vite/Nginx 转发 = 服务器到服务器,绕开了浏览器的同源检查;开发期用 Vite 代理,生产期用 Nginx 反向代理,前端代码不用改一行配法:proxy: { '/api': { target: 'http://127.0.0.1:5000', changeOrigin: true } }
先讲为什么跨域的判断发生在浏览器。那就换个思路:让浏览器以为自己在请求同一个源。
Vite 开发服务器可以充当「中间人」:前端把请求发给自己(localhost:5173/api/...,同源,浏览器不拦),Vite 收到后在服务器端替你把请求转发到 http://127.0.0.1:5000,再把响应原样回给浏览器。
服务器之间没有同源策略,所以这一步畅通无阻。
动手做在项目根目录的 vite.config.js 里配置 server.proxy。
vite.config.jsjs
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'

export default defineConfig({
  plugins: [vue()],

  resolve: {
    alias: {
      // 让 import xxx from '@/utils/request' 能指向 src/utils/request
      '@': path.resolve(__dirname, 'src')
    }
  },

  server: {
    port: 5173,
    proxy: {
      // 只要请求路径以 /api 开头,开发服务器就替你转发到 Flask
      '/api': {
        target: 'http://127.0.0.1:5000',   // 课程二的后端地址
        changeOrigin: true                 // 把请求头里的 Host 改成目标地址
        // 不需要 rewrite:后端路由本来就是 /api/xxx,原样转发即可
      }
    }
  }
})
页面效果浏览器运行结果
运行结果: $ npm run dev VITE v5.4.10 ready in 423 ms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose ➜ press h + enter to show help (注意:改了 vite.config.js 必须重启 dev server,热更新不会读取新配置) 前端发起 request.get('/article/list', { params: { page: 1, page_size: 10 } }) → 浏览器实际请求 http://localhost:5173/api/article/list?page=1&page_size=10 → Vite 开发服务器转发到 http://127.0.0.1:5000/api/article/list?page=1&page_size=10 → Flask 返回 JSON,原样回给浏览器 Network 面板里看到的地址始终是 localhost:5173(同源),跨域报错消失
讲透
  • 前端代码一个字都不用改:baseURL 始终是 '/api'。代理是开发服务器的行为,对业务代码完全透明。
  • 代理只在开发期存在。vite dev server 是开发工具;npm run build 之后产出的只是静态文件,没有任何转发能力——这正是下一节要讲 Nginx 的原因。
  • 为什么用 changeOrigin:把转发请求的 Host 头改成目标地址,后端做域名校验或生成链接时才不会出问题。配上基本不会有副作用。
  • 「同源」和「代理」配合起来看:浏览器只认 localhost:5173,它根本不知道后面还有 5000 端口。这就是整件事最巧妙的地方。
  • 联调时的排错顺序:先确认后端单独能访问(浏览器直接开 http://127.0.0.1:5000/api/article/list?page=1 能出 JSON),再确认 Vite 里配了代理、重启过,最后才看前端代码。
避坑两个最常见的「配了代理还是不行」:① 忘了重启——vite.config.js 的改动不参与热更新,改完必须 Ctrl+C 重启;② 后端没起——Flask 没在 5000 端口跑,Vite 转发过去连接被拒,浏览器收到 500 / ECONNREFUSED。看到这类报错先去确认后端在不在,而不是怀疑前端。

生产期解法:Nginx 反代

一句话版
讲透线上没有 Vite 开发服务器,同一件事交给 Nginx:它一边托管前端打包出来的静态文件,一边把 /api 开头的请求反代给本机的 Flask。前端和接口共用同一个域名,天然同源,跨域问题根本不会出现。关键在于:前端代码里的 baseURL 依然是 '/api',一行都不用改——这就是第 3 节坚持写相对路径的回报。
/etc/nginx/conf.d/hivue.confnginx
server {
    listen 80;
    server_name 你的域名;

    # 前端打包产物(npm run build 生成的 dist 目录)
    root /var/www/hivue/dist;
    index index.html;

    # Vue Router 是 History 模式:找不到的路径统统交给 index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 接口反向代理:前端与接口同源,不存在跨域
    location /api/ {
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
效果预览运行结果
运行结果: 用户访问 https://你的域名/api/article/list?page=1&page_size=10 → Nginx 命中 location /api/,转发给本机 Flask: http://127.0.0.1:5000/api/article/list?page=1&page_size=10 → JSON 原样返回 浏览器看到的地址始终是 https://你的域名/...,前后端同源,没有跨域 用户访问 https://你的域名/article/3 并按 F5 → 磁盘上没有这个文件,try_files 兜底返回 index.html → 前端路由接手,正常显示详情页(不会 404)
讲透:一句话记住这套分工
  • 开发期靠 Vite 代理,生产期靠 Nginx 反代;前端代码里的 baseURL 始终是 '/api',谁的活谁干。
  • 这解释了「为什么不该在代码里写死后端地址」:写死了,开发和生产就得改两遍代码。
  • 这段配置里还顺手解决了第 7 章留下的 History 模式刷新 404 问题(try_files)——Vue 项目和 Nginx 的配合就这么两处,记住就够了。

综合:api/article.js 与页面调用

把接口按模块收好,页面只管用
先讲为什么封装了 request 之后,如果直接在组件里写 request.get('/article/list', ...),还是有两个问题:接口地址散落在各个组件里,后端一改路径就得全局搜字符串;同一个接口在多个页面调用时参数容易写得不一样。
再加一层:按业务模块把接口收进 src/api/ 目录。组件里只调函数名,不关心 URL 长什么样。
动手做按接口文档封一层作品模块的接口。
src/api/article.jsjs
import request from '@/utils/request'

// 作品列表:分页 / 分类筛选 / 关键词搜索 / 排序,参数名与接口文档一致
// params 形如 { page: 1, page_size: 10, category_id: 1, keyword: '唐', order_by: 'hot' }
export function getArticleList(params) {
  return request.get('/article/list', { params })
}

// 作品详情:GET /api/article/detail/<id>,id 直接拼在路径上
export function getArticleDetail(id) {
  return request.get(`/article/detail/${id}`)
}

// 首页推荐位:GET /api/article/recommend?limit=6
export function getRecommend(limit = 6) {
  return request.get('/article/recommend', { params: { limit } })
}
页面效果浏览器运行结果
运行结果: getArticleList({ page: 1, page_size: 10, order_by: 'hot' }) → 实际请求 GET /api/article/list?page=1&page_size=10&order_by=hot → 返回 data(拦截器已拆过一层): { list: [...], total: 6, page: 1, page_size: 10 } getArticleDetail(1) → 实际请求 GET /api/article/detail/1 → 返回 data: { id: 1, title: "静夜思", author: "李白", dynasty: "唐", category_name: "唐诗", content: "床前明月光……", views: 329, likes: 42, is_favorite: true, is_liked: false, comment_count: 2 } getRecommend() → 实际请求 GET /api/article/recommend?limit=6 → 返回一个数组,长度不超过 6
src/views/ArticleList.vue(在页面里用)vue
<script setup>
import { ref, onMounted } from 'vue'
import { getArticleList } from '@/api/article'

const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)

// 拦截器已经把 { code, msg, data } 拆开,这里拿到的直接就是 data
async function loadList() {
  loading.value = true
  try {
    const data = await getArticleList({
      page: page.value,
      page_size: pageSize.value,
      order_by: 'hot'
    })
    list.value = data.list
    total.value = data.total
  } finally {
    loading.value = false
  }
}

onMounted(loadList)
</script>

<template>
  <div v-if="loading">加载中…</div>
  <ul v-else class="article-list">
    <li v-for="item in list" :key="item.id">
      <h3>{{ item.title }}</h3>
      <p>{{ item.author }} · {{ item.dynasty }} · 浏览量 {{ item.views }}</p>
    </li>
  </ul>
  <p>共 {{ total }} 篇</p>
</template>
页面效果组件渲染结果
运行结果: (await 拿到的就是拦截器 return 的 data) { "list": [ { "id": 3, "title": "念奴娇·赤壁怀古", "author": "苏轼", "dynasty": "宋", "category_name": "宋词", "views": 412, "likes": 58, "created_at": "2026-10-12 10:00:00" }, { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "views": 328, "likes": 42, "created_at": "2026-10-12 10:00:00" } ], "total": 6, "page": 1, "page_size": 10 } 页面渲染出来: 念奴娇·赤壁怀古 苏轼 · 宋 · 浏览量 412 静夜思 李白 · 唐 · 浏览量 328 共 6 篇
讲透:前端请求的三层结构
  • 第 1 层 utils/request.js:和服务器打交道——地址、超时、token、状态码。全项目只有这一个文件包含「传输细节」。
  • 第 2 层 api/article.js:和接口打交道——URL、参数名。后端改了路径,只改这里。
  • 第 3 层 views/*.vue:和业务打交道——调函数、拿数据、渲染页面。它完全不知道 HTTP 是什么。
  • 这样分层的好处:想换掉 axios?只动第 1 层。后端接口重构?只动第 2 层。写业务的人永远只看第 3 层。
  • 分页参数的写法要跟接口文档对齐:page 从 1 开始、page_size 默认 10,返回的 data 里有 list / total / page / page_size 四个字段。第 10 章的分页组件就靠这四个值。
动手练习
1写出 src/utils/request.js 的完整封装:baseURL: '/api'、10 秒超时、请求自动带 Authorization: Bearer、响应统一拆 {code, msg, data}、401 清 token 并跳登录页。
2在 vite.config.js 里配置代理把 /api 转发到 http://127.0.0.1:5000。然后回答:为什么前端代码里的 baseURL 不需要改成后端的绝对地址?生产环境又靠什么解决同一个问题?
3迁移练习:「红影赏析」要展示电影列表和电影详情,接口是 GET /api/movie/list(支持 page / page_size / genre)和 GET /api/movie/detail/<id>,响应同样是 {code, msg, data}。请写出 api/movie.js,并在组件里调用渲染。再想一想:这套封装相对「古典文学网」要改的地方有几处?
参考答案
练习答案(src/utils/request.js,第 1 题)js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'

const request = axios.create({
  baseURL: '/api',
  timeout: 10000
})

// 请求拦截器:自动带 token
request.interceptors.request.use(
  (config) => {
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器:统一拆 {code, msg, data}
request.interceptors.response.use(
  (response) => {
    const res = response.data

    if (res.code === 200) return res.data

    if (res.code === 401) {
      const userStore = useUserStore()
      userStore.logout()
      ElMessage.error(res.msg || '登录已过期,请重新登录')
      if (router.currentRoute.value.name !== 'login') {
        router.push({
          name: 'login',
          query: { redirect: router.currentRoute.value.fullPath }
        })
      }
      return Promise.reject(new Error(res.msg || '未登录'))
    }

    ElMessage.error(res.msg || '请求失败')
    return Promise.reject(new Error(res.msg || '请求失败'))
  },
  (error) => {
    const msg = error.response
      ? `请求失败(HTTP ${error.response.status})`
      : '网络异常,请检查后端服务是否已启动'
    ElMessage.error(msg)
    return Promise.reject(error)
  }
)

export default request
页面效果浏览器运行结果
运行结果: 成功 → 组件里 await 拿到 data,无提示 401 → 清 token + 提示「登录已过期,请重新登录」+ 跳 /login?redirect=原地址 400/403/404/500 → 弹后端 msg 后端没启动 → 弹「网络异常,请检查后端服务是否已启动」
练习答案(vite.config.js,第 2 题)js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': path.resolve(__dirname, 'src') }
  },
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://127.0.0.1:5000',
        changeOrigin: true
      }
    }
  }
})
页面效果浏览器运行结果
运行结果: 前端请求 GET /api/movie/list?page=1&page_size=10 → 浏览器请求同源的 http://localhost:5173/api/movie/list?page=1&page_size=10 → Vite 转发到 http://127.0.0.1:5000/api/movie/list?page=1&page_size=10 → 跨域报错消失 为什么 baseURL 不用改成绝对地址: 浏览器只认「自己请求的是不是同源」。写 /api,浏览器看到的是 5173, 同源,不拦;至于 Vite 在背后转发到 5000,浏览器根本不知道。 生产环境靠什么: Nginx 反代。location /api/ { proxy_pass http://127.0.0.1:5000; } 前端静态文件和接口共用同一个域名,天然同源。
练习答案(src/api/movie.js + 组件调用,第 3 题)js
// src/api/movie.js —— 与 api/article.js 完全同构
import request from '@/utils/request'

// 电影列表:分页 + 类型筛选
export function getMovieList(params) {
  // params 形如 { page: 1, page_size: 10, genre: '科幻' }
  return request.get('/movie/list', { params })
}

// 电影详情:GET /api/movie/detail/<id>
export function getMovieDetail(id) {
  return request.get(`/movie/detail/${id}`)
}
页面效果浏览器运行结果
运行结果: getMovieList({ page: 1, page_size: 10, genre: '科幻' }) → GET /api/movie/list?page=1&page_size=10&genre=科幻 → data: { list: [...], total: 8, page: 1, page_size: 10 } getMovieDetail(8) → GET /api/movie/detail/8 → data: { id: 8, title: "流浪地球", genre: "科幻", rating: 8.4, ... } 要改的地方只有 1 类:接口路径与参数名(api/movie.js)。 utils/request.js 一行都不用动,因为「带 token、拆 code、处理 401」 这些事和具体业务无关——这正是分层封装的价值所在。

第 3 题的关键:换的只是「接口地址和业务参数」,不变的是「request 实例 + 拦截器 + api 模块」这套骨架。能迁移过去,说明你掌握了前后端对接的通用套路,剩下的都是体力活。

本章小结