JavaScript学习笔记:16.模块
上一篇用迭代器和生成器搞定了“智能遍历”,这一篇咱们来解锁JS大型项目的“核心管理工具”——模块(Modules)。你肯定经历过这样的场景:写小demo时,把所有代码堆在一个script标签里,清爽又省事;但项目一变大,几百行代码挤在一起,变量重名、函数调用混乱、依赖关系像一团乱麻——这就是传说中的“代码屎山”。
模块的出现,就像给代码建了一套“公司部门分工系统”:把代码按功能拆成独立文件(部门),比如工具模块、用户模块、订单模块,每个模块只干自己的活(职责单一),通过“导出(对外提供服务)”和“导入(使用其他部门服务)”协作,既避免了“变量打架”,又让代码结构清晰、维护性翻倍。今天咱们就用“公司运营”的生活化比喻,把模块的核心特性、导入导出语法、实战场景和避坑指南彻底讲透,让你从“堆代码”升级为“管代码”。
一、先破案:为什么需要模块?无模块时代有多坑?
在ES6模块(ES Modules,简称ESM)出现前,JS没有原生模块系统,前端开发者只能用“脚本拼接”的方式写代码,坑点多到让人崩溃:
1. 无模块时代的三大痛点
- 变量全局污染:所有脚本的变量都在全局作用域,不小心重名就会“打架”:
// script1.js let name = "张三"; // 全局变量 // script2.js let name = "李四"; // 覆盖全局变量 console.log(name); // "李四"(script1的name被覆盖, Bug诞生) - 依赖关系混乱:多个脚本按顺序加载,一旦调整顺序就可能报错:
<!-- 依赖顺序必须严格遵守,乱序就崩 --> <script src="tool.js"></script> <!-- 提供formatDate函数 --> <script src="user.js"></script> <!-- 依赖tool.js的formatDate --> <script src="order.js"></script> <!-- 依赖user.js的用户数据 --> - 代码复用困难:想复用某个函数,只能复制粘贴,或通过全局变量暴露,无法精准“按需引入”。
2. 模块的核心解决方案
模块完美解决这些问题,核心靠三个特性:
- 独立作用域:每个模块是单独的作用域,变量、函数不会污染全局,也不会被外部随意访问;
- 按需导入导出:模块只暴露需要对外提供的功能(导出),其他模块只引入需要的功能(导入),不冗余;
- 静态依赖解析:导入导出在代码编译时就确定,支持“树摇优化”(删除未使用的代码),减小打包体积。
简单说:模块让代码从“一锅大杂烩”变成“精致套餐”,每个菜品(模块)独立制作,按需组合。
二、模块的核心特性:理解“部门分工”的底层逻辑
要用好模块,先搞懂它的三个核心特性,这是避免踩坑的关键:
1. 独立作用域:模块是“封闭的部门”
每个模块的顶层变量(let/const/function)都是模块内私有,不会挂载到全局,外部无法直接访问,除非主动导出:
// tool.js(模块)
let internalVar = "我是模块内部变量"; // 私有变量,外部访问不到
export const formatDate = (date) => {
return date.toLocaleString();
};
// main.js(模块)
import { formatDate } from './tool.js';
console.log(formatDate(new Date())); // 正常使用导出的函数
console.log(internalVar); // ReferenceError: internalVar is not defined(私有变量无法访问)
就像公司部门的内部文件,只有对外公开的接口(导出),外部才能调用。
2. 静态导入导出:编译时确定“依赖关系”
模块的import和export只能写在模块顶层(不能写在if、函数里),编译时就解析依赖,这带来两个好处:
- 语法严谨:避免动态引入导致的依赖混乱;
- 支持树摇:打包工具(如Webpack、Vite)能识别未使用的导出,自动删除,减小文件体积。
// 反面例子:import不能写在代码块里
if (needFormat) {
import { formatDate } from './tool.js'; // 报错:Invalid import declaration
}
// 正面例子:import必须在模块顶层
import { formatDate } from './tool.js';
if (needFormat) {
formatDate(new Date());
}
3. 模块单例模式:“部门只成立一次”
同一个模块被多次导入,只会执行一次,后续导入的都是同一个模块实例,避免重复执行和内存浪费:
// counter.js(模块)
let count = 0;
export const increment = () => {
count++;
console.log(count);
};
// main1.js(模块)
import { increment } from './counter.js';
increment(); // 1(模块执行,count=1)
// main2.js(模块)
import { increment } from './counter.js';
increment(); // 2(模块未重复执行,复用之前的count)
就像公司部门只成立一次,不管多少个其他部门(模块)调用它,都是同一个部门提供服务。
三、导入导出语法:模块的“对外接口”与“协作方式”
导入(import)和导出(export)是模块协作的核心,分“默认导出”和“命名导出”两种,用法不同,不能混用。
1. 命名导出:“部门的多个对外窗口”
一个模块可以有多个命名导出,导出的是“带名字的功能”,导入时必须用相同的名字(可重命名)。
(1)导出语法:
// tool.js(模块)
// 方式1:直接导出
export const formatDate = (date) => date.toLocaleString();
export const add = (a, b) => a + b;
// 方式2:先定义,再集中导出(推荐,清晰)
const multiply = (a, b) => a * b;
const subtract = (a, b) => a - b;
export { multiply, subtract };
// 方式3:导出时重命名(避免名字冲突)
export { multiply as multiplyNum }; // 对外暴露的名字是multiplyNum
(2)导入语法:
// main.js(模块)
// 方式1:导入指定命名导出
import { formatDate, add } from './tool.js';
console.log(formatDate(new Date()), add(2, 3)); // 正常使用
// 方式2:导入时重命名(解决名字冲突)
import { multiply as mul } from './tool.js';
console.log(mul(2, 3)); // 6
// 方式3:导入所有命名导出(用*)
import * as tool from './tool.js';
console.log(tool.formatDate(new Date()), tool.add(2, 3)); // 通过tool对象访问
// 方式4:导入默认导出+命名导出(混合导入)
import toolDefault, { add } from './tool.js';
2. 默认导出:“部门的主窗口”
一个模块只能有一个默认导出,导出的是“默认功能”,导入时可以自定义名字(不用和导出名一致)。
(1)导出语法:
// user.js(模块)
// 方式1:直接默认导出
export default class User {
constructor(name) {
this.name = name;
}
}
// 方式2:先定义,再默认导出
const getUserInfo = () => {
return { name: "张三", age: 25 };
};
export default getUserInfo;
(2)导入语法:
// main.js(模块)
// 导入默认导出,自定义名字(不用和导出名一致)
import MyUser from './user.js'; // 导入默认导出的User类,命名为MyUser
const user = new MyUser("李四");
// 导入默认导出时,名字可以任意改
import getInfo from './user.js'; // 导入默认导出的getUserInfo,命名为getInfo
console.log(getInfo()); // { name: "张三", age: 25 }
3. 关键区别:默认导出 vs 命名导出
| 特性 | 默认导出 | 命名导出 |
|---|---|---|
| 数量限制 | 一个模块只能有一个 | 一个模块可以有多个 |
| 导入名字 | 可自定义,无需和导出名一致 | 必须和导出名一致(可重命名) |
| 适用场景 | 模块核心功能(如单个类、函数) | 模块多个辅助功能(如工具函数集合) |
| 导入语法 | import 自定义名 from '模块' | import { 导出名 } from '模块' |
避坑:不要在一个模块中同时默认导出和命名导出同一个功能,容易混淆。
四、实战场景:模块的“正确打开方式”
模块的用法分“浏览器环境”和“Node.js环境”,核心语法一致,但配置略有不同。
1. 浏览器环境:直接使用ESM
浏览器原生支持ESM,只需给script标签加type="module",就能使用模块:
(1)目录结构:
project/
├── tool.js(模块)
├── user.js(模块)
└── index.html(入口)
(2)代码实现:
// tool.js
export const formatDate = (date) => date.toLocaleString();
// user.js
import { formatDate } from './tool.js';
export default class User {
constructor(name, birthDate) {
this.name = name;
this.birthDate = formatDate(birthDate);
}
}
// index.html
<!DOCTYPE html>
<html>
<body>
<!-- 必须加type="module",声明这是模块脚本 -->
<script type="module">
import User from './user.js';
const user = new User("张三", new Date("2000-01-01"));
console.log(user); // User { name: "张三", birthDate: "2000/1/1 00:00:00" }
</script>
</body>
</html>
(3)浏览器模块的注意事项:
- 必须通过HTTP/HTTPS协议打开(不能直接双击本地文件,会报CORS错误);
- 导入路径必须是完整路径(相对路径
./、绝对路径或URL,不能省略./); - 模块脚本会延迟执行(相当于
defer),确保DOM加载完成后执行。
2. Node.js环境:支持ESM和CommonJS
Node.js默认支持CommonJS模块(require/module.exports),但从v14.13开始支持ESM,只需满足以下条件之一:
- 文件名后缀为
.mjs; - 在
package.json中添加"type": "module"。
(1)配置package.json:
{
"type": "module" // 声明项目使用ESM
}
(2)代码实现:
// tool.mjs(或tool.js,因package.json配置)
export const add = (a, b) => a + b;
// main.mjs
import { add } from './tool.js';
console.log(add(2, 3)); // 5
(3)CommonJS与ESM的互操作:
如果需要在ESM中导入CommonJS模块(如旧版npm包),直接导入即可;在CommonJS中导入ESM模块,需用动态import:
// CommonJS模块(commonjs.js)
exports.multiply = (a, b) => a * b;
// ESM模块(esm.js)
import { multiply } from './commonjs.js'; // 直接导入CommonJS模块
console.log(multiply(2, 3)); // 6
// CommonJS模块(main.cjs)
// 需用动态import导入ESM模块
import('./esm.js').then(({ add }) => {
console.log(add(2, 3)); // 5
});
3. 动态导入:按需加载“部门服务”
静态import必须在模块顶层,无法按需加载(如点击按钮后再导入)。动态import()是函数,返回Promise,支持在任意位置按需导入模块,适合懒加载场景(如路由切换、按需加载组件):
// main.js(模块)
// 点击按钮后,动态导入tool.js
document.querySelector('button').addEventListener('click', async () => {
const { formatDate } = await import('./tool.js');
console.log(formatDate(new Date())); // 按需加载并使用
});
4. 循环依赖:模块的“双向协作”
两个模块互相导入(A导入B,B导入A)称为循环依赖,ESM能自动处理,只要确保导入时模块已暴露部分功能即可:
// a.js
import { bFunc } from './b.js';
export const aFunc = () => {
console.log("aFunc执行");
bFunc();
};
// b.js
import { aFunc } from './a.js';
export const bFunc = () => {
console.log("bFunc执行");
};
// main.js
import { aFunc } from './a.js';
aFunc(); // 输出:aFunc执行 → bFunc执行(正常运行)
ESM通过“部分导出”机制处理循环依赖:模块在执行过程中会逐步暴露已定义的导出,后续导入能访问到已暴露的部分。
五、避坑指南:模块的“常见陷阱”
1. 陷阱1:忘记加type="module"
浏览器中使用模块时,script标签没加type="module",会把模块脚本当成普通脚本,import/export报错:
<!-- 反面例子:没加type="module" -->
<script src="main.js"></script> <!-- 报错:Unexpected token 'export' -->
<!-- 正面例子:加type="module" -->
<script type="module" src="main.js"></script>
2. 陷阱2:导入路径错误
ESM导入路径必须是完整路径,不能省略./,也不能像CommonJS那样省略文件后缀:
// 反面例子1:省略./,报错
import { formatDate } from 'tool.js'; // 浏览器会当成npm包,找不到
// 反面例子2:省略文件后缀(Node.js ESM不支持)
import { formatDate } from './tool'; // 报错:Cannot find module './tool'
// 正面例子
import { formatDate } from './tool.js'; // 正确
3. 陷阱3:默认导出和命名导出混用
导入时混淆默认导出和命名导出,导致报错:
// 模块导出:默认导出
export default class User {}
// 反面例子:用命名导入方式导入默认导出
import { User } from './user.js'; // 报错:Cannot destructure property 'User' of ...
// 正面例子:用默认导入方式
import User from './user.js'; // 正确
4. 陷阱4:模块顶层this不是全局对象
普通脚本的顶层this是window(浏览器)/global(Node.js),但模块顶层this是undefined,不要依赖this:
// 普通脚本
console.log(this === window); // true
// 模块脚本
console.log(this); // undefined
5. 陷阱5:循环依赖导致“未定义”
循环依赖时,若导入的功能在模块执行后期才定义,会导致暂时的undefined:
// a.js
import { bFunc } from './b.js';
export const aFunc = () => bFunc();
console.log(bFunc); // undefined(bFunc还没定义)
// b.js
import { aFunc } from './a.js';
export const bFunc = () => console.log("b");
避坑:循环依赖时,避免在模块顶层执行依赖的函数,把执行逻辑放在函数内部(延迟执行)。
六、总结:模块的核心价值与最佳实践
模块是JS大型项目的“基石”,核心价值是“结构化组织代码”,让代码从“混乱堆砌”变成“有序协作”。掌握模块的最佳实践,能让你的项目维护性翻倍:
1. 最佳实践
- 按功能拆分模块:一个模块只做一件事(如工具模块、用户模块、API请求模块);
- 优先使用命名导出:多个功能用命名导出,单个核心功能用默认导出,避免混淆;
- 按需导入:只导入需要的功能,不导入整个模块(减少冗余);
- 用动态导入实现懒加载:路由、弹窗等场景,按需加载模块,提升首屏加载速度。
2. 核心价值总结
- 解决全局污染:模块独立作用域,变量不冲突;
- 简化依赖管理:静态导入导出,依赖关系清晰;
- 提升代码复用:精准导入导出,无需复制粘贴;
- 支持工程化:配合打包工具实现树摇、压缩,优化项目性能。
从ES6模块开始,JS终于有了原生的“代码组织方案”,这也是现代前端工程化(Webpack、Vite、Rollup)的基础。掌握模块,你就能轻松应对大型项目的代码管理,告别“代码屎山”。
1579

被折叠的 条评论
为什么被折叠?



