Skip to content

模块化与构建工具演进 ​

看在前面 ​

前端工程化史,是一部**"从没有 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 = { ... }            // 导出在编译期可枚举
维度CommonJSESM
加载时机运行时(可在分支里)编译期确定依赖图
导出语义值拷贝动态绑定(模块内变量变了,导入方看得到)
顶层 thismodule.exportsundefined
循环依赖拿到半成品对象拿到活绑定,定义时才可读
静态分析不可可以 → 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 秒版本 ​

  1. 演进线:全局命名空间 → IIFE 闭包 → CommonJS(Node 同步) / AMD(浏览器异步) → ESM 一统。UMD 是过渡适配器。
  2. ESM 本质:静态依赖图 + 动态绑定(vs CJS 运行时加载 + 值拷贝)。静态性是 tree-shaking 与代码分割的授权书。
  3. 构建工具:Webpack 全量 bundle 换全能。Vite dev 用浏览器原生 ESM + esbuild 预构建裸依赖、按需编译,prod 交 Rollup。快在"不打包"与 Go 编译器。
  4. 转译:语法降级与 polyfill 是两回事,target 由 browserslist 决定(≈ 最低 JDK 版本);vue-tsc 管类型不管降级。
  5. 依赖:锁文件钉死整棵树(≈ dependencyManagement);pnpm 符号链接结构治幽灵依赖与磁盘爆炸。

开场模板:

"我把前端工程化当 Java 构建史的压缩重放。ESM 之于 CommonJS 就是静态模块之于运行时 require。所以 tree-shaking 本质是 ESM 授权的。Vite 的快不是玄学,是 dev 不打包——浏览器自己就是 ESM 加载器,esbuild 只管现场转译和预构建依赖。"

2026-10 新增(F 轨前端 · 首批)。