前端工程化不是把 Webpack 换成 Vite 就大功告成。当项目从”一个仓库一个应用”长成”十几个应用、共享几十个组件库”时,真正的难题是依赖怎么复用、构建怎么不重复、团队规范怎么落地。本文把前端工程化从工具演进讲到组织层面:用 pnpm workspace 把多包管起来、用 Turborepo 编排任务、用模块联邦打通微前端,再用 CI 缓存和提交规范把效率与质量锁死。想看构建工具本身的取舍,可以先读我们的前端构建工具演进:Webpack → Vite → Rspack 横评。
一、为什么需要工程化
单应用时期,npm install 一份依赖、跑一个 dev server 就够了。但业务一旦膨胀,你会发现三个痛点:依赖重复(每个项目都装一份 lodash、axios,磁盘与版本都失控)、构建重复(改一个公共组件,十个应用全部重打包)、规范飘忽(有人用 Tab、有人用双引号,PR 里一半是在吵格式)。前端工程化要解决的,正是”多仓库协作时的复用、提速与一致性”。
它和性能优化是两条线但互相成就:规范让代码可维护,而前端性能优化里提到的首屏与 Lighthouse 指标,也需要统一的构建链路来保障。工程化做得好,性能优化才能规模化落地。换句话说,工程化是”让对的事自动发生”的体系,而不是某几个工具的集合。
二、用 pnpm Workspace 搭 Monorepo
Monorepo 把多个相关项目放进一个仓库,靠”软链接”共享本地包,而不是发到 npm 再装回来。pnpm 的 workspace 是最轻量的实现,一个配置文件就搞定:
// pnpm-workspace.yaml:声明哪些目录是 workspace 成员
packages:
- "packages/*" # 公共组件库、工具函数、SDK
- "apps/*" # 各业务应用(web-admin / web-h5 / web- docs)
目录结构建议按”packages 放可复用资产、apps 放消费方”切分,本地包直接通过 "@org/ui": "workspace:*" 引用,pnpm 会自动软链,改一处所有引用方即时生效:
// apps/web-admin/package.json
{
"dependencies": {
"@org/ui": "workspace:*",
"@org/utils": "workspace:*"
}
}
// 安装与过滤执行(只装依赖 / 只跑某个应用)
pnpm install
pnpm --filter @org/web-admin dev
2.1 为什么是 pnpm 而不是 npm/yarn
此外,pnpm 的磁盘占用优势在 CI 机器上更明显:几十个 runner 反复 checkout 安装,全局 store 能直接命中已下载的包版本,单任务安装时间常常从分钟级降到十几秒。对按分钟计费的云构建机来说,这是实打实的成本节省。
pnpm 用”全局内容寻址存储 + 硬链接”避免重复下载,node_modules 体积通常只有 npm 的 1/2 甚至更少;严格的依赖隔离还会直接报错”未声明就 import”,逼你把依赖写清楚,告别幽灵依赖。这对多人协作尤其重要。
三、用 Turborepo 编排任务
Monorepo 里最怕”改一行,全量构建”。Turborepo 用任务依赖图 + 缓存解决:它知道 build 依赖各自 package 的 build,只重跑受影响的链路,并缓存输出,命中就秒回。
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"], // 先构建依赖的上游包
"outputs": ["dist/**"]
},
"test": { "dependsOn": ["^build"] },
"dev": { "cache": false, "persistent": true }
}
}
// 只构建受影响的应用,其余走缓存
pnpm turbo run build --filter=@org/web-admin
配合 CI 时,把 .turbo 缓存目录上传到对象存储,第二次构建往往只剩”拉代码 + 跑命令”,省下大量机器时间。
四、用模块联邦打通微前端
当多个应用要共享同一块”购物车组件”又不想整合成一个巨无霸时,模块联邦(Module Federation)让应用之间在运行时互相暴露/消费模块,各自独立部署、各自上线:
// app-a (生产者):暴露 Cart 组件
new ModuleFederationPlugin({
name: "app_a",
filename: "remoteEntry.js",
exposes: { "./Cart": "./src/components/Cart" }
})
// app-b (消费者):直接引用对方运行时模块
new ModuleFederationPlugin({
name: "app_b",
remotes: { app_a: "app_a@https://app-a.example.com/remoteEntry.js" }
})
模块联邦和 Monorepo 是互补关系:Monorepo 解决”开发期代码复用”,模块联邦解决”运行期独立部署的应用间复用”。大型中台通常两者并用。需要特别注意的是共享依赖版本:用 shared: { react: { singleton: true } } 强制只加载一份 React,否则两个应用各自带一份运行时,会出现”事件不互通、状态不同步”的经典坑。
五、CI 提速:缓存与并行
工程化最终要落到流水线。把本地验证(lint / test / build)搬进 CI,并靠缓存避免重复劳动。这里正好复用我们讲过的GitHub Actions 实战的能力:
# .github/workflows/ci.yml 关键片段
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Build affected apps
run: pnpm turbo run build --filter=...[HEAD~1] # 只构建本次提交影响的包
缓存 pnpm store + 只构建 diff 影响的包,是 Monorepo CI 提速的两个最大杠杆。再叠加 Turborepo 的远程缓存,CI 时间可以压到分钟级。
六、规范化:让团队不靠自觉
工程化的一半是”人”的规范。用工具把约定固化,比写在 wiki 里有效十倍:
| 工具 | 职责 | 解决的问题 |
|---|---|---|
| ESLint | 代码静态检查与自动修复 | 风格统一、常见 bug 拦截 |
| Prettier | 格式化(与 ESLint 分工) | 告别”该用 Tab 还是空格”的争论 |
| commitlint | 约定式提交(Conventional Commits) | commit message 可生成 changelog |
| husky + lint-staged | 提交前只校验改动文件 | 把关左移到本地,不污染远端 |
// package.json:提交前只对暂存文件跑 lint
{
"lint-staged": {
"*.{ts,tsx,js}": ["eslint --fix", "prettier --write"]
}
}
// commitlint.config.js:强制 type(scope): subject 格式
module.exports = { extends: ["@commitlint/config-conventional"] }
配上 TypeScript 的严格类型,团队代码质量会有质变,类型体操的进阶用法可参考TypeScript 高级类型体操。规范不只约束”代码长相”,也约束”设计语言”:把颜色、间距、圆角抽成 Design Token(如 --color-primary),再由组件库消费,能避免十几个应用里”主色有八种红”的视觉分裂,也让暗黑模式切换变成改一份变量而不是改全站样式。
七、常见坑与规避
- 幽灵依赖:npm/yarn 的扁平 node_modules 会”借”到没声明的包,换 pnpm 严格模式能在本地直接暴露问题。
- 循环依赖:package A 引用 B、B 又引用 A,Turborepo 会报错并提示拓扑环,早拆早轻松。
- 缓存击穿:CI 缓存 key 只按 lock 文件算,改了源码却命中旧缓存——记得把源码 hash 也纳入 key,或显式清缓存重跑。
- 模块联邦版本错配:生产与消费双方的依赖大版本不一致会运行时报错,约定”共享依赖单版本”用
shared字段兜底。
八、小结
前端工程化的本质,是用”结构 + 工具 + 规范”把多项目协作的成本压下去。落地路线很清晰:用 pnpm workspace 把多包收进一个仓库、用 Turborepo 做任务编排与缓存只构建受影响部分、用 模块联邦 在运行期复用独立部署的应用模块,再用 CI 缓存 + commitlint/husky 把提速与规范锁死。记住三件事——能软链复用的绝不发 npm 包、CI 永远缓存 store 且只构建 diff、规范用工具强制而非靠自觉。工程化做到位,性能优化与团队协作才能真正规模化。




