JavaScript 模块系统
大约 7 分钟约 1998 字
JavaScript 模块系统
模块系统的发展历程
JavaScript 模块系统解决了代码组织、依赖管理和命名冲突问题。从早期的全局变量污染,到 CommonJS(Node.js)的服务端模块方案,再到 ES Modules(ESM)成为语言标准,JavaScript 的模块化经历了漫长演进。
全局变量 → IIFE 模式 → CommonJS (require) → AMD (require.js) → UMD → ES Modules (import/export)
↑ 当前标准为什么需要模块化?
// 问题 1:全局变量污染
// file-a.js
var name = '张三';
// file-b.js
var name = '李四'; // 覆盖了 file-a.js 的变量
// 问题 2:依赖管理混乱
// 需要手动管理 script 标签的加载顺序
// <script src="jquery.js"></script>
// <script src="underscore.js"></script>
// <script src="app.js"></script> <!-- 依赖前两者 -->
// 解决:模块化
// 每个文件是独立的作用域,通过 import/export 建立明确的依赖关系ES Modules(ESM)
ES Modules 是 JavaScript 的官方模块系统,使用 import 和 export 语法。它具有静态分析能力——编译时就能确定模块的依赖关系,这使得 Tree Shaking、代码分割等优化成为可能。
命名导出
// utils.js — 一个文件可以有多个命名导出
export const PI = 3.14159;
export function formatDate(date) {
if (!(date instanceof Date)) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function debounce(fn, delay) {
let timer = null;
const debounced = function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
debounced.cancel = () => clearTimeout(timer);
return debounced;
}
export function throttle(fn, interval) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
export function deepClone(obj, cache = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj;
if (cache.has(obj)) return cache.get(obj);
const clone = Array.isArray(obj) ? [] : {};
cache.set(obj, clone);
for (const key of Object.keys(obj)) {
clone[key] = deepClone(obj[key], cache);
}
return clone;
}默认导出
// Logger.js — 一个文件只能有一个默认导出
export default class Logger {
constructor(prefix = '') {
this.prefix = prefix;
this.level = 'info';
}
log(msg) {
console.log(`[${this.prefix}] [LOG] ${msg}`);
}
warn(msg) {
console.warn(`[${this.prefix}] [WARN] ${msg}`);
}
error(msg) {
console.error(`[${this.prefix}] [ERROR] ${msg}`);
}
setLevel(level) {
this.level = level;
}
}
// 也可以导出函数作为默认
// export default function createLogger(prefix) { ... }导入语法
// 导入默认导出
import Logger from './Logger';
// 导入命名导出
import { formatDate, debounce } from './utils';
// 导入所有命名导出为命名空间对象
import * as Utils from './utils';
Utils.formatDate(new Date());
Utils.debounce(fn, 300);
// 同时导入默认和命名
import Logger, { formatDate } from './Logger';
// 重命名导入
import { formatDate as format } from './utils';
// 仅执行模块(不导入任何内容)
import './polyfills';重导出(Barrel Files)
// index.ts — 桶文件(Barrel File)
// 统一导出模块的公共 API
export { formatDate, debounce, throttle } from './utils';
export { default as Logger } from './Logger';
export { default as httpClient } from './http';
export { useAuth, useUser } from './hooks/auth';
// 好处:外部只需要从一个入口导入
// import { formatDate, Logger, useAuth } from '@/lib';
// 而不是
// import { formatDate } from '@/lib/utils';
// import Logger from '@/lib/Logger';
// import { useAuth } from '@/lib/hooks/auth';动态导入
动态 import() 返回 Promise,支持按需加载、条件加载和懒加载。
// 按需加载模块
async function loadChart() {
const { Chart } = await import('chart.js');
const canvas = document.getElementById('myChart');
return new Chart(canvas, config);
}
// 条件加载
async function loadEditor() {
if (featureFlags.useRichEditor) {
const { RichEditor } = await import('./editors/RichEditor');
return new RichEditor();
} else {
const { PlainEditor } = await import('./editors/PlainEditor');
return new PlainEditor();
}
}
// 预加载(空闲时加载)
const modulePromise = import('./heavy-module');
// 不 await,浏览器在空闲时加载
// 后续使用时:
// const module = await modulePromise;
// Vue Router 懒加载
const routes = [
{ path: '/dashboard', component: () => import('./views/Dashboard.vue') },
{ path: '/devices', component: () => import('./views/Devices.vue') },
{ path: '/settings', component: () => import('./views/Settings.vue') },
];
// React Router 懒加载
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
// 带注释的预加载提示
const HeavyComponent = lazy(() => import(
/* webpackChunkName: "heavy-component" */
/* webpackPrefetch: true */
'./HeavyComponent'
));CommonJS(CJS)
CommonJS 是 Node.js 使用的模块系统,使用 require() 和 module.exports。
// 导出
// utils.js
function formatDate(date) {
return new Intl.DateTimeFormat('zh-CN').format(date);
}
function debounce(fn, delay) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); };
}
module.exports = { formatDate, debounce };
// 或
exports.formatDate = formatDate;
exports.debounce = debounce;
// 导入
const { formatDate, debounce } = require('./utils');
const utils = require('./utils');ESM vs CJS 对比
| 维度 | ES Modules | CommonJS |
|---|---|---|
| 语法 | import/export | require/module.exports |
| 加载时机 | 编译时静态分析 | 运行时动态加载 |
| Tree Shaking | 支持(静态分析) | 不支持(动态加载) |
| 顶层 this | undefined | module.exports |
| 循环依赖 | 已解决(引用) | 部分解决(值拷贝) |
| 异步加载 | import() 动态导入 | require() 同步加载 |
| 使用场景 | 浏览器 + Node.js | Node.js |
ESM 和 CJS 互操作
// ESM 导入 CJS — 可以
import { formatDate } from './cjs-utils.js'; // OK
// CJS 导入 ESM — 默认不支持
// 需要使用动态 import()
const { formatDate } = await import('./esm-utils.js'); // OK
// CJS 导入 ESM 的默认导出
const mod = await import('./esm-module.js');
const defaultExport = mod.default;Tree Shaking
Tree Shaking 是打包工具(Webpack、Rollup、Vite)利用 ESM 的静态分析能力,在构建时移除未被使用的导出代码。
Tree Shaking 友好的代码
// Good — 命名导出,打包工具可以按需保留
export function used() {
return '这个函数被使用了';
}
export function unused() {
return '这个函数未被使用,打包时会被移除';
}
// Bad — 整体导出,无法 Tree Shake
export default {
used() { return '被使用'; },
unused() { return '未使用,但因为整体导出无法移除' },
};
// Good — 使用命名导出
// 只导入需要的内容
import { used } from './utils';sideEffects 配置
// package.json
{
"name": "my-lib",
"sideEffects": false,
// 告诉打包工具这个包的所有文件都没有副作用
// 打包工具可以安全地移除未使用的导出
// 如果某些文件有副作用
"sideEffects": [
"*.css",
"*.less",
"./src/polyfills.ts"
]
}// 有副作用的代码(不能被 Tree Shake)
// 即使未被导入,也不应该被移除
Array.prototype.myCustomMethod = function() {
// 修改全局原型
};
window.customEvent = new Event('init');package.json 的 exports 字段
exports 字段定义了包的模块入口和条件导出,是现代 npm 包的推荐配置方式。
{
"name": "@myorg/utils",
"version": "2.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./format": {
"types": "./dist/format.d.ts",
"import": "./dist/format.js",
"require": "./dist/format.cjs"
},
"./styles": "./dist/styles.css",
"./package.json": "./package.json"
},
"files": ["dist"],
"sideEffects": false
}条件导出
{
"exports": {
".": {
"node": {
"import": "./dist/node/index.js",
"require": "./dist/node/index.cjs"
},
"browser": "./dist/browser/index.js",
"default": "./dist/index.js"
}
}
}循环依赖
循环依赖是指两个或多个模块互相引用,可能导致 undefined 或运行时错误。
问题示例
// a.js
import { b } from './b.js';
export const a = () => `a + ${b()}`;
// b.js
import { a } from './a.js';
export const b = () => `b + ${a()}`;
// 执行 b.js 时,a.js 还未完成导出 → a 为 undefined → TypeError解决方案
// 方案 1:延迟导入
// a.js
export const a = () => `a + ${b()}`;
// b.js
export const b = () => 'b result';
// 方案 2:提取共享逻辑到第三个模块
// shared.js
export const shared = () => 'shared result';
// a.js
import { shared } from './shared.js';
export const a = () => `a + ${shared()}`;
// b.js
import { shared } from './shared.js';
export const b = () => `b + ${shared()}`;
// 方案 3:动态导入打破循环
// a.js
export const a = () => {
const { b } = require('./b.js'); // 动态导入
return `a + ${b()}`;
};检测循环依赖
# 使用 madge 工具检测
npx madge --circular src/
# 输出
# 1. src/utils/a.js → src/utils/b.js → src/utils/a.js路径别名
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"],
"@utils/*": ["src/utils/*"],
"@stores/*": ["src/stores/*"],
"@types/*": ["src/types/*"]
}
}
}
// vite.config.ts
import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
'@hooks': resolve(__dirname, 'src/hooks'),
},
},
});
// 使用
import { formatDate } from '@/utils/format';
import { useAuth } from '@/hooks/useAuth';
import { UserCard } from '@/components/UserCard';常见误区
误区 1:循环依赖导致 undefined
// 症状:导入的值是 undefined
import { value } from './module';
console.log(value); // undefined
// 原因:循环依赖导致模块尚未完成导出
// 解决:重构代码消除循环依赖,或使用延迟导入误区 2:动态导入路径使用变量
// Bad — 打包工具无法静态分析,无法做代码分割
const moduleName = 'Dashboard';
const module = await import(`./views/${moduleName}.vue`);
// Good — 使用明确的映射
const modules = {
dashboard: () => import('./views/Dashboard.vue'),
settings: () => import('./views/Settings.vue'),
};
const module = await modules[moduleName]();误区 3:混用 default 和 named export
// Bad — 容易混淆
export default function main() {}
export const helper = () => {};
// 导入时
import main, { helper } from './module'; // OK
import { default as main, helper } from './module'; // 也可以最佳实践总结
- 统一使用 ESM 语法 — 新项目避免混用 CJS 和 ESM
- 使用命名导出 — 便于 Tree Shaking 按需保留
- 路由和大型组件使用动态导入 — 减小首屏体积
- 使用 barrel file 统一导出 — 简化导入路径
- 配置路径别名 — 避免深层相对路径
- 检测和消除循环依赖 — 使用 madge 工具
