前端工程化到底在解决什么:从一个项目越改越慢说起
本文是「前端工程现场」系列的第 1 篇。
这一系列不打算只介绍工具怎么配置,而是从真实开发问题出发,聊聊前端项目为什么会逐渐变难改,以及工程化到底能帮我们解决什么。
一个前端项目刚开始时,通常都很美好。
页面不多,组件不多,src 目录也很干净。产品提一个需求,找到对应页面,改完代码,本地跑一下,顺手提交。
整个过程流畅得让人产生一种错觉:
这个项目以后应该也挺好维护的。
再过几个月,项目还是那个项目,开发体验却逐渐变了。
加一个筛选条件,要先确认状态到底藏在哪;改一个公共组件,发现它被七八个页面悄悄依赖;本地明明运行正常,CI 却像第一次见到这份代码;线上出现问题后,大家围着发布记录研究半天,试图找出这次构建到底混进了什么。
代码没有突然变笨,框架也没有趁半夜偷偷退化。
只是当页面、人员、依赖和业务规则一起增加后,原来依靠记忆和默契维持的开发方式,开始不够用了。
这正是前端工程化开始发挥作用的地方。
一、项目变慢,不一定是页面加载变慢
提到"前端项目变慢",很多人第一反应是性能问题:
- 首屏加载慢;
- 接口返回慢;
- 页面渲染卡顿;
- 打包体积太大。
这些当然属于前端工程的一部分,但本文所说的"慢",更多是另一种慢:
一个需求从提出到稳定上线,所需要的时间越来越长了。
代码可能只改了二十行,开发者却花了半天确认影响范围。
一个按钮可能只换了文案,却要检查它是不是来自远程配置、国际化文件或公共组件。
一个依赖可能只升级了小版本,测试环境却突然白屏。
真正拖慢项目的,往往不是"写代码"本身,而是围绕这次改动产生的大量附带工作:
text
找代码入口
→ 理解已有逻辑
→ 判断影响范围
→ 修改代码
→ 本地验证
→ 处理环境差异
→ 等待构建
→ 回归测试
→ 发布上线
→ 出问题后回溯
前端工程化要做的,不是让业务复杂度凭空消失,而是尽量减少这些过程中的混乱和重复劳动。
换句话说:
工程化主要解决的不是代码能不能运行,而是代码能不能被持续、稳定、低风险地修改。

二、从一个"只加筛选条件"的需求说起
假设我们正在维护一个订单管理页面。
产品提出了一个听起来非常正常的需求:
在订单列表中增加"订单来源"筛选条件,并且刷新页面后保留当前筛选状态。
从页面效果看,无非是多一个下拉框。
如果项目规模很小,代码可能是这样的:
vue
<script setup lang="ts">
import { ref } from 'vue'
const source = ref('all')
function fetchOrders() {
// 请求订单列表
}
</script>
给页面加一个变量,发请求时带上参数,需求基本就完成了。
但在一个已经运行了一段时间的项目里,开发者很快会遇到一连串问题:
- 筛选条件应该放在页面局部状态、Pinia,还是 URL 查询参数中?
- 切换筛选条件后,当前页码要不要重置为第一页?
- 页面刷新后保留状态,是读取 URL、
sessionStorage,还是全局缓存? - 请求参数是页面自己拼,还是交给统一的查询转换函数?
- 返回上一页时,要恢复上次查询,还是重新请求?
- 这个页面是否复用了公共表格组件?
- 公共表格组件内部是不是已经偷偷管理了分页?
- 权限变化后,某些筛选项是否应该隐藏?
需求还是那个需求,下拉框也还是那个下拉框。
只是它已经不是一个孤立控件,而是进入了页面状态、路由状态、请求参数、缓存策略、权限逻辑和公共组件之间的关系网。
这就是项目越改越慢的第一个原因:
功能增加后,代码之间的关系越来越多,但这些关系没有被清楚地表达出来。
开发者真正花时间的地方,不是写下拉框,而是猜测:
我改这里,会不会把别的地方也弄坏?
工程化的第一个目标,就是让这种猜测尽可能少一点。
三、工程化真正关注的是"变更成本"
很多人刚接触前端工程化时,会把它理解为一组工具:
- Vite 或 Webpack;
- npm、pnpm 或 Yarn;
- ESLint、Prettier;
- TypeScript;
- Monorepo;
- Git Hooks;
- CI/CD。
这些工具都很重要,但它们并不等于工程化。
一个项目装了 ESLint,不代表代码边界就清楚。
一个项目用了 pnpm,不代表依赖一定合理。
一个项目搭了 Monorepo,也不代表它从此进入现代化管理阶段。有时只是把原本一个仓库里的混乱,整齐地分散到了五个包里。
判断一个工程化方案有没有价值,可以看它是否降低了下面几类成本。
| 成本 | 常见表现 |
|---|---|
| 理解成本 | 新成员找不到入口,旧成员也要重新回忆 |
| 修改成本 | 一个小需求需要同时改很多不相关模块 |
| 验证成本 | 不知道该测哪些页面,只能全量回归 |
| 协作成本 | 每个人的目录、命名和提交方式都不一样 |
| 交付成本 | 本地正常,测试或生产环境异常 |
| 回溯成本 | 上线出错后,找不到对应代码和构建记录 |
所以,我更愿意用一句话概括前端工程化:
通过约定、工具和自动化,让项目中的每次变更更加可预测。
所谓"可预测",不是保证永远不出 Bug。
而是尽量做到:
- 知道代码应该改在哪里;
- 知道这次改动会影响什么;
- 知道提交前要经过哪些检查;
- 知道构建使用了什么环境;
- 知道发布的是哪个版本;
- 知道出问题后从哪里开始排查。
如果一套复杂工具链没有让这些事情更清楚,它可能很先进,但不一定真的适合当前项目。
四、第一件事:让代码边界能被看见
项目变乱,往往不是因为开发者完全不会写代码。
更多时候,是因为一段逻辑刚出现时很小,大家觉得"先放这里也没事",后来它不断长大,却再也没有被重新整理。
1. utils 是怎么逐渐长胖的
utils 大概是前端项目里最容易长胖的目录。
刚创建时,它通常只有这些内容:
text
utils/
formatDate.ts
formatMoney.ts
debounce.ts
看起来很正常。
几个月后再打开,里面可能已经住进了:
text
utils/
formatDate.ts
formatMoney.ts
request.ts
permission.ts
user.ts
track.ts
orderStatus.ts
storage.ts
router.ts
download.ts
fixSomePageBug.ts
名字还是 utils,职责已经接近社区服务中心。
问题不在于文件多,而在于开发者无法从目录名称判断:
- 这些函数是否依赖浏览器环境;
- 是否会读取全局状态;
- 是否会发送请求;
- 是否只适用于订单业务;
- 是否可以在其他项目中复用。
当一个目录无法表达边界时,所有代码都可能往里面放,所有模块也可能依赖它。
久而久之,utils 就会变成项目里的交通枢纽。大家都从这里经过,但谁也不敢轻易施工。
2. 按职责拆分,而不是按文件类型堆放
对于中小型项目,可以先按业务和职责进行划分:
text
src/
modules/
order/
api/
components/
hooks/
pages/
types/
utils/
user/
api/
components/
pages/
shared/
components/
hooks/
utils/
infrastructure/
request/
router/
storage/
这里的关键不是目录名称必须一模一样,而是每一层都尽量回答一个问题。
modules/order 表示订单业务内部能力。
shared 表示确实可以被多个业务复用的能力。
infrastructure 表示请求、路由、存储这类基础设施。
这样,当订单页面需要一个特殊的状态转换函数时,我们不会因为它"长得像工具函数",就立刻把它扔进全局 utils。
它可以先留在订单模块中:
text
modules/order/utils/transformOrderStatus.ts
等到其他业务真的出现相同需求,再考虑抽象。
工程化中的复用,不是看到两行代码相似就马上合并。
更稳妥的顺序是:
text
先保证职责清晰
→ 再观察是否重复
→ 最后决定是否抽象
过早抽象的结果,经常是得到一个参数很多、分支很多、名字很通用,但没人敢改的公共函数。
3. 公共组件不应该等于"所有页面都能往里塞逻辑"
公共组件也容易出现类似问题。
例如一个表格组件,最初只负责:
- 接收列配置;
- 接收数据;
- 展示分页。
后来为了减少页面代码,它又开始负责:
- 自动发请求;
- 保存查询条件;
- 读取路由参数;
- 判断按钮权限;
- 导出 Excel;
- 处理不同业务的状态颜色;
- 打开详情弹窗。
最后它确实"很强大",但每个页面使用时都要传十几个参数。
这种组件看起来提高了复用率,实际上只是把多个业务页面的复杂度集中到了一个文件里。
一个更清晰的拆法是:
text
基础表格组件:负责展示和基础交互
业务列表 Hook:负责请求、分页和查询状态
业务页面:负责组合组件与业务规则
例如:
ts
const {
data,
loading,
pagination,
query,
search,
reset
} = useOrderTable()
表格组件不需要知道订单业务,订单页面也不用重复编写分页请求逻辑。
这类边界一旦清楚,后续改动就更容易判断应该落在哪一层。

五、第二件事:让依赖关系可追踪
前端项目中至少存在两类依赖。
第一类是 npm 包依赖:
text
项目
→ Vue
→ Vue Router
→ Pinia
→ Axios
→ 各种构建插件
第二类是项目内部的代码依赖:
text
页面
→ 业务组件
→ 业务 Hook
→ 请求模块
→ 基础设施
包管理工具主要管理第一类。
目录结构、导入规范和模块设计主要约束第二类。
两类依赖任何一边失控,项目都会开始出现一些熟悉的现象:
- 我的电脑能运行,你的电脑不能运行;
- 删除一个文件前,全组搜索三遍仍然不放心;
- 页面通过五层
../../引用了另一个业务模块; - 一个公共文件被几十个页面依赖;
- 依赖升级后,完全无关的页面突然报错;
package.json里没有声明的包,代码却照样能引用。
1. 锁文件不是装饰品
不少项目会把 package-lock.json、pnpm-lock.yaml 或 yarn.lock 当成安装依赖后顺手生成的文件。
实际上,锁文件承担着一个很重要的任务:
尽量让不同开发者、不同 CI 环境安装出一致的依赖树。
package.json 中经常会出现版本范围:
json
{
"dependencies": {
"some-package": "^1.3.0"
}
}
^1.3.0 并不只代表 1.3.0,它允许包管理工具在规则范围内选择更新版本。
如果没有稳定锁文件,不同时间安装依赖,得到的间接依赖可能并不完全相同。
于是就会出现经典场景:
昨天还能跑,今天重新安装依赖后就不行了。
代码一行没改,项目自己获得了新的想法。
所以在团队项目里,锁文件应该提交到版本库,并在 CI 中使用能够严格遵守锁文件的安装命令。
例如 pnpm:
bash
pnpm install --frozen-lockfile
如果锁文件与 package.json 不一致,CI 应该直接失败,而不是现场帮你重新生成一份新的依赖答案。
2. 固定的不只是依赖版本
要稳定复现一个前端项目,通常还要明确:
- Node.js 版本;
- 包管理器及版本;
- 环境变量;
- 构建命令;
- 操作系统差异;
- 私有源或镜像配置。
可以在 package.json 中声明:
json
{
"engines": {
"node": ">=20 <21"
},
"packageManager": "pnpm@10.0.0"
}
也可以通过 .nvmrc、Volta 或其他版本管理工具统一 Node.js 环境。
这些配置看起来都不复杂,却能减少很多"我这里没问题"的争论。
因为计算机不会被"我这里真的没问题"说服。
它更相信版本号。
3. 内部依赖也需要方向
除了 npm 包,业务模块之间的依赖方向也应该尽量清晰。
例如可以约定:
text
页面可以依赖业务组件
业务组件可以依赖业务 Hook
业务 Hook 可以依赖 API 和基础设施
shared 不能反向依赖具体业务模块
错误方向通常长这样:
text
shared/components/Table
→ import modules/order/constants
一旦通用层开始反向依赖业务层,它就不再真正通用。
后续其他模块使用这个组件时,也会被迫带上订单业务的概念。
依赖方向越稳定,修改时越容易判断影响范围。
反过来,如果所有模块都能互相引用,目录结构再漂亮也只是文件夹版的蜘蛛网。
六、第三件事:让开发和构建环境可以复现
本地开发顺利,并不意味着项目已经具备稳定交付能力。
很多问题会在代码离开开发者电脑后才出现:
- CI 使用的 Node.js 版本不同;
- 测试环境缺少某个环境变量;
- Linux 对文件名大小写更敏感;
- 本地使用缓存,CI 从零安装;
- 构建插件在不同版本下行为不同;
- 生产环境的接口前缀配置错误。
这类问题有一个共同点:
项目运行依赖了某些条件,但这些条件没有被明确记录。
一个可复现的构建过程,至少应该接近下面这个公式:
text
确定的代码
+ 确定的依赖
+ 确定的运行时
+ 确定的环境配置
= 可预期的构建结果
这里的"确定"不是说所有内容永远不变,而是变化必须可追踪。
1. 环境变量要有边界
前端项目通常会有多套环境:
text
开发环境
测试环境
预发布环境
生产环境
环境变量至少应该说明:
- 变量名称;
- 变量用途;
- 哪些变量可以暴露给浏览器;
- 哪些变量由部署平台注入;
- 缺失变量时应该直接失败还是使用默认值。
例如:
env
VITE_API_BASE_URL=https://example.com/api
VITE_APP_ENV=production
同时提供一份不包含敏感值的示例:
env
# .env.example
VITE_API_BASE_URL=
VITE_APP_ENV=
需要特别注意的是:
前端环境变量最终可能进入构建产物,它不是存放真正秘密信息的保险箱。
私钥、数据库密码、服务端密钥不应该因为前面加了一个 VITE_,就突然获得在浏览器里公开展示的资格。
2. 把构建流程交给脚本
项目中的关键操作应该尽量通过脚本执行,而不是依赖口头说明。
json
{
"scripts": {
"dev": "vite",
"lint": "eslint .",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"build": "vite build"
}
}
这样本地、CI 和其他开发者都可以执行同一组命令。
一个常见的 CI 流程可以是:
text
安装指定 Node.js
→ 安装指定 pnpm
→ 根据锁文件安装依赖
→ Lint
→ TypeScript 检查
→ 单元测试
→ 构建
→ 保存构建产物
流程不一定要一开始就很复杂。
先让它稳定、快速、可理解,再逐步增加检查。
如果一个 CI 流水线有二十多个步骤,但每次失败只留下一句"Process exited with code 1",那它对开发者的帮助可能还不如本地的一条报错信息。

七、第四件事:把检查放到问题发生之前
很多前端问题并不难解决,难的是它们发现得太晚。
例如一个类型错误:
- 在编辑器里发现,可能十秒钟就能修好;
- 在提交前发现,可能一分钟修好;
- 在 CI 中发现,需要重新提交并等待流水线;
- 在测试阶段发现,需要重新构建和回归;
- 在线上发现,就会顺便附赠群聊提醒、紧急回滚和一段难忘的夜晚。
工程化中的质量门禁,本质上是在做一件事:
让能够自动发现的问题,尽可能早地被自动发现。
1. 不同问题放在不同阶段检查
可以按成本分配检查位置。
| 检查内容 | 适合阶段 |
|---|---|
| 格式化 | 编辑器保存时 |
| 基础 ESLint 规则 | 本地开发、提交前 |
| TypeScript 类型检查 | 本地、CI |
| 单元测试 | 本地按需、CI |
| 构建验证 | CI |
| 页面核心流程 | 预发布环境、E2E |
| 性能和错误 | 线上监控 |
并不是所有检查都要塞进 Git 提交钩子。
如果开发者每次提交都要等待十几分钟,他首先学会的可能不是提高代码质量,而是如何跳过钩子。
好的检查应该具备三个特点:
- 足够稳定,不要今天通过明天随机失败;
- 足够快速,不要明显打断开发节奏;
- 报错明确,告诉开发者哪里错了以及如何定位。
2. TypeScript 的价值不只是"有类型"
TypeScript 常被当作前端工程化中的标准配置,但真正有价值的不是把所有变量后面都补上类型。
它更重要的作用是让模块之间的契约变得明确。
例如订单接口返回:
ts
interface Order {
id: string
source: 'web' | 'miniapp' | 'offline'
status: 'pending' | 'paid' | 'cancelled'
}
页面、表格、状态转换和接口模块都可以围绕同一份数据结构工作。
当后端字段变化或状态新增时,类型检查能够帮助我们找到受影响的位置。
这比上线后看到页面展示一个 undefined,再从浏览器控制台开始考古,要友好得多。
3. 测试应该保护高价值逻辑
不是每一行代码都需要写单元测试。
对于前端项目,优先保护这些内容通常更划算:
- 金额和日期计算;
- 权限判断;
- 查询参数转换;
- 状态机;
- 复杂表单校验;
- 公共 Hook;
- 高频使用的基础组件。
例如订单查询参数转换:
ts
export function transformOrderQuery(query: OrderQuery) {
return {
source: query.source === 'all' ? undefined : query.source,
page: query.page,
pageSize: query.pageSize
}
}
这类函数输入输出明确,很适合单元测试。
而一个只负责展示标题的简单组件,未必需要为了追求覆盖率数字写一堆几乎没有价值的测试。
工程化不是让报表更漂亮,而是保护真正容易出错、出错代价较高的部分。
八、第五件事:让发布结果可回溯
代码通过检查并成功构建,不代表工程化流程已经结束。
项目上线后,还需要回答几个问题:
- 当前线上运行的是哪个版本?
- 这个版本对应哪个 Git 提交?
- 构建时使用了哪些环境配置?
- 谁在什么时间发布了它?
- 出现错误后能否快速定位到源码?
- 新版本异常时能否回滚?
如果这些信息都找不到,线上排查就很容易变成集体猜谜,诶我本地没问题啊~
1. 版本、提交和产物要对应
比较基础的做法是,在构建产物中注入版本信息:
ts
export const BUILD_INFO = {
version: import.meta.env.VITE_APP_VERSION,
commit: import.meta.env.VITE_GIT_COMMIT,
buildTime: import.meta.env.VITE_BUILD_TIME
}
然后在系统的"关于"页面、控制台或监控上报中带上这些信息。
这样出现问题时,至少可以确认:
用户当前看到的页面,到底是哪一次构建。
不要小看这一步。
很多线上问题最尴尬的阶段,不是不会修,而是大家讨论了十分钟后才发现,有人看的测试环境,有人看的生产环境,还有人浏览器里一直开着上周的缓存页面。
2. 监控不是上线后才临时打开控制台
前端监控至少可以覆盖:
- JavaScript 运行错误;
- Promise 未处理异常;
- 接口失败;
- 资源加载失败;
- 白屏或关键页面异常;
- Core Web Vitals 等性能指标;
- 用户操作链路;
- 发布版本信息。
监控的目标不是收集越多数据越好。
更重要的是让错误具备上下文:
text
哪个用户
在哪个页面
执行了什么操作
使用哪个版本
发生了什么错误
是否影响核心流程
只有错误堆栈,没有版本和操作信息,排查时仍然可能一头雾水。
3. 回滚能力也是工程能力
发布系统不应该只会前进。
当新版本出现严重问题时,团队需要能够快速恢复到上一份稳定产物。
这里强调的是"上一份稳定产物",而不是临时切回旧代码再现场重新构建。
因为重新构建可能使用不同依赖、不同环境或不同配置,得到的东西未必真的是原来的版本。
可回溯的产物管理,会让回滚更可靠。

九、工程化不是工具越多越好
聊到这里,很容易产生另一种误解:
既然工程化这么重要,那是不是应该把所有工具一次性装齐?
并不是。
一个只有两三页的活动页面,可能不需要 Monorepo、微前端、复杂状态管理和完整组件平台。
一个三人团队维护的单体后台,也未必需要先花两周设计一套"支持未来二十个项目"的基础架构。
工程化方案本身也有成本:
- 学习成本;
- 配置成本;
- 维护成本;
- 升级成本;
- 团队沟通成本;
- 构建时间成本。
判断一个工具是否值得引入,可以先问四个问题:
text
1. 当前项目是否真的存在这个问题?
2. 这个问题出现得是否足够频繁?
3. 工具带来的收益是否高于维护成本?
4. 团队成员能否理解并长期使用它?
例如项目只有一个应用,却为了"显得工程化"引入复杂 Monorepo,可能会增加额外配置。
但如果团队已经维护多个共享组件、多个应用和多个独立发布单元,Monorepo 就可能明显降低依赖同步和版本管理成本。
工具没有绝对先进或落后。
只有是否适合当前问题。
工程化不是把项目装修成工具展览馆,而是持续清理开发过程中的摩擦。
十、已经很乱的项目,应该从哪里开始
如果一个项目已经运行了很久,目录、依赖和流程都有些混乱,通常不适合突然宣布:
下周停止业务开发,我们进行一次彻底重构。
这种计划听起来很有决心,最后往往会变成一条长期存在、谁也不敢合并的重构分支。
更稳妥的方式,是从最频繁、最影响交付的问题开始。
场景一:依赖安装经常不一致
先做这些:
text
统一包管理器
→ 提交并维护锁文件
→ 固定 Node.js 和包管理器版本
→ CI 使用冻结锁文件安装
场景二:公共组件修改风险很高
先做这些:
text
梳理高频公共组件
→ 记录主要使用场景
→ 移除明显的业务逻辑
→ 为核心行为补测试
→ 明确变更规则
场景三:页面状态越来越混乱
先区分:
text
组件内部临时状态
页面查询状态
URL 可分享状态
跨页面全局状态
服务端数据状态
不是所有状态都应该放进 Pinia,也不是所有筛选条件都应该藏在组件内部。
场景四:发布经常出问题
先做这些:
text
统一构建命令
→ 明确环境变量
→ 在 CI 中执行完整构建
→ 保存构建产物
→ 记录版本与提交关系
工程化改造最有效的方式,通常不是一次把所有问题解决,而是每解决一个高频摩擦,就把它沉淀成团队可以重复执行的规则或自动化流程。
十一、如何判断项目是否需要加强工程化
可以用下面这份检查表做一次简单盘点。
开发环境
- 新成员能否根据 README 在半天内运行项目?
- Node.js 和包管理器版本是否明确?
- 依赖安装结果能否稳定复现?
- 环境变量是否有示例和说明?
代码组织
- 新需求到来时,能否快速找到对应模块?
- 业务代码和通用能力是否有清晰边界?
- 公共组件是否混入大量具体业务规则?
- 模块依赖方向是否基本稳定?
质量检查
- 类型错误能否在合并前发现?
- 核心业务逻辑是否有必要的测试?
- CI 失败信息是否足够明确?
- 检查流程是否稳定且不会过度拖慢开发?
构建发布
- 本地和 CI 是否执行相同构建命令?
- 每次发布是否对应明确的提交和产物?
- 线上错误是否带有版本信息?
- 出现严重问题时是否能够快速回滚?
这份检查表不是评分表。
没有全部做到,也不代表项目不合格。
更有价值的用法是找出:
哪一项正在让团队反复浪费时间?
工程化应该优先解决这个问题,而不是先挑一个最近很火的工具安装进去。
十二、小结
前端项目越改越慢,通常不只是因为代码数量增加了。
更深层的原因是:
- 模块边界逐渐模糊;
- 依赖关系难以追踪;
- 开发环境无法稳定复现;
- 问题发现得太晚;
- 构建发布缺少记录;
- 线上结果难以回溯。
前端工程化做的,就是通过目录约定、依赖管理、类型系统、自动化检查、构建流程和监控体系,让每一次改动更容易理解、更容易验证,也更容易安全地交付。
它不是某个具体工具,也不是一套固定答案。
Vite、pnpm、TypeScript、ESLint、Monorepo 和 CI 都只是手段。
真正的判断标准始终是:
它有没有降低项目的变更成本?
下一次再遇到"明明只是改了两行代码,为什么花了半天"的情况,可以先别急着怪需求、怪框架或者怪昨天写代码的自己。
顺着这次改动的链路看一遍:
text
入口是否难找?
边界是否模糊?
依赖是否混乱?
环境是否不一致?
检查是否太晚?
发布是否不可追踪?
找到最耗时间的那一环,工程化工作通常就可以从那里开始。
下一篇准备聊聊前端依赖管理:
package.json、锁文件和 pnpm 到底分别解决了什么,以及"我电脑可以运行"为什么不能算一个可靠结论。