☰
React中后台脚手架完整复盘:从权限设计到性能优化
2026/10/21 5:41:56 网站建设 项目流程

1. rea 是什么:一个内部中后台脚手架项目的完整复盘

项目代号 rea,全称是我随手缩写的 React Enterprise Application。起因很简单,团队里前后端加起来不到二十人,后台管理系统一年要交付七八个,每次接到新项目都是从老项目里复制一份代码,把登录、权限、菜单、表格那套东西原样搬过去,再改改页面就上线。表面看效率挺高,实际上埋了一堆雷:老代码里过期的依赖没人敢动,权限判断散落在各个页面组件里,换了需求就要改好几个地方,最要命的是一个人写的东西只有他自己看得懂。rea 就是为了终结这种"复制粘贴式开发"而做的内部框架。

这个框架解决的核心问题,一句话就能说清楚:把后台系统里大量重复的"基础设施"抽出来变成约定,让业务开发只写真正的业务逻辑。具体包括三块:基于路由和按钮的权限控制、统一封装并进行异常处理的请求层、以及只需配置列配置就能生成表格页面的通用组件。三年的实际使用下来,新项目从初始化到第一个可演示页面,基本上能控制在两个小时以内。

这篇文章写给两类人:一类是刚入行一两年、正在被后台系统各种重复劳动折磨的前端,看完你能理解一套脚手架背后的设计思路,而不是只会用别人封装好的模板;另一类是团队里还没有统一前端规范、每次开工都要从零搭一次的人,我这边踩过的坑、做过的取舍、以及可以直接抄走的代码,都在后面。需要提前说明的是,rea 只是我们团队内部的项目代号,不是什么开源项目的名字,文中所有方案都是基于真实项目场景的常见实践整理出来的。

2. 架构设计:为什么我偏偏选这套技术栈

2.1 第一版是从"抄"开始的,但抄完必须消化

做 rea 之前,我先后用过好几个现成的中后台模板,包括社区里很流行的那几个 React 后台项目。不可否认它们功能齐全,但用起来总有一种"受制于人"的感觉:模板作者对权限的设计、对目录的划分、对组件的抽象有自己的偏好,你不一定认可;一旦模板发版升级,本地改过的代码很容易冲突;更麻烦的是,模板里很多功能你可能根本用不到,却必须带着它们一起构建,首屏体积动不动就几百 KB。

所以做 rea 的时候,我的原则是"借鉴思路,不照搬代码"。权限怎么做、路由怎么动态加载、请求层怎么拦截,这些问题先梳理成文档,再自己动手一行行写。这样做的最大收益是,框架里的每一处逻辑我都能讲清楚为什么存在,团队里任何人接手都能顺着代码思路走。代价当然也有,初期开发周期比直接套模板长了不少,但从后续维护和二次开发的角度看,这笔账是划算的。

2.2 技术栈选型:每个选择背后都有明确理由

最终定下来的技术栈是 Vite + React 18 + TypeScript + React Router v6 + Zustand + TanStack Query + Ant Design。逐项说一下理由。

构建工具选 Vite 而不是 Webpack,主要是开发体验差距太明显:Vite 基于 ESM 的按需编译,冷启动基本在秒级,热更新也是毫秒级反馈,对于需要频繁调表格、调样式的后台开发场景,等待时间直接决定心情。框架选 React 18 而不是 Vue,没有特别的立场,纯粹是因为团队现有代码存量以 React 为主,换 Vue 意味着全员重新学习成本。

状态管理选了 Zustand 而不是 Redux Toolkit,核心原因是 Redux 的样板代码太多了。后台系统里真正需要全局共享的状态本来就不多,无非是用户信息、权限列表、菜单数据、几个全局开关,用 Zustand 只需要几行代码就能完成 store 的定义和订阅,而且它的getState()可以在 React 组件外直接调用,这对请求拦截器里读取 token 来说极其方便。服务端数据缓存选了 TanStack Query,这个选择直接影响后面表格组件的设计,稍后会专门展开。

组件库用 Ant Design,是因为它在中后台场景下覆盖度最高,表格、表单、弹窗、上传这些高频组件都比较成熟,加上 v5 以后改用 CSS-in-JS 方案,按需样式不再需要额外的 babel 插件,配置成本低很多。

2.3 目录结构:按类型分还是按业务分

目录结构我纠结了很久。按类型分,就是常见的components/、hooks/、utils/、pages/,优点是结构清晰、新人容易理解;缺点是业务相关代码被拆得七零八落,改一个用户管理功能要同时在四五个目录里找文件。按业务分,就是features/user/、features/order/这种模式,优点是内聚,缺点是目录一多容易重复造轮子。

最终 rea 采用的是混合方案:全项目共用的基础组件和工具函数走类型分目录,业务功能走特性分目录。具体结构大致是:

src/ components/ # 通用组件:ProTable、Auth、PageContainer 等 directives/ # 权限相关封装(按钮级、路由级) hooks/ # 通用 hooks:useTable、usePermission 等 layouts/ # 布局组件:侧边栏、顶部栏、多页签 pages/ # 页面级组件,按业务模块再分子目录 router/ # 路由配置、路由守卫、动态路由生成 services/ # 请求层封装、接口定义 stores/ # Zustand store utils/ # 工具函数:鉴权、格式化、常量

这套结构实践下来比较顺手。共用组件放在最顶层,所有人都能发现它,避免重复造轮子;业务页面各自成块,改动影响范围可控,删掉一个模块直接移除对应目录就行。

2.4 服务端状态和客户端状态必须分开管

这是 rea 架构设计里我认为最关键的一个认知。很多后台项目的通病,是把接口请求回来的数据一股脑塞进全局 store,然后在组件里手动触发重新请求。问题是这些数据本质上是"服务端状态的缓存",它有自己的生命周期:什么时候过期、什么时候失效、多个组件共同依赖同一份数据时如何同步。这些逻辑如果揉进组件代码里,很快就会被各种if (xxx === null)的判断淹死。

TanStack Query 解决的就是这个问题。每个接口调用通过useQuery({ queryKey, queryFn })声明,queryKey 是缓存的唯一标识,组件重新挂载时直接读缓存;数据失效通过queryClient.invalidateQueries({ queryKey })主动触发;多组件共享同一份数据的时候,只要 queryKey 相同,就能天然同步。而 Zustand 只负责真正的客户端状态,比如 token、当前用户信息、菜单折叠状态、页面全局 loading。这个分工明确之后,代码的可维护性提升了一个台阶,这也是 rea 如今能支撑多个业务线同时开发的基础。

3. 权限、请求、表格三大核心模块的落地细节

3.1 权限体系:路由权限和按钮权限分开做

权限是后台系统里最容易做乱的部分。rea 把权限拆成两层:路由权限控制的是"你能不能访问这个页面",按钮权限控制的是"这个页面里的某个操作你能不能看到/点击"。两者分开实现,逻辑才清晰。

路由权限的实现思路是:用户登录后,后端返回它的角色标识和权限码列表,前端根据权限码动态过滤出可访问的路由表,再通过 React Router v6 的createBrowserRouter注册进去。未登录或 token 失效时,路由守卫统一拦截跳转登录页。这里用到了useUserStore的非组件调用能力,在路由守卫里直接读 store 状态:

// src/router/guard.tsx import { createBrowserRouter, Navigate, useRouteError } from 'react-router-dom' import { useUserStore } from '@/stores/user' const WHITE_LIST = ['/login', '/error/404'] function AuthGuard({ children }: { children: React.ReactNode }) { const token = useUserStore((s) => s.token) const userInfo = useUserStore((s) => s.userInfo) const location = useLocation() if (!token) { return <Navigate to="/login" replace state={{ from: location.pathname }} /> } if (!userInfo) { return <PageLoading /> } return <>{children}</> }

这里注意一个细节:if (!userInfo)时我渲染的是全局 loading,而不是直接跳转,因为这个阶段正在异步拉取用户信息和权限数据,没有任何跳转动作。很多项目在这里处理不当,刷新页面时会看到权限菜单先一闪而过,然后再跳走,体验很差。rea 的做法是在应用入口处先阻塞渲染,等数据拉完再挂载路由组件,后面实践部分会给出完整代码。

按钮权限在 React 里没有 Vue 那种自定义指令语法,但我们可以封装一个等价的能力。我的做法是一个<Auth>组件,传入需要的权限码,内部判断当前用户是否拥有:

// src/components/Auth.tsx import { useUserStore } from '@/stores/user' export function Auth({ code, children }: { code: string; children: React.ReactNode }) { const hasPermission = useUserStore((s) => s.hasPermission(code)) if (!hasPermission) return null return <>{children}</> } // 用法: // <Auth code="user:create"><Button type="primary">新增用户</Button></Auth>

至于权限码本身,我强烈建议用"模块:操作"的冒号格式,比如user:create、order:export,比单纯的数字 ID 可读性强得多。权限码的来源要唯一:后端接口返回的权限码列表就是唯一事实来源,前端永远不要写死权限判断。这样做的好处是,权限调整只需要改后端配置,前端代码完全不用动。

3.2 请求层:拦截器里处理的远远不止 token

后台系统的请求层是另一个容易乱的地方。很多人只是简单封装一个 axios 实例,然后把 token 塞到 header 里就完事了。rea 的请求层做了更多事情:统一的 baseURL 和超时设置、请求头自动带上 token、响应统一解包、401 自动登出、错误码统一处理、请求超时和服务端异常单独提示。

核心实现大致如下:

// src/services/http.ts import axios from 'axios' import { message } from 'antd' import { useUserStore } from '@/stores/user' const http = axios.create({ baseURL: '/api/v1', timeout: 15000, }) // 请求拦截器:注入 token http.interceptors.request.use((config) => { const token = useUserStore.getState().token if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一解包 http.interceptors.response.use( (response) => { const { code, data, message: msg } = response.data if (code === 200) { return data } if (code === 401) { useUserStore.getState().logout() window.location.href = '/login' return Promise.reject(new Error('登录已过期')) } message.error(msg || '请求失败') return Promise.reject(new Error(msg || '请求失败')) }, (error) => { if (error.code === 'ECONNABORTED') { message.error('请求超时,请重试') } else if (error.response?.status >= 500) { message.error('服务异常,请稍后重试') } else if (error.response?.status === 403) { message.error('没有权限执行此操作') } else { message.error(error.message || '网络异常') } return Promise.reject(error) } )

这里特别提醒一下,响应拦截器的返回值和 axios 默认行为不一样,我们直接返回了response.data.data,也就是说业务层拿到的已经是真正的业务数据,不需要在每个接口调用里再解一层res.data.data。这个约定一旦定下来,全项目都要遵守,否则新人很容易写混。另外一个感触是,401 的处理必须放在拦截器里而不是业务代码里,否则每个请求失败回调都要写一遍"跳登录、清空本地状态",迟早会漏。

3.3 配置化表格页面:写业务页不再需要复制粘贴

后台系统百分之八十的页面本质上是"一个查询表单 + 一张表格 + 分页 + 操作列",它们的逻辑几乎完全一样,区别只是字段配置和接口地址。rea 把这块抽象成了 ProTable 组件,业务侧只需要提供列配置和请求函数,分页、加载、查询、重置、刷新这些交互全部收敛在组件内部。

一个典型业务页面的完整代码是这样的:

// src/pages/user/UserList.tsx import { ProTable } from '@/components/ProTable' import { fetchUserList } from '@/services/user' const columns: ProColumn[] = [ { title: '用户名', dataIndex: 'name', width: 160 }, { title: '角色', dataIndex: 'roleName', width: 120 }, { title: '状态', dataIndex: 'status', width: 100, render: (value: number) => (value === 1 ? '启用' : '停用'), }, { title: '最近登录', dataIndex: 'lastLoginAt', valueType: 'dateTime' }, ] export default function UserList() { return <ProTable columns={columns} request={fetchUserList} search={{ keyword: '用户名' }} /> }

ProTable 内部的结构长这样,关键点在于用 TanStack Query 管理查询参数:

// src/components/ProTable/index.tsx export function ProTable({ columns, request, search }: ProTableProps) { const [query, setQuery] = useState<Record<string, unknown>>({ page: 1, size: 10 }) const { data, isLoading, refetch } = useQuery({ queryKey: ['pro-table', query], queryFn: () => request(query), }) // 内部处理排序、分页、列宽度自适应等 // 搜索表单提交时 setQuery({ page: 1, size: 10, ...values }) // 重置时 setQuery({ page: 1, size: 10 }) }

用 TanStack Query 管理表格数据,最大的优势是天然带了防抖、缓存和并发去重。同一个页面来回切换不会反复请求接口,切回来直接命中缓存,响应是即时的。要刷新数据时调用refetch或者invalidateQueries都行。这套抽象在 rea 内部叫"配置化页面",后来连表单的查询区也做成了配置式:一个searchFields数组就能生成带默认值、带校验的查询表单,业务开发基本不用碰 Ant Design 的 Form API。

3.4 动态路由:菜单由后端返回之后怎么落地

权限模块里最难的一环是"动态路由"。很多后台项目菜单是前端写死的,但真实场景中不同角色能看到的菜单不一样,所以 rea 的做法是:登录成功后,后端返回一个菜单树,每个节点包含路径、组件路径、标题、图标等元信息,前端把它映射成 React Router 的路由表。

组件映射是这里最核心的难点。后端返回的只是一个字符串,比如"sys/user/UserList",前端怎么把它变成真正的组件?我用的方案是import.meta.glob全量注册页面组件,再按字符串找组件:

// src/router/dynamic.ts const modules = import.meta.glob('/src/pages/**/*.tsx') function lazyLoad(componentPath: string) { const loader = modules[`/src/pages/${componentPath}.tsx`] if (!loader) { return <Navigate to="/error/404" replace /> } const Component = lazy(loader as () => Promise<{ default: React.ComponentType }>) return ( <Suspense fallback={<PageLoading />}> <Component /> </Suspense> ) } export function generateRoutes(menus: MenuItem[]): RouteObject[] { return menus.map((menu) => ({ path: menu.path, element: lazyLoad(menu.component), children: menu.children?.length ? generateRoutes(menu.children) : undefined, })) }

用import.meta.glob的好处是构建时 Vite 会做静态分析,自动把所有页面组件打进单独的 chunk,配合路由级懒加载,首屏只加载当前用到的页面。这里的隐患是动态路由拼接发生在运行时,后端返回的组件路径一旦和目录结构对不上就会白屏,所以我们前端约定了一个硬规则:后端菜单里的component字段必须和src/pages下的相对路径完全一致,比如sys/user/UserList对应src/pages/sys/user/UserList.tsx。

4. 从零搭到上线:完整实操记录与关键代码

4.1 初始化工程:pnpm + Vite 的环境准备

搭一个 rea 这样的项目并不神秘,关键步骤就那么多,难的是每一步背后的参数选择。先说工程初始化,这一步我推荐直接用 pnpm,而不是 npm 或 yarn。pnpm 的硬链接 + 符号链接机制能显著减少磁盘占用,多个项目共用一套依赖也不会冲突,更重要的是它天生具备"幽灵依赖"检测能力,package.json 里没声明却能 import 到的第三方包,会直接报错,这在做框架时尤其有用,逼着每个人显式声明依赖,层级清晰很多。

初始化命令很简单:

mkdir rea && cd rea pnpm create vite . --template react-ts pnpm install

接下来安装核心依赖。注意版本号我没有写死,实际使用时建议以安装时的最新稳定版为准,锁定版本交给 lock 文件:

# 运行时依赖 pnpm add react-router-dom @tanstack/react-query zustand axios antd dayjs # 开发依赖 pnpm add -D @types/node vite-plugin-compression

装完后第一件事是配置路径别名,不然../../../../这种引用能把人看疯。在vite.config.ts里加resolve.alias,再在tsconfig.json里加上对应的baseUrl和paths,两边必须保持一致:

// vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, }, server: { proxy: { // 本地开发时把 /api 开头的请求代理到后端 '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (p) => p.replace(/^\/api/, ''), }, }, }, })

这里代理配置值得多说一句。开发环境走代理,生产环境由 nginx 或网关统一转发,这是前后端分离项目的标准姿势。如果你把http://localhost:8080直接写死在 axios 的 baseURL 里,上线后要么 ajax 请求头带着本机地址被拦截,要么还得再改代码,不划算。统一走相对路径/api,环境切换只动配置不动代码。

4.2 核心代码:路由守卫、权限组件、请求封装

工程搭好后,按照依赖关系,先写 stores、再写 services、再写 router,顺序不能乱,因为后面每个模块都要依赖前面的状态。

用户 store 是最底层的模块,它负责 token 的持久化和用户信息的异步加载:

// src/stores/user.ts import { create } from 'zustand' import { fetchUserInfo } from '@/services/user' interface UserState { token: string userInfo: null | { id: number; name: string; perms: string[]; menus: MenuItem[] } login: (token: string) => void fetchUserInfo: () => Promise<void> hasPermission: (code: string) => boolean logout: () => void } export const useUserStore = create<UserState>((set, get) => ({ token: localStorage.getItem('rea_token') || '', userInfo: null, login: (token) => { localStorage.setItem('rea_token', token) set({ token }) }, fetchUserInfo: async () => { const userInfo = await fetchUserInfo() set({ userInfo }) }, hasPermission: (code) => Boolean(userInfo?.perms.includes(code)), logout: () => { localStorage.removeItem('rea_token') set({ token: '', userInfo: null }) }, }))

注意这里我把hasPermission定义在 store 上而不是单独的工具函数里,这样权限判断逻辑集中在一处,避免在组件里出现userStore.userInfo?.perms.includes(...)这种散落代码。login方法里同时写了localStorage和set,两者一定同步,比如 token 要持久化就都持久化,不持久化就都不要,否则会出现内存里有 token、刷新后却跳走的情况。

路由守卫的落地,我选择在应用入口处用一个封装组件统一处理。要理解这个思路,得先想明白 React Router v6 的守卫逻辑和 Vue 路由守卫不同,React 没有beforeEach钩子,而是用组件包裹的方式实现。rea 的做法是定义一个RootProvider,在路由渲染之前检查登录状态和数据加载状态:

// src/router/RootProvider.tsx import { Navigate, useLocation } from 'react-router-dom' import { useUserStore } from '@/stores/user' export function RootProvider({ children }: { children: React.ReactNode }) { const location = useLocation() const token = useUserStore((s) => s.token) const userInfo = useUserStore((s) => s.userInfo) const fetchUserInfo = useUserStore((s) => s.fetchUserInfo) // 还在白名单页面,直接放行 if (location.pathname === '/login' || location.pathname === '/error/404') { return <>{children}</> } // 没有 token,跳登录页,并把来源路径带上 if (!token) { return <Navigate to="/login" replace state={{ from: location.pathname }} /> } // 有 token 但用户信息没加载,则先加载再渲染 children if (!userInfo) { return ( <Suspense fallback={<PageLoading />}> <AsyncInit onInit={fetchUserInfo}>{children}</AsyncInit> </Suspense> ) } return <>{children}</> }

这个AsyncInit组件内部一个useEffect触发fetchUserInfo,加载中显示全局 loading,完成后渲染 children。这样能保证菜单数据和权限数据一定在页面组件渲染前就绪,不会出现刷新页面时菜单先闪一下再重新生成的尴尬。我见过太多后台系统在这个细节上翻车,必须把它当成硬性要求来做。

请求封装在 3.2 节已经给出,这里不再重复代码,但要补充一个实操心得:接口定义最好集中放在services目录,并且所有接口返回类型都用 TypeScript 泛型标注。比如fetchUserList(params): Promise<PageResult<User>>,这样业务层拿到的数据天然有类型提示,写错字段名在编译期就会报错,而不是等到运行时才发现。

4.3 动态菜单和路由注册的完整串联

等到用户信息拉取完了,我们就可以根据返回的菜单数据生成路由了。这里的顺序是:先通过generateRoutes(menus)生成业务路由表,再把它和固定的登录页、404 页、布局组件拼成完整的路由对象。

// src/router/index.tsx import { createBrowserRouter } from 'react-router-dom' import { useUserStore } from '@/stores/user' import { generateRoutes } from './dynamic' export function buildRouter() { const menus = useUserStore.getState().userInfo?.menus ?? [] const dynamicRoutes = generateRoutes(menus) return createBrowserRouter([ { path: '/login', element: <LoginPage />, }, { path: '/', element: <RootProvider><MainLayout /></RootProvider>, children: dynamicRoutes, }, { path: '*', element: <NotFoundPage />, }, ]) }

关于createBrowserRouter,一个非常容易踩的坑是 history 路由模式刷新 404。BrowserRouter 依赖前端路由 history fallback,如果部署的服务器没有把未知路径重写到index.html,用户一刷新就直接请求了这个不存在的路径,返回 404。这个问题和前端代码无关,是服务器配置问题,后面排查章节再说具体解法。开发环境下 Vite 内置了 fallback,所以这个坑往往要等到测试环境才暴露。

4.4 构建优化:从首屏 800KB 压到 200KB 以内

rea 第一个正式项目上线后,我做的第一件事就是开 DevTools 看请求体积,结果吓了一跳:光入口 JS 就有 800 多 KB,这还是没有 gzip 的原始体积。首屏加载慢得让人怀疑人生,尤其后台系统面对的往往是内网弱网环境,体验非常糟糕。

第一刀砍在路由懒加载上。React Router v6 支持通过React.lazy+Suspense实现路由级代码分割,我把所有业务页面改成了懒加载。这个改动立竿见影,入口 chunk 从 800KB 直接降到 300KB 左右,但还不够。

第二刀是手动分包,把体积庞大的第三方库拆成独立 chunk。放在vite.config.ts的build.rollupOptions.output.manualChunks里:

// vite.config.ts build: { sourcemap: false, rollupOptions: { output: { manualChunks: { 'react-vendor': ['react', 'react-dom', 'react-router-dom'], 'antd-vendor': ['antd', '@ant-design/icons'], 'query-vendor': ['@tanstack/react-query'], }, }, }, },

这样设置的目的是充分利用浏览器缓存:项目代码频繁编译上线,但 react、antd 这些大依赖版本稳定,独立 chunk 的 hash 不变的话,用户访问时可以直接命中缓存,不用反复下载。实际优化后,首屏只要加载index、react-vendor、antd-vendor、axios-vendor四个 chunk,再配合 nginx 开启 gzip,首屏传输量压到 200KB 以内,白屏时间从原来的 3 秒以上降到 1 秒左右。

第三刀是压缩产物。本地装一个vite-plugin-compression,构建时自动生成 .gz 文件,前提是服务器能正确处理.gz的 Content-Encoding。如果服务器不支持预压缩产物,也可以直接用 gzip 在线压缩,效果一样,只是稍微增加点 CPU 开销。这三个优化是性价比最高的组合,强烈建议所有做后台项目的都配上。

5. 常见问题排查实录:刷新 404、请求缓存、样式错乱

5.1 刷新页面 404,却以为是前端路由写错了

这是 BrowserRouter 最经典的坑。现象是在页面上点击路由跳转正常,但地址栏手动刷新或者直接输入 URL 就 404。第一反应往往是在前端找原因,检查路由配置、检查 basename、检查有没有拼错路径,折腾半天发现代码完全没问题。

问题的根源在服务器。所有前端路由都是 History API 在浏览器端模拟的,服务器上的真实文件里并不存在/user/list这个路径,刷新时请求发到服务器,服务器找不到对应资源就返回 404。解决方案是要在服务器层配置 fallback:所有未知路径都重写到index.html,让前端路由接管。以 nginx 为例,这是标准配置:

location / { try_files $uri $uri/ /index.html; }

排查这类问题的思路,我后来总结成一个口诀:先分端,再分级。分端是看请求到底打到哪一端,F12 网络面板里那个红色 404 请求的 URL,如果是完整的前端地址,说明请求已经发出去了,问题多半在服务器;如果根本没发出请求,那才是前端路由的问题。分级是看 404 是在页面层面还是资源层面,页面 404 通常是路由 fallback 没配好,图片、CSS、JS 404 则多半是静态资源路径前缀配错了,比如部署到子路径但没有在 Vite 里配置base。

5.2 表格的查询条件"好像被缓存了",切换参数不生效

用上 TanStack Query 之后,团队里出现了一个让我印象深刻的 bug:某个表格第一次按关键词查询,结果正常;换一个关键词再查,数据还是旧的,必须手动刷新页面才更新。看代码觉得逻辑完全正确,setQuery调用触发了重新渲染,新的关键词也传进去了,但useQuery返回的数据就是不动。

原因其实不复杂:TanStack Query 的缓存机制依赖queryKey,只有queryKey变了才会重新请求,我一开始只用了['pro-table']作为 key,没有把查询参数放进去,导致每次都是同一个 key,命中缓存直接返回旧数据。修正后:

const { data, isLoading } = useQuery({ queryKey: ['pro-table', JSON.stringify(query)], queryFn: () => request(query), })

这次踩坑给我一个重要教训:用任何带缓存能力的数据请求库,第一件事就是搞清楚它的缓存键是怎么生成的,键的设计直接决定数据更新的正确性。后来我们做了一个约定,所有接口的查询参数都要序列化进 queryKey,并且保证参数的顺序一致。这样既不会出现脏数据,也方便在 DevTools 里精确看出当前页面是基于什么参数请求的。

5.3 开发环境下接口请求了两次,差点改成请求去重

这个现象几乎每个用 React 18 的开发者都会遇到:页面一加载,同一个接口在 Network 面板里出现两次请求,而且长得一模一样。我当时的第一反应是请求层重复触发了,准备加一个全局请求去重逻辑,后来仔细排查才发现,这根本是 React StrictMode 在开发环境下的故意行为。

React 18 的 StrictMode 会在开发模式下对组件的挂载、卸载、再挂载做一次额外的模拟,目的是提前暴露组件内部不安全的副作用。如果你在组件里直接写了useEffect(() => { fetchData() }, []),这个副作用会被执行两次,这是 React 官方有意为之的提醒,不是 bug。生产环境的构建里去掉了 StrictMode 的额外行为,请求只会发一次。

正确的应对有两种。一是接受这个行为,只要确保接口是幂等的(GET、查询类接口天然幂等),开发时多一次请求并不影响正确性。二是把严格模式去掉,但我不建议这么做,因为 StrictMode 对发现内存泄漏、不规范副作用很有价值。如果你的场景实在对重复请求敏感,比如本地开发联调时后端告警,也可以在useEffect里加一个标识变量控制,但千万别为了这个去全局加请求去重,那会掩盖真正的问题。

5.4 打包后样式错乱的真正原因不是 CSS 冲突

还有一个印象很深的坑:本地开发一切正常,打出来的测试环境包,Ant Design 的某些弹窗和抽屉组件样式完全是乱的,像是少了 CSS。一开始以为是样式顺序问题,找了半天 CSS 都没有,后来才意识到这可能和 antd v5 的 CSS-in-JS 机制有关。

Ant Design v5 用的是 CSS-in-JS,样式是通过运行时注入到<style>标签里的。如果页面数据是动态渲染的,且组件的样式 hash 生成时机不对,可能会出现样式丢失。排查步骤是看浏览器控制台有没有类似 "find style" 的警告,再检查ConfigProvider的配置。我在 rea 里最终使用的方案是给ConfigProvider设置theme={{ hashed: process.env.NODE_ENV !== 'production' }},在动态样式场景下关闭 hash,本质上让样式选择器更简单,避免动态渲染时的样式匹配问题。

这类问题的排查思路是:本地正常、生产异常,优先怀疑构建配置和运行环境的差异,不要一上来怀疑第三方库。把问题逐层剥离,先看是不是资源加载失败,再看是不是样式注入时机不对,再看是不是配置项有环境差异,基本都能找到根因。

5.5 常见问题速查表

现象根因解决方案
刷新页面 404服务器未配置 history fallbacknginx 加try_files $uri $uri/ /index.html
表格查询条件不刷新queryKey 没有包含查询参数将查询参数序列化后放入 queryKey
开发环境接口请求两次React StrictMode 的刻意行为确认接口幂等即可,无需修改
打包后弹窗样式错乱antd v5 动态样式 hash 问题按环境关闭 theme hashed
动态路由刷新白屏组件路径与后端菜单配置不一致约定菜单 component 字段与 pages 路径严格对应
点击按钮没反应、控制台无错误按钮被 Auth 组件拦截渲染成了 null检查权限码是否正确、用户权限列表是否已加载
构建产物极大缺少路由懒加载和手动分包配合 React.lazy 和 manualChunks 优化

6. 后续想做的事与实际操作中沉淀的体会

rea 做到现在这个程度,满足日常业务开发已经问题不大,但我心里清楚它还有很长的路要走。团队里已经有人提出,希望把 ProTable 进一步扩展成支持拖拽排序、列固定、导出 Excel 这些高级能力,还有人建议做一个可视化表单设计器,让业务同学自己配置查询表单。这些方向我都认同,但每次动手前我会提醒自己一句话:框架的每一行代码都会有团队里的人读,宁可慢一点,也要保证设计的一致性。

实际维护这几年来,我最大的体会反而是技术之外的东西:一套内部框架能不能存活,不取决于它的代码有多优雅,而取决于用它的团队是不是真的理解它的约束。很多人拿到框架后,第一反应是这个权限组件不好用我要改,那个 ProTable 不灵活我要自己写一个,结果项目里很快就出现了两套"半标准"的写法。所以我在团队里定了两个规矩:一是所有公共能力的变更必须先在框架仓库里做,业务项目只允许引用框架而不是修改框架;二是新人入职第一周不看业务代码,先花时间通读 rea 的目录结构和核心模块,理解每个约定背后的原因。

最后分享一个小的实操技巧,也是我在框架里保留至今的一个习惯:所有业务页面的接口请求,建议统一放在services目录下通过http.get、http.post这些封装方法调用,不要在页面组件里直接用 axios 或者裸 fetch。这样做的第一个好处是接口地址集中管理,后端改了路径只动一处;第二个好处是后续如果需要给某个模块统一加埋点或者加缓存,只需要在services层做一层包装,业务代码完全无感。就凭这一条,后面避免的返工就远超我当初封装那点代码的时间成本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询