ES Modules 的核心就在于它的导入导出机制。通过这套机制,你可以把代码拆分成独立的模块,每个模块只暴露必要的接口,再在其他地方按需引入。ESM 提供了两种导入方式和多种导出形式,理解它们各自的特点与使用场景,是工程化开发的基本功。
静态导出(Named & Default Export)
导出是模块对外提供功能的唯一途径。一个模块可以导出多个命名值,也可以导出一个默认值,两者可以同时存在。
命名导出(Named Export)
命名导出允许你导出多个变量、函数或类。其他模块在导入时必须使用对应的名字,名字必须匹配。
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
// 或者一次性导出
const PI = 3.14159;
function add(a, b) { return a + b; }
export { PI, add };
默认导出(Default Export)
每个模块只能有一个默认导出,通常用于导出模块的主要功能或类。导入时可以自定义名字,因为这个导出本身没有名字限制。
// calculator.js
export default function multiply(a, b) {
return a * b;
}
// 也可以绑定到变量再导出
const multiply = (a, b) => a * b;
export default multiply;
混合使用
一个模块可以同时包含命名导出和默认导出。
// utils.js
export default function greet(name) {
return `Hello, ${name}`;
}
export const version = '1.0.0';
静态导入(Static Import)
静态导入是指使用 import 语句在模块顶层导入其他模块。它是“静态”的,因为导入的路径必须是字符串字面量,不能动态拼接或根据条件决定——这意味着 JavaScript 引擎可以在代码执行前就确定模块依赖关系,并完成加载和解析(这也是 Tree Shaking 能够实现的基础)。
// 导入默认导出,可以任意命名
import multiply from './calculator.js';
// 导入命名导出,使用精确的名字
import { PI, add } from './math.js';
// 混合导入
import greet, { version } from './utils.js';
// 重命名导入(解决命名冲突)
import { add as mathAdd } from './math.js';
import { add as stringAdd } from './string.js';
// 导入整个模块作为命名空间对象
import * as math from './math.js';
console.log(math.PI, math.add(2, 3));
静态导入的优点在于清晰的依赖关系、可预测性和性能优化(比如代码分割、Tree Shaking)。它的限制也很明显:不能嵌套在条件语句、函数、try-catch 中,因为静态分析需要提前确定依赖图。
动态导入(Dynamic Import)
当模块路径需要根据运行时条件决定,或者你想延迟加载某些模块以优化首屏性能时,就需要用到动态导入。import() 是 ES2020 正式纳入标准的特性,它返回一个 Promise,使得模块按需加载成为可能。
const modulePath = './someModule.js';
import(modulePath) // 动态拼接路径
.then(module => {
module.default(); // 使用默认导出
module.namedFunc();
})
.catch(err => console.error('模块加载失败', err));
// 配合 async / await
const { someFunction } = await import('./heavyModule.js');
someFunction();
动态导入的典型应用场景
- 按需加载:如用户点击按钮后才加载某个大型图表库,减少首屏体积。
- 条件加载:根据浏览器特性或用户语言加载不同的模块。
- 路由懒加载:前端路由(如 React Router、Vue Router)普遍使用动态导入实现页面组件的代码拆分。
// 路由懒加载示例(React)
const HomePage = React.lazy(() => import('./pages/Home'));
const AboutPage = React.lazy(() => import('./pages/About'));
// 条件加载
let polyfill;
if (!window.fetch) {
polyfill = import('./fetch-polyfill.js');
}
导出再导出(Re-export)
在实际项目中,经常会创建一个 index.js 文件作为 public API 的入口,把分散的模块重新聚合导出,对外隐藏内部结构。这种模式称为再导出。
// components/index.js
export { Button } from './Button.js';
export { Input } from './Input.js';
export { default as Modal } from './Modal.js'; // 处理后转命名导出
export * from './icons.js'; // 全部转发
通过再导出,可以对外暴露统一的接口,而调用者只需要 import { Button, Input } from './components',无需关心内部文件分布。
静态导入与动态导入的对比
| 维度 | 静态导入 | 动态导入 |
|------|----------|----------|
| 语法 | import ... from ... | import() |
| 运行时机 | 模块解析时(编译阶段) | 运行时按需加载 |
| 是否支持顶层 | 仅顶层作用域 | 任意位置(函数、条件等) |
| 返回值 | 直接引用模块 | Promise,resolve 后拿到模块 |
| Tree Shaking | 支持(静态分析依赖) | 不支持(运行时决定) |
| 代码分割 | 需要配合构建工具(如 Webpack / Rollup) | 天然支持代码分割(构建工具会自动拆分) |
实践建议
- 优先使用静态导入:依赖明确,性能更优,工具链支持最好。
- 对非首屏必需的大型模块使用动态导入:显著优化首屏加载速度,提升用户体验。
- 谨慎使用默认导出:命名导出的可预测性更强,IDE 提示更友好,重构时也更安全。在团队规范中,可以约定优先使用命名导出。
- 使用再导出统一模块边界:让模块树更清晰,避免其他模块“穿透”内部结构。
掌握了这些导入导出方式,你就能够从小到单个工具函数,大至整个应用架构的模块化组织上,做到收放自如、可维护、可优化。