React + Vite Todo 实践:用 Axios 与 Mock 解开前后端等待

3 阅读14分钟

React + Vite Todo 前端先行实践:用路由、Axios 接口层与 Mock 解开前后端等待

在前后端分离项目里,前端负责页面、交互和状态,后端负责业务接口与数据持久化。职责可以拆开,开发节奏却很难彻底互不影响:前端页面最终仍要依赖后端返回的数据。

以 Todo 应用为例,Todo 页面需要一个数组才能渲染任务。如果后端的 /todos 接口还没有完成,前端是停下来等待,还是先把路由、请求和状态链路搭好?

更实用的做法,是在前端内部建立一层统一的 API 工程:

  1. 页面只调用前端封装好的请求函数;
  2. Axios 实例统一管理请求前缀与超时时间;
  3. 开发阶段由 Vite Mock 返回符合约定的数据;
  4. 后端接口就绪后,只切换请求基地址,不改页面的调用方式。

本文围绕这个过程,搭建一个 React + Vite 的 Todo 应用骨架,并详细拆解路由懒加载、Axios 实例、Mock 接口、useState、useEffect、工程配置和当前实现边界。

一、先明确应用的职责边界

这个应用的技术方向可以分成两部分:

  • 前端:React、React Router、Axios、Vite Mock,以及后续可使用的 Zustand;
  • 后端:Node.js + Koa,目标是提供 /todos 接口,MySQL 属于后续的数据持久化规划。

当前真正打通的是前端开发链路:

浏览器页面
  → React Router 匹配 /todos
  → Todos 页面调用 getTodos()
  → Axios 请求 /api/todos
  → Vite Mock 拦截请求
  → 返回 Todo JSON
  → setTodos 更新组件状态

这里需要特别区分“规划”和“已经实现”:Koa 已经被声明为后端依赖,但后端服务入口和真实 /todos 路由还没有编写;Zustand 已经被加入前端依赖,但当前 Todo 数据仍由组件内的 useState 管理;Todo 数据已经进入状态,页面却还没有把数组渲染出来。

因此,这不是一个已经完成增删改查的 Todo 产品,而是一套已经把页面路由、接口封装和 Mock 取数串起来的前端工程骨架。

二、项目结构:页面、接口和 Mock 各司其职

只保留与本文主线直接相关的目录后,结构可以整理为:

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 挂载点并渲染应用:

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 页面。

路由入口如下:

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 让页面按需加载

const Home = lazy(() => import("./pages/Home"));
const Todos = lazy(() => import("./pages/Todos"));

静态 import 会在应用入口加载时直接引入模块,而 lazy 配合动态 import(),会把页面变成按需加载的模块。实际生产构建中,Home 和 Todos 会形成独立的 JavaScript 产物,这说明页面级代码分割已经生效。

当前代码已经导入 Suspense,却没有真正使用。也就是说,懒加载页面等待下载时没有显式的加载占位内容。保持原结构不变,可以把路由表补成:

<Router>
  <Nav />
  <Suspense fallback={<div>Loading...</div>}>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/todos" element={<Todos />} />
    </Routes>
  </Suspense>
</Router>

这样既使用了已经导入的 Suspense,也给页面模块加载过程提供了明确反馈。

3. Link 完成客户端导航

导航组件非常精简:

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 层

页面当然可以直接写:

axios.get("/api/todos")

但如果每个组件都自行决定域名、前缀、超时和响应解析,后端地址发生变化时,就要在许多组件里逐个修改。独立 API 层把这种变化收敛到固定位置。

当前接口工程分成两层:

config.js:管理 Axios 的公共配置
todos.js:管理 Todo 领域的接口函数

1. 创建统一的 Axios 实例

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 接口被封装成一个普通异步函数:

import instance from "./config";

export const getTodos = async () => {
  const res = await instance.get("/todos");
  return res.data;
};

Axios 会把实例的 baseURL 和请求路径组合起来:

baseURL       /api
请求路径      /todos
最终地址      /api/todos

instance.get 返回的是 Axios 响应对象,真正的接口响应体位于 res.data,所以 getTodos 只把业务需要的数据交给调用方。页面不需要知道 Axios 响应对象的其他字段,也不需要关心当前数据来自 Mock 还是真实后端。

这层封装体现了一个很重要的边界:组件负责“什么时候获取数据、拿到数据后怎么更新界面”,API 模块负责“向哪里发请求、如何取得响应体”。

六、用 Vite Mock 在开发阶段提供接口

前端要在后端接口完成之前独立运行,需要让 Vite 开发服务器临时提供 /api/todos。

Vite 配置同时启用了 React 插件和 Mock 插件:

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 产生了作用。与当前版本对齐后,可以写成:

viteMockServe({
  mockPath: "mock",
  enable: true
})

如果只需要默认的“开发时开启”,也可以仅保留 mockPath,让插件使用默认值。这个版本差异很重要:配置对象不会因为属性名字看起来合理就自动生效,最终仍要以已安装版本实际支持的选项为准。

Todo Mock 接口定义为:

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 时才有必要声明它。这里返回的是固定数据,可以保持行为不变,直接写成:

response: () => {
  return {
    code: 0,
    todos: [
      { id: 1, title: "学习前端接口工程", completed: true },
      { id: 2, title: "学习后端接口工程", completed: false }
    ]
  };
}

七、在组件中完成异步取数和状态更新

Todo 页面把接口数据保存到本地状态:

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([]) 为什么从空数组开始

const [todos, setTodos] = useState([]);

接口最终返回 Todo 数组,因此初始状态也使用数组。首次渲染时数据尚未到达,todos 是空数组;请求完成后,setTodos(data.todos) 写入两条 Mock 数据,并触发组件重新渲染。

2. useEffect 为什么使用空依赖数组

useEffect(() => {
  // 获取数据
}, []);

空依赖数组表达的是:这段 Effect 不依赖组件中的某个变化值,用于组件进入页面后的初始化取数。它和入口处的 StrictMode 一起使用时,需要记住开发环境可能执行额外检查,不能简单地把开发阶段观察到的重复 GET 当成生产行为。

3. 为什么在 Effect 里使用异步 IIFE

getTodos 是异步函数,需要使用 await。当前写法没有直接把 Effect 回调声明为 async,而是在同步回调内部创建并立即执行一个异步函数:

(async () => {
  const data = await getTodos();
  setTodos(data.todos);
})();

这个函数不需要名称,定义完成后立刻通过末尾的 () 执行,因此称为 IIFE(立即执行函数)。

4. 数据已经进入状态,但还没有进入视图

这是当前实现最容易误判的一点。setTodos(data.todos) 已经完成状态更新,但返回的 JSX 只有静态文本 Todos,没有读取 todos:

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 依赖:

{
  "dependencies": {
    "koa": "^3.2.1"
  }
}

当前还没有后端入口、启动命令和接口路由。MySQL 只存在于架构规划中,依赖和连接代码尚未出现。

因此,现在能够为前端提供数据的是 Vite Mock,而不是 Koa。

当后端将来在 http://localhost:3000/todos 提供相同结构的数据时,API 模块的调用方式可以保持不变:

export const getTodos = async () => {
  const res = await instance.get("/todos");
  return res.data;
};

只需把 Axios 实例的基地址从开发期 Mock 前缀切到后端服务地址:

const instance = axios.create({
  baseURL: "http://localhost:3000",
  timeout: 5000
});

这个切换之所以简单,是因为页面从一开始依赖的就是 getTodos(),而不是某个写死在组件里的服务器地址。真正连接前后端的,是已经约定好的请求路径和 JSON 结构。

十、工程自检暴露出的未完成项

当前生产构建能够成功,并且路由页面会被拆分为独立产物;但 lint 仍有 5 个错误:

  1. Mock 响应函数的 req 未使用;
  2. Mock 响应函数的 res 未使用;
  3. App.jsx 的默认 React 导入未使用;
  4. Suspense 已导入但未渲染;
  5. todos 状态已声明但未参与 JSX。

这些错误不是随机出现的,它们准确反映了代码的完成度:Mock 暂时不读取请求参数,现代 JSX 写法不需要默认 React 变量,懒加载边界还未补齐,Todo 列表还未展示。

十一、一次请求究竟经历了什么

把前面的模块串起来,访问 /todos 后会发生以下过程:

  1. BrowserRouter 根据地址匹配 /todos;
  2. lazy 加载 Todos 页面模块;
  3. Todos 首次渲染,todos 的值是空数组;
  4. useEffect 执行异步 IIFE;
  5. IIFE 调用 getTodos();
  6. getTodos() 使用 Axios 实例请求 /todos;
  7. Axios 把 baseURL 与路径组合成 /api/todos;
  8. Vite Mock 匹配 GET 请求并等待 2000 毫秒;
  9. Mock 返回包含两条任务的对象;
  10. API 函数取出 res.data;
  11. 页面执行 setTodos(data.todos);
  12. 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 与真实后端先后履行同一份数据约定。