JavaScript学习笔记:16.模块
上一篇用迭代器和生成器搞定了“智能遍历”,这一篇咱们来解锁JS大型项目的“核心管理工具”——模块(Modules)。你肯定经历过这样的场景:写小demo时,把所有代码堆在一个script标签里,清爽又省事;但项目一变大,几百行代码挤在一起,变量重名、函数调用混乱、依赖关系像一团乱麻——这就是传说中的“代码屎山”。
模块的出现,就像给代码建了一套“公司部门分工系统”:把代码按功能拆成独立文件(部门),比如工具模块、用户模块、订单模块,每个模块只干自己的活(职责单一),通过“导出(对外提供服务)”和“导入(使用其他部门服务)”协作,既避免了“变量打架”,又让代码结构清晰、维护性翻倍。今天咱们就用“公司运营”的生活化比喻,把模块的核心特性、导入导出语法、实战场景和避坑指南彻底讲透,让你从“堆代码”升级为“管代码”。
一、先破案:为什么需要模块?无模块时代有多坑?
在ES6模块(ES Modules,简称ESM)出现前,JS没有原生模块系统,前端开发者只能用“脚本拼接”的方式写代码,坑点多到让人崩溃:
1. 无模块时代的三大痛点
- 变量全局污染:所有脚本的变量都在全局作用域,不小心重名就会“打架”:
// script1.jsletname="张三";// 全局变量// script2.jsletname="李四";// 覆盖全局变量console.log(name);// "李四"(script1的name被覆盖, Bug诞生) - 依赖关系混乱:多个脚本按顺序加载,一旦调整顺序就可能报错:
<!-- 依赖顺序必须严格遵守,乱序就崩 --><scriptsrc="tool.js"></script><!-- 提供formatDate函数 --><scriptsrc="user.js"></script><!-- 依赖tool.js的formatDate --><scriptsrc="order.js"></script><!-- 依赖user.js的用户数据 --> - 代码复用困难:想复用某个函数,只能复制粘贴,或通过全局变量暴露,无法精准“按需引入”。
2. 模块的核心解决方案
模块完美解决这些问题,核心靠三个特性:
- 独立作用域:每个模块是单独的作用域,变量、函数不会污染全局,也不会被外部随意访问;
- 按需导入导出:模块只暴露需要对外提供的功能(导出),其他模块只引入需要的功能(导入),不冗余;
- 静态依赖解析:导入导出在代码编译时就确定,支持“树摇优化”(删除未使用的代码),减小打包体积。
简单说:模块让代码从“一锅大杂烩”变成“精致套餐”,每个菜品(模块)独立制作,按需组合。
二、模块的核心特性:理解“部门分工”的底层逻辑
要用好模块,先搞懂它的三个核心特性,这是避免踩坑的关键:
1. 独立作用域:模块是“封闭的部门”
每个模块的顶层变量(let/const/function)都是模块内私有,不会挂载到全局,外部无法直接访问,除非主动导出:
// tool.js(模块)letinternalVar="我是模块内部变量";// 私有变量,外部访问不到exportconstformatDate=(date)=>{returndate.toLocaleString();};// main.js(模块)import{formatDate}from'./tool.js';console.log(formatDate(newDate()));// 正常使用导出的函数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(newDate());}3. 模块单例模式:“部门只成立一次”
同一个模块被多次导入,只会执行一次,后续导入的都是同一个模块实例,避免重复执行和内存浪费:
// counter.js(模块)letcount=0;exportconstincrement=()=>{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:直接导出exportconstformatDate=(date)=>date.toLocaleString();exportconstadd=(a,b)=>a+b;// 方式2:先定义,再集中导出(推荐,清晰)constmultiply=(a,b)=>a*b;constsubtract=(a,b)=>a-b;export{multiply,subtract};// 方式3:导出时重命名(避免名字冲突)export{multiplyasmultiplyNum};// 对外暴露的名字是multiplyNum(2)导入语法:
// main.js(模块)// 方式1:导入指定命名导出import{formatDate,add}from'./tool.js';console.log(formatDate(newDate()),add(2,3));// 正常使用// 方式2:导入时重命名(解决名字冲突)import{multiplyasmul}from'./tool.js';console.log(mul(2,3));// 6// 方式3:导入所有命名导出(用*)import*astoolfrom'./tool.js';console.log(tool.formatDate(newDate()),tool.add(2,3));// 通过tool对象访问// 方式4:导入默认导出+命名导出(混合导入)importtoolDefault,{add}from'./tool.js';2. 默认导出:“部门的主窗口”
一个模块只能有一个默认导出,导出的是“默认功能”,导入时可以自定义名字(不用和导出名一致)。
(1)导出语法:
// user.js(模块)// 方式1:直接默认导出exportdefaultclassUser{constructor(name){this.name=name;}}// 方式2:先定义,再默认导出constgetUserInfo=()=>{return{name:"张三",age:25};};exportdefaultgetUserInfo;(2)导入语法:
// main.js(模块)// 导入默认导出,自定义名字(不用和导出名一致)importMyUserfrom'./user.js';// 导入默认导出的User类,命名为MyUserconstuser=newMyUser("李四");// 导入默认导出时,名字可以任意改importgetInfofrom'./user.js';// 导入默认导出的getUserInfo,命名为getInfoconsole.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.jsexportconstformatDate=(date)=>date.toLocaleString();// user.jsimport{formatDate}from'./tool.js';exportdefaultclassUser{constructor(name,birthDate){this.name=name;this.birthDate=formatDate(birthDate);}}// index.html<!DOCTYPEhtml><html><body><!--必须加type="module",声明这是模块脚本--><script type="module">importUserfrom'./user.js';constuser=newUser("张三",newDate("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配置)exportconstadd=(a,b)=>a+b;// main.mjsimport{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.jsdocument.querySelector('button').addEventListener('click',async()=>{const{formatDate}=awaitimport('./tool.js');console.log(formatDate(newDate()));// 按需加载并使用});4. 循环依赖:模块的“双向协作”
两个模块互相导入(A导入B,B导入A)称为循环依赖,ESM能自动处理,只要确保导入时模块已暴露部分功能即可:
// a.jsimport{bFunc}from'./b.js';exportconstaFunc=()=>{console.log("aFunc执行");bFunc();};// b.jsimport{aFunc}from'./a.js';exportconstbFunc=()=>{console.log("bFunc执行");};// main.jsimport{aFunc}from'./a.js';aFunc();// 输出:aFunc执行 → bFunc执行(正常运行)ESM通过“部分导出”机制处理循环依赖:模块在执行过程中会逐步暴露已定义的导出,后续导入能访问到已暴露的部分。
五、避坑指南:模块的“常见陷阱”
1. 陷阱1:忘记加type="module"
浏览器中使用模块时,script标签没加type="module",会把模块脚本当成普通脚本,import/export报错:
<!-- 反面例子:没加type="module" --><scriptsrc="main.js"></script><!-- 报错:Unexpected token 'export' --><!-- 正面例子:加type="module" --><scripttype="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:默认导出和命名导出混用
导入时混淆默认导出和命名导出,导致报错:
// 模块导出:默认导出exportdefaultclassUser{}// 反面例子:用命名导入方式导入默认导出import{User}from'./user.js';// 报错:Cannot destructure property 'User' of ...// 正面例子:用默认导入方式importUserfrom'./user.js';// 正确4. 陷阱4:模块顶层this不是全局对象
普通脚本的顶层this是window(浏览器)/global(Node.js),但模块顶层this是undefined,不要依赖this:
// 普通脚本console.log(this===window);// true// 模块脚本console.log(this);// undefined5. 陷阱5:循环依赖导致“未定义”
循环依赖时,若导入的功能在模块执行后期才定义,会导致暂时的undefined:
// a.jsimport{bFunc}from'./b.js';exportconstaFunc=()=>bFunc();console.log(bFunc);// undefined(bFunc还没定义)// b.jsimport{aFunc}from'./a.js';exportconstbFunc=()=>console.log("b");避坑:循环依赖时,避免在模块顶层执行依赖的函数,把执行逻辑放在函数内部(延迟执行)。
六、总结:模块的核心价值与最佳实践
模块是JS大型项目的“基石”,核心价值是“结构化组织代码”,让代码从“混乱堆砌”变成“有序协作”。掌握模块的最佳实践,能让你的项目维护性翻倍:
1. 最佳实践
- 按功能拆分模块:一个模块只做一件事(如工具模块、用户模块、API请求模块);
- 优先使用命名导出:多个功能用命名导出,单个核心功能用默认导出,避免混淆;
- 按需导入:只导入需要的功能,不导入整个模块(减少冗余);
- 用动态导入实现懒加载:路由、弹窗等场景,按需加载模块,提升首屏加载速度。
2. 核心价值总结
- 解决全局污染:模块独立作用域,变量不冲突;
- 简化依赖管理:静态导入导出,依赖关系清晰;
- 提升代码复用:精准导入导出,无需复制粘贴;
- 支持工程化:配合打包工具实现树摇、压缩,优化项目性能。
从ES6模块开始,JS终于有了原生的“代码组织方案”,这也是现代前端工程化(Webpack、Vite、Rollup)的基础。掌握模块,你就能轻松应对大型项目的代码管理,告别“代码屎山”。