Appearance
模块化与构建工具演进
看在前面
前端工程化史,是一部**"从没有 classpath 的蛮荒,到发明模块系统,再到追求秒级增量"**的历史。
Java 工程师视角看这条线会感到舒适。script 标签时代 ≈ 把所有 .java 文件平铺进一个目录靠类名区分。CommonJS ≈ Maven 出现前的手工 jar 管理。ESM ≈ JPMS 之前的 Java 模块约定。锁文件 ≈ 固定版本的 dependencyManagement。Vite 的 dev server ≈ 热替换的增量编译器(对比全量 mvn package)。
本篇按"模块化演进 → ESM 详解 → 构建工具 → 转译 → 依赖管理"推进。素材:star-site-frontend(Vite 8 + vue-tsc + 按需插件)与 star-site-app(pnpm-lock.yaml 可考)。风格对齐 typescript/从Java视角看TypeScript-开篇,产物优化见 前端性能优化体系。
模块化前史:从全局变量到 IIFE
面试官:script 标签时代到底有什么问题?
<script src="jquery.js"></script> <!-- 往 window 挂 $ -->
<script src="utils.js"></script> <!-- 往 window 挂 utils -->
<script src="app.js"></script> <!-- 依赖前两个,靠加载顺序隐式保证 -->三大困难。命名冲突:全局对象是唯一通信介质,两个库抢 $ 就得靠 noConflict 让位。依赖不透明:依赖关系藏在"引入顺序"里,顺序错了直接运行时爆炸,没有任何静态检查。加载即执行:无法按需、无法树摇。
IIFE(立即执行函数)是第一个民间补丁——用函数作用域造出私有命名空间。jQuery = (function(){ ... return api })(),顺手发明了"模块=闭包"的范式。但依赖声明依然靠顺序,问题的根(没有显式的依赖语法)没动。
CommonJS 与 AMD:同步与异步的两条路
CommonJS(Node 的选择):require 是同步函数调用,因为 Node 读本地文件快,同步无妨。三个决定性特征。运行时加载:require 可以写在 if 里,条件加载。值拷贝:导出的是值的快照,模块内部后续变化不反映到已导入方。模块即对象:module.exports 导出任意对象,无法静态分析结构。
AMD(浏览器的补救):HTTP 请求慢且必须异步。于是 define(['dep1'], factory) 把依赖前置声明、回调拿结果——RequireJS 时代的产物, callback hell 的近亲。UMD 则是"两头讨好"的适配器模板(判断环境选 CommonJS 或 AMD)。
追问:为什么"运行时加载 + 值拷贝"后来成了原罪?
因为静态分析无从下手:require 写在 if 里,构建器不执行代码就不知道你用了什么。tree-shaking、代码分割、循环依赖检测全部受限。这直接催生了 ESM 的设计原则:语法必须是静态的。
ESM:静态结构换来的超能力
面试官:ESM 和 CommonJS 的本质区别?
js
import { listReports } from '@/api/health' // 顶层静态声明,不可放进 if
export const PLAN_STATUS = { ... } // 导出在编译期可枚举| 维度 | CommonJS | ESM |
|---|---|---|
| 加载时机 | 运行时(可在分支里) | 编译期确定依赖图 |
| 导出语义 | 值拷贝 | 动态绑定(模块内变量变了,导入方看得到) |
| 顶层 this | module.exports | undefined |
| 循环依赖 | 拿到半成品对象 | 拿到活绑定,定义时才可读 |
| 静态分析 | 不可 | 可以 → tree-shaking / code-split / 提示补全 |
动态绑定的经典例题。模块 A 导出 let count = 0 且内部 setInterval 改它。B 里 import { count } 每次读到新值。CommonJS 里 count 永远是导入那刻的快照。静态结构是构建工具一切魔法的授权书(这也是性能篇 tree-shaking 一节的原理出处)。浏览器侧,<script type="module"> 现已原生支持 ESM,node 侧 "type": "module" 启用(star-site-frontend/package.json 里就这么声明)。
Webpack vs Vite:bundle 一切 vs 原生 ESM
面试官:Vite 为什么快?Webpack 慢在哪?
Webpack dev:启动即全量打包 Vite dev:按需编译,不打包
源码(N+模块) ─▶ bundle.js ─▶ 浏览器 源码 ─▶ dev server(原生 ESM) ─▶ 浏览器
项目越大启动越慢(分钟级) 首屏只编译请求到的模块(毫秒级)
改一行 → 增量重打包(受 bundle 规模拖累) 改一行 → 单模块失效(HMR 边界内秒级)Webpack 的哲学是"打包一切":dev 也把全部模块 bundle 成大文件,模块图全在内存,启动成本随项目线性涨。换来的是极强的资源处理能力(任意文件类型经 loader 进模块图)与成熟的生态。
Vite 的哲学是"浏览器本来就是 ESM 运行时":dev 阶段不打包。浏览器 import 到哪个模块,dev server 才现场编译哪个(esbuild 转译,Go 写的,比 JS 写的编译器快一个量级)。HMR 只需让改动模块失效。唯一的坑是裸依赖(import axios from 'axios' 是几百个文件的 ESM 瀑布)。依赖预构建解决:启动时 esbuild 把依赖打成单文件 ESM 并强缓存(node_modules/.vite)。页面依赖请求从此是 O(1)。
prod 换引擎:Vite 生产构建用 Rollup(不是 dev 的 esbuild)。因为原生 ESM 在生产有瀑布请求与兼容性问题,必须 code-split/压缩/tree-shake 成静态产物。所以准确表述是:Vite = dev 用原生 ESM + esbuild 预构建,prod 用 Rollup(rolldown 融合两者是进行时,可作追问储备)。面试加分点:Webpack 也在补课(cache、esbuild-loader、unbundle 模式),选型差异更多在 dev 体验哲学,而非"谁能做 prod"。
真实配置走读:star-site-frontend/vite.config.ts
ts
export default defineConfig({
plugins: [
vue(), // 单文件组件编译
AutoImport({ resolvers: [ElementPlusResolver()] }), // 按需自动导入(产物瘦身)
Components({ resolvers: [ElementPlusResolver()] }),
],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, // ≈ Maven 目录约定之外的 import 缩写
server: {
port: 5173,
proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } }, // dev 反向代理治 CORS
},
})三处值得讲。alias 让 @/api/health 直达 src 目录,是"路径治理"而不是语法糖。dev proxy 把 /api 转发到后端 8080——开发期同源免 CORS,生产由网关/nginx 做同一件事,前端代码对代理无感知。这解释了 demo2 里 H5 与小程序 BASE_URL 条件编译的分叉:小程序没有 dev server 可代理。plugins 里的按需解析在性能篇已算过账。一套配置同时回答"dev 怎么联调""产物怎么瘦身""路径怎么管",面试拿来即用。
HMR:热更新的最小失效边界
追问:改一行代码为什么浏览器不刷新就生效?
HMR(Hot Module Replacement)三部曲。文件变更 → dev server 经 WebSocket 推送变更模块列表。→ 浏览器端 runtime 沿 import 关系向上找到 HMR 边界(如 Vue 组件自带 import.meta.hot.accept)。边界内替换模块并重执行。找不到边界(比如改了入口或配置)才整页刷新。
失效是模块级的,不是页面级的——组件状态尽量保留,这就是"改样式组件不闪、改 store 整页刷"的原理。对照 Java:这相当于 Arthas 热替换方法体的加强版,DevTools/JRebel 做梦的秒级迭代体验,前端十年前就有了。
babel 与 target:前端的 javac --release
Babel(或 esbuild/SWC)做语法降级:箭头函数、可选链编译成 ES5 兼容写法。目标由 browserslist 决定(.browserslistrc 或 package.json 字段),target: '> 0.5%, last 2 versions, not dead' 这类查询就是前端版的"最低支持到 JDK 几"。一个关键区分,面试常考:语法降级(syntax transform)与 API 垫片(polyfill)是两回事。?. 编译器能改写。但 Promise/Array.includes 是运行时缺的函数,得 core-js polyfill 注入;现代 Vite 的 build.target 只管语法,polyfill 策略要自己定(core-js 或 Polyfill.io 服务)。还有 TS 的角色分工:vue-tsc 管类型擦除与检查,不降语法;star-site-frontend 的 build 脚本 vue-tsc -b && vite build 就是"类型关卡 + esbuild/Rollup 产物"两段式。
npm 与 pnpm:依赖地狱与锁文件
面试官:为什么要有锁文件?pnpm 比 npm 好在哪?
npm 早期允许版本区间依赖(^1.2.0),两次安装解析出不同小版本——"我这能跑"的经典来源。锁文件(package-lock.json / pnpm-lock.yaml)把整棵依赖树的精确版本+完整性哈希钉死。等价于 Maven 的 dependencyManagement + 释放(release)固定。star-site-app 用 pnpm(根下 pnpm-lock.yaml 可考),它解决 npm 的两个结构病:
npm(扁平化 node_modules): pnpm(符号链接 + 内容寻址存储):
node_modules/ node_modules/
├─ express ├─ .pnpm/ ← 真实文件全局内容寻址存储
├─ (依赖被提升上来,幽灵依赖) │ └─ express@4.18.1/node_modules/express
└─ my-utils └─ express -> .pnpm/... (符号链接)
未声明的包也能 require → 爆雷 只链接 package.json 声明过的依赖
同包多版本复制多份 → 磁盘爆炸 同包全局仅存一份,硬链接复用幽灵依赖(phantom dependencies):扁平化把间接依赖提升到顶层,代码里能 require 没声明的包。哪天上游不再依赖它,你的构建原地爆炸。pnpm 的非扁平结构从物理上杜绝,还省磁盘(Monorepo 下收益巨大)。对照后端一句话:npm 扁平化像"把所有传递依赖塞进 classpath 根目录",pnpm 像"严格 classpath 隔离 + 构件仓库去重"。
monorepo:workspace 就是多模块 Maven
前端版"一个仓库多工程"用 workspace 协议("workspace: *")声明内部依赖。pnpm 用符号链接把它接到本地目录——改动即生效,不用发包:
star-site/
├─ pnpm-workspace.yaml ← 声明 packages: ['apps/*', 'packages/*']
├─ apps/frontend ← 依赖 "@st/types": "workspace:*"
├─ apps/miniapp
└─ packages/types ← 共享契约类型(呼应 TS 篇的"共享类型包"治理终态)与 Maven 多模块(parent + modules + 内部 <dependency> 同版本号联动)完全同构。统一的版本仲裁在根、内部依赖走本地链接、CI 一把构建。前端比 Java 多的收益是"跨端共享一个 types 包"零发包成本——这正是 TS 开篇里契约治理的第三级方案。
依赖安全与审计
工程化收尾的加分项。npm audit / pnpm audit 查已知 CVE(对照 OWASP dependency-check)。锁文件完整性哈希防篡改(供应链攻击,typosquatting 恶意仿名包是 npm 生态特产)。CI 里开 --frozen-lockfile 禁止悄悄改依赖树。锁文件进版本库 + CI 冻结安装这对措施,等价于后端"版本锁进 release 分支"。
面试官视角小结:30 秒版本
- 演进线:全局命名空间 → IIFE 闭包 → CommonJS(Node 同步) / AMD(浏览器异步) → ESM 一统。UMD 是过渡适配器。
- ESM 本质:静态依赖图 + 动态绑定(vs CJS 运行时加载 + 值拷贝)。静态性是 tree-shaking 与代码分割的授权书。
- 构建工具:Webpack 全量 bundle 换全能。Vite dev 用浏览器原生 ESM + esbuild 预构建裸依赖、按需编译,prod 交 Rollup。快在"不打包"与 Go 编译器。
- 转译:语法降级与 polyfill 是两回事,target 由 browserslist 决定(≈ 最低 JDK 版本);vue-tsc 管类型不管降级。
- 依赖:锁文件钉死整棵树(≈ dependencyManagement);pnpm 符号链接结构治幽灵依赖与磁盘爆炸。
开场模板:
"我把前端工程化当 Java 构建史的压缩重放。ESM 之于 CommonJS 就是静态模块之于运行时 require。所以 tree-shaking 本质是 ESM 授权的。Vite 的快不是玄学,是 dev 不打包——浏览器自己就是 ESM 加载器,esbuild 只管现场转译和预构建依赖。"
2026-10 新增(F 轨前端 · 首批)。