前端接口工程:axios + mock,前端不再傻等后端
这是 JavaScript 全栈的一个重要章节,前后端分离开发中有一个关键环节,如何不依赖后端,直接完成一个项目的前端部分
让我们通过一个全栈项目:Todos,来搞清楚一件事------前后端分离以后,前端到底靠什么独立跑起来。
这一次,我们要解决的问题是:当后端接口还没写好的时候,前端怎么不傻等,自己把整个应用做完,并且等后端好了能一键切过去。
答案藏在一组对立里:耦合 和 解耦 。工具是 axios + mock。
一、前后端分离的真相:分离的不是代码,是节奏
一开始,咱们先把 Todos 项目拆成两块:
text
前端应用 后端服务
───────────────────────── ─────────────────────────
react + react-router + zustand node koa + mysql
组件(响应式) 提供 api:/todos
路由 调用后返回 json 数组
状态管理(银行)
react、react-router、zustand 我称之为叫做小型前端项目独立开发的"三架马车"。组件负责响应式渲染,路由负责页面切换,zustand 作为状态管理也可以称作"银行"------整个应用的数据中心,所有组件都从那里存取数据。三样凑齐,前端就是一个独立的应用。
这里有一个重点:
前后端能够完全分离吗?实际上是不可能实现完全分离的,至少会出现一次耦合,那就是数据,或者说数据是前后端绕不开的必须互通的纽带。
耦合点不在代码,在数据 。前端界面写得再漂亮,没有数据就是空的;而后端什么时候能把接口给出来,前端说了不算。前端真正卡住的,不是写不出组件,是等不到数据。
所以真正的问题不是"怎么分离",而是"步调不一致怎么办":
如何不耦合,真正的前后端独立。
- 让前端不用等后端接口,就可以直接把界面写完 ------
mockjs- 等后端把 api 真正写完之后,再把请求切过去(替换)
核心线索就这一句:前端要造一个后端的"影子"先跑起来,等真后端好了再切回去。
二、前端其实有两类路由
在讲怎么造影子之前,让我们了解一下前端其实有两类路由。
text
前端路由
├── 页面级别路由 ← react-router-dom 处理
│ 例:pages/Home、pages/Todos
│ 表现:浏览器地址变化、组件切换
│
└── 接口路由 /api/... ← 不是 react-router-dom 的范围
例:/api/todos
表现:数据请求,返回 json
谁来处理?→ vite + mockjs
第一类是客户端页面级别路由,react-router-dom 处理,比如 / 去 Home、/todos 去 Todos。
第二类是接口路由 实际是 HTTP API 的请求路径(endpoint) ,例如 /api/todos。它不是页面跳转,而是浏览器发出的数据请求,react-router-dom 不负责处理。
在开发阶段,这类请求通常由 Vite 开发服务器处理:要么通过代理转发给真实后端,要么借助 Mock.js、vite-plugin-mock、MSW 或自定义中间件返回模拟数据。生产环境中,一般由实际后端或网关处理。
三、api/ 目录:前端工程化里被忽略的一环
前端工程化不只是组件化、构建、lint 这些。接口也是前端工程化的一环。
前端可以独立做路由,也可以独立做数据接口(mock,开发阶段),不用傻等后端给接口。
路由能自己做我早知道,但数据接口前端也能自己做,这个我没想过。
方案是建一个 /api 目录,把所有前端接口统一管理起来。理由很直接:后端接口往往不能及时提供,前端需要一层自己的接口层。
后端的节奏和前端永远对不齐------后端可能还在设计数据库,前端不能干等着。所以前端要先把接口的"形状"定下来,用假数据跑通,等后端好了再切过去。这不是临时凑的,是前端工程化里正式的一环:
前端工程里面的一环,即 API(接口)工程。
四、axios vs fetch:不是"能不能",是"要不要自己封装"
定下来要有 api/ 目录,下一个问题是:用什么发请求。
原生 fetch 是浏览器底层 API,能力并不弱------请求、响应、流式读取、AbortController 取消都能做。但它不内置 axios 那样的实例、拦截器、超时语义,要实现统一管理得自己包一层薄封装。
选择 axios 的理由,不是"fetch 做不到",而是"axios 开箱即用、约定成熟,省掉重复封装"。看 src/api/config.js:
javascript
import axios from 'axios';
// 创建 axios 实例,统一配置 baseURL 和 timeout
// fetch 也能通过封装实现这些,但 axios 把这套能力做成了现成的、约定成熟的
const instance = axios.create({
// baseURL:API 服务的源地址,通过环境变量切换开发/生产
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10_000,
});
export default instance;
这里有几个工程上的关键点:
baseURL是 API 服务的源地址,不是"待替换地址"。 接口路径如/todos是稳定的 API 契约,不应理解为未来要逐个替换;真正会变化的是 API 服务地址,由baseURL统一处理。- 环境切换不该靠手改代码。 应该用
.env.development、.env.production分别提供VITE_API_BASE_URL,部署时由构建工具注入,而不是在代码里把/api手改成生产地址。 /api通常是开发服务器的代理前缀。 例如 Vite 把/api/todos转发给真实后端,也可能被 MSW、Mock.js 等工具拦截做 mock。它本身不等于"模拟接口"。
fetch 和 axios 的真实差别不是"能不能统一管理",而是"要不要自己封装":
| 能力 | 原生 fetch | axios |
|---|---|---|
| 发请求 | ✅ | ✅ |
| 创建带默认配置的客户端 | ⚠️ 需自行封装 | ✅ axios.create() |
| 统一 baseURL | ⚠️ 需自行拼接/封装 | ✅ 实例默认配置 |
| 超时 | ⚠️ AbortController 实现 |
✅ timeout 配置 |
| 请求/响应拦截 | ⚠️ 需自行封装 | ✅ 拦截器 |
| 自动 JSON 请求/响应处理 | ⚠️ 需显式处理 | ✅ 较便利 |
| 开发/生产地址切换 | ✅ 环境变量 + 配置模块 | ✅ 环境变量 + 配置模块 |
所以 axios 的核心价值不是"发请求",也不是"只有 axios 能统一管理"------而是把 HTTP 客户端实例、默认配置、超时、拦截器、统一错误处理做成了开箱即用的能力,减少重复封装、统一请求策略。fetch 通过一层薄封装也能做到,只是要自己写。
当然我们这里对 axios 的配置十分简单,总结下来就两句话:
- 先伪装数据接口
- 再通过 baseURL 一键切换
"先伪装,再切换"作为入门教学的简化描述可以,但更准确的工程说法是:先确定稳定的接口契约,开发环境通过 mock 或代理提供服务,部署时通过环境配置切换 API 服务地址。
五、接口模块:一个模块一个 js 文件,但 import 的不是 axios
src/api/todos.js 是接口模块:
javascript
// 一个模块一个 js 文件
import axios from './config';
// api 目录的职责 提供数据接口,接口不是直接去后端调用
// 后端与前端分离开发,后端没有开发好,
export const getTodos = async () => {
const res = await axios.get('/todos');
return res.data;
}
这段代码很短,但藏着四个学习信号:
- "一个模块一个 js 文件" ------ api/ 目录下,每个业务模块(todos、用户、商品......)都对应一个 js 文件,各自管理自己的接口。
- import 的是
./config,不是原生 axios ------ 所有请求都走上一节那个配好baseURL的实例。接口模块和请求库之间隔了一层配置。 - "接口不是直接去后端调用" ------ 这个函数看起来像在调后端,但它只定义了"请求的形状",真正返回什么数据,取决于
baseURL指向哪里。 axios.get('/todos')写的是/todos,拼上baseURL的/api,最终请求/api/todos。这个细节在后面切换 baseURL 的时候会变得很关键。
第 2 点是我之前最容易忽略的:接口模块不直接 import axios,而是 import 配好的实例。这就是"统一管理"的落地------所有请求都走同一套配置,没有例外。
六、mockjs:把后端的影子造出来
接口函数写好了,baseURL 配成 /api 了,但 /api/todos 这个地址根本没有后端在响应------后端还没写好。
那这个请求谁接?vite-plugin-mock。vite.config.js 里挂上插件:
javascript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), viteMockServe({
mockPath: 'mock',
localEnabled: true
})],
})
两个配置:mockPath: 'mock' 告诉它 mock 文件放在 mock 目录下;localEnabled: true 启用本地开发环境的 mock。
然后在 mock/todos.js 里描述接口:
javascript
export default [
{
url: '/api/todos',
method: 'GET',
timeout: 2000,
response:(req, res) => {
return {
code: 0,//成功,没问题
todos: [
{
id: 1,
title: '学习前端接口工程',
completed: true,
},
{
id: 2,
title: '学习 Vue',
completed: false,
},
],
}
}
},
];
几个细节值得记:
url: '/api/todos'------ 注意是完整的/api/todos,不是/todos。mock 拦截的是最终请求地址。method: 'GET'------ 大写。timeout: 2000------ 模拟 2 秒网络延迟。response是函数 ,接收req, res,返回一个对象,结构是{ code: 0, todos: [...] },注释写code: 0表示"成功,没问题"。- mock 数据里两条 todo,第一条 title 是"学习前端接口工程",completed 是 true;第二条是"学习 Vue",completed 是 false。
配好以后,前端发请求到 /api/todos,vite 就会用 mock 目录里定义的数据来响应,根本不需要后端。
总结下来整条流程是这样的:
前端需要数据状态,数据由数据接口提供, 不能直接走后端数据接口,前后端开发分离,步调不一致。 前端也需要独立完备整个应用开发工程系统,纳入了前端接口工程。
串起来是一条因果链:
text
前端需要数据
↓
数据由接口提供
↓
但不能直接走后端接口(步调不一致)
↓
前端自己搞一套接口工程(api/ 目录)
↓
用 axios 配 baseURL 统一管理地址
↓
用 mock 造数据,让接口跑通
↓
后端好了,只改 baseURL,一键切换
七、一键切换:baseURL 是开关,url 永远不变
现在到了这节课最精妙的地方。
笔记里有一节叫"前后端连调":
- 前端独立的完成整个 APP
- axios 配置 /api 前缀接口
- /api/todos 前端接口 返回 json 数据?
- 目前没有这个前端接口,我们需要提供
"目前没有这个前端接口,我们需要提供"------这句话说的就是用 mock 提供。
那等后端真正写好了 /api/todos(或者某个真实地址),怎么办?笔记最后一句给了答案:
接口 url 不论是实际 url 还是开发环境的 url,都是一样的。 所以需要 axios 来配置 baseURL 管理。
关键词:接口 url 不论是实际 url 还是开发环境的 url,都是一样的。
这是整个设计最精妙的地方。在页面组件里,调的永远是 getTodos(),里面请求的永远是 /todos(拼上 baseURL 是 /api/todos)。这个 url 在开发环境和生产环境是一样的,变的只是 baseURL:
text
开发阶段 生产阶段
───────────────────── ─────────────────────
baseURL: '/api' baseURL: 'https://api.example.com'
↓ ↓
请求 /api/todos 请求 https://api.example.com/todos
↓ ↓
vite mock 拦截 真实后端响应
返回假数据 返回真实数据
页面代码:一行不改
baseURL 就是那个切换的开关。url 不变,所以切换的成本为零。
八、代码实践:接口工程怎么落到组件里
最后把整个前端代码串一遍,看这套接口工程怎么落到组件里。
入口 src/main.jsx,标准 React 19 + Vite:
javascript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
src/App.jsx 是路由配置:
javascript
import React, { lazy, Suspense } from 'react';
import { Routes, Route, BrowserRouter as Router } from 'react-router-dom';
import Nav from './components/Nav';
const Home = lazy(() => import('./pages/Home'));
const Todos = lazy(() => import('./pages/Todos'));
function App() {
return (
// 路由接管一切
<Router>
<Nav />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/todos" element={<Todos />} />
</Routes>
</Router>
)
}
export default App
注释写"路由接管一切"------Router 包在最外层,所有页面都在 Routes 里。Home 和 Todos 用 lazy 做懒加载。
但这代码有一个真正的 bug :第 1 行 Suspense 被 import 进来了,JSX 里却没用 <Suspense> 包住 <Routes>。React.lazy 加载的组件在对应 chunk 下载完前会"挂起",必须有一个 <Suspense> 祖先提供 fallback,否则 React 会抛错或白屏。也就是说,第一次进 / 或 /todos 时,Home/Todos 还没加载完,没有 Suspense 兜底,页面直接挂掉。
正确写法是用 <Suspense> 包住 <Routes>:
jsx
<Router>
<Nav />
<Suspense fallback={<div>加载中...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/todos" element={<Todos />} />
</Routes>
</Suspense>
</Router>
fallback 是组件还在加载时显示的内容。包在 <Routes> 外层,任意路由切换目标还没加载完时都会显示 加载中...。注意第 1 行 import 里的 Suspense 这下才真正用上------之前是"引了没用"的死代码。
导航 src/components/Nav.jsx:
javascript
import { Link } from 'react-router-dom';
function Nav() {
return (
<nav style={{padding:10, borderBottom:'1px solid #ccc'}}>
<Link to="/">Home</Link>
<Link to="/todos">Todos</Link>
</nav>
)
}
export default Nav
Home.jsx 很简单:
javascript
function Home() {
return (
<>
Home
</>
)
}
export default Home
重点是 src/pages/Todos.jsx,这里才是接口工程落地的地方:
javascript
import { getTodos } from '../api/todos';
import { useEffect, useState } from 'react';
function Todos() {
const [todos, setTodos] = useState([]);
useEffect(() => {
// IIFE 立即执行函数
(async () => {
const data = await getTodos();
setTodos(data);
})()
}, [])
return (
<>
Todos
</>
)
}
export default Todos
这段代码我拆开看:
import { getTodos } from '../api/todos'------ 从 api 模块引入接口函数。组件不直接碰 axios,只跟 api 模块打交道。这就是接口层的意义:组件和请求库之间隔了一层。useState([])------ todos 初始值是空数组。useEffect里用了 IIFE(立即执行函数),注释也标了"IIFE 立即执行函数"。这是因为useEffect的回调不能直接是async函数,所以用一个立即执行的 async 函数包一层。const data = await getTodos()------ 调接口拿数据。setTodos(data)------ 存进状态。
但这里有个问题我注意到了 :mock 返回的数据结构是 { code: 0, todos: [...] },getTodos 里 return res.data,页面里 setTodos(data) 直接把 data 存进去。这意味着存进状态的是整个 { code, todos } 对象,而不是 todos 数组。
text
mock 返回 getTodos 返回 setTodos 存的
───────────── ───────────── ─────────────
{ code: 0, → { code: 0, → { code: 0,
todos: [...] } todos: [...] } todos: [...] }
按现在写法,todos 状态其实是整个对象,不是数组。目前页面只输出了 Todos 文字,还没渲染列表,所以暂时看不出问题。等后面真要渲染列表,这里得改成 data.todos,或者让 getTodos 直接 return res.data.todos。这是个埋点,先记下。
九、依赖总览:技术栈拼图
把 frontend/todos/package.json 的依赖列一下,能看清整个技术栈:
json
{
"dependencies": {
"axios": "^1.19.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"vite": "^8.2.0",
"vite-plugin-mock": "^3.0.2"
}
}
对应笔记里的"三架马车":
| 角色 | 依赖 |
|---|---|
| 组件(响应式) | react / react-dom |
| 路由 | react-router-dom |
| 状态管理(银行) | zustand |
| 标准请求库 | axios |
| mock 支持 | vite-plugin-mock |
这几个就是这节课的全部技术拼图。
我现在怎么理解这节课
在此之前,我对"前后端分离"的理解是模糊的------以为就是前端写前端、后端写后端,各干各的。这节课让我理清了一条完整的因果链:
text
前端需要数据状态来渲染界面
→ 数据由接口提供
→ 但后端接口不能及时提供,步调不一致
→ 前端自己搞一套接口层(api/ 目录)
→ 用 axios 配 baseURL 统一管理请求地址
→ 用 mock 造假数据,让接口跑通
→ 后端好了,只改 baseURL,一键切换,页面代码不动
这里最关键的设计是:接口 url 不论是实际 url 还是开发环境的 url,都是一样的 。正因为 url 不变,切换的成本为零。axios 的 baseURL 就是那个切换的开关。
我还意识到一件事:api/ 目录不只是"放接口函数的地方",它是一道隔离层。
text
组件 → api 模块 → axios 实例 → baseURL
↓
开发:/api(mock 拦截)
生产:真实后端地址
组件只跟 api 模块打交道,api 模块只跟 axios 实例打交道,axios 实例只跟 baseURL 打交道。后端怎么变,组件都不用动。这就是接口工程的意义。
回到 Vibe Coding 时代,这套东西其实更重要了。AI 可以帮我很快写出组件、写出页面,但如果接口层没设计好,AI 写的代码一碰到"后端没好"就卡住了。把接口工程搭好,AI 就能在 mock 数据上把整个应用跑通,等后端好了无缝衔接。
这节课教的不是某个 API 怎么用,而是一种让前端真正独立起来的工程思维:分离不是"断开",而是"前端自己把后端的影子造出来,再随时能切回去"。