Skip to content

模块化 ​

模块化发展史 ​

JavaScript 诞生之初并没有模块系统,所有代码共享全局作用域。随着应用复杂度增长,模块化经历了以下演进阶段:

IIFE 时代 ​

最早的模块化方案是利用立即执行函数表达式(IIFE)创建独立作用域,通过闭包实现私有变量:

js
const MyModule = (function () {
  let _private = 0

  function increment() {
    _private++
  }

  function getCount() {
    return _private
  }

  return { increment, getCount }
})()

MyModule.increment()
MyModule.getCount()

IIFE 解决了全局污染问题,但模块之间的依赖关系需要手动管理,加载顺序必须人工保证。jQuery 插件体系就是典型的 IIFE 模块化实践。

CommonJS ​

2009 年 Node.js 采用 CommonJS 规范,将模块化带入服务端。核心思想是:每个文件就是一个模块,通过 require 加载、module.exports 导出。

js
const utils = require('./utils')

function handler(req, res) {
  const result = utils.parse(req.body)
  res.json(result)
}

module.exports = { handler }

CommonJS 是同步加载的,适用于服务端磁盘 I/O 场景,但不适合浏览器环境的网络异步加载需求。

AMD(Asynchronous Module Definition) ​

为解决浏览器端异步加载问题,RequireJS 推出了 AMD 规范:

js
define(['jquery', 'lodash'], function ($, _) {
  function render(data) {
    const sorted = _.sortBy(data, 'name')
    $('#app').html(sorted.map(item => `<li>${item.name}</li>`).join(''))
  }

  return { render }
})

AMD 使用 define 声明模块,依赖前置声明、异步加载。但语法冗长,开发体验不佳。

UMD(Universal Module Definition) ​

UMD 是一种兼容方案,让模块同时支持 CommonJS、AMD 和全局变量:

js
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    define(['dependency'], factory)
  } else if (typeof module === 'object' && module.exports) {
    module.exports = factory(require('dependency'))
  } else {
    root.MyModule = factory(root.Dependency)
  }
})(typeof self !== 'undefined' ? self : this, function (Dependency) {
  return { }
})

UMD 本质上是运行时的环境检测分支,常见于需要同时发布到多种环境的类库(如 Lodash、Axios 的旧版本)。

ESM(ES Modules) ​

ES2015 正式引入语言层面的模块系统。import/export 是静态声明,必须出现在模块顶层:

js
import { parse } from './utils.js'
import defaultExport from './config.js'

export function handler(req) {
  return parse(req.body)
}

export default handler

ESM 是目前的标准方案,浏览器和 Node.js 均已原生支持。

时间线总览 ​

1995  全局变量 / 命名空间
2003  IIFE 模块模式
2009  CommonJS (Node.js)
2011  AMD (RequireJS)
2013  UMD
2015  ESM (ES2015 标准)
2017  Node.js 开始实验性支持 ESM
2020  Node.js 正式稳定支持 ESM

CommonJS 原理 ​

require 缓存机制 ​

Node.js 中每个模块在首次 require 时会被编译执行,执行结果缓存在 require.cache 中(以文件绝对路径为 key)。后续再次 require 同一模块时直接返回缓存结果,不会重新执行模块代码。

js
const mod1 = require('./counter')
const mod2 = require('./counter')

mod1 === mod2

缓存对象的结构是 Module 实例:

js
{
  id: '/absolute/path/to/counter.js',
  filename: '/absolute/path/to/counter.js',
  loaded: true,
  exports: { ... },
  children: [...],
  paths: [...]
}

可以通过删除缓存强制重新加载(常用于热更新场景):

js
delete require.cache[require.resolve('./counter')]
const fresh = require('./counter')

值拷贝特性 ​

CommonJS 导出的是值的拷贝(对于原始类型)。模块内部变量变化后,外部已经引入的值不会同步更新:

js
let count = 0

function increment() {
  count++
}

function getCount() {
  return count
}

module.exports = { count, increment, getCount }
js
const counter = require('./counter')

counter.count
counter.increment()
counter.count
counter.getCount()

counter.count 仍然是 0,因为导出时 count 被拷贝到 exports 对象上。而 getCount() 返回 1,因为函数闭包引用的是模块内部的 count 变量。

如果导出的是引用类型(对象/数组),则外部可以通过引用修改内部状态——这是因为 JavaScript 中引用类型赋值的是指针拷贝。

运行时加载 ​

CommonJS 的 require 是一个运行时函数调用,可以出现在任何位置,支持动态路径和条件加载:

js
const moduleName = condition ? './moduleA' : './moduleB'
const mod = require(moduleName)

if (process.env.NODE_ENV === 'development') {
  const devTools = require('./devTools')
  devTools.init()
}

这种灵活性使得静态分析工具无法在编译时确定模块依赖关系,也是 CommonJS 不利于 Tree Shaking 的根本原因。

循环引用处理 ​

CommonJS 对循环引用采用部分加载策略。当模块 A 加载模块 B,而模块 B 又反过来加载模块 A 时,B 获取到的是 A 当前已执行部分的导出,而非完整导出:

js
exports.loaded = false

const b = require('./b')

exports.loaded = true
exports.b = b
js
const a = require('./a')

console.log(a.loaded)

exports.fromB = true

执行 node a.js 时的过程:

  1. 开始加载 A,设置 a.exports.loaded = false
  2. 遇到 require('./b'),暂停 A 的执行,开始加载 B
  3. B 中 require('./a') 获取到 A 当前的 exports:{ loaded: false }
  4. B 执行完毕,返回 B 的 exports
  5. 回到 A 继续执行,设置 a.exports.loaded = true

这种机制避免了无限循环,但可能导致获取到不完整的模块导出——这是一个需要警惕的陷阱。


ESM 原理 ​

静态分析 ​

ESM 的 import/export 声明必须出现在模块顶层,不能嵌套在 if、for、函数等块级作用域中。这使得引擎可以在编译阶段(代码执行前)就确定所有模块的依赖关系。

js
import { foo } from './foo.js'

if (condition) {
  import { bar } from './bar.js'
}

静态结构带来的核心优势:

  • 编译时即可构建完整的模块依赖图
  • 支持 Tree Shaking 消除未使用代码
  • 支持循环引用的正确处理(基于绑定而非拷贝)
  • 可以进行静态类型检查

值引用(Live Binding) ​

ESM 导出的是值的实时绑定(Live Binding),而非拷贝。外部模块引入的变量始终指向源模块中的原始绑定:

js
export let count = 0

export function increment() {
  count++
}
js
import { count, increment } from './counter.js'

console.log(count)
increment()
console.log(count)

第二次 console.log(count) 输出 1,因为 count 是对源模块变量的实时引用。

这背后的机制是:ESM 导出的不是值本身,而是一个间接引用(Indirect Export)。规范中使用 Module Record 的 [[Bindings]] 来维护导入模块到导出模块之间变量的映射关系。

需要注意:导入的绑定是只读的,不能在导入方重新赋值:

js
import { count } from './counter.js'
count = 10

编译时确定依赖 ​

ESM 的模块解析分为三个阶段:

1. 构建(Construction)

从入口模块开始,解析所有 import 声明,递归获取依赖模块的源代码,建立模块记录(Module Record)。此阶段不执行任何代码。

2. 实例化(Instantiation)

为所有模块创建模块环境记录(Module Environment Record),将导出的变量名分配内存空间(但不赋值),建立导入与导出之间的绑定关系。

3. 求值(Evaluation)

按照深度优先、后序的顺序执行模块代码,填充变量的实际值。

入口模块
├── import A
│   ├── import C    ← C 最先求值
│   └── import D    ← D 其次
└── import B        ← B 再次
                    ← 入口模块最后求值

这种三阶段设计使得 ESM 能够在代码执行前就完成所有绑定的连接,从而正确处理循环引用——即使模块尚未执行完毕,绑定本身已经建立。

import() 动态导入 ​

ES2020 正式引入 import() 动态导入语法,返回一个 Promise,可以在任何位置使用:

js
const loadModule = async (modulePath) => {
  const module = await import(modulePath)
  return module.default
}

button.addEventListener('click', async () => {
  const { Chart } = await import('./chart.js')
  const chart = new Chart('#container')
  chart.render(data)
})

const routes = {
  '/home': () => import('./pages/Home.js'),
  '/about': () => import('./pages/About.js'),
  '/dashboard': () => import('./pages/Dashboard.js'),
}

import() 的关键特性:

  • 运行时执行,可以使用动态路径
  • 返回 Promise,加载失败可通过 .catch() 捕获
  • Webpack/Vite 等打包工具会将 import() 作为代码分割点,生成独立 chunk
  • 可与 React.lazy、Vue 异步组件等框架 API 无缝结合

CommonJS vs ESM 核心差异 ​

维度CommonJSESM
加载时机运行时加载编译时静态分析
导出机制值拷贝(原始类型)值引用(Live Binding)
语法位置require 可出现在任意位置import 必须在模块顶层
动态导入require(variable) 原生支持import() 返回 Promise
this 指向this 指向 module.exportsthis 是 undefined
循环引用返回已执行部分的导出(可能不完整)通过 Live Binding 正确处理
文件扩展名.js(默认) / .cjs.mjs 或 package.json 中 "type": "module"
Tree Shaking不支持(动态特性阻碍静态分析)原生支持
顶层 await不支持支持(ES2022)
__filename / __dirname原生可用不可用,需通过 import.meta.url 替代

Tree Shaking 原理 ​

Tree Shaking 是一种基于 ESM 静态结构的死代码消除(Dead Code Elimination, DCE) 技术。名称来源于"摇晃树木让枯叶掉落"的比喻——将未被引用的导出从最终产物中移除。

DCE 基础 ​

传统的 DCE 由压缩工具(如 Terser/UglifyJS)执行,它能消除以下代码:

  • 不可达代码(return 后的语句)
  • 执行结果不被使用的代码
  • 只写不读的变量
js
function unused() {
  return 'never called'
}

function used() {
  return 'called'
}

const result = used()

if (false) {
  console.log('dead code')
}

Tree Shaking 在 DCE 的基础上更进一步:它利用 ESM 的静态 import/export 结构,在模块级别判断哪些导出从未被其他模块引用,从而将整个未使用的导出及其相关代码移除。

副作用标记 ​

Tree Shaking 的核心难点在于副作用(Side Effects)。如果一个模块在导入时会执行副作用代码(修改全局变量、注册事件、执行 polyfill 等),即使没有使用其导出,也不能安全地移除。

js
export function pure() {
  return 42
}

window.__INITIALIZED__ = true

Array.prototype.customMethod = function () {}

上面的模块即使 pure 未被使用,也不能被 Tree Shaking 移除,因为导入该模块会产生副作用。

sideEffects 字段 ​

package.json 中的 sideEffects 字段允许库作者向打包工具声明模块的副作用情况:

json
{
  "name": "my-library",
  "sideEffects": false
}

"sideEffects": false 表示包内所有模块都是纯的,未使用的导出可以安全移除。

也可以指定哪些文件有副作用:

json
{
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.js",
    "./src/register-global.js"
  ]
}

CSS 文件通常被标记为有副作用,因为它们的导入本身就是有意义的(注入样式)。

实践建议 ​

确保代码能被有效 Tree Shaking 的关键原则:

  • 使用 ESM 语法发布(提供 module 或 exports 字段指向 ESM 产物)
  • 避免模块顶层副作用
  • 优先使用具名导出而非默认导出(便于打包工具分析引用关系)
  • 正确配置 sideEffects 字段
  • 避免重导出整个模块(export * from './xxx'),尽量使用具名重导出

Node.js 中 CJS 与 ESM 互操作 ​

Node.js 同时支持两种模块系统,但它们的互操作存在一些限制。

模块类型判定 ​

Node.js 通过以下规则确定文件的模块类型:

  • .cjs → CommonJS
  • .mjs → ESM
  • .js → 取决于最近的 package.json 中的 "type" 字段
    • "type": "module" → ESM
    • "type": "commonjs" 或缺省 → CommonJS

ESM 中加载 CJS ​

ESM 可以通过 import 加载 CJS 模块,但有限制:

js
import cjsModule from './lib.cjs'

import { namedExport } from './lib.cjs'

Node.js 会将 CJS 模块的 module.exports 作为 ESM 的默认导出。从 Node.js v16.x 开始,Node.js 会尝试对 CJS 模块进行静态分析以提取具名导出,但这不总是可靠的。

CJS 中加载 ESM ​

CJS 不能同步 require ESM 模块(因为 ESM 支持顶层 await,而 require 是同步的):

js
const esmModule = require('./lib.mjs')

替代方案是使用动态 import():

js
async function loadESM() {
  const { default: esmModule } = await import('./lib.mjs')
  return esmModule
}

从 Node.js v22.x 开始,require() 在特定条件下可以加载同步的 ESM 模块(即不包含顶层 await 的 ESM),这是通过 --experimental-require-module 标志启用的。

双模式发布(Dual Package) ​

为了让包同时支持 CJS 和 ESM 消费者,可以使用 package.json 的 exports 字段实现条件导出:

json
{
  "name": "my-package",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "types": "./dist/types/index.d.ts"
    },
    "./utils": {
      "import": "./dist/esm/utils.js",
      "require": "./dist/cjs/utils.cjs"
    }
  }
}

双包危险(Dual Package Hazard):如果一个包同时被 CJS 和 ESM 方式引入,可能会创建两个独立的模块实例,导致 instanceof 检查失败、单例被破坏等问题。解决方案之一是使用 CJS 作为核心实现,ESM 入口作为薄封装层重新导出:

js
import cjsModule from '../cjs/index.cjs'
export const { featureA, featureB } = cjsModule
export default cjsModule

打包工具如何处理模块 ​

Rollup ​

Rollup 从设计之初就以 ESM 为核心,其处理方式:

模块合并(Scope Hoisting):Rollup 将所有模块的代码提升到同一个作用域中,消除模块边界。这意味着最终产物中没有 require/define 等模块加载器代码,体积更小、运行更快。

js
export const add = (a, b) => a + b
export const subtract = (a, b) => a - b
js
import { add } from './math.js'
console.log(add(1, 2))

Rollup 打包结果:

js
const add = (a, b) => a + b
console.log(add(1, 2))

subtract 被 Tree Shaking 移除,模块边界完全消失。

对 CJS 的处理:Rollup 原生不支持 CommonJS,需要通过 @rollup/plugin-commonjs 插件将 CJS 转换为 ESM 后再处理。

Webpack ​

Webpack 的设计哲学是兼容一切模块格式,其处理方式:

模块包装(Module Wrapping):Webpack 将每个模块包装在一个函数中,通过自实现的 __webpack_require__ 模块加载器管理依赖:

js
var __webpack_modules__ = {
  './src/math.js': function (module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.d(__webpack_exports__, {
      add: function () { return add }
    })
    const add = (a, b) => a + b
    const subtract = (a, b) => a - b
  },
  './src/index.js': function (module, __webpack_exports__, __webpack_require__) {
    var _math = __webpack_require__('./src/math.js')
    console.log((0, _math.add)(1, 2))
  }
}

从 Webpack 3 开始引入 ModuleConcatenationPlugin(即 Scope Hoisting),对于 ESM 模块可以实现类似 Rollup 的模块合并优化。

对比 ​

维度RollupWebpack
设计理念ESM 优先通用兼容
默认行为Scope HoistingModule Wrapping
Tree Shaking原生精确需要 ESM + 配置
CJS 支持需要插件原生支持
代码分割基于 import() + output.manualChunks基于 import() + SplitChunksPlugin
产物体积更小(无运行时开销)稍大(包含模块加载器)
适用场景类库打包应用打包
HMR需要插件原生支持
生态精简庞大(loader/plugin 生态)

现代趋势 ​

Vite 在开发环境利用浏览器原生 ESM 实现零打包启动,生产环境使用 Rollup 打包。esbuild 以 Go 语言实现,提供极快的打包速度。Rspack 以 Rust 实现,兼容 Webpack API 的同时大幅提升构建性能。

这些工具的共同趋势是:以 ESM 为基石,通过编译型语言提升性能。理解模块化原理,是掌握这些工具行为的基础。

用心学习,用代码说话 💻