深入理解 ES Modules 与 HTTP 协议对现代构建的影响

从模块化演进史讲起,厘清 ESM 与 CommonJS 的本质差异,以及 HTTP/2、HTTP/3 如何重塑打包策略。

模块化是前端工程的地基,但很多同学对 ESM 的理解停留在 import/export 语法层面。本文从历史与运行时两个维度,讲清楚 ESM 到底解决了什么问题,以及它如何与 HTTP 协议相互作用。

从 IIFE 到 ESM 的演进

JavaScript 最早没有模块系统,靠全局变量和 IIFE(立即执行函数)隔离作用域。随后社区先后出现了 CommonJS(Node 端)与 AMD(浏览器端),最终由 TC39 在 ES2015 标准中确立了原生 ESM。

关键的转折是 CommonJS 与 ESM 的语义差异:

  • CommonJS 是运行时、动态、同步的,require 可以在任意位置调用,导出的是一个可变对象的拷贝;
  • ESM 是编译期、静态、可静态分析的,import 必须位于顶层,导出的是只读的绑定引用。

正是「可静态分析」这一特性,让 tree-shaking 成为可能——构建工具能在编译期就确定哪些导出未被使用并剔除。

原生 ESM 与 HTTP 的相爱相杀

浏览器原生支持 ESM 后,出现了一个诱人的设想:不再打包,直接让浏览器按需请求模块。

但现实很骨感。一个大型应用可能有数千个模块,HTTP/1.1 下每个请求都要经历握手与队头阻塞(head-of-line blocking),冷加载会慢得不可接受。这正是 HTTP/2 多路复用与 HTTP/3(QUIC)要解决的问题。

即便如此,打包仍然是主流,原因在于:

  1. 请求粒度与缓存策略的平衡难以把握;
  2. 依赖链中的重复代码(如多版本的库)无法自动去重;
  3. 转译(TS、JSX)与压缩仍然需要构建步骤。

现代构建策略的分层

主流工具如今采用「入口级打包 + 按需分包」的折中方案:

  • 用动态 import() 做路由级代码分割;
  • 通过 manualChunks 或分包插件把第三方依赖拆成可独立缓存的 chunk;
  • 借助 HTTP/2 让 chunk 并行加载,缓解请求数压力。

结语

理解 ESM 的静态语义,才能真正理解 tree-shaking、代码分割与打包器设计。理解 HTTP 的演进,才能明白为什么「不打包」至今难以成为大规模生产实践。地基打牢,上层建筑才不会摇晃。