JavaScript学习笔记:16.模块

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. 静态导入导出:编译时确定“依赖关系”

模块的importexport只能写在模块顶层(不能写在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不是全局对象

普通脚本的顶层thiswindow(浏览器)/global(Node.js),但模块顶层thisundefined,不要依赖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. 核心价值总结

  1. 解决全局污染:模块独立作用域,变量不冲突;
  2. 简化依赖管理:静态导入导出,依赖关系清晰;
  3. 提升代码复用:精准导入导出,无需复制粘贴;
  4. 支持工程化:配合打包工具实现树摇、压缩,优化项目性能。

从ES6模块开始,JS终于有了原生的“代码组织方案”,这也是现代前端工程化(Webpack、Vite、Rollup)的基础。掌握模块,你就能轻松应对大型项目的代码管理,告别“代码屎山”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿蒙Armon

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值