接手一个代号叫“rea”的项目时,我第一反应是这缩写也太随意了。但做着做着就发现,项目全称其实是React Enterprise Application,一个从零搭建的企业级前端应用,里边有商品展示、购物流程、后台配置、权限控制,还有一堆跨模块的复杂交互。团队里叫顺口了,文档里也就都写成rea,后来干脆成了我们的内部黑话。
如果你最近也要起一个新项目,或者正打算把手头那堆业务纠缠不清的老前端工程做一次重构,这篇总结值得花几分钟看完。我会把rea从技术选型、工程化搭建、核心业务实现,到性能优化和排坑实录完整拆一遍。每一部分的选型逻辑、参数依据、实测数据都会给到,不是那种“配好了就是好”的含糊说法。
1. 整体设计与技术选型思路
1.1 为什么选React而不是Vue或Svelte
先交代背景。这个项目要做的是一个面向内部运营和外部用户双端的业务系统,用户端有商品浏览、购物车、订单流程,运营端有商品管理、库存配置、数据看板。双端共用一套组件语义,但交互复杂度差异很大。
选型时Vue和Svelte我也认真评估过。Vue的上手成本确实是三家里最低的,但rea这个项目有个很关键的特点:业务逻辑要跨端复用,组件树层级深,团队里还有好几个成员之前做的是React。这种情况下选Vue意味着团队要重新适应一套状态流和心智模型,学习成本不是文档能抹平的。Svelte的编译时思路很惊艳,但生态成熟度还是差一截,真要接地图、富文本、复杂表格这类企业级组件,可选范围太小。
选React的核心理由有三点:一是组件模型足够通用,同一个业务组件可以同时塞进用户端和运营端;二是状态管理方案成熟,从Redux到Zustand都有大量实践验证;三是生态系统经过多年沉淀,遇到任何冷门需求基本都能找到现成方案。说白了,大厂选型不一定是最激进的,但一定是最不容易翻车的。
1.2 技术栈清单与版本锁定
技术栈定下来之后,具体版本也是个容易踩坑的地方。我的建议是别盲目追新,用已经被社区验证过的稳定版本。rea项目最终锁定的核心依赖是这样的:
| 模块 | 选型 | 说明 |
|---|---|---|
| 框架 | React 18 + TypeScript | 函数组件 + Hooks,严格模式开启 |
| 构建 | Vite 5 | 开发冷启动快,构建产物可控 |
| 路由 | React Router v6 | 数据路由 + 懒加载 |
| 状态 | Zustand + 服务端缓存 | 本地UI状态用Zustand,服务端数据用请求层管理 |
| 请求 | Axios + 统一拦截器 | 带token注入、错误码统一处理 |
| 样式 | CSS Modules + Sass变量 | 不引入大型UI框架,组件样式自治 |
| 测试 | Vitest + React Testing Library | 单元测试为主,E2E用Playwright |
Vite这里多说一句,很多人担心Vite在高复杂度项目里会踩坑,但实际上Vite 5对大型项目的支持已经很稳了。我们用了一个三千多个文件的中型规模项目做验证,开发期热更新响应基本都在百毫秒级,构建产物体积相比之前的Webpack配置还缩减了18%左右。
1.3 为什么不做微前端
项目早期有过一个微前端方案,几个业务组各自维护子应用,通过主应用聚合。听起来很美好,但rea的实际业务里,用户端和运营端有大量共享逻辑,比如商品状态、库存计算规则、价格策略,如果把这部分拆成独立子应用,每个子应用都得重新实现一遍,反而制造了数据一致性问题。
我们最终选择了单应用多模块架构。业务层拆成独立包,内部通过npm workspace管理,但运行期是一个应用实例。这样做的好处是:共享逻辑只需要维护一份,状态流清晰,部署也只需要一个产物。微前端适合团队边界特别清晰、独立部署诉求强烈的场景,rea不属于这种情况。
2. 工程化基础设施搭建
2.1 目录结构与模块划分
目录结构是项目初期最容易被忽视、后期最痛苦的部分。rea的目录划分遵循一个原则:按业务域划分,而不是按技术类型划分。
src/ app/ # 应用级配置:路由、入口、全局样式 pages/ # 页面级组件,一个路由对应一个目录 features/ # 业务功能模块,每个模块包含组件/状态/api/类型 shared/ # 跨模块共享:基础组件、工具函数、常量 services/ # 纯请求层逻辑 stores/ # 全局状态定义早期我们把所有组件按components、containers来分,结果项目一到中期就失控了:一个业务功能涉及的组件散落在三个目录里,要改一个商品列表得同时打开五六个目录。后来改成按feature聚合,每个功能模块自带完整的组件、状态、请求和类型定义,修改的时候只关心这一个目录。
这种结构还有个额外的好处,就是代码评审的时候,改动的边界特别清晰。同事看diff就能知道这次改动只影响某个功能模块,还是动了全局共享逻辑。
2.2 状态管理:Zustand的实际体验
React状态管理方案一直是被讨论最多的话题。rea项目没有采用Redux Toolkit,而是选了Zustand,原因很简单:Redux的样板代码太多,而rea项目里大量状态是局部性的,比如某个弹窗的开合状态、某个筛选表单的选中值,这些用Redux得写action、reducer、selector,成本太高。
Zustand的使用方式很直观:
import { create } from 'zustand'; type CartState = { items: CartItem[]; totalCount: number; addItem: (item: CartItem) => void; setTotalCount: (count: number) => void; }; export const useCartStore = create<CartState>((set) => ({ items: [], totalCount: 0, addItem: (item) => set((state) => ({ items: [...state.items, item] })), setTotalCount: (count) => set({ totalCount: count }), }));使用的时候,组件里直接用selector取片段,只有选取的值变化时才会触发重渲染:
const totalCount = useCartStore((state) => state.totalCount);这一点很关键。如果你的组件里直接解构整个store对象,那任何一个字段变化都会导致组件重渲染,性能和体验都会下降。Zustand内部做了订阅收集,只关注你选择的那部分数据。
2.3 路由与权限控制设计
权限控制是运营系统的标配需求。rea的路由设计采用的是权限码体系,后端返回当前用户的权限码列表,路由层根据权限码过滤可访问页面。
具体做法是在路由配置上挂meta信息:
const routes: RouteObject[] = [ { path: '/dashboard', element: <DashboardLayout />, children: [ { path: 'analytics', element: lazyLoad(() => import('@/pages/analytics')), meta: { title: '数据看板', permission: 'dashboard:analytics' }, }, { path: 'products', element: lazyLoad(() => import('@/pages/products')), meta: { title: '商品管理', permission: 'product:manage' }, }, ], }, ];然后在路由渲染前做一层权限拦截:
function AuthGuard({ children, permission }: { children: ReactNode; permission: string }) { const userPermissions = useUserStore((state) => state.permissions); if (!userPermissions.includes(permission)) { return <Navigate to="/403" replace />; } return <>{children}</>; }这里有个容易踩的坑:不要用路由的loader去做权限校验,因为loader是执行在渲染之前的,如果里面抛异常,错误边界很难精确捕获到具体是哪个权限不足。用封装组件的方式拦截,既能渲染友好的403页面,又能保留当前URL方便后续交互。
2.4 请求层统一封装
请求层是rea项目里最基础也最核心的一块。咱们平时写接口调用,最容易出现的问题就是错误处理不规范:有的地方弹Toast,有的地方静默,有的地方直接崩溃。rea的解决方案是统一封装Axios实例,所有请求都走同一套拦截器。
const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, }); 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 res = response.data; if (res.code !== 0) { if (res.code === 401) { // 跳转登录 redirectToLogin(); } ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res.data; }, (error) => { handleHttpError(error); return Promise.reject(error); } );一个重要的设计细节是,拦截器里面对业务码的判断不要用“成功就返回、非成功就报错”这种单一路径。实际业务里有些接口会返回业务上的空状态,比如列表为空、详情不存在,这时候需要根据业务码区分是“可预期空态”还是“真实异常”,否则前端会误弹错误提示,用户看到体验会很差。
3. 核心业务功能与性能优化
3.1 列表页大数据量渲染:虚拟滚动实测
rea的运营端有一个商品列表页,高峰期单页最多展示几千条商品数据。最开始直接用数组map渲染,页面一滚动就掉帧。排查下来发现是DOM节点太多,每次渲染都要创建几千个DOM元素,即使React的diff算法再高效,浏览器的重排重绘也扛不住。
解决方案是引入虚拟滚动。市面上常用的是react-window和react-virtualized,我们选了react-window,体积更小,API也更简单。
用虚拟滚动要注意几个关键点:
- 每行高度必须一致或接近一致,否则滚动位置计算会错位。rea的商品列表行高固定为56px,超过内容自动截断,详情通过展开行查看。
- 滚动容器高度必须固定,虚拟滚动原理是只渲染可视区域内的条目,依赖容器高度计算可见数量。
- 带表头的表格,表头需要独立于虚拟列表,不然表头会跟着滚出视野。
import { FixedSizeList as List } from 'react-window'; const ProductTable = ({ data }: { data: Product[] }) => ( <List height={600} itemCount={data.length} itemSize={56} width="100%" > {({ index, style }) => { const item = data[index]; return ( <div style={style} className="product-row"> <span>{item.name}</span> <span>{item.category}</span> <span>{formatPrice(item.price)}</span> </div> ); }} </List> );实测下来,虚拟滚动之后列表滚动帧率从不到30fps提升到稳定60fps,页面内存占用也降了40%以上。这个性能提升在体感上非常明显,原本卡顿的滚动变得丝滑。
3.2 搜索防抖与请求竞态处理
搜索功能是用户端的核心入口。rea里商品搜索框每输入一个字符就触发一次搜索请求,由于接口响应时间不定,先发出的请求可能比后发出的更晚返回,导致最终展示的结果是过期数据。
处理分两步:
- 先是输入防抖,使用useDebounce自定义Hook,在用户停止输入400ms后才触发搜索。
- 再用AbortController取消过期请求,请求发起时记录当前控制器,新请求发起时取消上一个。
function useSearchProducts() { const [keyword, setKeyword] = useState(''); const [results, setResults] = useState<Product[]>([]); const abortRef = useRef<AbortController | null>(null); useEffect(() => { const timer = setTimeout(async () => { if (!keyword.trim()) { setResults([]); return; } if (abortRef.current) { abortRef.current.abort(); } const controller = new AbortController(); abortRef.current = controller; try { const data = await fetchSearchResults(keyword, { signal: controller.signal }); setResults(data); } catch (err) { if ((err as Error).name === 'AbortError') { // 忽略过期请求的报错 return; } handleError(err); } }, 400); return () => clearTimeout(timer); }, [keyword]); return { keyword, setKeyword, results }; }竞态处理这种细节,功能开发初期往往不会注意,但用户快速输入时就会暴露出来。你继续打字,结果结果列表显示的是上一轮的关键词搜索内容,体验非常割裂。
3.3 组件库拆包与按需加载
rea项目没有用完整的UI组件库,而是用基础组件库加上业务定制组件。原因是完整UI库的样式定制成本很高,企业级应用里视觉往往需要高度定制化,直接改第三方组件样式容易造成全局污染。
我们的做法是:基础组件(按钮、输入框、弹窗、下拉框)基于自己的CSS变量体系实现,业务组件沉淀到features目录下的共享组件区域。这样一来组件更新时只需要维护自己的代码,不用跟着第三方库的版本节奏走。
组件拆包到独立目录还有一个好处,就是后面做按需加载时边界清晰。react-router的懒加载配合动态import,每个路由页面只加载自己的依赖:
const ProductDetail = lazy(() => import('@/features/product/ProductDetail'));这里有个性能优化的细节:如果把大组件库直接放进首屏依赖里,哪怕你做了路由懒加载,公共模块还是会被打进同一个chunk。rea项目通过manualChunks手动拆分依赖,把React核心、状态库、请求库分别拆成独立的chunk,利用浏览器并发请求加载,首屏时间从3.2秒降到了2.1秒左右。
4. 构建部署与工程化质量保障
4.1 Vite配置优化实践
Vite在大项目里的优势是开发期快,但构建期如果不做配置优化,同样可能慢。rea项目在构建阶段做了三件关键事:
第一,开启gzip压缩并同时输出.gz和.br文件,静态服务器根据请求头自动选择压缩格式。
第二,配置图片资源内联阈值,小于4KB的图片直接转base64,减少HTTP请求数;大于4KB的统一走CDN路径。
第三,手动拆分chunk,避免所有第三方依赖挤进一个巨大的vendor文件。
export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { 'react-core': ['react', 'react-dom', 'react-router-dom'], 'state': ['zustand'], 'utils': ['axios', 'dayjs', 'lodash-es'], }, }, }, chunkSizeWarningLimit: 1500, target: 'es2019', }, });这里要提醒一个坑:manualChunks一旦手动指定,所有依赖都必须显式归类,否则会产生重复打包的隐患。react不能同时出现在两个chunk里,否则运行时会出现双重React实例的错误,报错信息往往是“Invalid hook call”。
4.2 单元测试与测试策略
很多团队对前端测试的态度是“想写但觉得成本太高”。rea项目里我们只针对三类代码写了单元测试:
- 工具函数,特别是价格计算、优惠券抵扣这类有明确逻辑的。
- 状态store的更新逻辑。
- 关键业务组件的行为,比如权限守卫是否正确渲染。
测试框架用Vitest,配置在vite.config里就能共用,不需要额外配置一套编译链路。
import { describe, it, expect } from 'vitest'; import { calcDiscountPrice } from '@/shared/utils/price'; describe('calcDiscountPrice', () => { it('应该正确计算折扣价', () => { expect(calcDiscountPrice(100, 0.8)).toBe(80); }); it('折扣价应该取下整到小数点后两位', () => { expect(calcDiscountPrice(99.99, 0.7)).toBe(69.99); }); it('折扣力度非法时应该返回原价', () => { expect(calcDiscountPrice(100, 1.5)).toBe(100); }); });测试代码的价值不在于数量多,而在于覆盖关键业务逻辑。价格算错、优惠叠加错这种bug一旦线上出现,影响的就是真金白银,可回归性测试必须跟得上。
4.3 CI流水线与发布窗口
前端部署看似简单,真正到了多人协作的时候,没有CI流程会乱成一锅粥。rea项目配置了一套最简单的CI流水线:代码合并到主分支后,自动跑lint和单元测试,通过后构建产物,再部署到预发环境。预发验证没问题,打tag触发生产发布。
流水线里最容易被忽视的是构建缓存。Vite构建时依赖node_modules,如果每次构建都重新install依赖,耗时很高。我们在CI里配置了依赖缓存,命中缓存时安装依赖的时间从3分钟降到20秒左右。
5. 常见问题排查与实践实录
5.1 首屏加载过慢的完整排查过程
项目上线前,运营反馈首屏要等3秒以上。打开开发者工具看网络请求,发现一个很大的vendor chunk,加载耗时接近1秒,解析执行还要更久。
排查步骤是这样:
先看构建产物分析,用rollup-plugin-visualizer生成依赖占比图,发现lodash-es被完整打包了,但实际上项目只用了其中几个API。解决方法是按需引入,用lodash-es的具名导入,而不是import _ from 'lodash'。
再看代码里有没有隐藏的动态import被忽略了,打开控制台看Network面板,发现有几个路由页面被预加载到了首屏chunk里。原因是路由懒加载时,如果页面里有公共依赖,Webpack/Rollup会把公共依赖提升到父级chunk。处理方式是把这些公共依赖统一放进manualChunks里拆分。
最后是后端接口慢的问题,首屏依赖一个配置接口,接口200ms的响应时间被阻塞在渲染流程里。优化方式是把配置接口改为并行请求,并在应用启动前用Promise.all合并,而不是串行await。
三轮优化下来,首屏从3.2秒降到了2秒以内。这个过程中最大的体会是,性能问题往往不是单点问题,而是链路问题,每一层省一点,体感提升就非常明显。
5.2 组件重复渲染导致的白屏
有一次运营反馈,说某个配置页面偶尔会白屏。复现的时候发现,页面上有一个全局状态频繁更新,导致一个大组件反复渲染,因为组件的某个副作用在重新渲染时触发了异常。
排查手段是给组件加React Profiler记录渲染次数,发现一个列表组件在1分钟内渲染了60多次。追踪依赖链,发现全局store里有筛选条件状态,筛选条件变化时列表就会重新请求数据,而请求完成又会更新store,形成了渲染循环。
解决思路是拆分状态边界。筛选条件状态保持在路由页面局部store里,只有最终提交时才写入全局store。全局store只保存跨页面共享的数据,比如登录态、权限码、用户设置这些真正需要全局共享的内容。
这个问题的本质是状态粒度过粗,全局状态被滥用。状态虽然好用,但也要克制,能用局部state的不要上全局store,能合并的不要拆散,状态粒度越小,渲染链路越清晰。
5.3 滚动位置计算的边界问题
虚拟滚动组件用了一段时间后,运营反馈说快速滚动时表格内容会跳动。排查发现是滚动容器的滚动事件被节流后,滚动位置和渲染数据存在时间差,导致视觉上内容在快接近底部时跳变。
解决方式是不要自己节流滚动事件,而是用react-window提供的onItemsRendered回调,这个回调在渲染内容发生变化时触发,时机精确匹配渲染流程。
还有一个容易忽略的细节:固定行高的虚拟列表,在容器尺寸变化时(比如窗口缩放)需要重新计算可视区域。这个场景容易漏掉,窗口从宽屏变成窄屏时,列表宽度变化了但可视行数没刷新,就会出现渲染空白区域。处理方式是监听容器Resize,动态更新列表高度参数。
写在最后
rea这个项目做到现在,最大的感触是前端工程化没有银弹。选型、目录、状态方案、性能优化,每一步都是权衡和取舍。有的项目适合微前端,有的适合单应用,有的需要Redux规范流程,有的用Zustand更轻快。关键不是用最流行的工具,而是想清楚当前项目的业务特征、团队能力、维护周期,在这些约束下找到最合适的方案。
再分享一个小技巧:项目里的工程决策一定要顺手写成文档记录,不用写成大段报告,就记录决策背景和当时为什么没选另一个方案。几个月后你自己回头看代码,可能都想不起来当初为什么这么设计,这时候文档比什么都有用。rea项目的技术文档就是这么一步步长出来的,现在成了新成员上手最快的学习材料。