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)有什么本质区别?