最近我在梳理手头的一个前端练习项目时,发现一个很有意思的代号挂在文件夹里——rea。它是我用 React 生态完整搭起来的一个业务管理面板 Demo,用来模拟日常后台系统中最高频的那些场景:列表展示、筛选搜索、数据详情、状态流转、主题切换等等。这个项目本身不大,但“麻雀虽小五脏俱全”,从技术选型到目录规划,从状态管理到打包优化,几乎把 React 开发链路里的关键环节都过了一遍。
这篇文章就围绕 rea 这个项目展开,把我在设计和实现过程中的思路、关键代码、踩坑经历全部写下来。如果你正打算用 React 做一个小型完整项目,或者想了解一个常规业务后台从零到一是怎么搭出来的,这篇文章应该能给你不少可以直接拿去用的参考。我会尽量少讲虚的,多给实际操作的细节和参数,毕竟前端项目这东西,光看概念没什么感觉,真正动手敲过才知道坑在哪儿。
1. 整体设计与思路拆解
1.1 这个项目到底要解决什么问题
先明确一下 rea 的定位。它不是生产级的大型系统,而是一个用于技术验证和能力展示的演示级项目。这个定位决定了我在设计时不会把复杂度拉满,但也不会做一个只有几个静态页面的空壳。我的目标是让这个项目覆盖到业务系统中最常见的能力点:
- 用户登录与路由权限控制
- 内容列表的展示、搜索、筛选、分页
- 新增、编辑、状态切换这类基础管理操作
- 主题切换(亮色/暗色)与响应式布局
- 前端异常兜底与可访问性基础处理
为什么选这几个场景?因为它们是后台系统的“通用底盘”。不管你做的是内容管理平台、数据分析后台还是内部工具站点,最终都要面对这些相似的功能模块。先把这些场景跑通,再往里面填充具体业务逻辑,会顺手很多。
在设计上,我参考了“经典后台三层结构”:布局层负责页面框架,页面层负责业务功能,状态层负责跨页面共享数据。三层结构的好处在于,开发时可以并行推进,排查问题时也能很快定位是出在布局结构、页面逻辑还是数据流上。实际操作下来,这种分层对单人开发同样重要,因为代码写多了之后,你不可能记住每个组件内部的每一条依赖关系。
1.2 技术栈选型背后的取舍
技术选型是这类项目最先要做的决定,也是最容易纠结的地方。rea 最终选定的技术栈是:React 18 + TypeScript + Vite + React Router v6 + Zustand + Axios,样式部分用得比较轻,主要靠 CSS Modules 配合全局设计变量。
先说说为什么用 Vite 而不是 Create React App。CRA 虽然开箱即用,但打包速度和开发服务器的冷启动在大型项目里确实有些煎熬。Vite 基于 ESM 的开发服务器能做到毫秒级热更新,配置也足够简洁。实测下来,rea 在开发环境下修改一个组件,几乎保存瞬间就能在浏览器里看到变化,这种反馈速度对调试状态管理逻辑特别有用。
状态管理选了 Zustand 而不是 Redux Toolkit,很多人可能会问为什么不用 Redux,毕竟它还是 React 生态里最常见的选择。我的判断是,rea 是一个以列表页和详情页为核心的中小型项目,共享状态主要集中在用户信息、主题偏好、权限标识这几块,没有特别复杂的跨页面数据流转。Zustand 的 API 非常轻,写起来像直接用 useState,不需要额外引入 Provider 包裹顶层组件,也没有模板代码,对于这种体量的项目是性价比最高的方案。如果后续业务真的复杂到需要追踪多次异步更新的时序,再迁到 Redux Toolkit 也不迟,数据层和视图层解耦之后迁移成本并不高。
路由方面选了 React Router v6。它不仅支持嵌套路由,还提供了完整的路由守卫思路,配合自定义的封装的鉴权组件,就能在渲染页面之前做登录态校验。这个组合让我不用额外引入权限库,实际上减少了不少依赖体积。
2. 核心细节解析与实操要点
2.1 目录结构与组件划分
目录结构是项目刚开始时最值得花时间设计的东西。我的 rea 项目目录如下:
rea/ ├── src/ │ ├── api/ # 接口请求层 │ │ ├── request.ts # axios 实例封装 │ │ └── modules/ # 按业务域划分的接口模块 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件 │ │ ├── Layout/ │ │ ├── Table/ │ │ └── ThemeSwitch/ │ ├── hooks/ # 自定义 hooks │ ├── pages/ # 页面级组件 │ │ ├── Login/ │ │ ├── Dashboard/ │ │ ├── ListPage/ │ │ └── DetailPage/ │ ├── router/ # 路由配置 │ ├── store/ # Zustand 状态仓库 │ ├── styles/ # 全局样式与设计变量 │ ├── types/ # 公共类型定义 │ └── utils/ # 工具函数 ├── index.html ├── package.json ├── tsconfig.json └── vite.config.ts组件拆分我遵循的是“容器组件与展示组件分离”的原则。容器组件负责数据获取和交互逻辑,展示组件只接收 props 并通过回调事件通知父组件。拿列表页举例,ListPage是容器组件,它通过 store 获取列表数据、管理筛选条件、处理新增和编辑操作;而SearchBar、DataTable、PaginationFooter这些纯粹是展示组件,它们不知道数据从哪里来,只知道自己什么时候触发回调。这样拆的好处是单测好写、复用容易,也不会因为某个页面的逻辑改动牵连到整个组件树的重渲。
还要特别说明的是别名配置。用了 Vite 之后,我用@指向src目录,这样组件里的 import 路径就不会变成../../../components/Table这种灾难性的相对路径。配置方式很简单,在 vite.config.ts 里加一段 resolve 配置即可,同时要在 tsconfig.json 里把 paths 和 baseUrl 一并配上,不然 TypeScript 会提示找不到模块。
2.2 路由与页面骨架设计
rea 的路由设计分三层:首先是登录页和主应用页面的顶层区分,然后是主应用内布局与子页面的嵌套关系,最后是页面内的懒加载策略。
核心路由表大致是这样:
const router = createBrowserRouter([ { path: '/login', element: <LoginPage />, }, { path: '/', element: <RequireAuth><AppLayout /></RequireAuth>, children: [ { index: true, element: <Navigate to="/dashboard" replace /> }, { path: 'dashboard', element: lazyLoad(<Dashboard />) }, { path: 'list', element: lazyLoad(<ListPage />) }, { path: 'detail/:id', element: lazyLoad(<DetailPage />) }, { path: '*', element: <NotFound /> }, ], }, ]);这里有一个重要的细节:RequireAuth并不只是在路由组件层面做判断,它配合 store 中的登录态和 token 来做前置校验。如果用户未登录,组件会直接重定向到登录页,并且把用户原本想访问的路径记录下来,登录成功后还能自动跳回目标页。这个小体验在实际业务中很有用,用户不会因为登录中断而丢失原本的操作意图。
懒加载这里我用了一个lazyLoad辅助函数,内部调用React.lazy配合Suspense包裹。为什么一定要懒加载?因为后台系统页面数量一多,如果所有页面都打进一个 bundle,首屏加载时间会明显拉长。路由级懒加载能让浏览器在访问列表页时才去加载列表页对应的代码块。实测下来,首屏体积能减少四分之一左右,对于演示项目来说体验提升非常直观。
2.3 状态管理与数据流设计
前面说过 rea 用的是 Zustand,这里展示一个实际的 store 写法。以用户状态仓库为例:
import { create } from 'zustand'; import { loginApi, logoutApi } from '@/api/modules/auth'; interface UserState { token: string | null; userInfo: UserInfo | null; login: (payload: LoginParams) => Promise<void>; logout: () => Promise<void>; } export const useUserStore = create<UserState>((set) => ({ token: localStorage.getItem('rea_token'), userInfo: null, login: async (payload) => { const res = await loginApi(payload); localStorage.setItem('rea_token', res.token); set({ token: res.token, userInfo: res.userInfo }); }, logout: async () => { await logoutApi(); localStorage.removeItem('rea_token'); set({ token: null, userInfo: null }); }, }));很多人问,状态持久化到底放在 localStorage 还是 sessionStorage?我的习惯是:token 用 localStorage,刷新页面后登录态不丢;如果涉及更高的安全要求,则用 HttpOnly Cookie。在一个演示项目中 localStorage 是够用的,关键是每次刷新后 store 的初始化逻辑要正确,比如这里 token 用 localStorage 的初始值来赋值,就避免了刷新后还要再调一次“恢复登录态”的接口。
组件里使用 Zustand 状态也很简单:
const token = useUserStore((state) => state.token); const login = useUserStore((state) => state.login);这些写法对应一个核心思想:数据流是单向的,组件只负责发布意图(调用 action),状态的变更由 store 统一管理。视图层不需要关心数据是哪里来的,这样页面之间即使有共享状态也不会乱套。
3. 实操过程与核心环节实现
3.1 环境准备与项目初始化
“工欲善其事,必先利其器”,初始化项目这一步,尽量别留坑。我用的是 Node.js 20 LTS 版本、pnpm 作为包管理器、Vite 5 作为构建工具。选 pnpm 的原因很简单:磁盘占用小、依赖安装快,尤其是在 install 频繁的调试阶段,体验差异非常明显。
初始化命令如下:
pnpm create vite rea --template react-ts cd rea pnpm install这里要注意,用 Vite 的 react-ts 模板生成的项目自带一个基础目录结构,但通常是要删掉的。我一般会把src/App.tsx中默认的示例内容清空,只保留最基础的入口渲染。main.tsx里也要调整一下,引入全局样式和路由组件。
基础依赖安装完之后,还需要装几个运行时依赖:
pnpm add react-router-dom zustand axios如果要用 dayjs 处理日期、classnames 拼接类名,也可以一并装:
pnpm add dayjs classnames开发依赖中我加了几项辅助工具:
pnpm add -D sass @types/node eslint-plugin-react-hooks为什么用 sass?因为 rea 里我确实需要用到嵌套语法和部分变量组织。如果项目不用 sass,纯 CSS 配合 CSS 变量也完全够用,这个看个人项目需求来决定,没有绝对标准。
3.2 登录鉴权与路由守卫实现
登录流程是后台系统的“大门”。rea 的模拟登录接口不会真的请求服务器,而是在api/modules/auth.ts里用 Promise 包了一层假数据,延迟几百毫秒模拟网络耗时。
export const loginApi = (payload: LoginParams) => { return new Promise<LoginResult>((resolve) => { setTimeout(() => { resolve({ token: `mock_token_${Date.now()}`, userInfo: { name: payload.username, role: 'admin' }, }); }, 600); }); };路由守卫组件RequireAuth实现如下:
import { Navigate, useLocation } from 'react-router-dom'; export default function RequireAuth({ children }) { const token = useUserStore((state) => state.token); const location = useLocation(); if (!token) { return <Navigate to="/login" state={{ from: location.pathname }} replace />; } return children; }这个实现有几个细节值得展开。第一,state={{ from: location.pathname }}用来记录来源路径,登录成功后补跳回去;第二,用replace而不是默认的 push,是为了不让登录页出现在历史记录里,避免用户按后退键又回到登录页。第三,这个组件没有用useEffect去校验 token 是否过期,因为演示项目不涉及过期刷新,但如果接入真实接口,这里要注意在批量请求返回 401 时统一处理下线逻辑。
3.3 列表页与筛选逻辑实现
列表页是 rea 里最复杂的一个页面,涵盖了搜索、筛选、分页、表格渲染、操作按钮这一整套交互。我做的是一个模拟的用户/订单管理列表。
筛选模块的设计思路是将筛选项提升到页面层面,所有的查询条件统一由一个filterState对象管理。它的结构类似这样:
interface ListQuery { keyword: string; status: 'all' | 'active' | 'disabled'; page: number; pageSize: number; }页面生命周期里,我设置了一个fetchList函数,它从 store 中读取当前查询条件,然后调用接口模块获取数据。每次筛选条件变更时,我们会重置 page 为 1。分页组件变更页码时,保留筛选条件只改 page。
实际操作中发现一个很常见的坑:筛选条件更新时,如果直接用useEffect监听整个 filter 对象,那么分页变更是 fetch 数据,输入关键词也是 fetch 数据,中间可能有冗余请求。解决办法是分别监听page、pageSize和筛选条件三个来源,筛选条件变更统一重置 page。再进一步,我会用useDeferredValue来处理关键词输入,实测下来能有效减少输入过程中触发的接口请求频率,用户打字快了也不会一卡一卡地请求。
表格操作区我这里做了三个按钮:编辑、切换状态、删除。每个操作都是通过调用 store 中的 action 完成的,完成后再刷新当前列表数据。这里要注意操作按钮的 loading 状态,尤其是多次点击的场景。没有 loading 控制的话,用户连点删除很容易提交出多条重复请求。我通常会用按钮自身的 loading 属性去控制,请求没返回前禁止重复点击。
3.4 主题切换与控制台布局实现
主题切换看起来简单,真正做起来还是有不少细节。rea 的方案是 CSS 变量加>:root, [data-theme='light'] { --bg-color: #f5f6fa; --text-color: #1f2329; --primary-color: #2563eb; } [data-theme='dark'] { --bg-color: #16181d; --text-color: #e5e6eb; --primary-color: #3b82f6; }
在根组件中,通过订阅 store 里的主题状态,动态给document.documentElement设置>useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('rea_theme', theme); }, [theme]);
样式使用时,组件里直接引用变量即可:
.container { background: var(--bg-color); color: var(--text-color); }这样切主题不需要重新加载页面,也不涉及复杂的样式覆盖。要注意的一点是,暗色模式下图片、边框、阴影等元素也需要做配套调整,否则会出现“底色变暗但组件还亮得刺眼”的问题。我处理的方式是给阴影类样式做更克制的设定,暗色模式下调低阴影透明度,避免大面积的模糊色块影响整体质感。
响应式布局也是后台系统容易忽略的点。rea 的布局用 CSS Grid 实现:侧边栏固定宽度,内容区自适应,并使用媒体查询在窗口宽度低于 768px 时让侧边栏切换为抽屉式导航。我的做法是写一个useViewport的 hook 来监听视口宽度,并据此决定侧边栏是否显示。这种方式代码量不大,但能实打实改善移动端的浏览体验,现场演示的时候也更有看点。
4. 常见问题与排查技巧实录
4.1 依赖版本冲突与兼容性问题
项目开发中踩到最多的坑,还是依赖版本之间的兼容性。React Router v6 和 v5 在使用方式上差别很大,如果跟着网上旧教程配,很容易出现Switch不存在或者useHistory为 undefined 这类报错。我在 rea 里一开始就统一用 v6 的写法,避免走弯路。
另一个大坑是 Node 版本与 Vite 的兼容性。早期 Vite 某些版本会要求 Node 18 以上,如果你机器的 Node 版本偏旧,启动可能报出奇怪的语法错误。这类问题排查起来很费时间,我的建议是直接用 nvm 或 fnm 管理 Node 版本,项目内固定一个.nvmrc文件,内容写上版本号,例如20.11.0。这样不管到哪台机器都能快速切到正确版本。
4.2 热更新失效与 ESM 报错
Vite 的热更新偶尔会失灵,尤其在修改配置文件或者新增环境变量之后。最常见的表现是:改动文件后页面不刷新,或者控制台出现“The server is not running”之类的提示。我的处理顺序是:先检查终端是否有报错,再确认 vite.config.ts 的 alias 配置是否正确。如果两者都没问题,直接重启 dev server。
还有一个容易忽略的问题:TypeScript 编译错误会阻断 Vite 的模块转换,导致页面白屏。很多情况下 Vite 终端和浏览器控制台都会给出错误提示,但浏览器里显示的是空白页。我建议在出现白屏时优先看浏览器 Console 面板,而不是只盯着终端。
4.3 React key 告警与列表渲染性能
列表渲染的 key 问题,几乎是 React 项目里最经典的 warn 之一。如果用数组索引当作 key,当列表项被删除或者顺序变化时,React 的 diff 会拿错旧状态,容易出现诸如输入框内容错乱、勾选状态串行的问题。rea 的列表数据本身就带唯一 id,所以直接使用id作为 key,没有这个困扰。
如果你的项目里确实没有唯一字段,可以生成一个简单的哈希字符串,尽量避免用 index。还有一个容易被忽视的点:使用Array.map渲染列表时,另一个容易导致性能问题的情况是列表项组件内部没有做 memo 处理,以至于父组件任意状态变化都会导致整列重渲。给列表项包裹React.memo后,实测子组件重渲次数能肉眼可见地减少。
4.4 打包体积分析与手动分包
rea 在完成主体功能后,我顺手做了一次体积优化。先跑一遍构建,然后使用vite-plugin-visualizer查看产物分析报告,可以很清楚看到每个第三方库占了多少体积。结果显示axios和react-router-dom占比较高,但最突出的还是dayjs和echarts一类的图表库,如果页面里引入了一个大图表组件,整个首屏 bundle 都会被明显撑大。
针对这种情况,我的处理方案是手动分包:把第三方依赖按相关业务拆成多个 chunk。Vite 配置大致如下:
build: { rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom', 'react-router-dom'], vendor: ['axios', 'zustand'], charts: ['echarts'], }, }, }, },手动分包之后,公共代码能被浏览器缓存得更久,页面切换到不同业务模块时也不用重复下载框架代码。结果就是首屏加载速度和二次进入速度都有改善。对于打出来的大 chunk,如果超过 500kB,可以在build配置里设置chunkSizeWarningLimit,但更推荐的做法是从源头减少体积,比如按需引入图表组件,而不是整包引入。
常见问题速查表
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 页面白屏 | TS 编译错误 / 路由配置错误 | 查浏览器 Console 与终端报错,确认路由是否挂在根节点 |
| 热更新失效 | vite.config 修改后未重启 | 保存配置文件后重启 dev server |
| 列表数据展示错乱 | key 用了 index | 改为唯一 id,或生成稳定哈希 |
| 状态刷新后丢失 | store 未做持久化 | 初始化时从 localStorage 读取,变更时同步写入 |
| 按钮重复提交 | 缺少 loading 控制 | 请求期间禁止按钮点击,请求完成后再恢复 |
| 暗色模式组件刺眼 | 阴影和边框未适配 | 在暗色变量中降低阴影透明度,使用更深的表面色 |
| 首屏加载慢 | 大量页面打进同一个 bundle | 路由懒加载 + manualChunks 分包 |
| 接口跨域 | 开发环境跨域未配置 | Vite proxy 转发请求到目标服务 |
写在最后
rea 这个项目做下来,我个人最大的体会是:一个“完整的小项目”比“十个零散的知识点”更能帮你建立对 React 生态的全局认知。你在做 demo 过程中遇到的每个问题,几乎都能在真实业务中找到对应版本。踩过一次 key 的坑,下次直接在代码里避掉;被热更新折磨过一次,下次就知道先检查配置文件再查业务代码。
如果后续还有时间,我会继续给 rea 加上几块内容:接一个真实的后端接口做数据联调、补充细粒度的权限控制(比如按钮级权限和菜单级权限)、引入轻量的单元测试覆盖核心 store 和工具函数,再顺便做一个基于 Docker 的部署脚本。每一个扩展点都是独立成章的内容,这也是我喜欢用这种演示项目持续更新知识结构的原因。如果你也在折腾自己的 React 项目,不妨从这些小切入点逐个补齐,做“能用、可复现、讲得清原因”的代码,比堆砌技术名词有意义得多。
最后分享一个小技巧:无论项目大小,先花 10 分钟写一个 README,把自己当时的选型理由和项目结构记下来。很多取舍当下觉得理所当然,过一个月再看就会不清楚当初为什么要这么写。把这些决策过程记录下来,既方便你自己回顾,也方便后来接手的人快速进入状态。我这次就是把 rea 的每个模块为什么这么建的过程记录下来,才有了这篇复盘文章。