JavaScript ES 模块完全指南:import、export 与模块模式
ES 模块(ESM)是官方的 JavaScript 模块系统。深入理解它们可以避免循环依赖、摇树优化和动态加载方面的常见问题。
命名导出
// utils/math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// 也可以在末尾导出(推荐,可读性更好)
const subtract = (a, b) => a - b;
const divide = (a, b) => a / b;
export { subtract, divide };
export { subtract as sub, divide as div }; // 导出时重命名
// 导入命名导出
import { add, multiply } from './utils/math.js';
import { subtract as sub } from './utils/math.js'; // 导入时重命名
import * as MathUtils from './utils/math.js'; // 将所有导出作为命名空间导入
console.log(add(2, 3)); // 5
console.log(MathUtils.multiply(4, 5)); // 20
console.log(sub(10, 3)); // 7
默认导出
// 每个文件一个默认导出
// components/Button.jsx
export default function Button({ children, onClick }) {
return <button onClick={onClick}>{children}</button>;
}
// 或者先赋值再导出
class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
get(path) { return fetch(this.baseUrl + path); }
}
export default ApiClient;
// 导入默认导出(可以使用任意名称)
import Button from './components/Button';
import Api from './utils/ApiClient'; // 名称与导出不同——没问题!
import MyButton from './components/Button'; // 也可以
// 同时导入默认导出和命名导出
import React, { useState, useEffect } from 'react';
// ↑ 默认导出 ↑ 命名导出
命名导出 vs 默认导出:如何选择?
// ✅ 使用命名导出:
// - 工具函数(每个文件多个导出)
// - 常量
// - 类型/接口
// - 任何受益于一致命名的内容
export const formatDate = (date) => ...;
export const formatCurrency = (amount) => ...;
export const API_URL = 'https://api.example.com';
// ✅ 使用默认导出:
// - 组件(每个文件一个)
// - 代表明确“事物”的类
// - 主要入口点
export default class UserService { ... }
// ⚠️ 避免不一致地混合模式
// 选择一个约定并坚持使用
重新导出(桶文件)
// utils/index.js — 桶文件
export { add, multiply } from './math.js';
export { formatDate, formatCurrency } from './formatting.js';
export { validateEmail, validatePhone } from './validation.js';
// 重命名后重新导出
export { default as Button } from './Button.jsx';
export { default as Input } from './Input.jsx';
// 重新导出所有内容
export * from './helpers.js';
// 现在使用者可以从一个地方导入
import { add, formatDate, Button } from './utils';
// 而不是:
// import { add } from './utils/math';
// import { formatDate } from './utils/formatting';
// import Button from './utils/Button';
动态导入
// 静态导入:在解析时求值
import { heavyLibrary } from './heavy.js'; // 始终加载,即使未使用
// 动态导入:在运行时惰性加载
async function loadFeature() {
// 仅当调用此函数时才加载
const { heavyLibrary } = await import('./heavy.js');
return heavyLibrary.process(data);
}
// React 惰性加载(底层使用动态导入)
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
// 条件加载
async function loadChart() {
const chartLib = navigator.onLine
? await import('./charts/online')
: await import('./charts/offline');
return chartLib.render(data);
}
// 动态导入命名导出
const { formatDate } = await import('./utils/formatting.js');
模块实时绑定
ES 模块导出的是实时绑定,而非值——这是与 CommonJS 的关键区别。
// counter.js
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 中(require),这仍然是 0(复制值)
ESM 与 CommonJS 对比
// CommonJS(Node.js 传统方式)
const fs = require('fs');
const { readFile } = require('fs');
module.exports = { myFunction };
module.exports.myValue = 42;
// ES 模块
import fs from 'fs';
import { readFile } from 'fs';
export { myFunction };
export const myValue = 42;
| 特性 | CommonJS (CJS) | ES 模块 (ESM) |
|---|---|---|
| 语法 | require() / module.exports |
import / export |
| 求值 | 同步 | 异步 |
| 绑定 | 复制值 | 实时绑定 |
| 摇树优化 | ❌ 困难 | ✅ 原生支持 |
| 顶层 await | ❌ 不支持 | ✅ 支持 |
| 文件扩展名 | .js(默认) |
.mjs 或 "type":"module" |
| 浏览器支持 | ❌ 需要打包工具 | ✅ 原生支持 |
| 动态 | 运行时 | 静态(主要) |
循环依赖
// ⚠️ 循环依赖——常见错误来源
// a.js
import { b } from './b.js';
export const a = 'a uses: ' + b; // 此时 b 可能为 undefined!
// b.js
import { a } from './a.js';
export const b = 'b uses: ' + a; // 此时 a 可能为 undefined!
// ESM 通过实时绑定处理此问题——到代码运行时,绑定已解析
// 但初始化顺序很重要!
// ✅ 重构:提取共享依赖
// shared.js
export const shared = 'shared value';
// a.js
import { shared } from './shared.js';
export const a = 'a: ' + shared;
// b.js
import { shared } from './shared.js';
export const b = 'b: ' + shared;
摇树优化
ES 模块支持摇树优化(死代码消除),因为导入是静态的。
// ✅ 命名导入可被摇树优化
import { formatDate } from 'date-fns';
// 打包工具:仅包含 formatDate,而非整个库
// ❌ CommonJS 无法被摇树优化
const dateFns = require('date-fns');
dateFns.formatDate(new Date()); // 整个库被打包!
// ✅ 编写可摇树优化的库
// 分别导出每个函数
export function formatDate(date) { ... }
export function addDays(date, days) { ... }
export function startOfMonth(date) { ... }
// ❌ 导出对象(难以摇树优化)
export default {
formatDate,
addDays,
startOfMonth,
};
顶层 await(仅 ESM)
// 在 ES 模块中,可以在顶层使用 await
// config.js
const config = await fetch('/api/config').then(r => r.json());
export const API_URL = config.apiUrl;
export const FEATURE_FLAGS = config.features;
// 这会暂停模块求值,直到 fetch 完成
// 所有从 config.js 导入的模块都会等待它完成
在 Node.js 中使用
// 选项 1:.mjs 扩展名
// mymodule.mjs
import { readFile } from 'fs/promises';
export async function readConfig(path) {
return JSON.parse(await readFile(path, 'utf8'));
}
// 选项 2:在 package.json 中设置 "type": "module"
// package.json
{
"type": "module"
}
// 现在所有 .js 文件都被视为 ESM
// 使用 .cjs 扩展名表示 CommonJS 文件
// 互操作:从 ESM 导入 CommonJS
import legacyModule from './legacy.cjs'; // 默认导入有效
→ 使用 JSON 转 YAML 转换器 在 JSON 和 YAML 配置文件之间转换。