React + Vite Todo 前端先行实践:用路由、Axios 接口层与 Mock 解开前后端等待
在前后端分离项目里,前端负责页面、交互和状态,后端负责业务接口与数据持久化。职责可以拆开,开发节奏却很难彻底互不影响:前端页面最终仍要依赖后端返回的数据。
以 Todo 应用为例,Todo 页面需要一个数组才能渲染任务。如果后端的 /todos 接口还没有完成,前端是停下来等待,还是先把路由、请求和状态链路搭好?
更实用的做法,是在前端内部建立一层统一的 API 工程:
- 页面只调用前端封装好的请求函数;
- Axios 实例统一管理请求前缀与超时时间;
- 开发阶段由 Vite Mock 返回符合约定的数据;
- 后端接口就绪后,只切换请求基地址,不改页面的调用方式。
本文围绕这个过程,搭建一个 React + Vite 的 Todo 应用骨架,并详细拆解路由懒加载、Axios 实例、Mock 接口、useState、useEffect、工程配置和当前实现边界。
一、先明确应用的职责边界
这个应用的技术方向可以分成两部分:
- 前端:React、React Router、Axios、Vite Mock,以及后续可使用的 Zustand;
- 后端:Node.js + Koa,目标是提供
/todos接口,MySQL 属于后续的数据持久化规划。
当前真正打通的是前端开发链路:
text
浏览器页面
→ React Router 匹配 /todos
→ Todos 页面调用 getTodos()
→ Axios 请求 /api/todos
→ Vite Mock 拦截请求
→ 返回 Todo JSON
→ setTodos 更新组件状态
这里需要特别区分"规划"和"已经实现":Koa 已经被声明为后端依赖,但后端服务入口和真实 /todos 路由还没有编写;Zustand 已经被加入前端依赖,但当前 Todo 数据仍由组件内的 useState 管理;Todo 数据已经进入状态,页面却还没有把数组渲染出来。
因此,这不是一个已经完成增删改查的 Todo 产品,而是一套已经把页面路由、接口封装和 Mock 取数串起来的前端工程骨架。
二、项目结构:页面、接口和 Mock 各司其职
只保留与本文主线直接相关的目录后,结构可以整理为:
text
todos-fullstack/
├── backend/
│ └── package.json
└── frontend/todos/
├── mock/
│ └── todos.js
├── src/
│ ├── api/
│ │ ├── config.js
│ │ └── todos.js
│ ├── components/
│ │ └── Nav.jsx
│ ├── pages/
│ │ ├── Home.jsx
│ │ └── Todos.jsx
│ ├── App.jsx
│ └── main.jsx
├── package.json
└── vite.config.js
这个结构里有两类"路由",但它们不是同一种东西:
pages下的页面由 React Router 处理,例如/和/todos;api下的代码负责发起数据请求,例如/api/todos,它不属于 React Router 的管理范围。
换句话说,/todos 是浏览器页面地址,/api/todos 是数据接口地址。名称相似,职责完全不同。
三、React 应用入口
React 入口负责找到 HTML 中的 root 挂载点并渲染应用:
jsx
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>,
)
createRoot 创建 React 根节点,App 是整个组件树的起点。
StrictMode 只包裹组件,不会生成额外 DOM。它会在开发环境中帮助发现不纯的渲染和 Effect 清理问题。对当前 Todo 页面来说,一个直观现象是开发时 Effect 可能被额外执行一次,因此可能观察到不止一次 GET 请求;生产构建不会保留这种开发检查行为。
四、用 React Router 管理页面级路由
应用有两个页面:
/对应首页;/todos对应 Todo 页面。
路由入口如下:
jsx
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;
1. BrowserRouter 提供路由上下文
BrowserRouter 被重命名为 Router,它包裹导航和路由表。Link、Routes、Route 等路由组件只有放在这个上下文中才能协同工作。
Nav 位于 Routes 外部,所以访问首页或 Todo 页时它都会保留。Routes 负责从一组 Route 中找出与当前地址匹配的页面。
2. lazy 让页面按需加载
jsx
const Home = lazy(() => import("./pages/Home"));
const Todos = lazy(() => import("./pages/Todos"));
静态 import 会在应用入口加载时直接引入模块,而 lazy 配合动态 import(),会把页面变成按需加载的模块。实际生产构建中,Home 和 Todos 会形成独立的 JavaScript 产物,这说明页面级代码分割已经生效。
当前代码已经导入 Suspense,却没有真正使用。也就是说,懒加载页面等待下载时没有显式的加载占位内容。保持原结构不变,可以把路由表补成:
jsx
<Router>
<Nav />
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/todos" element={<Todos />} />
</Routes>
</Suspense>
</Router>
这样既使用了已经导入的 Suspense,也给页面模块加载过程提供了明确反馈。
3. Link 完成客户端导航
导航组件非常精简:
jsx
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;
to 对应前面声明的页面路径。样式通过 JSX 的 style 对象传入,因此属性名使用 JavaScript 写法,数值 10 会被 React 解释为像素值。
首页组件目前只返回 Home 文本。它的作用主要是验证 / 路由和页面懒加载链路。
五、为什么需要独立的 API 层
页面当然可以直接写:
js
axios.get("/api/todos")
但如果每个组件都自行决定域名、前缀、超时和响应解析,后端地址发生变化时,就要在许多组件里逐个修改。独立 API 层把这种变化收敛到固定位置。
当前接口工程分成两层:
text
config.js:管理 Axios 的公共配置
todos.js:管理 Todo 领域的接口函数
1. 创建统一的 Axios 实例
js
import axios from "axios";
const instance = axios.create({
baseURL: "/api",
timeout: 5000
});
export default instance;
这里没有直接使用全局 axios,而是通过 axios.create 得到一个实例:
baseURL: "/api"统一添加接口前缀;timeout: 5000表示请求最多等待 5000 毫秒。
开发阶段使用相对地址 /api,请求会发给当前 Vite 开发服务器,正好可以被 Mock 插件处理。
2. 一个业务模块管理一类接口
Todo 接口被封装成一个普通异步函数:
js
import instance from "./config";
export const getTodos = async () => {
const res = await instance.get("/todos");
return res.data;
};
Axios 会把实例的 baseURL 和请求路径组合起来:
text
baseURL /api
请求路径 /todos
最终地址 /api/todos
instance.get 返回的是 Axios 响应对象,真正的接口响应体位于 res.data,所以 getTodos 只把业务需要的数据交给调用方。页面不需要知道 Axios 响应对象的其他字段,也不需要关心当前数据来自 Mock 还是真实后端。
这层封装体现了一个很重要的边界:组件负责"什么时候获取数据、拿到数据后怎么更新界面",API 模块负责"向哪里发请求、如何取得响应体"。
六、用 Vite Mock 在开发阶段提供接口
前端要在后端接口完成之前独立运行,需要让 Vite 开发服务器临时提供 /api/todos。
Vite 配置同时启用了 React 插件和 Mock 插件:
js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig({
plugins: [
react(),
viteMockServe({
mockPath: "mock",
localEnabled: true
})
],
})
mockPath: "mock" 表示 Mock 定义放在项目根目录的 mock 目录中。配置里的 localEnabled: true 表达的是"本地启用 Mock"的意图,但当前安装的 vite-plugin-mock 3.0.2 已经没有这个选项,实际可用的开关名是 enable。
这里之所以仍能在开发模式启用 Mock,是因为 3.0.2 默认在 Vite 的 serve 命令下开启,而不是 localEnabled 产生了作用。与当前版本对齐后,可以写成:
js
viteMockServe({
mockPath: "mock",
enable: true
})
如果只需要默认的"开发时开启",也可以仅保留 mockPath,让插件使用默认值。这个版本差异很重要:配置对象不会因为属性名字看起来合理就自动生效,最终仍要以已安装版本实际支持的选项为准。
Todo Mock 接口定义为:
js
export default [
{
url: "/api/todos",
method: "get",
timeout: 2000,
response: (req, res) => {
return {
code: 0,
todos: [
{ id: 1, title: "学习前端接口工程", completed: true },
{ id: 2, title: "学习后端接口工程", completed: false }
]
};
}
}
];
这份定义同时约定了接口地址、请求方法、延迟和响应结构:
- 只匹配 GET
/api/todos; - 延迟 2000 毫秒返回,便于观察异步请求过程;
code: 0表示成功;todos是页面真正需要的数组;- 每个 Todo 包含
id、title和completed。
Mock 延迟是 2000 毫秒,而 Axios 超时是 5000 毫秒,所以正常情况下响应会在超时前返回。
response 的 req 和 res 参数目前没有参与任何计算,这会触发 ESLint 的未使用变量规则。当前插件版本的普通 response 回调接收的是一个请求信息对象;只有需要读取 URL、body、query 或 headers 时才有必要声明它。这里返回的是固定数据,可以保持行为不变,直接写成:
js
response: () => {
return {
code: 0,
todos: [
{ id: 1, title: "学习前端接口工程", completed: true },
{ id: 2, title: "学习后端接口工程", completed: false }
]
};
}
七、在组件中完成异步取数和状态更新
Todo 页面把接口数据保存到本地状态:
jsx
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.todos);
})();
}, []);
return (
<>
Todos
</>
);
}
export default Todos;
1. useState([]) 为什么从空数组开始
jsx
const [todos, setTodos] = useState([]);
接口最终返回 Todo 数组,因此初始状态也使用数组。首次渲染时数据尚未到达,todos 是空数组;请求完成后,setTodos(data.todos) 写入两条 Mock 数据,并触发组件重新渲染。
2. useEffect 为什么使用空依赖数组
jsx
useEffect(() => {
// 获取数据
}, []);
空依赖数组表达的是:这段 Effect 不依赖组件中的某个变化值,用于组件进入页面后的初始化取数。它和入口处的 StrictMode 一起使用时,需要记住开发环境可能执行额外检查,不能简单地把开发阶段观察到的重复 GET 当成生产行为。
3. 为什么在 Effect 里使用异步 IIFE
getTodos 是异步函数,需要使用 await。当前写法没有直接把 Effect 回调声明为 async,而是在同步回调内部创建并立即执行一个异步函数:
jsx
(async () => {
const data = await getTodos();
setTodos(data.todos);
})();
这个函数不需要名称,定义完成后立刻通过末尾的 () 执行,因此称为 IIFE(立即执行函数)。
4. 数据已经进入状态,但还没有进入视图
这是当前实现最容易误判的一点。setTodos(data.todos) 已经完成状态更新,但返回的 JSX 只有静态文本 Todos,没有读取 todos:
jsx
return (
<>
Todos
</>
);
所以打开页面只能看到标题文本,看不到两条任务。请求链路"已经打通"和列表"已经渲染"是两件不同的事。
这也解释了 ESLint 为什么会报告 todos is assigned a value but never used:状态被声明和写入,却没有在渲染逻辑中消费。下一步应围绕现有 todos 状态补上列表展示,而不是重新设计请求层。
八、局部状态与 Zustand 的当前边界
前端依赖中已经包含 Zustand,整体规划也把 React、路由和状态管理视为独立前端应用的三块能力:
- React 负责组件和响应式渲染;
- React Router 负责页面切换;
- 状态管理负责集中保存和分发共享状态。
不过,当前 Todo 数据只在 Todos 页面内部使用,实际代码选择的是 useState,并没有创建 Zustand store。不能因为依赖已经安装,就把尚未出现的 store、action 或跨组件共享描述成已经实现。
从现状看,路由与接口层已经落地,状态管理仍停留在局部组件状态阶段。
九、真实后端目前处于什么状态
后端目前只声明了 Koa 依赖:
json
{
"dependencies": {
"koa": "^3.2.1"
}
}
当前还没有后端入口、启动命令和接口路由。MySQL 只存在于架构规划中,依赖和连接代码尚未出现。
因此,现在能够为前端提供数据的是 Vite Mock,而不是 Koa。
当后端将来在 http://localhost:3000/todos 提供相同结构的数据时,API 模块的调用方式可以保持不变:
js
export const getTodos = async () => {
const res = await instance.get("/todos");
return res.data;
};
只需把 Axios 实例的基地址从开发期 Mock 前缀切到后端服务地址:
js
const instance = axios.create({
baseURL: "http://localhost:3000",
timeout: 5000
});
这个切换之所以简单,是因为页面从一开始依赖的就是 getTodos(),而不是某个写死在组件里的服务器地址。真正连接前后端的,是已经约定好的请求路径和 JSON 结构。
十、工程自检暴露出的未完成项
当前生产构建能够成功,并且路由页面会被拆分为独立产物;但 lint 仍有 5 个错误:
- Mock 响应函数的
req未使用; - Mock 响应函数的
res未使用; App.jsx的默认React导入未使用;Suspense已导入但未渲染;todos状态已声明但未参与 JSX。
这些错误不是随机出现的,它们准确反映了代码的完成度:Mock 暂时不读取请求参数,现代 JSX 写法不需要默认 React 变量,懒加载边界还未补齐,Todo 列表还未展示。
十一、一次请求究竟经历了什么
把前面的模块串起来,访问 /todos 后会发生以下过程:
BrowserRouter根据地址匹配/todos;lazy加载Todos页面模块;Todos首次渲染,todos的值是空数组;useEffect执行异步 IIFE;- IIFE 调用
getTodos(); getTodos()使用 Axios 实例请求/todos;- Axios 把
baseURL与路径组合成/api/todos; - Vite Mock 匹配 GET 请求并等待 2000 毫秒;
- Mock 返回包含两条任务的对象;
- API 函数取出
res.data; - 页面执行
setTodos(data.todos); - React 重新渲染组件,但因为 JSX 尚未读取
todos,界面仍只显示Todos。
这一条链路中,页面、请求配置、业务接口和 Mock 数据是分开的。任何一层都只承担自己的职责,后端到位后也只需要替换数据来源。
十二、当前实现的完整度清单
为了避免"代码能构建"与"功能已完成"混为一谈,可以用下面这张表检查进度:
| 模块 | 当前状态 | 说明 |
|---|---|---|
| React 入口 | 已完成 | createRoot 渲染 App,并启用 StrictMode |
| 页面路由 | 已完成 | / 与 /todos 均已声明 |
| 导航 | 已完成 | Link 可切换两个页面 |
| 页面懒加载 | 基本完成 | 已使用 lazy,但缺少实际的 Suspense 边界 |
| Axios 公共配置 | 已完成 | /api 前缀与 5 秒超时已统一管理 |
| Todo API 模块 | 已完成 | getTodos 返回 res.data |
| Mock 接口 | 已完成 | GET /api/todos 返回两条 Todo 数据 |
| 本地状态 | 已完成 | 数据会写入 todos |
| Todo 列表视图 | 未完成 | JSX 尚未消费 todos |
| Zustand | 未使用 | 只有依赖和技术规划,没有 store 实现 |
| Koa 接口 | 未完成 | 只有 Koa 依赖,没有服务入口和路由 |
| MySQL | 未实现 | 只存在于后端技术规划中 |
| 生产构建 | 可通过 | 页面模块可以正常生成独立构建产物 |
| ESLint | 未通过 | 存在 5 个与未完成代码直接相关的错误 |
总结
这个 Todo 骨架最值得掌握的,不是页面上暂时只有两个单词,而是前端如何在后端尚未完成时保持独立开发能力:
- React Router 把页面组织为
/和/todos; lazy为页面级按需加载建立基础,Suspense还需要真正接入;- Axios 实例集中管理
/api前缀和超时时间; getTodos把组件与具体请求细节隔开;- Vite Mock 用同一份接口契约提供开发数据;
useEffect在页面初始化时取数,useState保存响应数组;- 构建已经通过,但 lint 清楚指出了尚未完成的展示与清理工作;
- Koa、MySQL 和 Zustand 是后续方向,不能当作已经实现的功能。
前后端分离并不意味着两端毫无联系。它们仍然通过 URL、请求方法和 JSON 结构协作。真正降低等待和改动成本的关键,是让页面依赖稳定的前端 API 函数,再让 Mock 与真实后端先后履行同一份数据约定。