那个让我卡住的下午
说实话,刚开始做这个 Todos 项目的时候,我脑子里的 "前后端分离" 就是一句口号。
我把 React 组件写好了,路由配好了,页面也能跳转了。然后到了 Todos 页面,要展示一个任务列表 ------ 我下意识地写了个
fetch('http://localhost:3000/todos'),一跑,控制台红了一片。哦对,后端还没开始写呢。
当时我懵了。前端页面都搭好了,就差数据,总不能在那儿干等后端吧?但不等的话,我连列表长什么样都没法验证。那段时间我甚至想过直接在组件里写死一个数组
const todos = [{id:1, title:'...'}],等后端好了再换掉 ------ 现在想想,这办法不是不行,但太糙了,后面替换的时候肯定漏。后来我去翻了几个开源项目的源码,才发现人家前端根本不依赖后端。人家有自己的一套 "接口层",后端没好的时候用假数据顶着,后端好了改一行配置就切过去。
这篇文章就是我把这套东西搞明白的过程。
前后端分离,到底分离了个啥
在说怎么解决之前,我得先把我之前理解错的地方掰过来。
以前我以为前后端分离就是 "前端写页面,后端写接口,用 JSON 通信"。这话没错,但太浅了。真正的分离是 ------前端是一个独立的应用,后端是另一个独立的应用,它们之间唯一的耦合点是接口约定。
什么意思呢?前端这一侧,其实有自己的 "三驾马车":
- 组件:负责页面长什么样,数据变了自动刷新
- 路由:负责哪个 URL 显示哪个页面
- 状态管理:负责组件之间共享数据,像个银行,存了取、取了存
这三样东西凑齐,前端本身就是一个完整的应用了。它不依赖后端也能跑 ------ 只是没有真实数据而已。
那数据从哪来?从接口来。但接口不一定非得是后端提供的,前端自己也能 "造" 一个接口出来,只要返回的 JSON 格式和后端将来返回的一样就行。
这就是 mock 的意义:前端先把接口契约定下来,自己造数据把整个应用跑通,等后端把真接口写好了,改个地址就接上了。
我后来的理解前后端分离的核心不是 "用 JSON 通信",而是 "前端可以独立开发、独立运行、独立测试"。mock 就是实现这个独立性的关键一环。
项目结构长这样
几个关键目录说一下:
src/pages/------ 页面级组件,Home 和 Todossrc/components/------ 可复用组件,比如导航栏 Navsrc/api/------ 前端接口层,所有请求都从这里走mock/------ mock 数据,模拟后端接口
重点是 src/api/ 这个目录。很多初学者(包括以前的我)喜欢在组件里直接 fetch,但那样的话,接口地址散落在各个组件里,后面要改 baseURL 或者加统一拦截器,得一个个找,累死。
把所有请求集中到 api/ 目录,就是把 "数据获取" 这件事从组件里抽出来,单独管理。
先把路由和页面搭起来
这部分比较常规,但还是得说一下,因为后面的 mock 数据是在 Todos 页面里用的。
路由用的是 react-router-dom,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;
这里用了 lazy 懒加载,Home 和 Todos 页面会按需加载,首屏不用把所有代码都拉下来。不过懒加载要配合 Suspense 用,我上面的代码省了,实际项目里要包一层,不然切换路由会报错 ------ 别问我为什么知道,试了三次。
导航栏就两个链接:
javascript
import { Link } from 'react-router-dom';
function Nav() {
return (
<nav style={{padding:'10px', borderBottom: '1px solid #ccc'}}>
<Link to="/">Home</Link>
<Link to="/todos">Todos</Link>
</nav>
)
}
export default Nav;
Home 页面就是个占位,重点在 Todos 页面。
axios 配置:为什么不用 fetch
到了接口层,第一个选择是用什么发请求。原生 fetch 当然能用,但它有个问题 ------ 功能太少了。
你想统一加个超时?fetch 得自己包一层 AbortController。你想统一处理错误?fetch 只有网络错误才 reject,HTTP 404、500 它都认为是 "成功"。你想统一加请求头?每个请求都得手动传。
所以我直接上了 axios,先建个实例:
javascript
// src/api/config.js
import axios from 'axios';
// 实例化 axios
// fetch 缺点是功能小
// app /api/todos -> :3000/todos
// 统一管理,fetch 升级为 axios
const instance = axios.create({
baseURL: '/api', // dev 前端模拟的请求地址 /api/todos
// baseURL: 'http://localhost:3000', // 后端好了之后切到这行
timeout: 5000,
})
export default instance;
注意 baseURL 这一行。现在写的是 /api,意思是所有请求都会自动加上 /api 前缀。比如我调用 axios.get('/todos'),实际请求的是 /api/todos。
将来后端写好了,地址是 http://localhost:3000,我只要把 baseURL 改成那个地址就行了,其他代码一行都不用动。
这就是 "接口层" 的价值 ------所有和后端相关的配置都集中在一个地方,切换环境只改一处。
然后在 api/todos.js 里定义具体的接口函数:
javascript
// src/api/todos.js
// 一个模块一个 js 文件
import axios from './config';
// api 目录的职责:提供数据接口
// 不是直接去请求后端,后端还没有开发好,前后端分离
export const getTodos = async () => {
const res = await axios.get('/todos');
return res.data;
}
很简单,就一个函数。但这个函数就是前端和后端之间的 "契约"------ 不管数据是 mock 的还是真实的,组件调用的方式不变。
mock:前端自己造一个后端
现在问题来了:baseURL 是 /api,那 /api/todos 这个请求发给谁?
如果什么都不做,浏览器会请求当前域名下的 /api/todos,也就是 http://localhost:5173/api/todos,然后返回 404,因为前端开发服务器上根本没有这个接口。
这时候就需要 vite-plugin-mock 了。它的作用是:在 Vite 开发服务器里拦截指定的请求,返回你预先写好的假数据。
bash
pnpm i -D vite-plugin-mock
然后在 vite.config.js 里配置:
javascript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig({
plugins: [react(), viteMockServe({
mockPath: 'mock', // mock 文件放在哪个目录
localEnabled: true, // 开发环境开启 mock
})],
})
mockPath: 'mock' 的意思是去根目录下的 mock 文件夹里找 mock 定义。然后在 mock/todos.js 里写:
javascript
// mock/todos.js
export default [
{
url: '/api/todos',
method: 'get',
timeout: 2000, // 模拟网络延迟,2秒后返回
response: (req, res) => {
return {
code: 0, // 成功,没问题
todos: [
{ id: 1, title: '学习 React', completed: false },
{ id: 2, title: '学习 Vue', completed: true },
{ id: 3, title: '学习 Node.js', completed: false },
]
}
}
}
]
这样一来,当浏览器请求 /api/todos 的时候,Vite 开发服务器会拦截这个请求,等 2 秒(模拟网络延迟),然后返回上面那个 JSON。
我跑起来之后,在 Network 面板里看到的是这样:

看到 code: 0 和那三条 todo 的时候,说实话有点小激动 ------ 前端真的不依赖后端就能拿到数据了。
在页面里用起来
最后一步,在 Todos 页面里调用这个接口:
javascript
// src/pages/Todos.jsx
import { getTodos } from '../api/todos';
import { useEffect, useState } from 'react';
function Todos() {
const [todos, setTodos] = useState([]);
useEffect(() => {
// IIFE 立即执行函数表达式
// 因为 useEffect 的回调不能直接写 async,所以用 IIFE 包一层
(async () => {
const data = await getTodos();
setTodos(data.todos); // 注意是 data.todos,不是 data
})()
}, []);
return (
<>
<h1>Todos</h1>
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</>
)
}
export default Todos;
这里有个小细节:useEffect 的回调函数不能直接写成 async,因为 async 函数返回的是 Promise,而 useEffect 期望返回的是清理函数(或者 undefined)。所以用了一个 IIFE(立即执行函数表达式)把异步逻辑包起来。
还有就是 setTodos(data.todos) 这一行 ------ 接口返回的是 { code: 0, todos: [...] },所以要取 data.todos,不是直接 setTodos(data)。我一开始写错了,页面啥也不显示,调了半天才发现。
我踩过的坑
坑一:baseURL 少写了个斜杠
最开始我写的是 baseURL: 'api'(没有前导斜杠),结果请求发出去变成了 http://localhost:5173/todos/api/todos------ 因为相对路径会基于当前页面的 URL 拼接。
改成 baseURL: '/api' 就好了。带前导斜杠的是绝对路径,不带的是相对路径,这个区别坑了我半小时。
坑二:mock 文件没生效
配置完 vite-plugin-mock 之后,我改了 mock 数据,刷新页面,结果返回的还是旧数据。后来才发现,mock 文件的修改需要重启 Vite 开发服务器才能生效 ------ 它不像 React 组件那样有热更新。
不过新版本的 vite-plugin-mock 好像支持热更新了,我用的版本还不行,每次改完得手动 Ctrl+C 再 pnpm dev。
坑三:timeout 不是 axios 的 timeout
mock 里那个 timeout: 2000 是 mock 插件模拟的网络延迟,和 axios 配置里的 timeout: 5000 不是一回事。我一开始以为是同一个东西,还纳闷为什么设了 5 秒超时,请求却总是 2 秒就返回了。
注意mock 的
timeout是 "等多久再返回数据",axios 的timeout是 "最多等多久就认为请求失败"。两个是不同层面的东西。
坑四:生产环境忘了关 mock
localEnabled: true 只在开发环境生效,生产构建的时候 mock 是不会打进去的。但如果你用了 prodEnabled: true,那生产环境也会有 mock------ 这通常不是你想要的。
我目前只在开发环境用 mock,所以只开了 localEnabled。
往深里想一层:这套东西到底怎么运转的
搞通了之后,我又好奇了一下:vite-plugin-mock 是怎么拦截请求的?
其实原理不复杂。Vite 的开发服务器是基于 Node.js 的 Connect 中间件框架跑的。vite-plugin-mock 本质上就是往这个服务器里注入了一个中间件 ------ 每当有请求进来,它先检查 URL 和 method 有没有匹配上 mock 定义,如果匹配上了,就直接返回假数据,不再往后传递;如果没匹配上,就交给下一个中间件处理(比如返回 index.html 或者静态资源)。
所以整个请求链路是这样的:

而 axios 的 baseURL 就是给所有请求统一加个前缀。这个前缀在开发环境下刚好和 mock 的 URL 对上,在生产环境下可以改成后端的真实地址。前端代码完全不用改,改的只是配置。
这就是 "接口层" 和 "mock" 配合起来的威力:前端用假数据把整个应用跑通,后端用真数据把接口实现,最后两边一对接,完事。
不是万能的
说了这么多好处,也得说说 mock 什么时候不该用。
第一,如果后端接口已经写好了,就没必要 mock 了,直接连真接口就行。mock 是为了解决 "前后端开发步调不一致" 的问题,后端都好了还 mock 就是多此一举。
第二,mock 数据要和后端约定好格式。如果前端 mock 的返回结构和后端实际返回的不一样,那联调的时候还是要改代码。所以最好前后端先定好接口文档(返回字段、类型、错误码),再各自开发。
第三,mock 只能模拟正常返回,模拟不了真实的网络异常、服务器错误、并发冲突这些。所以 mock 阶段跑通了不代表真的没问题,联调还是得认真测。
最后说几句
搞完这一套之后,我最大的感受是:前端真的不是 "只能写页面"。
以前我总觉得前端得等后端给接口才能干活,现在发现不是 ------ 前端可以自己把数据层、路由层、组件层都搭好,用 mock 数据把整个应用跑起来,等后端好了直接对接。这种独立性才是 "前后端分离" 真正的意思。
如果让我拎三个最关键的点:
- api 目录是前端的接口层,所有请求集中管理,baseURL 一键切换环境
- mock 让前端可以独立开发,不用等后端,只要接口格式约定好就行
- 组件里不要直接发请求,通过 api 层调用,后面改起来才不痛苦
这套东西我也是刚搞明白不久,可能理解还有不到位的地方。你要是有不同的想法或者踩过别的坑,评论区聊聊,我也想看看你的理解。