跳到主要内容

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 模块普通脚本
严格模式自动启用需手动声明
顶层 thisundefinedwindow(浏览器)
变量作用域模块作用域全局作用域或函数作用域
执行时机延迟执行(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 defaultmodule.exports / exports
导入importrequire()
加载时机编译时静态分析运行时动态加载
值的行为实时绑定值拷贝
thisundefined指向当前模块
循环依赖实时绑定可能为 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 开发的基础设施:

  1. 模块化解决核心问题:全局污染、依赖管理、代码封装
  2. 核心特性:自动严格模式、单例模式、实时绑定、延迟执行
  3. 导出方式:命名导出、默认导出、聚合导出
  4. 导入方式:命名导入、默认导入、动态导入
  5. 高级特性:顶层 await、Import Maps、非 JS 资源导入
  6. 与 CommonJS 的差异:加载时机、值的行为、循环依赖处理
  7. 最佳实践:清晰的目录结构、Barrel Files、单一职责、避免循环依赖

练习

  1. 将一个单文件应用拆分成多个模块
  2. 实现一个使用动态导入的路由系统
  3. 创建一个模块聚合入口文件
  4. 使用 Import Maps 配置第三方库的加载
  5. 实现一个支持按需加载的插件系统

参考资源