模块开发
# 10.模块开发
# 目录介绍
# 10.1 模块化概述
export 是 JavaScript 模块化的核心语法,支持导出变量、函数、类等内容,并通过 import 在其他模块中使用。掌握 export 和 import 的使用可以更好地组织和管理代码。
# 10.1.1 模块化概念
模块化是指将一个大的程序文件,拆分成许多小的文件,然后将小文件组合起来。
JavaScript 中的 export 是 ES6(ECMAScript 2015)引入的模块化语法,用于从模块中导出变量、函数、类等,以便其他模块可以使用 import 导入这些内容。
模块化的核心价值:
- 可维护性:每个模块职责单一,修改时影响范围小
- 可复用性:模块可以在不同项目中复用
- 命名空间隔离:避免全局变量污染
- 依赖管理:模块之间的依赖关系明确
# 10.1.2 模块化的演进历史
疑惑:为什么 JavaScript 在诞生很久之后才有模块系统?
答疑:JavaScript 最初是为浏览器中简单的页面交互设计的,没有考虑大规模应用的需求。随着前端工程化的发展,社区先后提出了多种模块化方案。
论证——技术演变过程:
// ===== 阶段1:全局函数(原始时代)=====
// 所有函数和变量都在全局作用域中,极易冲突
var count = 0;
function increment() { count++; }
// ===== 阶段2:命名空间模式 =====
var MyApp = {};
MyApp.count = 0;
MyApp.increment = function() { MyApp.count++; };
// ===== 阶段3:IIFE模块模式 =====
var CounterModule = (function() {
var count = 0; // 私有
return {
increment: function() { return ++count; },
getCount: function() { return count; }
};
})();
// ===== 阶段4:CommonJS(2009,Node.js)=====
// 同步加载,适合服务端
const fs = require('fs');
module.exports = { readConfig: () => {} };
// ===== 阶段5:AMD(2010,RequireJS)=====
// 异步加载,适合浏览器
define(['jquery'], function($) {
return { init: function() {} };
});
// ===== 阶段6:UMD(兼容 CommonJS + AMD + 全局)=====
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof module === 'object') {
module.exports = factory();
} else {
root.MyLib = factory();
}
}(this, function() {
return { name: 'MyLib' };
}));
// ===== 阶段7:ES Module(2015,语言标准)=====
// 静态结构,支持 Tree Shaking
import { readFile } from 'fs/promises';
export const config = { debug: false };
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
结果展示:
| 阶段 | 方案 | 加载方式 | 适用环境 | 年代 |
|---|---|---|---|---|
| 1 | 全局变量 | - | 浏览器 | 1995 |
| 2 | 命名空间 | - | 浏览器 | 2002 |
| 3 | IIFE | - | 浏览器 | 2008 |
| 4 | CommonJS | 同步 | Node.js | 2009 |
| 5 | AMD | 异步 | 浏览器 | 2010 |
| 6 | UMD | 兼容 | 通用 | 2011 |
| 7 | ES Module | 异步/静态 | 通用 | 2015 |
# 10.2 导出模块
# 10.2.1 基本导出
1.导出单个变量、函数或类
使用 export 关键字直接导出。
// module.js
export const name = 'Alice';
export function greet() {
console.log('Hello!');
}
export class Person {
constructor(name) {
this.name = name;
}
}
2
3
4
5
6
7
8
9
10
2.导出默认值
使用 export default 导出模块的默认值,一个模块只能有一个默认导出。
// module.js
const message = 'Hello, World!';
export default message;
2
3
# 10.2.2 批量导出
1.导出多个内容
在模块底部统一导出。
// module.js
const name = 'Alice';
function greet() {
console.log('Hello!');
}
class Person {
constructor(name) {
this.name = name;
}
}
export { name, greet, Person };
2
3
4
5
6
7
8
9
10
11
12
2.重命名导出
使用 as 关键字重命名导出的内容。
// module.js
const name = 'Alice';
function greet() {
console.log('Hello!');
}
export { name as userName, greet as sayHello };
2
3
4
5
6
7
# 10.2.3 重新导出
1.重新导出
从一个模块中导入内容并立即导出,常用于创建"聚合模块"(barrel file)。
// index.js —— 聚合模块
export { name, greet } from './anotherModule.js';
export { default as Config } from './config.js';
export * from './utils.js'; // 重新导出所有命名导出
// 使用者只需导入聚合模块
import { name, greet, Config, formatDate } from './index.js';
2
3
4
5
6
7
2.重新导出默认值
重新导出另一个模块的默认值。
// module.js
export { default } from './anotherModule.js';
2
# 10.2.4 命名导出与默认导出的设计权衡
疑惑:什么时候用命名导出(export),什么时候用默认导出(export default)?
答疑:两种方式各有适用场景,但现代最佳实践倾向于优先使用命名导出。
论证:
// ===== 命名导出的优势 =====
// 1. 导入时有明确的名称,IDE 自动补全友好
import { formatDate, parseDate } from './date-utils.js';
// 2. 强制使用原始名称(除非显式 as 重命名),团队一致性好
// import { formatDate as fd } from './date-utils.js'; // 显式重命名
// 3. 支持 Tree Shaking —— 未使用的导出会被打包工具移除
export function used() { /* ... */ }
export function unused() { /* ... */ } // 不被导入则不打包
// ===== 默认导出的优势 =====
// 1. 导入时可以使用任意名称
import MyComponent from './component.js'; // 叫什么都行
import Comp from './component.js';
// 2. 适合"一个模块一个主要内容"的场景
export default class Button { /* ... */ }
// ===== 默认导出的问题 =====
// 1. 导入名称不一致,不同文件可能用不同名字导入同一个东西
// fileA.js: import Button from './Button';
// fileB.js: import Btn from './Button'; // 同一个东西,不同名字
// 2. 不利于自动重构 —— IDE 无法自动关联重命名
// 3. re-export 时语法不直观
// export { default as Button } from './Button'; // 较啰嗦
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
结果展示:推荐的导出策略——
| 场景 | 推荐方式 | 示例 |
|---|---|---|
| 工具函数集合 | 命名导出 | export function formatDate() {} |
| 类/组件(一个文件一个) | 命名导出或默认导出均可 | export class Button {} |
| 常量/配置 | 命名导出 | export const API_URL = '...' |
| 聚合模块 | 命名导出 + re-export | export { A } from './a' |
# 10.3 导入模块
# 10.3.1 导入使用
普通导入,作用:导入实际的代码或值,会在编译后的 JavaScript 代码中包含这些内容。
使用场景:当你需要实际使用类、函数或变量时。
1.导入命名导出
使用 import { ... } from 'module' 导入命名导出。
// main.js
import { name, greet, Person } from './module.js';
console.log(name); // 输出: Alice
greet(); // 输出: Hello!
const person = new Person('Bob');
console.log(person.name); // 输出: Bob
2
3
4
5
6
2.导入默认导出
使用 import defaultExport from 'module' 导入默认导出。
// main.js
import message from './module.js';
console.log(message); // 输出: Hello, World!
2
3
3.导入所有内容
使用 import * as alias from 'module' 导入所有内容并绑定到一个对象。
// main.js
import * as module from './module.js';
console.log(module.name); // 输出: Alice
module.greet(); // 输出: Hello!
2
3
4
4.同时导入默认导出和命名导出
import React, { useState, useEffect } from 'react';
// React 是默认导出,useState/useEffect 是命名导出
2
5.仅执行模块(副作用导入)
import './polyfill.js'; // 只执行模块代码,不导入任何值
import './styles.css'; // Webpack 等打包工具支持导入 CSS
2
# 10.3.2 类型导入
作用:仅导入类型信息,不会在编译后的 JavaScript 代码中包含这些类型。
使用场景:当你只需要类型信息(例如定义 this 的类型)时。
类型导入 vs 普通导入。示例:
import type MainSession from "./index-session"; // 类型导入。只在编译时存在,运行时会被删除
import { handleQRRegister } from "./index-session"; // 普通导入(函数)。会导入实际的类和运行时代码
2
为什么使用类型导入
export async function handleQRRegister(
this: MainSession, // 这里使用 MainSession 作为 this 的类型
qrCodeContent: string
): Promise<void> {
// 函数体中可以访问 this.logger, this.sessionKey 等属性
const logger = this.logger;
// ...
}
2
3
4
5
6
7
8
函数绑定机制,在 index-session.ts 中,这个函数被绑定到类实例:
在 handleQRRegister 函数中,this 的类型被定义为 MainSession。
使用 call 方法将 this 绑定到 MainSession 的实例。
async handleQRRegister(qrCodeContent: string): Promise<void> {
return handleQRRegister.call(this, qrCodeContent); // 使用 call 绑定 this
}
2
3
# 10.3.3 动态导入
使用 import() 动态加载模块,返回一个 Promise。
// main.js
import('./module.js').then(module => {
console.log(module.name); // 输出: Alice
module.greet(); // 输出: Hello!
});
// 配合 async/await
async function loadModule() {
const { name, greet } = await import('./module.js');
console.log(name);
greet();
}
// 条件加载
async function loadLocale(lang) {
const module = await import(`./locales/${lang}.js`);
return module.default;
}
// 路由懒加载(React)
const LazyComponent = React.lazy(() => import('./HeavyComponent'));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 10.3.4 注意事项
- 模块作用域:模块中的变量、函数、类默认是模块作用域,不会污染全局作用域。
- 严格模式:模块默认启用严格模式。
- 文件扩展名:在浏览器中使用模块时,文件扩展名必须是
.js或.mjs。 - 跨模块引用:模块路径可以是相对路径、绝对路径或模块名称(如
'lodash')。 - import 语句提升:
import语句会被提升到模块顶部,在任何代码执行前完成。
// import 语句会被提升
console.log(name); // "Alice"(不会报错,因为 import 被提升了)
import { name } from './module.js';
// 但 import() 动态导入不会被提升
const module = await import('./module.js'); // 按正常顺序执行
2
3
4
5
6
# 10.3.5 导入的底层机制:活绑定
疑惑:ES Module 导入的值是"拷贝"还是"引用"?
答疑:ES Module 的导入是活绑定(Live Binding)——导入的变量始终指向导出模块中的原始值。如果导出模块修改了变量,导入方也能看到最新值。
论证:
// 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 —— 能看到变化!这就是活绑定
// 注意:导入的变量是只读的
// count = 10; // TypeError: Assignment to constant variable
// 只能通过导出模块提供的函数来修改
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
与 CommonJS 的对比:
// CommonJS —— 值拷贝
// counter.js
let count = 0;
module.exports = {
count,
increment() { count++; }
};
// main.js
const { count, increment } = require('./counter.js');
console.log(count); // 0
increment();
console.log(count); // 0!! —— CommonJS 是值拷贝,看不到变化
2
3
4
5
6
7
8
9
10
11
12
13
结果展示:
| 特性 | ES Module | CommonJS |
|---|---|---|
| 绑定类型 | 活绑定(引用) | 值拷贝 |
| 导入变量 | 只读 | 可修改 |
| 加载时机 | 编译时(静态) | 运行时(动态) |
顶层 this | undefined | module.exports |
# 10.4 CommonJS模块
# 10.4.1 require和module.exports
CommonJS 是 Node.js 使用的模块系统,使用 require() 导入和 module.exports 导出:
// math.js
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
module.exports = { add, subtract };
// 或者逐个导出
// exports.add = add;
// exports.subtract = subtract;
// main.js
const { add, subtract } = require('./math');
console.log(add(1, 2)); // 3
2
3
4
5
6
7
8
9
10
11
12
module.exports vs exports 的陷阱:
// exports 是 module.exports 的引用
console.log(exports === module.exports); // true
// 可以给 exports 添加属性
exports.foo = 'bar'; // OK,等同于 module.exports.foo = 'bar'
// 但不能直接赋值 exports
exports = { foo: 'bar' }; // 错误!这会断开引用
// 此时 module.exports 仍然是 {}
// 正确做法
module.exports = { foo: 'bar' };
2
3
4
5
6
7
8
9
10
11
12
# 10.4.2 CommonJS与ESM的核心区别
// ===== CommonJS =====
// 1. 动态加载(运行时)
const mod = require(condition ? './a' : './b'); // 可以用变量
// 2. 同步加载
const fs = require('fs'); // 阻塞执行直到加载完成
// 3. 值拷贝
const { count } = require('./counter');
// count 是快照值,不会随源模块变化
// 4. 可以在任意位置 require
function foo() {
if (someCondition) {
const lib = require('heavy-lib'); // 条件加载
}
}
// ===== ES Module =====
// 1. 静态加载(编译时)
import mod from './a'; // 不能用变量,必须是字面量路径
// 2. 异步加载
import { readFile } from 'fs/promises'; // 非阻塞
// 3. 活绑定
import { count } from './counter';
// count 始终反映源模块的最新值
// 4. import 必须在模块顶层
// if (condition) { import x from './x'; } // SyntaxError!
// 但可以用动态导入
if (condition) {
const x = await import('./x');
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 10.4.3 循环依赖的处理
疑惑:如果两个模块相互导入(A 导入 B,B 也导入 A),会发生什么?
答疑:CommonJS 和 ES Module 对循环依赖的处理方式不同。
论证:
// ===== CommonJS 的循环依赖 =====
// a.js
console.log('a 开始执行');
exports.done = false;
const b = require('./b'); // 此时跳转到 b.js 执行
console.log('在 a 中, b.done =', b.done);
exports.done = true;
console.log('a 执行完毕');
// b.js
console.log('b 开始执行');
exports.done = false;
const a = require('./a'); // a.js 还没执行完,拿到的是不完整的 exports
console.log('在 b 中, a.done =', a.done); // false(不完整值)
exports.done = true;
console.log('b 执行完毕');
// 执行 node a.js 输出:
// a 开始执行
// b 开始执行
// 在 b 中, a.done = false ← 拿到了不完整的值!
// b 执行完毕
// 在 a 中, b.done = true
// a 执行完毕
// ===== ES Module 的循环依赖 =====
// a.mjs
import { bValue } from './b.mjs';
export const aValue = 'a';
console.log('在 a 中, bValue =', bValue); // 'b'(活绑定,最终能拿到正确值)
// b.mjs
import { aValue } from './a.mjs';
export const bValue = 'b';
console.log('在 b 中, aValue =', aValue); // undefined 或报错(取决于执行顺序)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
结果展示:ES Module 的活绑定在循环依赖中表现更好(最终能拿到正确值),但如果在变量初始化前就访问(TDZ),仍然会报错。最佳实践是尽量避免循环依赖。
# 10.5 模块打包与构建
# 10.5.1 Tree Shaking原理
Tree Shaking 是一种在打包时移除未使用代码的技术,只有 ES Module 支持。
疑惑:为什么 Tree Shaking 只能用于 ES Module?
答疑:Tree Shaking 依赖于 ES Module 的静态结构——import/export 在编译时就能确定依赖关系,打包工具可以分析出哪些导出被使用、哪些没有。CommonJS 的 require() 是动态的(可以用变量路径),无法在编译时分析。
论证:
// utils.js
export function formatDate(d) { /* ... */ }
export function formatNumber(n) { /* ... */ }
export function formatCurrency(n) { /* ... */ }
// main.js —— 只使用了 formatDate
import { formatDate } from './utils.js';
console.log(formatDate(new Date()));
// 打包后:formatNumber 和 formatCurrency 不会出现在最终代码中
2
3
4
5
6
7
8
9
10
影响 Tree Shaking 的因素:
// 1. 副作用代码无法被 Tree Shake
export function pure() { return 1; } // 纯函数,可以安全移除
export const result = calculate(); // 有副作用,不敢移除
// 2. package.json 的 sideEffects 字段
{
"sideEffects": false, // 告诉打包工具:本包的所有模块都没有副作用
// 或者指定有副作用的文件
"sideEffects": ["./src/polyfill.js", "*.css"]
}
// 3. import * 会阻碍 Tree Shaking(部分打包工具)
import * as utils from './utils'; // 不如具名导入
import { formatDate } from './utils'; // 更好
2
3
4
5
6
7
8
9
10
11
12
13
14
# 10.5.2 代码分割与懒加载
代码分割(Code Splitting):将应用代码拆分为多个 chunk,按需加载。
// 路由级别的代码分割(React)
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
// 条件加载重型依赖
async function exportToExcel(data) {
// xlsx 库只在用户点击"导出"时才加载
const XLSX = await import('xlsx');
const workbook = XLSX.utils.json_to_sheet(data);
// ...
}
// Webpack 魔法注释
const Chart = lazy(() => import(
/* webpackChunkName: "chart" */
/* webpackPreload: true */
'./components/Chart'
));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 10.6 模块设计模式
# 10.6.1 模块的封装模式
// 模式1:工具函数集合(命名导出)
// date-utils.js
export function formatDate(date, format) { /* ... */ }
export function parseDate(str) { /* ... */ }
export function addDays(date, days) { /* ... */ }
// 模式2:类模块(默认导出)
// logger.js
class Logger {
#level;
constructor(level = 'info') { this.#level = level; }
log(msg) { /* ... */ }
error(msg) { /* ... */ }
}
export default Logger;
// 模式3:工厂模块
// database.js
let instance = null;
export function getDatabase(config) {
if (!instance) {
instance = new Database(config);
}
return instance;
}
// 模式4:常量模块
// constants.js
export const API_BASE = 'https://api.example.com';
export const MAX_RETRIES = 3;
export const TIMEOUT = 5000;
// 模式5:桶文件(Barrel file)—— 聚合导出
// components/index.js
export { Button } from './Button.js';
export { Input } from './Input.js';
export { Modal } from './Modal.js';
// 使用者只需:import { Button, Input, Modal } from './components';
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 10.6.2 依赖注入
// 不好的做法:模块内部硬编码依赖
import { PostgresDB } from './postgres.js';
export class UserService {
constructor() {
this.db = new PostgresDB(); // 硬依赖,无法测试
}
}
// 好的做法:依赖注入
export class UserService {
constructor(db) {
this.db = db; // 依赖从外部传入
}
async getUser(id) {
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
}
// 生产环境
import { PostgresDB } from './postgres.js';
const service = new UserService(new PostgresDB());
// 测试环境
const mockDB = { query: () => ({ id: 1, name: 'Test' }) };
const service = new UserService(mockDB);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27