JavaScript 模块化
模块化是将代码拆分成独立、可复用单元的最佳实践。ES6 模块是 JavaScript 官方的模块标准,它从根本上改变了我们组织和管理代码的方式。
为什么需要模块化
在早期的 JavaScript 开发中,所有代码都写在一个文件里,随着项目规模增长,这种方式暴露出严重问题:
全局变量污染:多个脚本共享全局作用域,变量名冲突难以避免。
// script1.js
var name = "张三";
var config = { debug: true };
// script2.js
var name = "李四"; // 覆盖了 script1.js 的 name
依赖关系混乱:脚本加载顺序至关重要,很难理清模块之间的依赖关系。
<!-- 必须按正确顺序加载 -->
<script src="utils.js"></script>
<script src="validator.js"></script>
<script src="api.js"></script>
<script src="app.js"></script> <!-- 依赖前面的文件 -->
代码难以维护:缺乏封装机制,内部实现细节暴露无遗。
模块化方案的出现正是为了解决这些问题。它提供了:
- 封装性:模块内部的作用域是独立的,不会污染全局
- 显式依赖:通过
import语句明确声明依赖关系 - 可复用性:模块可以在不同项目中复用
- 可维护性:每个模块职责单一,易于理解和测试
ES6 模块的核心特性
ES6 模块与传统脚本有本质区别,理解这些特性对于正确使用模块至关重要。
自动严格模式
ES6 模块自动采用严格模式,无需手动声明 "use strict"。这意味着:
// 模块中以下行为会直接报错
var undeclaredVar = 1; // 必须先声明后使用
delete Object.prototype; // 不能删除不可配置的属性
function foo(a, a) {} // 不能有重名参数
this; // 顶层的 this 是 undefined
严格模式帮助捕获常见错误,是编写可靠代码的基础。
模块是单例
每个模块只会被加载和执行一次,多次导入同一个模块得到的是同一个实例:
// counter.js
let count = 0;
export function increment() {
return ++count;
}
export function getCount() {
return count;
}
// a.js
import { increment } from './counter.js';
increment(); // count = 1
// b.js
import { getCount } from './counter.js';
console.log(getCount()); // 1(不是 0,说明是同一个实例)
这个特性使得模块非常适合作为共享状态的容器,而不需要全局变量。
模块的值是实时绑定
ES6 模块的导入是值的实时绑定,而不是复制。当导出模块中的值发生变化时,导入方会立即看到新值:
// lib.js
export let counter = 0;
export function increment() {
counter++;
}
// main.js
import { counter, increment } from './lib.js';
console.log(counter); // 0
increment();
console.log(counter); // 1(实时更新)
这与 CommonJS 的值拷贝行为完全不同。实时绑定让模块间的数据同步变得简单可靠。
模块代码延迟执行
模块脚本默认具有 defer 行为:它们会异步下载,但在文档解析完成后、DOMContentLoaded 事件之前按顺序执行:
<!-- 模块脚本自动延迟执行 -->
<script type="module" src="app.js"></script>
<!-- 等同于 -->
<script defer src="app.js"></script>
这意味着模块脚本不会阻塞 HTML 解析,性能更好。
导出(export)
export 语句用于将模块内的功能暴露给外部使用。
命名导出
命名导出允许一个模块导出多个功能:
// 方式一:在声明前加 export
export const PI = 3.14159;
export const E = 2.71828;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export class Calculator {
// ...
}
// 方式二:统一在末尾导出
const PI = 3.14159;
const E = 2.71828;
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
export { PI, E, add, subtract };
// 方式三:导出时重命名
export {
PI as PI_VALUE,
add as sum,
subtract as minus
};
默认导出
每个模块只能有一个默认导出。默认导出适合模块的主要功能:
// 导出匿名函数
export default function(a, b) {
return a + b;
}
// 导出命名函数
export default function multiply(a, b) {
return a * b;
}
// 导出类
export default class User {
constructor(name) {
this.name = name;
}
}
// 导出值
export default {
name: "config",
debug: true
};
// 先定义后导出
const api = { /* ... */ };
export default api;
聚合导出(Re-export)
export ... from 语法允许从一个模块导出,然后直接再导出给另一个模块:
// shapes/index.js
export { draw as drawCircle } from './circle.js';
export { draw as drawSquare } from './square.js';
export { draw as drawTriangle } from './triangle.js';
// 一次性重新导出所有命名导出
export * from './utils.js';
// 重新导出默认导出
export { default as UserService } from './user.js';
// 重新导出所有(包括默认导出)
export * from './api.js';
export { default } from './api.js';
聚合导出常用于创建模块的统一入口,隐藏内部结构:
// user/index.js - 模块入口
export { User } from './User.js';
export { UserService } from './UserService.js';
export { default as UserModel } from './UserModel.js';
// 使用者只需导入一次
import { User, UserService, UserModel } from './user/index.js';
导入(import)
import 语句用于导入其他模块导出的功能。
命名导入
// 导入指定的导出
import { add, subtract } from './math.js';
// 导入时重命名
import { add as sum, subtract as minus } from './math.js';
// 导入所有命名导出到对象
import * as math from './math.js';
console.log(math.add(1, 2));
console.log(math.PI);
默认导入
// 导入默认导出(不需要大括号)
import multiply from './math.js';
// 默认导入和命名导入混合
import multiply, { PI, add } from './math.js';
// 导入默认导出并重命名
import { default as multiply } from './math.js';
// 导入所有(包括默认导出)
import * as math from './math.js';
console.log(math.default(2, 3)); // 访问默认导出
导入的只读性
导入的绑定是只读的,不能重新赋值:
import { counter } from './counter.js';
counter = 10; // TypeError: Assignment to constant variable
// 但如果是对象,可以修改其属性
import { config } from './config.js';
config.debug = false; // 允许
导入提升
import 语句会被提升到模块顶部,无论它写在哪里:
console.log(add(1, 2)); // 正常工作
import { add } from './math.js'; // 导入被提升了
虽然可以工作,但为了代码可读性,建议将所有 import 语句放在文件顶部。
动态导入
静态 import 语句在模块加载时就执行,有时我们需要按需加载模块。import() 函数提供了动态导入的能力:
基本语法
// import() 返回一个 Promise
import('./module.js')
.then(module => {
module.doSomething();
})
.catch(error => {
console.error('模块加载失败:', error);
});
// 使用 async/await
async function loadModule() {
const module = await import('./module.js');
module.doSomething();
}
按需加载场景
1. 路由懒加载
// 根据路由动态加载页面组件
async function loadPage(route) {
let page;
switch (route) {
case 'home':
page = await import('./pages/home.js');
break;
case 'about':
page = await import('./pages/about.js');
break;
case 'contact':
page = await import('./pages/contact.js');
break;
}
return page.default;
}
// 或者更简洁的方式
const pages = {
home: () => import('./pages/home.js'),
about: () => import('./pages/about.js'),
contact: () => import('./pages/contact.js')
};
async function navigate(route) {
const module = await pages[route]();
const Page = module.default;
new Page().render();
}
2. 条件加载
// 根据浏览器支持情况加载 polyfill
async function loadPolyfills() {
if (!window.IntersectionObserver) {
await import('intersection-observer');
}
if (!window.ResizeObserver) {
await import('resize-observer-polyfill');
}
}
// 根据用户权限加载功能
async function loadAdminPanel(user) {
if (user.isAdmin) {
const { AdminPanel } = await import('./admin.js');
return new AdminPanel();
}
return null;
}
3. 用户交互触发加载
// 点击按钮时加载大型库
document.getElementById('loadChart').addEventListener('click', async () => {
const { Chart } = await import('chart.js');
new Chart(ctx, { /* 配置 */ });
});
// 打开模态框时加载
document.getElementById('openModal').addEventListener('click', async () => {
const { Modal } = await import('./modal.js');
new Modal().open();
});
动态导入的特点
import()可以在普通脚本中使用,不限于模块- 返回的是 Promise,可以用在任何支持 Promise 的地方
- 加载的模块会立即执行
- 可以动态构建模块路径
// 动态构建路径
function loadLanguage(lang) {
return import(`./i18n/${lang}.js`);
}
// 注意:动态路径需要有限制,Webpack 会打包所有可能的文件
顶层 await
ES2022 引入了顶层 await,允许在模块顶层直接使用 await,无需包裹在 async 函数中:
基本用法
// config.js
const response = await fetch('/api/config');
export const config = await response.json();
// main.js
import { config } from './config.js';
// 这里的代码会在 config 加载完成后才执行
console.log(config);
顶层 await 会暂停当前模块的执行,等待 Promise 完成,但不会阻塞兄弟模块的加载:
// a.js
console.log('a 开始');
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('a 完成');
export const a = 'a';
// b.js
console.log('b 开始');
export const b = 'b';
// main.js
import './a.js';
import './b.js';
// 输出顺序:
// a 开始
// b 开始(不等待 a 完成)
// b 完成
// a 完成(1秒后)
实际应用场景
1. 加载配置文件
// config.js
const env = process.env.NODE_ENV;
const config = await import(`./config/${env}.json`, {
assert: { type: 'json' }
});
export default config;
2. 数据库连接
// database.js
const { MongoClient } = await import('mongodb');
const client = new MongoClient(process.env.DB_URL);
await client.connect();
export const db = client.db('myapp');
3. 条件导出
// feature.js
const hasFeature = await checkFeatureFlag('new-ui');
export { hasFeature };
export const Component = hasFeature
? (await import('./NewComponent.js')).default
: (await import('./OldComponent.js')).default;
Import Maps(导入映射)
Import Maps 允许开发者控制模块标识符的解析方式,可以用简洁的名称导入模块:
基本用法
<script type="importmap">
{
"imports": {
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/",
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<script type="module">
// 使用简洁名称导入
import _ from 'lodash';
import { debounce } from 'lodash/debounce.js';
import { createApp } from 'vue';
</script>
版本管理
Import Maps 可以管理不同版本的依赖:
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]",
"react-dom": "https://esm.sh/[email protected]"
},
"scopes": {
"/legacy/": {
"react": "https://esm.sh/[email protected]",
"react-dom": "https://esm.sh/[email protected]"
}
}
}
</script>
缓存优化
使用 Import Maps 管理带哈希的文件名,便于缓存:
<script type="importmap">
{
"imports": {
"app": "/dist/app.a1b2c3d4.js",
"utils": "/dist/utils.e5f6g7h8.js"
}
}
</script>
当文件更新时,只需更新 import map,无需修改源代码中的导入语句。
加载非 JavaScript 资源
ES 模块支持导入 JSON 和 CSS 等非 JavaScript 资源:
导入 JSON
// 使用 import attributes(新语法)
import config from './config.json' with { type: 'json' };
console.log(config.apiKey);
// 或使用 assert(旧语法)
import data from './data.json' assert { type: 'json' };
导入 CSS
// 导入 CSS 样式表
import styles from './styles.css' with { type: 'css' };
// styles 是 CSSStyleSheet 对象
document.adoptedStyleSheets = [...document.adoptedStyleSheets, styles];
动态导入资源
// 动态导入 JSON
async function loadData() {
const { default: data } = await import('./data.json', {
with: { type: 'json' }
});
return data;
}
模块与脚本的区别
理解模块和普通脚本的区别,有助于避免常见陷阱:
| 特性 | ES6 模块 | 普通脚本 |
|---|---|---|
| 严格模式 | 自动启用 | 需手动声明 |
顶层 this | undefined | window(浏览器) |
| 变量作用域 | 模块作用域 | 全局作用域或函数作用域 |
| 执行时机 | 延迟执行(defer) | 立即执行(除非 defer/async) |
| 重复加载 | 只执行一次 | 每次都执行 |
import/export | 可用 | 不可用 |
| 跨域限制 | 需要正确的 CORS | 较宽松 |
模块作用域示例
// module.js
const privateVar = '我是模块私有变量';
export const publicVar = '我是导出的变量';
// 普通脚本无法访问模块私有变量
<script type="module">
import { publicVar } from './module.js';
console.log(publicVar); // 正常
// console.log(privateVar); // ReferenceError
</script>
循环依赖
循环依赖是指两个或多个模块相互依赖。ES6 模块可以处理循环依赖,但需要谨慎设计:
循环依赖的示例
// a.js
import { b } from './b.js';
export const a = 'a';
console.log('a.js 中 b:', b);
// b.js
import { a } from './a.js';
export const b = 'b';
console.log('b.js 中 a:', a);
// main.js
import './a.js';
// 输出:
// b.js 中 a: undefined(a 还未导出)
// a.js 中 b: b
处理循环依赖的策略
1. 重构代码避免循环
最根本的解决方案是重构模块结构:
// 将共享逻辑提取到独立模块
// shared.js
export const sharedFunction = () => { /* ... */ };
// a.js
import { sharedFunction } from './shared.js';
// b.js
import { sharedFunction } from './shared.js';
2. 延迟导入
将导入移到函数内部,在使用时才导入:
// a.js
export function doA() {
const { doB } = require('./b.js'); // 动态导入
return doB() + ' from a';
}
// b.js
export function doB() {
return 'b';
}
3. 使用函数封装导出值
// a.js
import { getB } from './b.js';
export function getA() {
return 'a' + getB();
}
// b.js
import { getA } from './a.js';
export function getB() {
return 'b';
}
CommonJS 兼容性
Node.js 同时支持 ES 模块和 CommonJS。理解两者的差异有助于迁移和混合使用:
CommonJS 基础
// 导出
module.exports = {
name: 'utils',
add: (a, b) => a + b
};
// 或
exports.subtract = (a, b) => a - b;
// 导入
const utils = require('./utils.js');
const { add } = require('./utils.js');
主要差异
| 特性 | ES 模块 | CommonJS |
|---|---|---|
| 导出 | export / export default | module.exports / exports |
| 导入 | import | require() |
| 加载时机 | 编译时静态分析 | 运行时动态加载 |
| 值的行为 | 实时绑定 | 值拷贝 |
this | undefined | 指向当前模块 |
| 循环依赖 | 实时绑定可能为 undefined | 得到部分导出 |
值拷贝 vs 实时绑定
// counter.cjs (CommonJS)
let count = 0;
module.exports = {
count,
increment: () => ++count
};
// counter.mjs (ES Module)
export let count = 0;
export const increment = () => ++count;
// 使用对比
// CommonJS
const counter = require('./counter.cjs');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0(值拷贝,不会更新)
// ES Module
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1(实时绑定)
在 Node.js 中启用 ES 模块
方式一:使用 .mjs 扩展名
// app.mjs
import { readFileSync } from 'fs';
方式二:在 package.json 中设置 type
{
"name": "my-app",
"type": "module",
"version": "1.0.0"
}
设置后,.js 文件会被视为 ES 模块,CommonJS 文件需要使用 .cjs 扩展名。
互操作
ES 模块可以导入 CommonJS 模块,但 CommonJS 模块不能直接 require ES 模块:
// ES 模块导入 CommonJS
import fs from 'fs'; // fs 是 CommonJS 模块
import { readFileSync } from 'fs';
// CommonJS 导入 ES 模块(需要动态导入)
const module = await import('./es-module.mjs');
模块最佳实践
目录结构设计
src/
├── modules/
│ ├── user/
│ │ ├── index.js # 模块入口(barrel file)
│ │ ├── User.js # 用户类
│ │ ├── UserService.js # 用户服务
│ │ └── types.js # 类型定义
│ ├── product/
│ │ ├── index.js
│ │ └── ...
│ └── utils/
│ ├── index.js
│ ├── format.js
│ └── validation.js
├── components/
├── pages/
└── main.js
Barrel Files(索引文件)
为每个模块目录创建入口文件,统一导出:
// modules/user/index.js
export { User } from './User.js';
export { UserService } from './UserService.js';
export type { UserType, UserRole } from './types.js';
// 使用时简洁明了
import { User, UserService } from './modules/user/index.js';
单一职责原则
每个模块只做一件事,导出相关的功能:
// 好的做法:一个模块一个职责
// validation.js - 只做验证
export function validateEmail(email) { /* ... */ }
export function validatePhone(phone) { /* ... */ }
export function validatePassword(password) { /* ... */ }
// 不好的做法:一个模块做太多事
// utils.js - 职责不清晰
export function validateEmail() { /* ... */ }
export function formatDate() { /* ... */ }
export function fetchAPI() { /* ... */ }
避免循环依赖
设计模块时遵循单向依赖原则:
高层模块 → 低层模块 → 基础模块
(应用层) (业务层) (工具层)
合理使用默认导出
// 类和主要功能用默认导出
export default class User { }
// 工具函数用命名导出
export function formatDate() { }
export function parseDate() { }
// 常量用命名导出
export const API_URL = '...';
预加载模块
对于关键模块,可以使用 <link rel="modulepreload"> 预加载:
<link rel="modulepreload" href="./critical-module.js">
<script type="module" src="./app.js"></script>
小结
ES6 模块是现代 JavaScript 开发的基础设施:
- 模块化解决核心问题:全局污染、依赖管理、代码封装
- 核心特性:自动严格模式、单例模式、实时绑定、延迟执行
- 导出方式:命名导出、默认导出、聚合导出
- 导入方式:命名导入、默认导入、动态导入
- 高级特性:顶层 await、Import Maps、非 JS 资源导入
- 与 CommonJS 的差异:加载时机、值的行为、循环依赖处理
- 最佳实践:清晰的目录结构、Barrel Files、单一职责、避免循环依赖
练习
- 将一个单文件应用拆分成多个模块
- 实现一个使用动态导入的路由系统
- 创建一个模块聚合入口文件
- 使用 Import Maps 配置第三方库的加载
- 实现一个支持按需加载的插件系统