前后端分离做了这么多年,我越来越觉得,所谓“前端工程化”这件事,最核心的评判标准不是脚手架有多炫,也不是依赖装了多少,而是换一个人接手时,项目还能不能顺畅跑起来,能不能在半小时内理解业务代码和组织方式。rea 这个项目,就是我在这个方向上做的一次收敛尝试。
rea 是我给一套轻量级中后台前端工程化方案取的代号,完整拆开是 Render Efficient App,核心目标很朴素:让一个开发者在拿到项目代码后,用最少的认知成本完成从启动、开发到打包上线的全流程。这篇文章会把我在搭建 rea 过程中的所有关键决策、实现细节和踩坑记录都摊开来讲,包括为什么不用更重的方案、路由权限怎么设计、请求层如何封装、首屏性能怎么优化,以及那些排查了一整天才找到原因的问题。不管你是刚接触前端工程化的新人,还是想找一个可落地的中后台项目参考的中级开发者,这篇内容应该都能让你少走一些弯路。
1. 项目背景与核心目标
1.1 为什么会有 rea 这个项目
事情得从一次不算愉快的接手经历说起。当时我接手一个维护了两年的后台系统,代码量不算大,但光启动项目就要等将近一分钟,环境变量散落在四个文件里,路由表里混着权限判断和菜单配置,请求层每个页面自己写一套,光是统一错误提示就改了一个星期。那段时间我每天打开代码的第一反应不是写功能,而是先搞清楚“这个页面到底走了哪条链路”。这种感觉我相信不少做前端的朋友都体会过:不是业务有多复杂,而是工程的混乱在持续消耗精力。
所以当我开始规划 rea 的时候,第一反应不是“我要用什么新框架”,而是“我要怎么把一套中后台项目最常见的需求,用一种足够简单、足够清晰的方式组织起来”。所谓中后台项目,其实需求是高度相似的:登录鉴权、路由权限、列表查询、表单提交、状态反馈、接口联调。这些场景占了日常开发的大头,与其每次从零搭一套,不如沉淀一份基线,让业务代码只需要关注自己的页面逻辑。
rea 的目标用户也很明确:一是准备从零开始搭建中后台项目的开发者,二是手上已有多个前端项目、想统一技术栈和技术规范的小团队。它的定位不是一个大而全的低代码平台,也不是一个强约束的企业级框架,而是一个“约定的集合”,通过目录结构、数据流方式、代码规范和打包策略,把复杂度控制在看得见的范围内。
1.2 项目的四个核心设计原则
在设计 rea 的时候,我给自己定了四条原则,后来回头看,这几条原则几乎贯穿了所有具体实现。
第一条是“约定优于配置”。这词儿在工程领域被用得太多了,但真正落地时意味着什么呢?意味着每个页面目录下固定放 index.tsx、hooks.ts、service.ts 和 style.ts,意味着新增一个页面只需要复制一个模板文件夹,而不是去改路由配置、菜单配置、权限配置三处地方。约定可以减少大脑的切换成本,也让自动化工具更容易实现。
第二条是“按需引入,拒绝全家桶”。很多人搭项目习惯把能装的都装上:状态管理库、组件库、工具函数库、动画库、图表库,先全量引入再说。但中后台项目里很多重型依赖其实只被用到了一个小角落,代价却是几 MB 的构建体积和更长的首屏解析时间。rea 的原则是每个依赖进入项目时都要问一句:有没有更轻的替代方案?没有的话,它服务的场景是否高频?
第三条是“数据流单向清晰”。中后台项目最容易出 bug 的地方就是状态管理失控:多个页面共享一个全局状态,谁都能改,改完还不通知,定位问题全靠猜。rea 内部把数据分为服务端状态和客户端状态两类,分别用不同的机制管理,这一点后面章节会细说。
第四条是“错误可追踪”。代码跑起来出现异常不可怕,可怕的是异常被吞掉。rea 在三个层面做了兜底:接口层统一错误拦截、组件层用 Error Boundary 捕获渲染异常、全局监听未处理的 Promise 异常,所有异常都会带上上下文信息输出到控制台,方便排查。
这四条原则看起来不复杂,但真正执行起来,每一步都意味着取舍。比如“约定优于配置”和“按需引入”之间就有天然矛盾:约定通常意味着统一封装,封装又容易导致过度设计,让简单的需求也被迫走一套复杂的流程。所以我在后续实现中反复提醒自己:约定要提供默认能力,但不能把扩展的路堵死。
2. 技术选型与整体架构
2.1 构建工具选型:为什么选 Vite 而非 Webpack
技术选型是搭建项目碰到的第一个分叉口。当前主流构建工具无非 Webpack、Vite、Turbopack 和 Rspack 这几类。对于 rea 这种偏中后台的场景,我最终选了 Vite,核心原因有三个。
第一个原因是开发体验。Webpack 的冷启动和热更新在项目体积变大后不可避免地会变慢,尤其是 node_modules 里依赖多的时候,每次改代码要等两三秒才能看到结果。Vite 利用浏览器原生 ESM 的能力,开发环境下按需编译,只转换浏览器当前真正请求到的模块,所以冷启动几乎是秒开,热更新也是毫秒级反馈。对于一天要写几十次页面交互的开发者来说,这种反馈速度直接影响心情和效率。
第二个原因是配置心智负担。Webpack 的配置复杂度是公认的,loader、plugin、resolve、optimization,每一块都有大量选项,新手光是把 babel、ts、css、静态资源这几类 loader 配对,就得花不少时间。而 Vite 开箱即用地支持 TS、JSX、CSS Modules、静态资源导入,大部分场景不需要额外配置,需要定制时也只需要写一个简单的插件。
第三个原因是生态兼容性。Vite 基于 Rollup 做生产构建,Rollup 的插件生态非常成熟,尤其是 tree-shaking 和代码分割方面的插件都很完善。而且现在主流 UI 组件库、图表库都已经提供了 ESM 格式的产物,配合 Vite 可以做到更彻底的按需加载。
当然 Vite 也有它的短板,最典型的是大型单体依赖(比如含有上万模块的图表库)在开发模式下首次加载会比较慢,因为没有做预构建的依赖会走原生 ESM 请求,数量多时浏览器并行请求会受限。这个问题的解法是合理配置 optimizeDeps,把这类重型依赖加到预构建列表中,rea 里我已经把常见的重依赖预先优化了。
从实际体验来看,选 Vite 带来的收益是立竿见影的,同一台机器上启动时间从 Webpack 的 40 秒左右降到 1 秒内,持续开发中的热更新也稳定很多。如果你的项目团队对 Webpack 已经非常熟练,并且现有工程迁移成本很高,那可能不需要刻意切换,但如果是新项目,我建议直接上 Vite,省下的配置时间用来完善业务逻辑更划算。
2.2 目录结构与模块划分
目录结构是最能体现工程约定的一部分。很多人觉得目录结构随便分分就行,实际上一旦项目规模上来,不合理的目录会让代码之间的依赖关系变得错综复杂。rea 的目录设计遵循一个思路:按业务域聚合,而不是按技术类型聚合。
我最终采用的结构大致是:
src/ pages/ // 业务页面,每个子目录是一个路由页面 dashboard/ index.tsx // 页面组件 hooks.ts // 页面专属逻辑 service.ts // 页面相关接口定义 type.ts // 页面数据类型 components/ // 跨页面复用的通用组件 Table/ Form/ hooks/ // 跨页面复用的自定义 Hook stores/ // 全局状态(仅存放真正跨页面共享的) utils/ // 纯函数工具集 api/ // 接口请求封装与实例 assets/ // 静态资源 router/ // 路由配置与守卫这种结构的核心逻辑是“让代码和业务一起移动”。当你需要改动某个页面功能时,进入对应目录就能看到这个页面所有的相关代码,不必在几十个文件夹之间来回跳转。很多项目喜欢把接口统一放在 api 目录下按模块划分,结果页面代码和平级目录的接口文件隔得很远,改一个功能要同时改三个地方,而且经常漏改。
在 rea 里,service.ts 负责页面专属的接口调用,api 目录下的 request 实例负责统一的请求机制,这样既保留了接口调用的集中封装,又让页面和它的接口数据之间保持近距离。如果有接口被多个页面复用,再提升到 api 目录下的公共模块,属于“先用局部,发现复用再提升”的演进式设计。
还有一点值得提的是,pages 下每个页面目录名都用小写加横杠,比如 user-list、order-detail,而不是用 UserList。原因有两个,一是 URL 路径通常是小写加横杠风格,目录名与路由路径保持一致会降低认知成本;二是避免在不同操作系统上因文件名大小写不一致导致 Git 提交或构建出问题,这类问题在团队协作中很容易出现。
2.3 状态管理与数据流设计
状态管理是前端项目里争议最多的部分。Redux 统治了很多年,但它的样板代码确实重;Mobx 响应式用起来爽,但可变数据在排查问题时需要额外小心。在 rea 项目里,我选择 Zustand 作为全局状态方案,同时明确了一条规则:全局状态只放真正跨页面共享的数据。
什么是真正跨页面共享的数据?最典型的就是用户信息、权限标识、当前选中的租户或者组织。这类数据的特点是:一旦变化,多个页面同时需要感知。而那些只在单个页面内部流转的数据,比如列表筛选条件、表单填写状态,一律放在页面组件的局部状态里,不要提升到全局,否则会污染全局命名空间,也会让状态流难以追踪。
Zustand 的用法很简洁,创建一个 store 只需要写一个 create 函数:
import { create } from 'zustand'; interface UserState { userInfo: UserInfo | null; permissions: string[]; setUserInfo: (info: UserInfo) => void; setPermissions: (perms: string[]) => void; } export const useUserStore = create<UserState>((set) => ({ userInfo: null, permissions: [], setUserInfo: (userInfo) => set({ userInfo }), setPermissions: (permissions) => set({ permissions }), }));组件使用时直接调用 Hook 即可,并且可以用选择器控制订阅粒度,避免不必要的重渲染:
const userInfo = useUserStore((state) => state.userInfo);为什么不用 Redux Toolkit?不是因为它不好,而是 rea 的业务场景以中后台 CRUD 为主,大部分数据都可以通过接口请求直接获得,真正需要放在全局状态里的东西很少。Redux 的 Immer 集成、thunk 异步流、devtools 集成当然都很完善,但对于小团队和中小型项目来说,Zustand 用最小的 API 覆盖了 80% 的需求,剩下的 20% 复杂度完全不值得引入。
对于服务端数据,rea 里没有把它放进全局 store,而是单独封装了请求层的缓存和并发去重机制。接口数据是服务端状态的镜像,它的生命周期应该跟随路由页面,页面卸载后缓存就应该释放,不适合和全局状态混在一起。这个区分是我在维护老项目时吃了亏才总结出来的:全局状态里的数据越来越臃肿,谁也不知道某个字段到底还有没有人在用,只能一直留着不敢删。
3. 核心功能实现与踩坑实录
3.1 动态路由与按钮级权限控制
中后台项目的路由和权限基本是绑在一起的。rea 采用动态路由方案:路由配置分成两部分,一部分是任何人都可以访问的基础路由(登录页、404 页),另一部分是需要权限控制的业务路由,由后端返回的权限码决定是否注册。
路由表本身就是一个普通数组,每个路由对象包含 path、component、meta 三个核心字段:
const businessRoutes: RouteRecordRaw[] = [ { path: '/dashboard', component: Layout, children: [ { path: '', component: () => import('@pages/dashboard/index'), meta: { title: '工作台', permission: 'dashboard:view' }, }, ], }, // 更多业务路由... ];在路由守卫中做拦截逻辑,核心伪码是这样的:
router.beforeEach((to) => { const token = getToken(); if (!token && to.path !== '/login') { return { path: '/login', query: { redirect: to.fullPath } }; } const userStore = useUserStore.getState(); if (token && !userStore.userInfo) { // 拉取用户信息和权限码,再动态添加路由 return initUserAndRoutes().then(() => ({ ...to, replace: true })); } if (to.meta.permission && !hasPermission(to.meta.permission)) { return { path: '/403' }; } return true; });这里有几个很容易踩的坑。第一个是动态添加路由后直接跳转,页面会白屏,因为路由表还没来得及注册。解决方式是在 addRoute 之后使用router.replace({ path: to.path, query: to.query })重新进入一次,或者像上面的伪码一样,在权限初始化完成后再返回一个 replace 的导航对象。第二种方案体验更自然,不会出现闪烁。
第二个坑是 404 路由的注册时机。如果 404 路由在动态路由注册前就已经存在,当用户访问一个未授权页面时,会优先匹配到 404 而不是权限不足页面。rea 的做法是把 403 和 404 都放在基础路由里,并且在权限判断时优先检查 meta.permission,不通过就直接跳转 403,这样顺序就对了。
第三个坑是按钮级权限。很多人做好了页面权限,但页面里的操作按钮和菜单项没有做权限控制,导致无权限的用户也能看到“新建”“删除”按钮,点到之后才收到接口报错。rea 里封装了一个v-permission自定义指令,在按钮上直接声明需要的权限码:
app.directive('permission', { mounted(el, binding) { const required = binding.value; if (required && !hasPermission(required)) { el.parentNode?.removeChild(el); } }, });使用时只需要<Button v-permission="'user:create'">新建用户</Button>。注意这个指令在对应的 Vue/React 实现里语法不同,如果是在 React 中用,由于没有指令机制,需要封装成一个AuthButton组件,内部判断权限后决定是否渲染子元素。这个差异我在项目文档里特别标注了,避免团队里的人混淆。
3.2 请求层封装与接口异常处理
请求层是每个前端项目都必须面对的基础设施。rea 选择了 axios 而不是原生 fetch,主要原因在于拦截器机制成熟、支持请求取消、超时配置方便,这些都是中后台项目每天都要用到的能力。
封装的思路是把请求层分成三层。最底层是 axios 实例,配置 baseURL、超时时间和默认 headers;中间层是拦截器,统一处理 token 附加、HTTP 错误、业务错误码映射;最上层是暴露给业务使用的get、post、put、delete方法,统一返回已经解包后的 data。
拦截器设计上有几个容易忽略的细节。一个是 token 失效的处理。当接口返回 401 时,不能只是简单地跳转登录页,还要考虑多个请求同时失败的情况,否则会弹出多次跳转提示。rea 用了一个简单的队列机制:标记是否正在刷新 token,如果是,就把后续请求暂存在数组里,刷新完成后再逐个重放。
另一个是重复请求的竞态问题。列表页筛选条件变化很快时,前一个请求的返回可能晚于后一个请求,导致页面显示旧数据。这个问题有两种解法,一种是加请求序号,响应回来后只渲染最后一次请求的结果;另一种是直接取消前面的请求。rea 默认用第二种,通过 axios 的 AbortController 在请求发起前取消同页面同接口的未完成请求。实测下来这个方案更省网络资源,但需要封装时注意取消请求不能影响公共请求队列。
业务错误码的处理也是大坑。后端返回的格式一般是这样的:
interface ApiResponse<T> { code: number; message: string; data: T; }其中 code 为 0 表示成功,非 0 都是业务异常。有人喜欢在拦截器里对非 0 的 code 统一弹出错误提示,这样做的问题是部分业务需要在 catch 里做定制处理,比如支付超时要跳转收银台,统一弹窗会挡掉定制逻辑。rea 的折中方案是:拦截器只处理网络错误和 HTTP 状态码错误(404、500 等),业务错误码只负责在 response 里附带一个标记,由业务代码决定是直接使用默认错误提示还是自行 catch。通过一个silent参数控制业务方法的默认行为,开发者在调用时如果传了silent: true,错误提示改为由调用方自己处理,否则走全局默认提示。
3.3 组件复用与业务拆分的边界
组件复用这个话题,很多教程都在讲怎么写通用组件,但很少有人讲通用组件的边界在哪。rea 项目里我吃过一次亏:一开始把列表页封装成了一个超高配置的 ProTable,支持列配置、排序、筛选、分页、行选择、批量操作、列拖拽,结果每接一个新页面都要花半天去理解配置项,出了问题还得一层层翻代码,复用变成了负担。
后来我调整了策略,把组件分成三个层次。第一层是基础组件,比如按钮、输入框、卡片这些,直接基于组件库二次封装,主要是统一样式和默认属性。第二层是业务组件,比如图片上传组件、部门选择器、地区联动选择,它们封装了特定业务域的交互逻辑,可以被相关页面复用。第三层是页面级组件,比如用户列表、订单详情,这些基本不跨页面复用,就不应该被抽成通用组件。
这个分层的主要价值是控制抽象的扩散。基础组件保持精简,不要塞业务逻辑;业务组件在 props 设计上保留一定的控制能力,但内部逻辑固定;页面级组件则完全自由,怎么方便怎么来。rea 里还做了一个约定:任何组件超过 300 行就要考虑拆分子组件,拆出来的子组件如果只是当前页面使用,就放在页面目录下,不需要强行提升到公共组件目录。
组件复用的另一个关键是 props 设计。我见过很多组件把十几个 props 堆在一起,每个 props 又可能是对象或函数,调用方根本不知道哪些必填哪些可选。rea 的经验是:props 超过五个就要考虑分组,一部分是最常用的基础配置,一部分是扩展配置,扩展配置统一收进一个 options 对象里。这样组件的常用路径和高级路径被分开,学习成本自然降下来了。
4. 性能优化与工程化细节
4.1 首屏加载优化与体积瘦身
中后台项目虽然不直接面向 C 端用户,但首屏加载太慢同样影响体验。我见过一个后台系统,首屏要加载 4MB 多的 JavaScript,白屏时间接近三秒,业务方天天催优化。rea 在做首屏优化时用了三招:路由懒加载、依赖预构建、构建产物分析。
路由懒加载是默认开启的,每个页面组件都通过动态 import 引入,配合 Vite 的代码分割能力,首屏只加载当前页面需要的代码。但懒加载也有一个问题:页面切换时会出现短暂的白屏和 loading 闪烁。rea 的做法是在 Layout 的二级路由出口处包了一层 Suspense,配合一个轻量的 loading 占位组件,切换路由时用户看到的是占位骨架而不是白屏。
依赖预构建方面,Vite 默认会对 node_modules 里的 CommonJS 依赖做预构建,我可以手动指定某些大型 ESM 依赖也进入预构建列表,避免浏览器在开发模式下发出太多 module 请求。这个配置在 optimizeDeps.include 里,指向的是组件库、图表库这类重依赖。生产环境构建时,这些依赖通过 manualChunks 被拆成独立 chunk,利用浏览器缓存减少重复下载。
构建产物的体积分析是优化前必须做的动作。rea 集成 rollup-plugin-visualizer,每次构建后会自动打开依赖体积报告,哪些包占比最大、哪些代码被重复打包,一目了然。我第一次跑分析时就发现一个工具库占据了 300KB 的体积,而整个项目里其实只用了它的一个方法,换成原生实现后体积立刻降了一截。
还有一个小细节是 moment 的替换。很多老项目还在用 moment,它体积大且不可 tree-shaking,一个时间格式化功能就要占用几十 KB 的体积。rea 统一使用 dayjs,API 兼容,体积却小了一个数量级。替换的成本主要是时区插件和语言包的导入方式不同,但收益非常明显,构建产物直接少了近 200KB。如果你在维护老项目,这个替换是我最建议优先做的低风险高收益优化。
4.2 构建速度与缓存策略的取舍
构建速度和缓存策略经常被放到一起讨论,因为二者的平衡需要针对项目特性来做。rea 的实践可以拆成两块:开发环境构建和 CI 构建。
开发环境的构建速度主要是靠 Vite 的缓存机制保证的。Vite 在 node_modules/.vite 目录下保存预构建依赖的缓存,只要依赖版本没有变化,启动时不会重复构建。团队协作时容易遇到一个情况:有人升级了依赖版本,其他人拉代码后发现 vite 报错,说预构建依赖不一致。这种问题的处理办法很简单,删掉 node_modules/.vite 目录重启一次就好,rea 的文档里专门写了一条命令:
rm -rf node_modules/.vite && npm run dev如果是 Windows 环境,可以直接删目录或者用npx rimraf node_modules/.vite。
CI 环境下的构建速度则受缓存策略影响更大。rea 的 CI 流程分两步:安装依赖和打包。依赖安装阶段使用 pnpm 的全局缓存加 lockfile,如果 lockfile 没变化,安装基本几秒内完成,不需要重新下载所有包。打包阶段则是将 node_modules 和 dist 目录都作为缓存保留,只有在源码变化时才重新打包。
这里有一个容易踩的坑:CI 机器上如果缓存了旧的 node_modules,而 lockfile 已经更新,pnpm install 可能不会完全同步依赖树,导致本地开发正常、CI 构建报错。rea 的解决方式是安装命令加上--frozen-lockfile参数,强制要求 lockfile 和依赖声明完全匹配,只要有任何不一致就终止安装,从源头避免这类环境差异问题。
缓存策略的另一个细节是 dist 目录的产物命名。rea 里所有生成的文件名都带 hash,因为只有内容变化时文件名才会变化,CDN 才能充分利用长缓存。但这里有一个前提:index.html 本身不能被缓存,否则用户拿到的 HTML 永远是旧的,不会去请求新的带 hash 的文件。所以在发布配置里,index.html 的缓存策略需要设置为 no-cache,这也是很多团队容易忽略的。
4.3 代码规范与提交钩子
工程化里最影响长期维护质量的,除了架构,还有代码规范。rea 的规范体系包括三个部分:代码格式化、静态检查、提交信息约束。
代码格式化用 Prettier 统一,配置主要固定了几个关键选项:单引号、无分号、行宽 100、缩进 2 空格。这些风格问题本身没有对错,但团队里每个人一套风格的话,每次 code review 都会有一半的时间在争论格式,所以格式化必须交给工具来做,而不是靠自觉。
静态检查用 ESLint,配合 TypeScript 插件。rea 的规则配置以 recommended 为基础,额外开启了一些容易踩坑的规则,比如禁止 any 的隐式使用、强制显式返回类型、不允许未使用的变量。其中最有价值的规则是no-floating-promises,它强制要求 Promise 必须被处理,要么 await,要么 catch,否则 lint 直接报错。这个规则帮我避免了一大批“接口报错了但控制台没有任何提示”的问题。
提交信息约束方面,rea 用 commitlint 配合 husky 做到了 commit message 的自动校验。提交信息统一使用 Conventional Commits 的格式:
feat: 新增用户导出功能 fix: 修复列表页筛选条件重置失效的问题 refactor: 重构请求层缓存逻辑husky 的配置在 package.json 里,核心是两块。一块是 pre-commit 钩子,运行 lint-staged 对暂存区的文件做格式化和 lint 检查,只处理本次提交的文件,而不是全量检查,速度会快很多。另一块是 commit-msg 钩子,调用 commitlint 校验提交信息格式。
我第一次配置 lint-staged 时犯过一个错误,在 pre-commit 里跑了完整的npm run lint,结果项目逐步变大后每次提交都要等十几秒,很快团队成员就开始偷偷用--no-verify绕过检查了。后来改成 lint-staged,只检查本次提交的文件,提交时间降到一两秒内,规则执行率反而提高了不少。这条经验值得分享:任何工程化手段都应该是低摩擦的,一旦摩擦太大,就会被绕过,最终形同虚设。
5. 常见问题与排查经验实录
5.1 热更新失效的排查思路
Vite 的热更新偶尔会失效,现象是改了代码页面不刷新,或者控制台提示 full reload。根据 rea 项目中的经验,这类问题可以从三个方向排查。
第一个方向是文件监听本身的问题。如果项目在虚拟机、Docker 容器或者网络映射盘中运行,文件监听的事件可能无法正常传递到宿主机,导致改动不触发重新编译。解决方法是配置 Vite 的 server.watch 选项,使用 polling 模式,虽然会消耗更多 CPU,但能保证事件被捕获:
server: { watch: { usePolling: true, interval: 100, }, }第二个方向是缓存异常。Vite 的依赖预构建缓存和浏览器缓存有时会不一致,特别是改了 vite.config.ts 里的 resolve.alias 或者 optimizeDeps 后,偶尔会出现“模块被外部化,但无法解析”的报错。这时的处理方式依次是:刷新浏览器、清掉 node_modules/.vite 重启 dev server、更新浏览器版本。如果还不行,就检查 alias 配置里的路径大小写是否和实际文件一致。
第三个方向比较隐蔽,是代码里动态导入路径的问题。如果你的页面组件是循环引用或者动态导入的路径写错,Vite 在开发模式下可能不会报错,而是直接把某个模块标记为失效,后续热更新就只对这个模块失效,其他模块正常。这种问题可以通过访问该模块对应的路由来复现,然后查看控制台有没有模块解析相关的警告。
5.2 依赖版本冲突的处理套路
pnpm 严格的项目依赖隔离机制帮 rea 避免了很多冲突,但同时也引入了新的问题。最典型的是“幽灵依赖”被 pnpm 默认禁止后,有些包在自己的 dependencies 里没有声明某个依赖,却在实际代码里引用了它,导致 pnpm 安装后运行报错“Cannot find module”。
这类问题的排查套路很简单:先看报错是在哪个包里出现的,再看这个包的 package.json 的 dependencies 字段,确认它是否声明了所引用的依赖。如果没有声明,有两种处理方式。第一种是最好的,升级这个包到修复了依赖声明问题的版本。第二种是作为临时方案,通过 pnpm 的pnpm.overrides字段把对应依赖固定到一个版本,强制让所有子依赖都解析到同一个版本。
还有一类冲突来自共享依赖的多个版本。比如项目里有两个包分别依赖了某工具库的不同大版本,这两个版本的实例同时存在,会造成某些通过引用比较判断的逻辑失效,比如instanceof判断。排查这种问题的方法是在构建产物里搜索包名的重复 chunk,一旦发现就考虑用 resolve.alias 将两个版本统一,或者用 pnpm overrides 固定版本。
rea 在处理这类问题时的经验是:不追求所有依赖都保持最新,而是保持一个稳定的、测试过的依赖集合。每次升级依赖时单独提交一个 commit,并在提交信息里标注升级的原因,比如修复了某个安全漏洞、解决了某个 bug、性能有提升。这样可以随时回滚到上一个可用状态,也方便团队 review 时理解升级意图。
5.3 环境变量丢失与跨环境发布
环境变量是部署阶段最容易出问题的地方,也是 rea 项目中出过事故最多的部分。
Vite 暴露环境变量的规则是:只有以 VITE_ 开头的变量才会被打进客户端代码,其他变量只在构建配置和服务端环境中可用。很多人第一次用 Vite 时会踩这个坑,写了一个以其他前缀开头的变量,在页面里怎么取都取不到,翻文档才发现前缀规则。
rea 的环境变量文件按照 Vite 的加载顺序做了分层:
.env // 所有环境通用的变量 .env.development // 开发环境 .env.production // 生产环境 .env.test // 测试环境加载优先级是:具体环境的文件覆盖通用文件,也就是说 .env.production 里的同名变量会覆盖 .env 里的值。这个机制用起来很方便,但要注意一个细节:如果 .env.production 和 .env 都定义了 VITE_API_BASE_URL,本地 build 时用的是 production 的文件,这个没问题;但如果某次发布是直接在服务器上用环境变量注入的方式修改配置,就需要确保服务器环境变量里的值能覆盖文件里的值,而 Vite 并不会自动读取服务器的环境变量来完成覆盖,需要在构建时显式处理。
环境变量另一个常见问题是密钥泄漏。有人会把后端接口密钥或者加密密钥直接写进 VITE_ 开头的变量里,然后就随着前端代码一起被打包到公网可访问的 JS 文件中。这是非常危险的。rea 里有一条强制规定:任何敏感信息,包括密钥、Token、内部接口地址,一律不写入前端环境变量,这些信息应该由后端接口返回,或者通过网关卡访问。
我在实际项目中还遇到过一次环境变量丢失事故。起因是某位同事在 .env.production 里新增了 VITE_ 开头的变量,但是本地测试时用的是 .env.development,这个变量在开发环境下被通配符匹配到了默认值,功能正常。上线之后,生产环境的 .env.production 文件是从构建机读取的,构建机上却没有同步这个新增变量,导致线上功能直接不可用。从那以后,rea 里增加了一个运行时校验函数,在应用启动时检查所有必需的 VITE_ 变量是否存在,缺失时直接给出醒目的报错,而不是等到用户点某个按钮才暴露问题。
这个教训衍生出来的另一个经验是:所有环境变量都要有默认值。就算某个变量在生产环境并不需要显式配置,也应该在 .env 通用文件里预设一个安全默认值,避免变量未定义导致代码报错。默认值的粒度要保守,宁可功能降级,也不要让用户直接看到一串 undefined。
以上这些都是我在搭建 rea 过程中实实在在遇到过、并且已经形成解决方案的问题。回过头来复盘,这个项目的价值不在于它用了多新的技术,而在于它把中后台项目里那些高频场景的解法沉淀了下来,每个新成员加入时不用再从头踩一遍我踩过的坑。
最后再说一个体会。前端工程化工具链更新很快,今天觉得好用的方案,几个月后可能就有更优的替代品。我现在的做法是不再追求“最先进”,而是追求“最顺手”:只要某个约定能让团队里的每一个人都在 10 分钟内看懂项目结构并开始写业务代码,它就是合格的工程化。技术选型会过时,但好的约定和清晰的组织方式,会在项目漫长的生命周期里持续带来收益。