ESM 模块

3 min read
Zekari
JavaScript模块系统ESM前端架构

模块是边界

模块不是为了拆分代码。拆分只是表象。

模块的核心是边界。它明确地说:这是我提供的,这是我依赖的。

在没有模块系统的时代,JavaScript 文件之间的依赖是隐式的。你把所有 script 标签按顺序排列,祈祷变量不要冲突,祈祷加载顺序不要出错。全局作用域是所有代码的公共空间,谁都可以访问,谁都可以修改。

这种隐式性带来的问题不只是命名冲突。更深层的问题是:你无法知道一个文件依赖了什么。

// 这个文件依赖了什么?
function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price * TAX_RATE, 0)
}

TAX_RATE 从哪里来?你不知道。可能在另一个文件定义,可能在全局,可能根本不存在。你只能通过阅读所有代码、查看所有 script 标签来推断。

模块化的第一步是让依赖变得显式。

import { TAX_RATE } from './config.js'

function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price * TAX_RATE, 0)
}

现在很清楚:这个文件依赖 config.js 中的 TAX_RATE

💡 Click the maximize icon to view in fullscreen

依赖关系不再是隐藏的,而是明确声明的。这让工具可以理解代码结构,让人可以快速把握架构。

静态与动态

CommonJS 和 ESM 都能实现模块化,但它们的哲学不同。

CommonJS 的 require 是运行时的。它是一个函数调用,可以出现在任何地方。

function loadModule(name) {
  if (name === 'dev') {
    return require('./dev-config')
  } else {
    return require('./prod-config')
  }
}

这很灵活。你可以根据条件动态加载模块,可以在循环中 require,可以在函数内部 require。

但这种灵活性有代价:工具无法在运行前知道你会加载哪些模块。

ESM 的 import 是静态的。它必须在文件顶层,不能在条件语句或函数中。

// ✅ 合法
import config from './config.js'

// ❌ 不合法
if (isDev) {
  import config from './dev-config.js'
}

这看起来是限制,但这个限制带来了能力。

因为 import 是静态的,工具可以在不运行代码的情况下分析依赖树。打包工具可以 tree shake,去掉未使用的代码。开发工具可以提供精确的自动补全和跳转。

静态性牺牲了运行时的灵活性,换来了编译时的确定性。

ESM 也支持动态导入,但语法不同:

if (isDev) {
  const config = await import('./dev-config.js')
}

import() 是一个函数,返回 Promise。它可以出现在任何地方,实现按需加载。

这是对静态导入的补充,而不是替代。绝大多数导入应该是静态的,只有在需要代码分割或条件加载时才用动态导入。

异步加载的必然

ESM 在浏览器中是异步的。

当浏览器解析到 import 语句时,它会暂停当前模块的执行,去加载依赖的模块。依赖加载完成后,再继续执行。

<script type="module" src="main.js"></script>

这个 script 标签默认是 defer 的。模块会并行下载,但按依赖顺序执行。

这和 CommonJS 不同。CommonJS 在 Node.js 中是同步的,require 会立即返回模块内容。

为什么 ESM 选择异步?

因为网络。

在浏览器环境中,加载一个模块需要网络请求。网络请求不可能是同步的——你不能让浏览器停下来等待服务器响应。

ESM 的设计从一开始就考虑了浏览器。它不是为 Node.js 设计然后移植到浏览器,而是为浏览器设计然后移植到 Node.js。

这带来了一个结果:Node.js 中的 ESM 也是异步的,即使它不需要网络请求。

💡 Click the maximize icon to view in fullscreen

命名导出与默认导出

ESM 有两种导出方式:命名导出和默认导出。

// 命名导出
export const PI = 3.14
export function add(a, b) {
  return a + b
}

// 默认导出
export default function calculate() {
  // ...
}

命名导出可以有多个,默认导出只能有一个。

// 导入命名导出
import { PI, add } from './math.js'

// 导入默认导出
import calculate from './calculator.js'

这两种方式看起来只是语法差异,但它们代表了不同的设计思想。

命名导出强调模块的多样性。一个模块可以提供多个功能,使用者按需选择。

默认导出强调模块的单一性。一个模块代表一个主要功能,使用者直接导入。

哪种更好?

这取决于模块的性质。

工具类模块适合命名导出:import { debounce, throttle } from './utils.js'

组件模块适合默认导出:import Button from './Button.js'

但这不是硬性规则。你可以混用:

export default function Button() {
  // ...
}

export const ButtonSize = {
  SMALL: 'small',
  MEDIUM: 'medium',
  LARGE: 'large',
}

关键是一致性。在一个项目中保持风格统一,比纠结哪种方式更好更重要。

默认导出有一个问题:重命名的随意性。

import Btn from './Button.js'
import MyButton from './Button.js'
import Component from './Button.js'

这三种写法都合法。因为默认导出没有固定名称,使用者可以随意命名。

这在小项目中无所谓,但在大项目中会导致混乱。同一个组件在不同文件中有不同名称,搜索和重构都变得困难。

命名导出强制使用者使用正确的名称(虽然也可以用 as 重命名,但至少有一个明确的原始名称)。

这不是说默认导出不好,而是要意识到这个权衡。

双模式的困境

Node.js 同时支持 CommonJS 和 ESM。这带来了复杂性。

一个包可能使用 CommonJS,另一个包可能使用 ESM。它们需要互相调用。

CommonJS 模块可以 require ESM 模块吗?不能(至少不能直接)。

ESM 模块可以 import CommonJS 模块吗?可以,但有限制。

// ESM 中导入 CommonJS
import pkg from 'commonjs-package'  // ✅ 默认导出
import { named } from 'commonjs-package'  // ❌ 命名导出可能不可用

这是因为 CommonJS 的 module.exports 是动态的,可以在运行时修改。ESM 需要静态分析,无法完全理解 CommonJS 的导出。

更麻烦的是包的双模式发布。一个库可能需要同时提供 CommonJS 和 ESM 版本:

{
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "import": "./dist/index.mjs"
    }
  }
}

这让发布流程变复杂,也让包的体积变大。

双模式不是过渡期的临时方案,而是可能长期存在的现实。

因为 CommonJS 的生态太大了。无数的包、工具、项目依赖它。完全迁移到 ESM 需要很长时间,甚至可能永远不会完成。

你需要接受这个现实,学会在两种模式中切换。

理解边界的意义

模块化不是技术问题,是设计问题。

技术层面,CommonJS 和 ESM 都能工作。但设计层面,模块划分决定了代码的可维护性。

一个模块应该有清晰的职责。它提供什么功能?它依赖什么?它的公开接口是什么?

// ❌ 模糊的边界
export const users = []
export const posts = []
export function addUser(user) { users.push(user) }
export function addPost(post) { posts.push(post) }

这个模块既管理用户又管理文章。职责不清晰。

// ✅ 清晰的边界
// users.js
export const users = []
export function addUser(user) { users.push(user) }

// posts.js
export const posts = []
export function addPost(post) { posts.push(post) }

现在每个模块有单一职责。

边界不只是文件的分割,更是概念的分离。

当你在设计模块时,问自己:这个模块代表什么?它的存在理由是什么?如果它消失了,系统失去了什么?

答案决定了边界。

最后

ESM 不是新技术。但它代表的思想——静态分析、明确依赖、清晰边界——是永恒的。

工具会变,语法会变,但对依赖关系的理解不会变。

模块系统教给我们的不是怎么 import 和 export,而是怎么思考代码的组织。

每一次 import 都是一个承诺:我依赖你提供的功能,我相信你会保持接口稳定。

每一次 export 都是一个契约:这是我提供的,这是你可以依赖的。

这种承诺和契约构成了软件的可靠性。


为什么 Python 的模块系统可以动态导入,而 JavaScript 选择了静态导入?这和语言的设计哲学有什么关系?

模块的粒度应该多细?一个文件一个函数,还是一个文件一个领域?

循环依赖在技术上可以工作,但为什么应该避免?它反映了什么设计问题?

如果重新设计 JavaScript 的模块系统,你会保留什么,改变什么?

前端的模块打包(webpack, rollup)和后端的模块加载(Node.js)有什么本质区别?


参考资源