包管理与前端工程化:从 Maven 到 pnpm
为什么这节课对 Java 开发者特别重要
前端工程化是后端开发者最容易卡壳的地方。同一份依赖,npm、yarn、pnpm 装出三种目录结构;package.json 里的字段多得让人心慌;Vite、Webpack、esbuild 各自扮演什么角色也不清楚。
这篇笔记用 Maven 做锚点,把前端工具链梳清楚。
1. package.json:前端的 pom.xml
每个前端项目根目录都有一个 package.json,它相当于 Maven 的 pom.xml + Spring 的 application.yml + 启动脚本的合体。
看项目根目录的 package.json:
{
"name": "in4vue",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --fix"
},
"dependencies": {
"vue": "^3.5.32",
"vue-router": "^5.0.6",
"pinia": "^3.0.4"
},
"devDependencies": {
"vite": "^8.0.10",
"typescript": "~6.0.2",
"eslint": "^10.3.0"
}
}
Java 对照:
| 字段 | Maven 对照 |
|---|---|
name / version |
<artifactId> / <version> |
type: "module" |
无,声明用 ES Module 还是 CommonJS |
scripts |
有点像 Maven 的 <build> 插件 goal,但更像 npm 自己的任务运行器 |
dependencies |
<dependencies> 里 <scope>compile</scope> |
devDependencies |
<scope>test</scope> 或 provided,只开发时用 |
2. 版本号前缀 ^ 和 ~:语义化版本
"vue": "^3.5.32" // 允许 3.x.x,但主版本号锁死
"typescript": "~6.0.2" // 允许 6.0.x,主+次都锁死
"axios": "1.16.0" // 完全锁死
规则:
^X.Y.Z:兼容主版本。^3.5.32允许装3.5.33 / 3.6.0 / 3.9.9,但不装 4.x~X.Y.Z:兼容补丁。~6.0.2允许装6.0.3 / 6.0.9,但不装6.1.xX.Y.Z:完全锁定*或latest:永远装最新(几乎没人这么写,不可控)
Java 对照:Maven 3.x 才支持 [1.0, 2.0) 这样的版本范围。前端几乎所有项目都用 ^ 范围 + lockfile 配合。
3. dependencies vs devDependencies vs peerDependencies
"dependencies": {
"vue": "^3.5.32" // 运行时需要,部署产物里会用到
},
"devDependencies": {
"vite": "^8.0.10", // 只开发时用的工具,构建完就不需要了
"typescript": "~6.0.2" // TS 编译成 JS 后就没它事了
},
"peerDependencies": {
"vue": "^3.0.0" // "我依赖宿主项目里的 Vue,请你自己装"
}
Java 对照:
| 前端 | Maven |
|---|---|
dependencies |
<scope>compile</scope>(默认) |
devDependencies |
<scope>test</scope> 或 <scope>provided</scope> |
peerDependencies |
<scope>provided</scope> 且让使用者自己提供(类似 Servlet 依赖 Tomcat 自带) |
什么时候放哪里:
- 组件库、工具库、框架 →
dependencies - 构建工具(Vite/Webpack)、类型(@types/*)、测试(Vitest)、linter →
devDependencies - 如果你在写库(不是应用),需要宿主提供 Vue 本身 →
peerDependencies
4. node_modules 和三种包管理器
前端有三个主流包管理器:npm / yarn / pnpm。它们做的事一样(下载依赖到 node_modules/),但实现差异巨大。
npm / yarn:扁平化 node_modules
默认会把所有依赖的依赖都提升到顶层 node_modules/,结构大致如下:
node_modules/
├── vue/ # 直接依赖
├── vue-router/ # 直接依赖
├── @vue/reactivity/ # vue 的依赖也被提升到这里
├── @vue/shared/ # 同上
└── ...一堆未声明的依赖
问题:
- 幻影依赖:你代码里
import { something } from '@vue/shared'能跑起来,但你没在package.json里声明它,哪天 Vue 换了依赖你就炸 - 占空间:100 个项目各自装一份 Vue,硬盘 100 份
- 装得慢:大项目
node_modules轻松上 GB
pnpm:硬链接 + 符号链接(项目在用)
pnpm 做法:
- 全局存放一份包:
~/.pnpm-store/ - 项目
node_modules/里放符号链接指向全局 store - 只有你声明的直接依赖才在
node_modules/顶层可见
node_modules/
├── vue → ~/.pnpm-store/vue@3.5.32 # 直接依赖才可见
├── vue-router → ...
└── .pnpm/ # 传递依赖都藏在这里
├── @vue+reactivity@3.5.34/node_modules/@vue/reactivity
└── ...
好处:
- 严格:你没声明的包,import 时直接报错
- 省空间:全局 store 共享,10 个项目共享一份 Vue
- 快:硬链接创建几乎零成本
Java 对照:
- npm/yarn 扁平 ≈ Maven 直接把所有 jar 塞到
WEB-INF/lib(没这问题,但类比而言) - pnpm 硬链接 ≈ Maven 的
~/.m2/repository本地仓库 + 项目只引用 - 本项目就用 pnpm,
CLAUDE.md里规定的
5. lockfile:锁死完整依赖树
^3.5.32 允许装任何 3.x.x,那不同时间/不同机器装的版本可能不一样。lockfile 就是锁死"本次实际装的版本号完整列表"的文件。
| 包管理器 | lockfile |
|---|---|
| npm | package-lock.json |
| yarn | yarn.lock |
| pnpm | pnpm-lock.yaml(本项目用这个) |
规则:
- 必须提交到 git(和 Maven 的
pom.xml一起提交) - 不要手动改它,让包管理器自己维护
- CI 构建时用
pnpm install --frozen-lockfile严格按 lockfile 装,一旦不一致就报错
Java 对照:Maven 默认没有 lockfile 机制(Maven 的 <version> 通常就是精确版本)。Gradle 有 gradle.lockfile 做类似的事。
6. npm scripts:类似 Maven 的 goal
package.json 里的 scripts 字段定义命令别名:
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --fix"
}
运行:
pnpm dev # 等于运行 vite
pnpm build # 等于运行 vue-tsc -b && vite build
pnpm lint # 等于运行 eslint . --fix
Java 对照:
| npm scripts | Maven lifecycle |
|---|---|
pnpm dev |
mvn spring-boot:run |
pnpm build |
mvn package |
pnpm test |
mvn test |
pnpm lint |
mvn checkstyle:check |
差别:
- Maven 的 goal 是插件定义好的,有固定生命周期(validate → compile → test → package → install)
- npm scripts 是你自己起的名,运行任意 shell 命令。更灵活但也更散
约定俗成的脚本名(大家都这么叫):
dev:启动开发服务器build:构建生产版本test:运行测试lint:代码检查preview:预览 build 产物
7. 构建工具:Vite / Webpack / esbuild
前端代码写完不能直接给浏览器用,要先打包:合并文件、转译 TS→JS、压缩、替换环境变量……
为什么要打包
- 浏览器不支持 TS,要转成 JS
- Vue 的
.vue文件是特殊格式,浏览器不认 - 几百个模块全加载太慢,要合并
- 生产环境需要代码压缩
Java 对照:类似把源码编译打包成 jar/war,但前端打包产物是 JS + CSS + HTML 的静态文件集合。
三个主流方案
| 工具 | 特点 | 场景 |
|---|---|---|
| Webpack | 老牌王者,生态最全,配置复杂,慢 | 老项目、复杂定制 |
| Vite(本项目用) | 开发模式用 ES Module 原生加载,秒启动;生产用 Rollup 打包 | 新项目首选 |
| esbuild | Go 写的,极快,但功能相对基础 | 工具库打包、Vite 底层就用它做 TS 转译 |
Vite 的魔法:开发模式下,你修改一个 .vue 文件,浏览器毫秒级更新,而且不需要重启 dev server。秘诀是利用浏览器原生的 ES Module,按需加载每个文件。
Vite 配置看一眼
项目里 vite.config.ts 的核心:
export default defineConfig({
plugins: [
vue(), // 支持 .vue 文件
tailwindcss(), // 支持 Tailwind v4
AutoImport({ imports: ['vue', 'vue-router'] }), // 自动 import
],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, // @ 指向 src
},
})
类比:相当于 Maven 的 <build> + 各种 plugin 配置,但写起来短得多。
8. import.meta.glob:Vite 特有的魔法
项目的 utils/notes.ts 里用了这个:
const rawNotes = import.meta.glob('@/notes/**/*.md', {
eager: true,
query: '?raw',
import: 'default',
})
它在构建时被 Vite 编译成一堆静态 import。效果:所有笔记 .md 文件在构建时被扫描、读取、打包进产物。
Java 对照:类似 Spring 的 @ComponentScan 或用 ClassLoader.getResources() 扫 classpath 下的资源文件。
9. 环境变量:多环境配置
Vite 约定:VITE_ 开头的环境变量会被注入到前端代码。
# .env.development
VITE_API_BASE_URL=http://localhost:8080
# .env.production
VITE_API_BASE_URL=https://api.example.com
代码里:
const url = import.meta.env.VITE_API_BASE_URL
Java 对照:
.env.development≈application-dev.yml.env.production≈application-prod.ymlVITE_前缀 ≈ Spring 的@Value("${VITE_xxx}")白名单
注意:不带 VITE_ 前缀的变量不会暴露给前端(防止误把 SECRET 打进前端产物)。
10. 常用命令清单
# 初始化新项目
pnpm create vite@latest my-app --template vue-ts
# 安装项目依赖(按 package.json + lockfile)
pnpm install
# 别名
pnpm i
# 加一个运行时依赖
pnpm add axios
# 加一个开发依赖
pnpm add -D eslint
# 加一个全局工具
pnpm add -g typescript
# 升级所有依赖(谨慎)
pnpm update
# 查看过时的依赖
pnpm outdated
# 运行 scripts
pnpm dev
pnpm build
pnpm run xxx # xxx 是自定义 script 名
# 清理
rm -rf node_modules
pnpm install
Java 对照:pnpm install ≈ mvn dependency:resolve,pnpm add X ≈ 在 pom.xml 里加 <dependency> 然后执行 resolve。
11. 踩过的坑
node_modules别进 git:它太大了,而且完全可重建。.gitignore必备- lockfile 必须进 git:不然每次装的版本都可能不同
- 不同包管理器 lockfile 不兼容:选定 pnpm 就全队用 pnpm,不要混用
type: "module"很重要:决定项目用 ESM (import) 还是 CommonJS (require)。现代项目全选modulepnpm add和pnpm install不一样:add装新包并更新package.json,install按package.json装- 镜像加速:国内直接
pnpm install有时候很慢,可以配淘宝镜像:pnpm config set registry https://registry.npmmirror.com
12. 原理深挖:ESM vs CommonJS(两套模块体系)
JS 历史上有两套模块系统并存,今天还在打架:
// CommonJS(CJS)—— Node.js 早期标准,require/module.exports
const fs = require('fs')
module.exports = { foo: 1 }
// ES Module(ESM)—— 语言标准,import/export
import fs from 'fs'
export const foo = 1
核心差异:
| 维度 | CommonJS | ES Module |
|---|---|---|
| 加载时机 | 运行时同步加载(require() 执行才加载) |
解析时静态加载(编译期就确定依赖图) |
| 导出语义 | 导出值的拷贝 | 导出值的绑定(引用) |
| 顶层 await | 不支持 | 支持 |
| 循环依赖 | 拿到部分加载的模块(容易出 undefined) | 拿到引用,绑定后会自动更新 |
| 文件后缀 | .cjs 或 package.json 没 type |
.mjs 或 package.json "type": "module" |
| 浏览器原生 | 不支持 | 支持(<script type="module">) |
关键差异:导出是值拷贝还是引用绑定?
// counter.cjs (CommonJS)
let count = 0
module.exports.count = count
module.exports.inc = () => count++
// 调用方
const m = require('./counter.cjs')
console.log(m.count) // 0
m.inc()
console.log(m.count) // 还是 0!因为 module.exports.count 拷贝的是 count 当时的值
// counter.mjs (ESM)
export let count = 0
export const inc = () => count++
// 调用方
import { count, inc } from './counter.mjs'
console.log(count) // 0
inc()
console.log(count) // 1(绑定了模块内的 count,自动同步)
这就是为什么 ESM 能做 tree-shaking 而 CJS 不能——ESM 的导入是静态可分析的、绑定是已知的,构建工具能在编译期算出"这个导出有没有人用"。CJS 的 require 可能写在 if 里、exports 可以动态赋值,根本没法静态分析。
Java 对照:CJS 像 Java 的 Class.forName() 反射加载——运行时按需。ESM 像 Java 的 import 声明——编译期固定,链接器(构建工具)能优化。
实战影响:
- 老库(lodash 4.x、moment)只导出 CJS,整包打进产物,体积大
- 新库(lodash-es、date-fns、Vue 3)有 ESM 版本,未用到的函数被摇掉
package.json的"exports"字段告诉 Node/打包器走哪份:"exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } }
13. 原理深挖:Vite 为什么开发模式快(不打包)
传统 Webpack 启动流程:
1. 从 entry 开始扫整个依赖图
2. 把所有模块串起来打成 bundle.js
3. 启 dev server
4. 你改一个文件 → 重新走部分流程 → 推送给浏览器
项目大了第一步就要几十秒。
Vite 的开发模式根本不打包:
1. 启 dev server,秒开
2. 浏览器请求 / → 返回 index.html
3. index.html 里 <script type="module" src="/src/main.ts">
4. 浏览器自己解析依赖图,按需向 dev server 请求每个 .ts/.vue 文件
5. dev server 收到请求 → esbuild 把这一个文件编译成 ESM → 返回
关键点:
- 利用浏览器原生 ESM——浏览器自己拉模块,dev server 只做"按需编译单个文件"
- 编译用 esbuild(Go 写的,比 tsc 快 20-100 倍)
- 第三方依赖(node_modules 里的)预打包成 ESM 缓存到
node_modules/.vite/deps/,避免每次都现编
浏览器 → "我要 main.ts"
← Vite: import xxx from 'vue' 改成 from '/node_modules/.vite/deps/vue.js'
浏览器 → "那我要 vue.js"
← Vite: 直接返回预打包好的文件
浏览器 → "我要 HomePage.vue"
← Vite: 把 .vue 拆成 script/template/style → esbuild 编译 → 返回 ESM
Java 对照:Webpack 像 Spring Boot fat jar——所有依赖打成一坨先启动;Vite 更像 IntelliJ 的 incremental compile + lazy load——只编译你正在用的那部分。
生产模式 Vite 还是要打包——浏览器并发 HTTP 数量有限,几百个小文件比一个 bundle 慢。生产用 Rollup 完整打包 + 压缩。
14. 原理深挖:HMR(热模块替换)怎么不刷新页面就更新
改一行 CSS、改一段 Vue 模板,浏览器不刷新但 UI 更新——这就是 HMR(Hot Module Replacement)。原理:
1. dev server 通过 WebSocket 和浏览器保持连接
2. 你改了 HomePage.vue
3. dev server: 检测到变化 → 推 ws 消息 { type: 'update', path: '/src/pages/HomePage.vue' }
4. 浏览器收到消息 → 动态 import 新版本的 HomePage.vue
5. Vue HMR runtime 把新组件替换掉旧组件实例,但保留组件 state(data/setup 变量)
关键设计:HMR 边界由模块自己声明。.vue 文件被 @vitejs/plugin-vue 自动注入了 HMR 处理代码。手写 HMR:
// 顶部
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// 模块自己更新时,替换旧的导出
})
import.meta.hot.dispose(() => {
// 旧模块即将被替换,做清理
})
}
为什么 state 能保留:Vue 的 HMR runtime 持有组件实例引用,新版本来了只换组件定义(render 函数、setup),旧实例的 reactive state 直接复用。
Java 对照:JRebel / Spring Boot DevTools 的"类替换"——但 JVM 层面只能改方法体不能改字段,HMR 是"应用层面"的智能替换。
15. 原理深挖:Tree-shaking 真正的工作方式
"未使用的代码被删掉"听起来简单,但实现一点都不轻:
// utils.js
export function used() { console.log('used') }
export function unused() { console.log('unused') }
// main.js
import { used } from './utils.js'
used()
打包工具要确认 unused 真的"完全没人用"才敢删。条件:
- 必须是 ESM——CJS 的
module.exports可以动态赋值,根本算不出 - 没有副作用——比如
import './polyfill'这种为副作用引入的,不能删 - 副作用标记——
package.json加"sideEffects": false告诉打包器"我所有文件都纯函数,放心删"{ "sideEffects": false, // 或精细标记 "sideEffects": ["./src/polyfills.js", "*.css"] } - 静态可分析——
import { foo as bar }行;const { foo } = await import('lib')不行(动态)
调试技巧:构建后看 dist/ 体积,怀疑没 tree-shake 干净时用 rollup-plugin-visualizer 出可视化报告。
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default {
plugins: [vue(), visualizer({ open: true })]
}
pnpm build 完后会弹出一张交互式热力图,每个模块占多少体积一目了然。
Java 对照:类似 ProGuard / R8 的 shrinking——按调用关系算可达性,不可达的类/方法删掉。但 ProGuard 可以分析 Java 字节码(很规范),JS 因为动态特性多得多,tree-shaking 比 Java 那套保守。
16. 原理深挖:Code Splitting 与 chunk 划分
vite build 出来不是一个 bundle.js,而是十几个 chunk:
dist/assets/
├── index-a3f2.js # 入口主 chunk
├── HomePage-x9c4.js # 路由懒加载产物
├── NotePage-b8d1.js # 路由懒加载产物
├── vendor-vue-7c8a.js # 第三方共享 chunk
└── vendor-element-plus-2a91.js
划分依据(Rollup 的算法,简化版):
- 每个动态
import()是一个新 chunk 的入口——路由懒加载就是这么实现的 - 被多个 chunk 共享的模块 会被抽到独立 vendor chunk,避免重复
- 手动配置通过
manualChunks:build: { rollupOptions: { output: { manualChunks: { 'vue-vendor': ['vue', 'vue-router', 'pinia'], 'ui-vendor': ['element-plus', '@element-plus/icons-vue'] } } } }
为什么要拆:
- 缓存效率:业务代码改了,第三方 vendor 没变 → vendor chunk 文件名 hash 不变 → 浏览器直接走缓存
- 首屏加载:首页只下载
index + vue-vendor + HomePage,不用一开始就拉 NotePage - 并行:HTTP/2 多路复用,多个小文件下载不比一个大文件慢
chunk 文件名的 hash:HomePage-x9c4.js 的 x9c4 是文件内容的 hash。内容变了 hash 变 → 强制浏览器重新拉。这是配合 Cache-Control: max-age=31536000 长期缓存的关键——文件改了名,缓存自动失效。
Java 对照:类似把一个 fat jar 拆成多个 jar + manifest 里写明依赖关系——但 fat jar 一般没人这么拆,因为 JVM 启动是一次性的;浏览器是流式加载,拆开收益巨大。
17. 原理深挖:node_modules 解析算法
import x from 'lodash' 浏览器/Node 怎么知道 lodash 在哪?
Node 的算法(简化):
1. 当前文件 → 找 ./node_modules/lodash/package.json
2. 没找到 → ../node_modules/lodash/package.json
3. 没找到 → ../../node_modules/lodash/package.json
4. 一直往上找到根目录都没有 → throw "Cannot find module"
找到 package.json 后看入口字段(按优先级):
{
"exports": { // ESM 时代的标准(最优先)
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"module": "./dist/index.mjs", // 老约定:ESM 入口
"main": "./dist/index.cjs" // 老约定:CJS 入口(默认)
}
pnpm 为什么能"严格":硬链接 + 符号链接的结构让 Node 的"逐层向上找"算法只能找到你声明的依赖。@vue/reactivity 没在你的 package.json 里 → node_modules/@vue/reactivity 不存在 → 解析失败。
浏览器里的 ESM 解析不一样——浏览器只认绝对路径或相对路径:
import x from 'lodash' // ❌ 浏览器不知道 lodash 是啥
import x from './lodash.js' // ✅
import x from 'https://cdn.skypack.dev/lodash' // ✅
Vite 在 dev server 里重写这种"裸模块名"为可解析的路径(/node_modules/.vite/deps/lodash.js)。这也是为什么浏览器能直接吃 Vite 出的产物。
Import Map 是浏览器的官方方案,让浏览器认裸模块名:
<script type="importmap">
{
"imports": {
"lodash": "/node_modules/lodash/index.js"
}
}
</script>
但 Vite 不依赖它(兼容性问题),自己重写了。
18. 原理深挖:source map 是怎么把压缩后的报错指回源码的
生产构建后 index-a3f2.js 是压缩到一行的混淆代码。出错时浏览器为什么能定位到 HomePage.vue:42 的某个变量?
Source Map 是个 JSON 文件 index-a3f2.js.map,长这样:
{
"version": 3,
"sources": ["src/pages/HomePage.vue"],
"names": ["filteredNotes", "searchQuery"],
"mappings": "AAAA;;EACE,IAAMA,gBAAa..."
}
mappings 是 VLQ 编码的"压缩后位置 → 源码位置"映射表。浏览器看到压缩文件末尾 //# sourceMappingURL=index-a3f2.js.map,自动加载 source map,DevTools 里就显示原始文件名和行号。
生产环境的取舍:
source-map:完整高质量 source map,最大但调试体验最好hidden-source-map:生成 source map 但产物里不引用(手动上传到 Sentry 等错误监控平台用)false:不生成,体积最小但线上报错堆栈只有混淆代码
Vite 默认生产不生成 source map,要开 build.sourcemap: true。接错误监控时必开 hidden —— 用户拿不到,但 Sentry 能解析。
Java 对照:类似 ProGuard 的 mapping.txt——也是把混淆后的类名/方法名映射回原始名字,崩溃日志通过 retrace 还原。
小结:心智模型
Java 世界 前端世界
────────────────── ──────────────────
pom.xml ↔ package.json
~/.m2/repository ↔ ~/.pnpm-store
target/ ↔ dist/
mvn install ↔ pnpm install
mvn package ↔ pnpm build
mvn spring-boot:run ↔ pnpm dev
application-{env}.yml ↔ .env.{mode}
Maven Central ↔ npm registry
<dependency> ↔ dependencies 字段
ProGuard mapping ↔ source map
JRebel 热替换 ↔ Vite HMR
fat jar ↔ bundle.js
分模块构建 ↔ code splitting / chunk
记住这张对照表,前端工程化的 80% 心智负担就解决了。剩下的 20% 是 Vite/Webpack 的具体配置,遇到了再查文档。
延伸阅读
- pnpm 官方文档 — 为什么选 pnpm 讲得最清楚
- Vite 官方指南 — 读完"功能"和"配置"两章就够日常用
- npm Docs: package.json — 字段说明大全
- Node.js 模块系统 — 想搞懂 ESM vs CJS 看这里
下一篇:进入第二阶段 —— Vue 3 模板语法与指令(v-if / v-for / v-bind / v-on)