REA 这个名字第一次出现其实挺随意的。当时要在仓库里新建一个前端项目目录大家正忙着重构那个维护了快三年的后台管理系统没人有心思给项目起什么正经名字我随手敲了 React Enterprise Application 的缩写REA。没想到后面大半年这个名字成了我们内部讨论问题时的高频词也成了那套前端工程体系的代称。这篇文章想从头聊聊 REA 是怎么从零搭起来、怎么从一堆“能跑的页面”变成一套“基本可控的工程”的。如果你正在被老项目里混乱的状态、越堆越厚的工具函数、一改就崩的页面搞得头大或者正准备接手一个中大型 React 应用的工程化改造那这篇里的选型思路、文件组织方式、状态管理层约定和真实踩坑记录应该能帮你少走不少弯路。说到底前端项目的痛大多不是某个功能难写而是整体结构撑不住新需求。REA 最大的价值不是在某个技术上用了多酷的方案而是把“接下来几个月怎么继续加功能”这件事变成了一套有规矩、有边界的流程。1. REA是什么从一团乱麻到可控的前端工程先说说为什么要起一个看起来像“文件夹名”的代号。REA 其实不是某个框架也不是某个开源库它就是我们对那套 React 企业级应用基础工程的总称。给它一个名字是为了让团队里每次说起“这一批新页面要不要改骨架”的时候大家能明确指的是同一套东西而不是含糊地聊“前端那边怎么弄”。1.1 为什么要重构而不是继续打补丁老系统最典型的问题是代码里各种写法并存。有的页面还在用字符串拼接 HTML 再插入节点有的组件里维护着一大坨状态散落各处有的同学喜欢把所有请求结果都塞进全局 store导致排查一个问题要翻好几个文件。最让人头疼的是改一个公共组件第二天就有人报“某个页面打不开了”因为根本没人知道这个组件被多少个地方依赖。这种状态下继续加需求其实就是每天给自己挖坑。REA 要解决的不是某个页面好不好看而是让项目回到“改动可预测”的状态。我们当时的目标很简单新功能来了知道该把代码放哪里旧页面出了问题能从报错信息一步步定位到具体位置。听起来很基础但对于已经乱了几年的系统来说这本身就是一次不小的改动。1.2 REA的边界我们明确不做什么做工程重构最容易犯的错是想着把所有老代码一次性重写最后工作量失控、业务停摆团队也跟着崩溃。所以 REA 在一开始就划了三条边界。第一不重写全部业务逻辑。老系统里很多特殊判断是拿真实业务坑换来的经验重写的时候极易丢。REA 只搭建新工程的架构和规范老页面迁移按优先级慢慢来。第二不追求 100% 的组件复用。很多组件看起来相似细化后完全不同强行抽象成一个通用组件只会让每个使用处都沾上一堆条件分支。第三不改动后端接口协议。接口变更涉及前后端联调、数据库逻辑、历史数据兼容范围太大REA 一期只在前端层面做事把数据边界留给后续单独治理。这三条边界看着保守却是 REA 能平稳落地的关键。很多重构失败不是技术能力不行而是没想清楚“这次做到哪里为止”。2. 脚手架与项目结构REA的骨架是怎么定的工程化改造的第一步是先把工程底座立住。选工具、定目录、理路由这三件事看似基础却决定了后续所有页面代码长成什么样。2.1 构建工具选型Vite React TypeScript 的基本盘构建工具我们最终选了 Vite搭配 React 和 TypeScript。对中大型项目来说Vite 带来的开发体验提升非常直观开发服务器启动快、热更新响应快不用像以前那样改一行代码等好几秒才能看到效果。Vite 之所以快是因为它利用浏览器原生 ESM 能力开发时按需编译而不是把所有模块一次性打包。初始化命令很简单pnpm create vite rea --template react-ts cd rea pnpm install pnpm add react-router-dom reduxjs/toolkit react-redux tanstack/react-query这里用了 pnpm 而不是 npm原因是 pnpm 通过硬链接共享依赖安装速度快而且严格的依赖隔离能逼着你把每个包都声明在 package.json 里避免在项目里“幽灵依赖”满天飞。TypeScript 是必须的对于多人协作的大型应用类型就是代码之间的契约重构时敢不敢动一个函数很大程度取决于类型系统能不能帮你兜底。2.2 目录分层让代码待在它该待的地方REA 的目录结构是花了最多时间讨论的部分之一。一开始大家习惯把所有组件都平铺在 components 目录里三个月后这个目录就变成了一个没人敢碰的垃圾场。最终我们定了这样的分层rea/ ├─ src/ │ ├─ app/ # 应用初始化、路由配置、全局样式 │ ├─ pages/ # 页面级组件一个路由对应一个页面 │ ├─ components/ # 跨页面复用的通用展示组件 │ ├─ features/ # 业务模块按领域组织 │ ├─ shared/ # 工具函数、常量、基础类型定义 │ └─ api/ # 接口请求封装 ├─ public/ └─ vite.config.ts这套结构的核心思路是把“通用组件”和“业务模块”分开。components 里只放不依赖业务数据的纯展示组件比如按钮、弹窗、表单控件features 则按业务域划分每个模块内部可以有自己的状态、组件和 API 调用。这样不同业务之间的代码不会互相纠缠后期某个模块要大改可以直接钻进 features 对应的目录里处理不用在全局组件堆里大海捞针。2.3 路由组织与懒加载保持页面级独立路由我们用 React Router 的 createBrowserRouter每个页面组件都通过懒加载引入。懒加载的意义不只是省流量更是让每个页面成为独立的模块边界页面之间不共享运行时状态。import { lazy } from react; const Dashboard lazy(() import(../pages/dashboard)); const UserList lazy(() import(../pages/user-list)); const OrderDetail lazy(() import(../pages/order-detail));懒加载配合 Suspense 使用。每个路由切换时如果目标页面的 chunk 还没加载就显示一个轻量级的 loading 占位。这里有一个容易被忽略的细节loading 状态不要做得太炫因为切换页面时出现空白比网络慢更让人困惑。我们后来把 fallback 设计成顶部一条细进度条既给用户反馈又不会让界面闪来闪去。3. 状态管理REA里数据流动的规矩状态管理是 React 项目里最容易写乱的地方。REA 里我们定了一条核心原则先看数据从哪里来再决定它应该住在哪里。这条原则避免了 80% 的混乱。3.1 告别全局state的滥用按数据来源分层以前的状态管理之所以乱是因为大家习惯把所有数据都往全局 store 里塞最终 store 变成一个没有类型的、任何组件都能改的公共沙盒。REA 把状态按来源和生命周期分成了三层状态类型典型场景存放位置UI 状态弹窗开合、折叠面板、局部 tab 切换组件内 useState / useReducer跨组件业务状态登录用户信息、权限点、主题设置全局 store未登录时不持久化服务端状态列表数据、详情数据、统计指标TanStack Query 缓存由请求层管理分层之后很多原本“必须”用全局 store 的状态其实都可以留在组件内部。比如一个页面的筛选条件只有当前页面关心就根本不需要放 store。我们当时做了个严格的代码审查约定任何新增的全局状态必须说明它是被多少个页面共享的少于两个页面使用的状态一律不准进 store。3.2 服务端数据交给请求层缓存管理服务端数据这块REA 直接引入了 TanStack Query。它解决的不只是请求状态管理更重要的是缓存与数据同步。以前每个列表页面都要自己写 loading、error、重新请求的逻辑而且切走页面再回来数据必然重新拉一次。TanStack Query 则会把请求结果按 query key 缓存起来同一份数据多个组件共用时只需要一次网络请求。function useUserList() { return useQuery({ queryKey: [user-list, currentPage], queryFn: () fetchUserList(currentPage), staleTime: 30 * 1000, }); }这段代码里queryKey 是缓存的坐标currentPage 变化时自动发起新请求staleTime 表示 30 秒内数据视为新鲜切换页面回来不会重复打接口。这样一个简单配置就省掉了以前一大片手写的状态逻辑。后来很多同事反馈用上这个之后写列表页的时间几乎少了一半。3.3 异步竞态一个REA里最常见的bug状态管理里最容易踩的坑是异步请求的竞态问题。典型场景是用户快速切换两个页面A 页面的请求还没返回用户已经切到 B 页面结果 A 的响应数据回来后被 setState 写进了当前页面表现出“页面显示的数据是上一页的”这种诡异现象。REA 里主要通过两种方式解决。一是依赖 TanStack Query 的请求去重与取消能力切换页面时旧请求不再绑定 UI二是对于手写 fetch 的场景统一用 AbortController 或清理函数处理。useEffect(() { const controller new AbortController(); fetchOrderDetail(id, { signal: controller.signal }) .then(setDetail) .catch(() {}); return () controller.abort(); }, [id]);这个习惯一定要养成。所有在 useEffect 里发起的异步请求都应该在清理函数里中断或标记失活否则你迟早会被“旧响应覆盖新页面”的 bug 折磨一个晚上。4. 组件规范从“能跑”到“经得起多人协作”工程化改造进行到中段代码量上来了新的问题也随之出现组件怎么拆、拆到什么粒度、谁来复用谁。没有规则的话每个人都会按自己的审美写项目很快会退化回没人敢动的状态。4.1 组件分层的三个桶REA 把组件分成了三个层级基础组件、业务组件、页面组件。基础组件是那些脱离业务也能存在的元素比如表格、弹窗、输入框它们通常只通过 props 接收数据和回调不直接发请求。业务组件则是对某个业务场景的封装比如“用户卡片”“订单状态标签”它们内部可以调用业务 hook也可以组合多个基础组件。页面组件就是路由对应的那一层负责把各种业务组件拼装成完整页面。这三个层级之间有明确的依赖方向页面组件可以依赖业务组件和基础组件业务组件只能依赖基础组件基础组件谁都不依赖。代码审查时一旦发现反向依赖全部打回重写。这条规则看似严格但它保证了组件之间的耦合度不会失控也让公共组件可以安心演进。4.2 表单与受控组件的处理表单在管理后台里是最常见的交互也是最容易写出重复账的地方。REA 最终选了 react-hook-form 作为表单方案。原因是它能减少受控组件引起的多余渲染同时用 Controller 把自定义组件接入表单时代码更整洁。const { control, handleSubmit } useForm(); Controller namename control{control} render{({ field }) Input {...field} /} /关于表单状态有一条硬约定表单内部状态不允许放进全局 store。理由很直接用户在输入框里打每一个字如果都触发全局状态更新整个应用都会跟着重渲染性能影响非常大。表单状态就应该活在表单组件自己的生命周期里只有最终提交结果才跨组件共享。4.3 组件拆分的判断什么时候值得重构组件不是拆得越细越好。拆太碎了props 会越传越多逻辑被拆得七零八落拆太粗了一个文件几百行改哪里都像是踩雷区。REA 实践下来常用的判断标准有三条文件超过 300 行、一个组件有五个以上的事件处理函数、同一个组件被两个以上页面复用。满足任意一条就该认真考虑拆分了。拆分的时候也要记住重构不是目的可维护性才是。如果某个组件内部逻辑虽然长但高度内聚也不一定要立刻拆。我们吃过强行抽象组件的亏两个看似类似的区域被合并成一个大组件后各种 branch 条件堆在上面最后比不拆还难维护。所以 REA 的规矩是拆分必须先有明确的业务边界而不是仅仅因为“代码看着太长”。5. 性能优化首屏加载时间从4秒多降到1.5秒性能优化不能靠感觉要先量化再动手。REA 上线后的第一次性能检查首屏加载时间是 4 秒多高峰期甚至要 5 秒。整个优化过程可以分成四步每一步都对应可验证的数据改善。5.1 资源体积分析先量化再动手我们没有一上来就猜是哪个组件慢而是先用打包体积分析工具看了一遍产物。结果很扎眼首屏依赖的一个主 chunk 有 2MB 多打开看分布第三方 UI 库占了将近一半业务代码也全被挤在了一起。这个分析直接决定了优化方向先拆分第三方库再按路由拆业务代码。5.2 代码分割与按需加载路由懒加载在前面的骨架部分已经做了但还不够。UI 组件库的大体积依然堆在主 chunk 里。我们做了两件事一是按需引入组件不再从入口文件统一 import 整个组件库二是在 Vite 的构建配置里手动拆分 vendor 包把 react、react-dom 和组件库分别拆到独立 chunk。build: { rollupOptions: { output: { manualChunks: { react: [react, react-dom], antd: [antd], }, }, }, }这样改完首屏加载只需要 html、运行时代码、react 相关 chunk 和页面自己的 chunkUI 库的沉重代码等到实际用到对应组件时再加载。这一步做完首屏时间直接砍掉了三分之一。5.3 长列表与重复渲染优化管理后台的列表页动辄几百行一次性渲染会让页面卡顿明显。REA 引入了虚拟滚动方案只渲染可视区域内的行。这个优化对于表格、日志列表这类长数据场景几乎是刚需。渲染优化方面我们用了 memo、useMemo、useCallback但也不是无脑乱加。React 的渲染优化本质是减少不必要的重复计算和子组件重渲染而不是把每一处都包一层缓存。我们定了个规矩只有当前组件确实因为父组件频繁更新而出现性能问题时才做这层优化普通场景直接写普通代码保持可读性。5.4 构建层面的压缩与缓存下一步是构建产物层面的优化。REA 在部署前用构建插件开启了 gzip 压缩资源体积进一步缩小。配合静态资源文件名加内容哈希用户在发版后不会因为浏览器缓存而加载到旧的代码。这里有个容易踩的坑gzip 只能在服务器开启相应响应头或者托管平台支持时生效。如果你只在本地压缩了文件服务器没有对应能力那这个优化等于白做。部署之后可以通过浏览器的网络面板看响应头 content-encoding 是否出现 gzip 字段确认真的生效了。经过这几轮优化REA 的首屏加载时间稳定在 1.5 秒左右高峰期也没有超过 2 秒。性能优化最怕没有度量每次改动前后都记录数据团队才知道投入产出比到底怎么样。6. 部署与线上稳定性REA上线前后的那几件事工程架子搭好了、页面也迁移了一批接下来就是上线。上线这件事代码能跑只是最基础的要求真正考验的是交付流程和异常处理。6.1 CI检查把低级错误挡在合并前REA 的代码仓库在合并前必须过一轮自动化检查。命令顺序是这样的先跑 lint 检查代码风格和明显错误再做类型检查然后跑核心组件和工具函数的单元测试最后执行生产构建。任何一步失败合并请求都会被拦截。这个流程的低级错误拦截率非常高。有了它那种“本地能跑、同事拉下来就报错”的尴尬情况大大减少。最常见的漏网之鱼是类型错误TypeScript 的严格模式下一个 null 可能没处理代码在开发环境不报错构建到生产环境下直接白屏。CI 里把 tsc 检查加上后这类问题在合并前就被解决了。6.2 构建产物与静态资源部署REA 的前端是纯静态资源构建后就是一个 dist 目录。部署时把 dist 下的文件同步到目标服务器或云存储再由网关托管。这里必须注意 history 路由的配合单页应用的所有前端路由路径在没有对应物理文件时都需要重写到 index.html。如果部署时忘了这一步用户刷新某个子页面就会出现 404。REA 在部署脚本里专门加了一个 rewrite 规则把所有非静态资源路径都指向 index.html同时排除掉带内容哈希的 js/css 文件让它们按真实文件路径访问。6.3 错误监控与版本回滚线上稳定性的底气来自监控。REA 接入了前端错误监控并且把 sourcemap 单独上传线上报错堆栈能还原成原始源码位置。这让“用户说页面打不开”变成了“后台监控显示某行代码抛了 TypeError”排查效率直接提升一个量级。回滚流程也是上线前就定好的。每次发版前部署系统都会把当前线上版本号记录下来如果新版本上线后监控数据出现异常一键回滚到上一个版本。这个操作一定要提前演练别等到出事的时候再翻部署文档。7. 复盘REA踩过的几个坑和现在的状态工程化改造的过程中我们踩的坑比想象中多。这里挑四个比较典型的记录下来给后来的人提个醒。7.1 npm依赖升级引发的连锁反应有一次需要升级某个构建相关依赖顺手把好几个包一起升了。结果升级完成后生产构建突然多了一堆兼容性错误排查了很久才发现是某个底层工具链的新版本改变了目标浏览器编译规则导致一段老代码在低版本浏览器上报错。这个坑给我们留下的教训是依赖升级永远要单独建分支一次只升一个包并且升级完必须跑完整构建和核心流程验证。7.2 路由懒加载带来的首屏白屏问题懒加载本身是个优化但它也带来了新的风险。如果一个页面 chunk 的加载失败或者网络异常用户会卡在白屏状态。这个坑我们发现是某次 CDN 缓存错误导致的所有新增页面 chunk 都返回了旧的 html 内容。后来我们在路由外层加了错误边界捕获加载失败后展示友好提示并提供重试按钮同时在加载期间用细进度条代替空白。7.3 状态缓存与服务端数据不同步TanStack Query 的缓存解决了很多重复请求问题但它也引入了数据“过期”的概念。有一个列表页用户修改了数据后回到列表列表依然是旧数据。就是因为列表接口被缓存了而修改操作并没有触发缓存失效。解决方案很简单在修改成功后调用一次 invalidateQueries让列表查询失效并重新拉取。这件事情告诉我们引入缓存机制的时候必须同步设计好失效策略。7.4 迁移老页面时丢失的组件状态老页面从全局 store 改为本地状态时最容易丢状态。有个典型的例子一个筛选面板的展开状态原本放在 store 里切换菜单再回来它还是展开的改成组件本地状态后组件卸载再重新挂载展开状态就没了。这个问题的本质是“这个状态到底应该跟随页面还是跟随组件”。REA 后来定了个判断标准如果用户离开页面再回来之后状态必须保留就把它归到页面级的状态容器如果只是一个临时交互状态跟随组件就够了。迁移老页面时我们用这个标准逐个核对之后就没再出现过类似问题。到现在REA 这个代号还在用虽然它的含义早从 React Enterprise Application 变成了大家默认的“我们那套架子”。回头再看这段工程化改造我最深的感触是一个前端项目的上限往往不是某个技术方案有多高级而是骨架是否稳定、边界是否清晰、状态是否可控。如果你也在做类似的改造先把状态分层和目录边界定死再开始写页面。架子决定上限代码决定下限这两件事都值得认真对待。