JavaScript学习笔记:16.模块

JavaScript学习笔记:16.模块

上一篇用迭代器和生成器搞定了"智能遍历",这一篇咱们来解锁JS大型项目的"核心管理工具"------模块(Modules)。你肯定经历过这样的场景:写小demo时,把所有代码堆在一个script标签里,清爽又省事;但项目一变大,几百行代码挤在一起,变量重名、函数调用混乱、依赖关系像一团乱麻------这就是传说中的"代码屎山"。

模块的出现,就像给代码建了一套"公司部门分工系统":把代码按功能拆成独立文件(部门),比如工具模块、用户模块、订单模块,每个模块只干自己的活(职责单一),通过"导出(对外提供服务)"和"导入(使用其他部门服务)"协作,既避免了"变量打架",又让代码结构清晰、维护性翻倍。今天咱们就用"公司运营"的生活化比喻,把模块的核心特性、导入导出语法、实战场景和避坑指南彻底讲透,让你从"堆代码"升级为"管代码"。

一、先破案:为什么需要模块?无模块时代有多坑?

在ES6模块(ES Modules,简称ESM)出现前,JS没有原生模块系统,前端开发者只能用"脚本拼接"的方式写代码,坑点多到让人崩溃:

1. 无模块时代的三大痛点

  • 变量全局污染 :所有脚本的变量都在全局作用域,不小心重名就会"打架":

    js 复制代码
    // script1.js
    let name = "张三"; // 全局变量
    
    // script2.js
    let name = "李四"; // 覆盖全局变量
    console.log(name); // "李四"(script1的name被覆盖, Bug诞生)
  • 依赖关系混乱 :多个脚本按顺序加载,一旦调整顺序就可能报错:

    html 复制代码
    <!-- 依赖顺序必须严格遵守,乱序就崩 -->
    <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)都是模块内私有,不会挂载到全局,外部无法直接访问,除非主动导出:

js 复制代码
// 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)能识别未使用的导出,自动删除,减小文件体积。
js 复制代码
// 反面例子:import不能写在代码块里
if (needFormat) {
  import { formatDate } from './tool.js'; // 报错:Invalid import declaration
}

// 正面例子:import必须在模块顶层
import { formatDate } from './tool.js';
if (needFormat) {
  formatDate(new Date());
}

3. 模块单例模式:"部门只成立一次"

同一个模块被多次导入,只会执行一次,后续导入的都是同一个模块实例,避免重复执行和内存浪费:

js 复制代码
// 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)导出语法:
js 复制代码
// 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)导入语法:
js 复制代码
// 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)导出语法:
js 复制代码
// user.js(模块)
// 方式1:直接默认导出
export default class User {
  constructor(name) {
    this.name = name;
  }
}

// 方式2:先定义,再默认导出
const getUserInfo = () => {
  return { name: "张三", age: 25 };
};
export default getUserInfo;
(2)导入语法:
js 复制代码
// 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)代码实现:
js 复制代码
// 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
json 复制代码
{
  "type": "module" // 声明项目使用ESM
}
(2)代码实现:
js 复制代码
// 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

js 复制代码
// 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,支持在任意位置按需导入模块,适合懒加载场景(如路由切换、按需加载组件):

js 复制代码
// 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能自动处理,只要确保导入时模块已暴露部分功能即可:

js 复制代码
// 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报错:

html 复制代码
<!-- 反面例子:没加type="module" -->
<script src="main.js"></script> <!-- 报错:Unexpected token 'export' -->

<!-- 正面例子:加type="module" -->
<script type="module" src="main.js"></script>

2. 陷阱2:导入路径错误

ESM导入路径必须是完整路径,不能省略./,也不能像CommonJS那样省略文件后缀:

js 复制代码
// 反面例子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:默认导出和命名导出混用

导入时混淆默认导出和命名导出,导致报错:

js 复制代码
// 模块导出:默认导出
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

js 复制代码
// 普通脚本
console.log(this === window); // true

// 模块脚本
console.log(this); // undefined

5. 陷阱5:循环依赖导致"未定义"

循环依赖时,若导入的功能在模块执行后期才定义,会导致暂时的undefined

js 复制代码
// 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)的基础。掌握模块,你就能轻松应对大型项目的代码管理,告别"代码屎山"。

相关推荐
im_AMBER6 小时前
Leetcode 79 最佳观光组合
笔记·学习·算法·leetcode
山土成旧客6 小时前
【Python学习打卡-Day22】启航Kaggle:从路径管理到独立项目研究的全方位指南
开发语言·python·学习
苏打水com6 小时前
第十七篇:Day49-51 前端工程化进阶——从“手动”到“自动化”(对标职场“提效降本”需求)
前端·javascript·css·vue.js·html
『 时光荏苒 』6 小时前
使用Vue播放M3U8视频流的方法
前端·javascript·vue.js
QiZhang | UESTC6 小时前
学习日记day50
学习
阿恩.7706 小时前
国际水电与电力能源期刊精选
经验分享·笔记·考研·动态规划·能源·制造
QT 小鲜肉6 小时前
【Linux命令大全】001.文件管理之chown命令(实操篇)
linux·运维·服务器·笔记
消防大队VUE支队6 小时前
🗓️ 2262年将有两个春节!作为前端的你,日历控件真的写对了吗?
前端·javascript
走在路上的菜鸟6 小时前
Android学Dart学习笔记第十八节 类-继承
android·笔记·学习·flutter