前后端联调总览¶
后端你已经会写了,这一章把 Vue 前端接上去。核心是 Axios + 拦截器——这是你前端天天写的东西,只不过现在后端也是你自己写的。
联调全景¶
flowchart LR
subgraph 前端["前端(Vue 5173)"]
V[页面调用 api]
V --> I["Axios 请求拦截器\n塞 Authorization: Bearer token"]
end
subgraph 后端["后端(Spring 8080)"]
J["JwtInterceptor\n校验 token,取 userId"]
J --> C[Controller → Service]
end
I -->|"/api 经 Vite 代理/Nginx 反代"| J
C -->|"统一响应 {code,message,data}"| R["Axios 响应拦截器\n拆 code、处理 401"]
R --> V
Axios 封装(核心)¶
一个 axios 实例 + 两个拦截器,搞定所有对接细节:
import axios from 'axios'
import { useUserStore } from '../stores/user'
// 第四篇 ch32:axios 实例 + 拦截器(这是前后端对接的核心)
// 对标后端的 JwtInterceptor:前端在"发请求前"塞 token,后端在"进 Controller 前"校验。
const request = axios.create({
baseURL: '/api', // 走 Vite 代理 / Nginx 反代,统一加前缀
timeout: 10000,
})
// 请求拦截器:每个请求自动带上 token
request.interceptors.request.use((config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
})
// 响应拦截器:统一拆 code、处理 401
// 关键:后端全局异常处理器把所有异常都返回 HTTP 200,业务码放在 body.code 里。
// 所以"token 失效"是 HTTP 200 + body.code===401,必须在这里按 code 识别(不会触发下面 HTTP 401 分支)。
request.interceptors.response.use(
(response) => {
const res = response.data // 后端统一返回 { code, message, data }
if (res.code === 200) {
return res.data // 直接把 data 拆出来,业务代码更干净
}
if (res.code === 401) {
// token 失效:清登录态,跳登录页(业务码 401,不是 HTTP 401)
useUserStore().logout()
window.location.href = '/login'
}
alert(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
},
(error) => {
// 网络层 / HTTP 非 2xx(后端目前不会走到这里,保留作防御)
if (error.response?.status === 401) {
useUserStore().logout()
window.location.href = '/login'
}
return Promise.reject(error)
},
)
export default request
两件事:
- 请求拦截器:每个请求自动带上
Authorization: Bearer <token>(token 从 Pinia store 拿)。 - 响应拦截器:统一拆后端的
{code, message, data},code===200就把data直接返回;遇到业务码 401(token 失效)自动登出跳转。
为什么 401 要在响应拦截器里判断,而不是 axios 的错误回调?
后端全局异常处理器把所有异常(包括 401 鉴权失败)都返回 HTTP 200,真正的状态码放在 body 的 code 字段里。所以"token 失效"是 HTTP 200 + body.code===401,会走进成功回调——必须在成功回调里按 code 判断,axios 的错误回调(HTTP 非 2xx)根本不会触发。
这样业务代码只管调接口、拿数据,完全不用碰 token 和错误处理。
接口定义¶
import request from './request'
// 第四篇 ch32:认证相关接口
// 登录成功后端返回 { token, user: { id, username } }(已被 request 拦截器拆出 data 层)。
export interface LoginResult {
token: string
user: { id: number; username: string }
}
export function login(username: string, password: string): Promise<LoginResult> {
// request 拦截器把 res.data 透出,但 axios 的 TS 类型仍是 AxiosResponse,故用断言还原真实返回类型
return request.post('/auth/login', { username, password }) as unknown as Promise<LoginResult>
}
export function register(username: string, password: string): Promise<void> {
return request.post('/auth/register', { username, password }) as unknown as Promise<void>
}
import request from './request'
// 第四篇 ch33:任务 CRUD 接口
export interface TaskPage {
records: Task[]
total: number
current: number
size: number
}
export interface Task {
id: number
title: string
description?: string
status: number
}
export function getTasks(params: { current?: number; size?: number; status?: number }): Promise<TaskPage> {
// request 拦截器把 res.data 透出,但 axios 的 TS 类型仍是 AxiosResponse,故用断言还原真实返回类型
return request.get('/tasks', { params }) as unknown as Promise<TaskPage>
}
export function createTask(data: { title: string; description?: string; status?: number }): Promise<Task> {
return request.post('/tasks', data) as unknown as Promise<Task>
}
export function updateTask(id: number, data: { title: string; description?: string; status?: number }): Promise<Task> {
return request.put(`/tasks/${id}`, data) as unknown as Promise<Task>
}
export function deleteTask(id: number): Promise<void> {
return request.delete(`/tasks/${id}`) as unknown as Promise<void>
}
开发期跨域:Vite 代理¶
前端 5173 调后端 8080 是跨域。开发期最省事的是用 Vite 代理(不用动后端 CORS):
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 第四篇 ch32:开发期用 Vite 代理,把 /api/* 转发到后端 8080,避开跨域。
// 生产环境改由 Nginx 反代(见 ch35),前端代码不用改。
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})
/api/* 被转发到 localhost:8080 并去掉 /api 前缀。所以前端 request.get('/tasks') 实际打到后端 http://localhost:8080/tasks。
token 的完整生命周期¶
sequenceDiagram
participant U as 用户
participant F as 前端
participant B as 后端
U->>F: 输入账密
F->>B: POST /auth/login
B-->>F: { token, user }
F->>F: 存 localStorage + Pinia
Note over F,B: 之后每个请求
F->>B: GET /tasks (带 token)
B->>B: 拦截器校验 token
B-->>F: 任务列表
:octicons-arrow-left-16: 上一章:接口测试 | 下一章:Vue 3 实现前端