包管理与前端工程化:从 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"      // 完全锁死

规则

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 自带)

什么时候放哪里


4. node_modules 和三种包管理器

前端有三个主流包管理器:npm / yarn / pnpm。它们做的事一样(下载依赖到 node_modules/),但实现差异巨大。

npm / yarn:扁平化 node_modules

默认会把所有依赖的依赖都提升到顶层 node_modules/,结构大致如下:

node_modules/
├── vue/              # 直接依赖
├── vue-router/       # 直接依赖
├── @vue/reactivity/  # vue 的依赖也被提升到这里
├── @vue/shared/      # 同上
└── ...一堆未声明的依赖

问题

pnpm:硬链接 + 符号链接(项目在用)

pnpm 做法:

  1. 全局存放一份包:~/.pnpm-store/
  2. 项目 node_modules/ 里放符号链接指向全局 store
  3. 只有你声明的直接依赖才在 node_modules/ 顶层可见
node_modules/
├── vue → ~/.pnpm-store/vue@3.5.32       # 直接依赖才可见
├── vue-router → ...
└── .pnpm/                                # 传递依赖都藏在这里
    ├── @vue+reactivity@3.5.34/node_modules/@vue/reactivity
    └── ...

好处

Java 对照


5. lockfile:锁死完整依赖树

^3.5.32 允许装任何 3.x.x,那不同时间/不同机器装的版本可能不一样。lockfile 就是锁死"本次实际装的版本号完整列表"的文件。

包管理器 lockfile
npm package-lock.json
yarn yarn.lock
pnpm pnpm-lock.yaml(本项目用这个)

规则

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

差别

约定俗成的脚本名(大家都这么叫):


7. 构建工具:Vite / Webpack / esbuild

前端代码写完不能直接给浏览器用,要先打包:合并文件、转译 TS→JS、压缩、替换环境变量……

为什么要打包

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 对照

注意:不带 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 installmvn dependency:resolvepnpm add X ≈ 在 pom.xml 里加 <dependency> 然后执行 resolve。


11. 踩过的坑

  1. node_modules 别进 git:它太大了,而且完全可重建。.gitignore 必备
  2. lockfile 必须进 git:不然每次装的版本都可能不同
  3. 不同包管理器 lockfile 不兼容:选定 pnpm 就全队用 pnpm,不要混用
  4. type: "module" 很重要:决定项目用 ESM (import) 还是 CommonJS (require)。现代项目全选 module
  5. pnpm addpnpm install 不一样add 装新包并更新 package.jsoninstallpackage.json
  6. 镜像加速:国内直接 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) 拿到引用,绑定后会自动更新
文件后缀 .cjspackage.json 没 type .mjspackage.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 声明——编译期固定,链接器(构建工具)能优化。

实战影响


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 → 返回

关键点

浏览器 → "我要 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 真的"完全没人用"才敢删。条件:

  1. 必须是 ESM——CJS 的 module.exports 可以动态赋值,根本算不出
  2. 没有副作用——比如 import './polyfill' 这种为副作用引入的,不能删
  3. 副作用标记——package.json"sideEffects": false 告诉打包器"我所有文件都纯函数,放心删"
    {
      "sideEffects": false,
      // 或精细标记
      "sideEffects": ["./src/polyfills.js", "*.css"]
    }
    
  4. 静态可分析——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 的算法,简化版):

  1. 每个动态 import() 是一个新 chunk 的入口——路由懒加载就是这么实现的
  2. 被多个 chunk 共享的模块 会被抽到独立 vendor chunk,避免重复
  3. 手动配置通过 manualChunks
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            'vue-vendor': ['vue', 'vue-router', 'pinia'],
            'ui-vendor': ['element-plus', '@element-plus/icons-vue']
          }
        }
      }
    }
    

为什么要拆

chunk 文件名的 hashHomePage-x9c4.jsx9c4 是文件内容的 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 里就显示原始文件名和行号。

生产环境的取舍

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 的具体配置,遇到了再查文档。


延伸阅读


下一篇:进入第二阶段 —— Vue 3 模板语法与指令(v-if / v-for / v-bind / v-on)