在 ES6 之前,JavaScript 从来没有官方的模块系统。我们靠全局变量、命名空间、IIFE、CommonJS、AMD 这些"民间方案"勉强维持着代码的组织秩序,直到 2015 年 ES Module 正式写进语言规范。它带来了静态的 import / export 语法、实时绑定、循环依赖处理、动态导入、顶层 await,并与打包工具、Tree-shaking、浏览器原生加载机制深度联动。本文将从演进历史讲到语法细节,从加载机制讲到工程实践,用 35 分钟带你彻底吃透 ES6+ 模块化开发。
从全局变量到 ES Module:模块化演进史
要理解 ES Module 的价值,必须先看清它解决了什么问题。在模块化标准出现之前,前端代码组织经历了几个阶段的"自救"。
function formatPrice(v) { return '¥' + v; }
function formatPrice(v) { return v.toFixed(2); }
// 后定义的覆盖前者,且毫无提示
/* 阶段二:命名空间 —— 用对象隔离 */
var MyApp = {};
MyApp.utils = {
format: function (v) { /* ... */ }
};
// 问题:内部实现仍可被外部随意改写
/* 阶段三:IIFE —— 利用闭包实现私有作用域 */
var Counter = (function () {
var count = 0;
return {
inc: function () { return ++count; }
};
})();
// 问题:依赖顺序靠 script 标签顺序,无法声明依赖
每一代方案都在解决上一代的痛点
ES Module(简称 ESM)与前几代方案最本质的区别在于:它的依赖关系是静态的——在代码执行之前,引擎就能通过解析源码构建出完整的模块依赖图。这一特性直接催生了 Tree-shaking、代码分割、静态类型分析等一系列现代工程能力。
基础语法:export 与 import
ESM 的核心只有两个关键字:export 用于对外暴露,import 用于引入依赖。它们都必须写在模块顶层,不能放在函数或条件语句内部。
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export class Vector {
constructor(x, y) { this.x = x; this.y = y; }
}
/* 也可以集中导出 */
const minus = (a, b) => a - b;
export { minus };
/* main.js —— 具名导入 */
import { PI, add, Vector } from './math.js';
console.log(add(1, 2)); // 3
'use strict'this 是 undefined,不再是 windowexport default function createApp(options) {
return { options: options };
}
/* 导入默认导出:名字随便起,不需要花括号 */
import appFactory from './app.js';
/* 默认导出 + 具名导出混用 */
import React, { useState, useEffect } from 'react';
导入导出的进阶用法
真实项目中,模块之间的关系远比"导出—导入"复杂。ESM 提供了重命名、聚合转发、命名空间导入等能力,让模块的依赖拓扑可以灵活组织。
import { add as sum, minus as sub } from './math.js';
/* 2. 导出时重命名 */
export { sum as add };
/* 3. 命名空间导入:把整个模块挂到一个对象上 */
import * as MathUtils from './math.js';
MathUtils.add(1, 2);
/* 4. 仅执行副作用,不导入任何绑定 */
import './polyfill.js';
/* 5. 聚合转发:统一出口,收敛模块边界 */
export { add, minus } from './math.js';
export * from './string.js';
export { default as Logger } from './logger.js';
聚合出口让调用方更简洁,但要注意别造成循环引用
一个容易踩的坑:具名导出的绑定是提升的,但默认导出不是。如果在模块顶层用 export default foo() 调用一个尚未初始化的函数表达式,会直接报错。稳妥做法是先声明、再在末尾统一 export default。
动态导入 import():按需加载的利器
静态 import 必须写在顶层,意味着模块及其依赖会在页面加载时被一并下载。而 import() 是一个返回 Promise 的函数,可以在任何位置调用,从而实现条件加载、路由懒加载、交互触发加载。
const module = await import('./heavy.js');
module.doWork();
/* 按需加载:只有用户点击才下载 */
btn.addEventListener('click', async () => {
const { exportPDF } = await import('./pdf-exporter.js');
exportPDF(data);
});
/* 条件加载:根据能力检测选择实现 */
const codec = supportsAVIF
? await import('./codec-avif.js')
: await import('./codec-webp.js');
/* 路由懒加载(伪代码) */
const routes = {
'/dashboard': () => import('./views/Dashboard.js'),
'/settings': () => import('./views/Settings.js')
};
/* 失败兜底 */
try {
await import('./optional-feature.js');
} catch (err) {
console.warn('模块加载失败,降级处理', err);
}
首屏体积更小,加载更快
注意:import() 虽然写法像函数调用,但它不是普通函数,不能被赋值给变量后调用(如 const load = import; load('x') 会报错)。它也不能在非模块环境中使用。另外,参数中不能使用完全动态的表达式,否则打包工具无法预判路径,会退化成"把整个目录都打包"。
模块加载机制与顶层 await
ESM 的加载过程分为三个阶段:解析(Parsing)→ 实例化(Instantiation)→ 求值(Evaluation)。理解这三步,才能理解为什么会有循环依赖、为什么会"暂时性死区"。
/* 阶段二:实例化 —— 建立绑定关系,分配内存 */
/* 阶段三:求值 —— 深度优先后序遍历,执行代码 */
/* 顶层 await:让模块本身变成异步 */
const res = await fetch('/config.json');
const config = await res.json();
export const API_BASE = config.apiBase;
/* 导入这个模块的一方,会等待其求值完成 */
import { API_BASE } from './config.js';
// 此处 API_BASE 一定已经就绪
浏览器中的模块:type="module"
浏览器原生支持 ESM,只需要在 script 标签上添加 type="module"。但随之而来的是一系列必须了解的行为变化。
<script src="app.js"></script>
<!-- 模块脚本:自动 defer,等文档解析完再执行 -->
<script type="module" src="app.js"></script>
<!-- 内联模块 -->
<script type="module">
import { init } from './main.js';
init();
</script>
<!-- 兼容旧浏览器:nomodule 回退 -->
<script nomodule src="legacy.js"></script>
<!-- 动态导入不需要 type="module" -->
<script>
import('./lazy.js').then(m => m.run());
</script>
defer 之外的方式控制本地开发请务必使用 HTTP 服务器
Import Maps:让裸模块名可用
浏览器不认识 import x from 'lodash' 这样的裸模块名,它要求路径必须是 ./、../ 或绝对 URL。Import Maps 正是为了解决这个问题而生的——它让浏览器具备了类似打包工具的路径解析能力。
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.example.com/lodash-es/lodash.js",
"lodash/": "https://cdn.example.com/lodash-es/",
"@utils/": "./src/utils/"
},
"scopes": {
"/legacy/": {
"lodash": "/legacy/lodash-3.js"
}
}
}
</script>
/* 之后就可以这样写了 */
import _ from 'lodash';
import { format } from '@utils/format.js';
无构建工具也能拥有规范的模块路径
ESM 与 CommonJS 的差异与互操作
Node.js 长期使用 CommonJS(CJS),ESM 与之并存带来了不少"踩坑"时刻。搞清楚两者的差异,是写出可靠 Node 代码的前提。
const { readFile } = require('fs');
module.exports = { foo: 1 };
/* ESM:静态加载,实时绑定 */
import { readFile } from 'fs';
export const foo = 1;
/* Node 中启用 ESM 的三种方式 */
// 1. 文件后缀 .mjs
// 2. package.json 中 "type": "module"
// 3. 动态 import() 在 CJS 中加载 ESM
/* CJS 中加载 ESM:必须用动态导入 */
(async () => {
const mod = await import('./esm-module.mjs');
mod.default();
})();
/* ESM 中获取 __dirname 的替代写法 */
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
· 值拷贝
· this 指向 exports
· 有 __dirname / __filename
· 同步加载
· 实时绑定
· this 为 undefined
· 用 import.meta.url
· 支持异步(顶层 await)
新项目优先选择 ESM
循环依赖与实时绑定
ESM 的"实时绑定"(Live Binding)意味着导入方拿到的不是值的拷贝,而是指向导出变量的引用。这既是它强大之处,也是循环依赖问题的根源。
export let count = 0;
export function increment() { count++; }
/* main.js */
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 —— 实时绑定,能看到更新!
/* 对比 CommonJS:拿到的是快照 */
// const { count } = require('./counter');
// count 永远是 0,因为解构时复制了值
/* 循环依赖:a.js 与 b.js 互相引用 */
/* a.js */
import { b } from './b.js';
export function a() { return 'a'; }
// 注意:不要在顶层立即调用 b()
① 抽取共享模块到第三方文件
② 用动态 import() 打破静态环
③ 把相互调用延迟到函数执行时
import.meta:模块的元数据入口
import.meta 是 ESM 提供的元数据对象,宿主环境可以往里挂载模块相关信息。浏览器中它至少包含 url 属性。
console.log(import.meta.url);
// "https://example.com/src/utils/format.js"
/* 基于模块路径解析同级资源 */
const workerUrl = new URL('./worker.js', import.meta.url);
const worker = new Worker(workerUrl);
/* 读取资源文件 */
const iconUrl = new URL('../assets/icon.svg', import.meta.url).href;
/* 热更新标记(由打包工具注入) */
if (import.meta.hot) {
import.meta.hot.accept();
}
/* Node.js 20+ 提供 resolve 方法 */
const resolved = import.meta.resolve('./config.json');
模块 URL
路径解析
环境变量(工具)
热更新(工具)
它是 ESM 世界中替代 __dirname 的标准方案
Tree-shaking:让打包体积更小
Tree-shaking 依赖 ESM 的静态结构:打包工具在构建阶段分析出哪些导出从未被引用,从而安全地删除它们。但要让 Tree-shaking 真正生效,代码写法很关键。
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
export function mul(a, b) { return a * b; }
/* 调用方只用了 add,sub 与 mul 会被移除 */
import { add } from './math.js';
/* ❌ 反面案例:整个对象默认导出,无法摇树 */
export default {
add(a, b) { return a + b; },
sub(a, b) { return a - b; }
};
/* ⚠ 注意副作用:顶层执行语句会阻止摇树 */
export const VERSION = '1.0.0';
console.log('模块被加载了'); // 副作用!
/* package.json 中标记无副作用 */
// "sideEffects": false
// 或只保留有副作用的文件
// "sideEffects": ["./src/polyfill.js", "*.css"]
具名导出 + 无副作用 = 极致体积
工程化实践:模块的组织与拆分
语法只是基础,如何在真实项目中划分模块边界、控制依赖方向,才是模块化设计的核心功力。
src/
├── api/ // 接口层,只依赖 utils
├── components/ // 通用组件,不依赖业务
├── features/ // 业务模块,按领域划分
├── hooks/ // 可复用逻辑
├── utils/ // 纯函数工具,零依赖
└── index.js // 应用入口
/* 依赖方向:单向流动,禁止反向依赖 */
/* utils ← hooks ← components ← features ← index */
/* 统一出口:为每个目录提供 index.js */
/* components/index.js */
export { Button } from './Button.js';
export { Modal } from './Modal.js';
/* 调用方 */
import { Button, Modal } from '@/components';
依赖只能向下,避免循环引用
最佳实践与总结
- 优先具名导出:便于静态分析、重命名与 Tree-shaking,默认导出留给"模块只有一个主输出"的场景
- 控制模块粒度:一个模块只做一件事,导出项不超过 10 个,超过就该考虑拆分
- 保持依赖单向:杜绝循环依赖,用分层结构约束引用方向
- 善用动态导入:路由、弹窗、图表库等非首屏资源一律按需加载
- 避免顶层副作用:模块顶层只做声明,副作用放进函数里,配合
sideEffects标记 - 用 import.meta.url 替代相对路径拼接:在 Worker、图片、JSON 加载场景尤其重要
- 统一出口要克制:barrel 文件方便但会破坏 Tree-shaking,深层目录谨慎使用
- 顶层 await 用在刀刃上:只在真正的初始化场景使用,避免阻塞关键路径
/* 高内聚 → 低耦合 → 单向依赖 → 按需加载 */
/* 一个完整的模块示例 */
// utils/format.js —— 纯函数,零依赖,具名导出
export const formatPrice = (v, currency = '¥') =>
currency + v.toFixed(2);
export const formatDate = (d) =>
new Intl.DateTimeFormat('zh-CN').format(d);
// features/order/index.js —— 业务模块,按需使用工具
import { formatPrice, formatDate } from '@/utils/format.js';
export async function renderOrder(order) {
// 重型依赖延迟加载,不进主包
const { drawChart } = await import('@/utils/chart.js');
return {
price: formatPrice(order.total),
date: formatDate(order.createdAt),
chart: drawChart(order.items)
};
}
模块化开发的价值,从来不只是"把代码拆成多个文件"。它是一套关于边界、依赖与协作的设计方法论。ES Module 用语言级的静态结构,把这种设计方法固化下来,让工具能够分析、优化、校验我们的代码。掌握它,是从"会写 JavaScript"迈向"会设计 JavaScript 应用"的关键一步。