JavaScript 模块化:从模块规范到打包原理

一、为什么需要模块化

JavaScript 早期没有官方模块系统。代码用 <script> 标签一个个堆上去,所有变量都挂在 window 上,问题很具体:

  • 全局变量互相污染,不同库的 utilshelper 随时命名冲突
  • 文件加载顺序完全靠手动排列,jQuery 必须在 app.js 之前,漏了就挂
  • 没有依赖声明,团队协作靠"口口相传"

随着 Web 应用变复杂,这套方式撑不住了,社区开始自己造模块系统。


二、社区模块方案:CJS、AMD、UMD

2.1 CommonJS:服务端的第一个方案

2009 年 Node.js 出现,它需要在服务端管理文件级别的模块,于是实现了 CommonJS 规范。

核心就三个东西:

js 复制代码
// 导出
module.exports = { add, sub }

// exports 是 module.exports 的快捷方式
exports.add = function(a, b) { return a + b }

// 导入
const math = require('./math')

一个常见的坑:

js 复制代码
// ❌ 断开了 exports 和 module.exports 的关联
exports = { add: function() {} }

// ✅ 整体替换要用 module.exports
module.exports = { add: function() {} }

原因是 Node.js 初始化模块时做了这件事:

js 复制代码
var module = { exports: {} }
var exports = module.exports  // 两个变量指向同一个对象

exports = {...}exports 指向了新对象,和 module.exports 断开。require() 只认 module.exports,所以拿到的是空对象。

Node.js 把每个文件包装成函数执行:

js 复制代码
(function(module, exports, require, __filename, __dirname) {
  // 你的代码
})

这就是为什么任何 CJS 文件里都能直接用 moduleexportsrequire------它们是包装函数传进来的参数,不是全局变量。

CommonJS 的问题:require() 是同步的。 服务端读磁盘是毫秒级,没问题。浏览器要走网络,同步等待 = 页面卡死,所以 CJS 不能直接用在浏览器。

2.2 AMD:专为浏览器设计的异步方案

为了解决浏览器异步加载的问题,社区提出了 AMD 规范,代表实现是 RequireJS。

js 复制代码
// 先声明依赖,加载完再回调执行
define(['jquery', 'underscore'], function($, _) {
  return {
    render: function() { ... }
  }
})

核心思路:先声明"我需要哪些依赖",加载器异步并行拉取,全部加载完再执行回调。解决了浏览器异步问题,但语法繁琐,嵌套深了不好维护。

2.3 UMD:库作者的万能适配器

如果你是开源库作者,你不知道用户在哪个环境------可能是 Node.js(CJS),可能是 RequireJS(AMD),可能是直接 <script> 标签引入(全局变量)。

UMD 的本质是一段运行时 if-else 检测代码:

js 复制代码
(function(global, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD 环境(RequireJS 暴露了 define 函数)
    define(factory)
  } else if (typeof module !== 'undefined' && module.exports) {
    // CJS 环境(Node.js 有 module.exports)
    module.exports = factory()
  } else {
    // 浏览器裸跑,挂到全局变量
    global.MyLib = factory()
  }
}(this, function() {
  // 库的实际代码
  return { ... }
}))

这里的 factory() 在手写 UMD 里通常就是库代码本身。如果这份 UMD 是 Webpack 这类打包器生成的,factory() 里面还会再进入打包器自己的模块运行时;这属于"打包产物的两层结构",后面再展开。

运行时判断当前是哪个环境,用对应的方式注册自己:

环境 typeof define typeof module 走哪条分支
RequireJS 'function' 无所谓 AMD
Node.js 'undefined' 'object' CJS
浏览器裸跑 'undefined' 'undefined' 全局变量

UMD 不是一种新格式,是把三种注册方式包在一起的胶水代码。


三、ES Modules:语言层面的标准

3.1 基本语法

2015 年 ES6 正式引入原生模块系统:

js 复制代码
// 导出
export default function add(a, b) { return a + b }
export function sub(a, b) { return a - b }

// 导入
import add from './math'
import { sub } from './math'

优势:

  • 静态分析,import/export 必须在顶层,打包工具可以做 tree-shaking
  • 浏览器原生支持(<script type="module">
  • 语言标准,不依赖运行时实现

但历史遗留库大量是 CJS/UMD,ESM 和 CJS 的互操作问题延续至今。

3.2 浏览器原生 ESM

现代浏览器已经原生支持 ESM:

html 复制代码
<script type="module" src="/src/index.js"></script>

模块脚本和普通脚本有几个明显区别:

  • 默认是严格模式
  • 顶层变量只属于当前模块,不会自动挂到 window
  • 默认延迟执行,行为接近 defer
  • 可以用静态 import/export 声明依赖
  • 也可以用动态 import() 按需加载模块
js 复制代码
// src/index.js
import { add } from './math.js'

console.log(add(1, 2))
js 复制代码
// src/math.js
export function add(a, b) {
  return a + b
}

浏览器会从入口文件开始,顺着 import 递归加载依赖,形成一张模块依赖图。

3.3 原生 ESM 的现实限制

原生 ESM 解决了语言层面的模块标准,但生产环境还有现实限制:

  • 每个模块都是一个网络请求,文件多了会有大量请求
  • 浏览器不认识 npm 包名解析,比如 import react from 'react'
  • 老旧浏览器不支持或支持不完整
  • 生产环境通常还需要压缩、拆包、缓存指纹、资源处理

所以浏览器支持 ESM 不等于前端项目就不需要打包工具。


四、为什么还需要打包器

Webpack、Rollup、Vite 这类工具解决的核心问题不是"发明模块规范",而是把开发阶段的模块依赖图加工成适合生产环境加载的产物。

4.1 生产环境需要解决的问题

它们通常负责:

  • 从入口文件开始分析 importrequire、动态 import(),构建完整依赖图
  • 解析 npm 包入口和路径别名
  • 兼容 ESM、CJS、UMD 等不同模块格式
  • 删除没有用到的导出,也就是 tree-shaking
  • 把代码拆成多个 chunk,实现按需加载
  • 给输出文件加 hash,方便长期缓存
  • 把 CSS、图片、字体等资源接入同一套依赖图

4.2 模块规范和模块打包不是一回事

所以现代前端模块化可以理解成两层:

层级 解决什么问题 代表
模块规范 代码如何声明导入和导出 ESM、CJS、AMD、UMD
模块打包 如何把模块图变成浏览器适合加载的文件 Webpack、Rollup、Vite

五、打包器内部如何运行模块

5.1 模块表、缓存和 __webpack_require__

浏览器不认识 CJS 的 require,也不认识构建工具内部的模块 ID。打包器会在输出文件里生成一小段模块运行时代码,用来注册模块、缓存模块、执行入口。

js 复制代码
// bundle.js 的核心结构
(function() {

  // 打包器生成的模块加载函数
  function __bundle_require__(moduleId) {
    if (moduleCache[moduleId]) return moduleCache[moduleId].exports

    var module = { exports: {} }
    moduleCache[moduleId] = module
    modules[moduleId](module, module.exports, __bundle_require__)
    return module.exports
  }

  var moduleCache = {}

  // 所有源文件都被打包进这个对象
  var modules = {
    './math.js': function(module, exports) {
      exports.add = function add(a, b) { return a + b }
    },
    './index.js': function(module, exports, require) {
      var math = require('./math.js')
      console.log(math.add(1, 2))
    }
  }

  __bundle_require__('./index.js')
})()

5.2 和 Node.js CommonJS 的对比

对比起来:

Node.js 打包器
require 内置,读磁盘 自己实现,读 modules 对象
缓存
module.exports
作用域隔离 函数包装 函数包装

这个例子只是说明"打包器运行时"的基本形态。真实的 Webpack/Rollup/Vite 输出会更复杂,因为它们还要表达 ESM 的 live binding、default export、chunk 异步加载、循环依赖等语义。


六、不同格式的依赖被打进 bundle 后会怎样

6.1 先接入统一模块图

打包器会把不同来源的模块统一接入自己的模块图:

javascript 复制代码
ESM 代码   → 静态分析 import/export → 接入模块图
CJS 库     → 识别 require/exports    → 接入模块图
AMD 库     → 识别 define             → 接入模块图
UMD 库     → 根据环境分支识别导出方式 → 接入模块图

最终输出不一定都等价于 module.exports。更准确地说,打包器会把不同模块格式统一编排到自己的模块运行时里,再按目标环境输出成一个或多个 JS 文件。

接入模块图以后,还有一个更容易混淆的问题:一个 npm 包被打进 bundle 以后,它执行时看到的 moduleexportsrequireimport/export,到底是谁提供的?

以 Webpack 为例,源码模块和 npm 包都会被放进 Webpack 的模块表,再由 Webpack runtime 调用:

js 复制代码
__webpack_modules__[moduleId](module, module.exports, __webpack_require__)

所以,被打进 bundle 的依赖不是直接运行在原始宿主环境里,而是运行在打包器构造出来的模块环境里。

6.2 CJS npm 包:module 由 Webpack 注入

CJS 包源码里经常会写:

js 复制代码
if (typeof module !== 'undefined' && module.exports) {
  module.exports = factory()
}

如果这个包被 Webpack 打进浏览器 bundle,这里的 module 不是 Node.js 的真实 module,而是 Webpack 执行内部模块函数时传入的对象:

js 复制代码
var module = { exports: {} }
__webpack_modules__[id](module, module.exports, __webpack_require__)

所以这个判断会命中 CJS 分支,但 module.exports 最终被 Webpack 的内部模块系统接住。其他模块再通过 __webpack_require__(id) 拿到这个包的导出。

6.3 ESM npm 包:import/export 被编排成内部导出关系

ESM 包源码通常是:

js 复制代码
export function add(a, b) {
  return a + b
}

export default function multiply(a, b) {
  return a * b
}

ESM 的重点不是运行时判断 module 是否存在,而是构建时静态分析。Webpack 会读取 import/export,把它们接入模块图,并在内部 runtime 里维护导出关系。

概念上可以理解成:

js 复制代码
// Webpack 内部记录:这个模块有哪些导出
__webpack_require__.d(__webpack_exports__, {
  add: function() { return add },
  default: function() { return multiply }
})

这样做是为了保留 ESM 的语义,比如命名导出、默认导出、live binding,以及 tree-shaking 所需要的静态结构。也就是说,ESM 包被打进 bundle 后,不是浏览器原生 import 逐个文件加载,而是由 Webpack 统一编排进自己的模块运行时。

6.4 UMD npm 包:环境判断也可能命中 Webpack 提供的环境

有些 npm 包发布的是 UMD 产物,里面可能有这种判断:

js 复制代码
if (typeof define === 'function' && define.amd) {
  define(factory)
} else if (typeof module !== 'undefined' && module.exports) {
  module.exports = factory()
} else {
  root.Lib = factory()
}

如果这段代码作为依赖被打进 Webpack bundle,它执行时也在 Webpack 的内部模块函数里。此时 module 可能是 Webpack 注入的内部模块对象,所以它会走 CJS 分支,而不是走浏览器全局变量分支。

这就是容易误解的地方:代码里写的是 UMD 环境判断,但它判断到的"环境"不一定是最终浏览器页面,而可能是打包器为这个依赖模块提供的内部执行环境。

6.5 不要和 bundle 的外层输出格式混淆

被打进 bundle 的依赖有自己的模块执行环境;整个 bundle 也有自己的外层输出格式。这是两层不同的问题:

层级 解决的问题 例子
依赖模块内部 某个 npm 包如何执行、缓存、导出 CJS 包写入 Webpack 内部的 module.exports
bundle 外层 整个产物如何交给宿主环境 library.type: 'umd' 输出到 module.exports、AMD 或全局变量

所以,一个 bundle 可以外层输出成 UMD;同时它内部某个 npm 包仍然按 CJS 方式被 Webpack 执行。前者决定"整个 bundle 暴露给谁",后者决定"bundle 里面的模块如何互相工作"。


七、现代前端项目的模块链路

markdown 复制代码
你写的代码(通常是 ESM)
        ↓
   打包器解析入口文件
        ↓
   构建完整模块依赖图
        ↓
   处理 ESM / CJS / UMD 等格式差异
        ↓
   tree-shaking、拆包、资源接入、产物优化
        ↓
   输出 bundle / chunks
        ↓
   浏览器按最终产物加载执行

分工总结:

  • ESM:现代前端代码的主流模块写法
  • CJS:Node.js 生态和历史 npm 包里的重要格式
  • UMD:主要服务于 CDN 直接引入
  • 打包器:把各种模块格式统一成浏览器适合加载的产物

八、发布 npm 包时的格式选择

现代库一般同时输出多个格式:

js 复制代码
// rollup.config.js
export default [
  { output: { file: 'dist/index.cjs.js', format: 'cjs' } },  // Node.js / 旧 Webpack
  { output: { file: 'dist/index.esm.js', format: 'esm' } },  // 现代打包工具,支持 tree-shaking
  { output: { file: 'dist/index.umd.js', format: 'umd', name: 'MyLib' } }  // CDN script 标签
]
json 复制代码
// package.json 告诉不同工具读哪个文件
{
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "exports": {
    ".": {
      "import": "dist/index.esm.js",
      "require": "dist/index.cjs.js"
    }
  },
  "unpkg": "dist/index.umd.js"
}

UMD 现在主要保留给"不用打包工具直接 CDN 引入"这个场景,其余场景被 ESM + CJS 双入口替代。

相关推荐
驰子1051 小时前
不重写、不换框架:让十年 jQuery 老项目写出 Vue 手感的渐进式改造
前端
月弦笙音1 小时前
# SSE 连接可靠性方案
前端
weedsfly1 小时前
从表单校验到订单流转,彻底搞懂前端开发中的策略模式与状态模式
前端·javascript·面试
qq_589666051 小时前
npm install npm install -g @vue/cli 分别是什么意思以及安装路径
前端·vue.js·npm
用户0207199207722 小时前
Kafka 与事务 Outbox
前端
IT_陈寒2 小时前
Vite的静态资源路径搞得我头秃,原来这样配才对
前端·人工智能·后端
BerryS3N2 小时前
前端应用的离线暂停更新策略:构建稳定可靠的渐进式部署方案
前端
zzx2006__2 小时前
Ajax➕node.js➕webpack
ajax·webpack·node.js
LaughingZhu3 小时前
Product Hunt 每日热榜 | 2026-07-17
前端·数据库·人工智能·经验分享·mysql·chatgpt·html