目录
注意事项二 JavaScript 模块 - JavaScript | MDN
一、模块化
1. 概念
模块化开发 :
- 事实上模块化开发最终的目的是将程序划分成一个个小的结构
- 这个结构中编写属于自己的逻辑代码,有自己的作用域,定义变量名词时不会影响到其他的结构
- 这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用
- 也可以通过某种方式,导入另外结构中的变量、函数、对象等
结构,就是模块 => 按照这种结构划分开发程序的过程,就是模块化开发的过程
JavaScript缺陷 :
- var定义的变量作用域问题
- 比如JavaScript的面向对象并不能像常规面向对象语言一样使用class
- 比如JavaScript没有模块化的问题
- ......
2. 历史
01 - 早期
在网页开发的早期,Brendan Eich开发JavaScript仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的
- 这个时候只需要将JavaScript代码写到<script>标签中即可
- 并没有必要放到多个文件中来编写
- 甚至流行:通常来说 JavaScript 程序的长度只有一行
02 - 现在
随着前端和JavaScript的快速发展,JavaScript代码变得越来越复杂
- ajax的出现,前后端开发分离,意味着后端返回数据后,我们需要通过JavaScript进行前端页面的渲染
- SPA的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过JavaScript来实现
- Node的实现,JavaScript编写复杂的后端程序,没有模块化是致命的硬伤
模块化已经是JavaScript一个非常迫切的需求
- 但是JavaScript本身,直到ES6(2015)才推出了自己的模块化方案
- 在此之前,为了让JavaScript支持模块化,涌现出了很多不同的模块化规范:AMD、CMD、CommonJS等
3. 问题
问题一
早期没有模块化带来了很多的问题:比如命名冲突的问题,作用域问题
立即执行函数可以解决 : ( 因为函数拥有作用域 )
01 - 定义 foo.js
const moduleFoo = (function () {
return {
fooName: 'moduleFoo'
};
})();
02 - 定义 bar.js
// 1. 接受立即执行函数中暴露出来的变量或方法
const moduleBar = (function () {
const name = 'star';
function baz() {
console.log('coder');
}
// 2. 返回需要被外界访问的变量和方法
return {
name,
baz
};
})();
03 - 定义 html 页面
<body>
<script src="./bar.js"></script>
<script src="./foo.js"></script>
<script>
console.log(moduleBar.name); // star
console.log(moduleFoo.fooName); // moduleFoo
</script>
</body>
问题二
- 第一,必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用
- 第二,代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写
- 第三,在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况
所以 :
- 需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码
- 这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性
- JavaScript社区为了解决上面的问题,涌现出一系列好用的规范
二、CommonJs
1. 概念
CommonJS是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了体现它的广泛性,修改为CommonJS,平时我们也会简称为CJS
- Node是CommonJS在服务器端一个具有代表性的实现
- Browserify是CommonJS在浏览器中的一种实现
- webpack打包工具具备对CommonJS的支持和转换
Node中对CommonJS进行了支持和实现,在开发node的过程中可以方便的进行模块化开发 :
- 在Node中每一个js文件都是一个单独的模块
- 这个模块中包括CommonJS规范的核心变量:exports、module.exports、require
- 可以使用这些变量来方便的进行模块化开发
模块化的核心是导出和导入,Node中对其进行了实现:
- exports和module.exports可以负责对模块中的内容进行导出
- require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容
2. exports导出
exports是一个对象,可以在这个对象中添加很多个属性,添加的属性会导出
01 - 定义 bar.js
const userName = 'star';
const foo = () => {
console.log('foo');
};
// 导出
exports.userName = userName;
exports.foo = foo;
另外一个文件中可以导入
02 - 定义 main.js
const bar = require('./bar.js');
03 - 原理
- 意味着main中的bar变量等于exports对象
- 也就是require通过各种查找方式,最终找到了exports这个对象
- 并且将这个exports对象赋值给了bar变量
- bar变量就是exports对象了
- 本质就是 => 引用赋值
3. CommonJs在Node中实现的本质
本质就是 => 引用赋值 , 指向同一个对象
ps : 都是执行main.js => node main.js
栗子一 🌰
01 - 定义 bar.js
const userName = 'star';
const foo = () => {
console.log('foo');
};
exports.userName = userName;
exports.foo = foo;
02 - 定义 main.js
const bar = require('./bar.js');
console.log(bar); // { userName: 'star', foo: [Function: foo] }
栗子二 🌰
01 - 定义 bar.js
const userName = 'star';
const foo = () => {
console.log('foo');
};
exports.userName = userName;
exports.foo = foo;
// 两秒后更改
setTimeout(() => {
exports.userName = 'codercoder'
}, 2000);
02 - 定义 main.js
const bar = require('./bar.js');
console.log(bar); // { userName: 'star', foo: [Function: foo] }
// 四秒后再次获取值
setTimeout(() => {
console.log(bar); // { userName: 'codercoder', foo: [Function: foo] }
}, 4000);
栗子三 🌰
01 - 定义 bar.js
const userName = 'star';
const foo = () => {
console.log('foo');
};
exports.userName = userName;
exports.foo = foo;
// 四秒后获取值
setTimeout(() => {
console.log(exports.userName); // 冲啊!!!!!!
}, 4000);
02 - 定义 main.js
const bar = require('./bar.js');
console.log(bar); // { userName: 'star', foo: [Function: foo] }
// 四秒后再次获取值
setTimeout(() => {
bar.userName = '冲啊!!!!!!';
}, 2000);
4. module.exports导出
Node中经常导出东西的时候,是通过module.exports导出的,同样 => 引用赋值
追根溯源,通过维基百科中对CommonJS规范的解析:
- CommonJS中是没有module.exports的概念的
- 但是为了实现模块的导出,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是module
- 所以在Node中真正用于导出的其实根本不是exports,而是module.exports
- 因为module才是导出的真正实现者
为什么exports也可以导出呢?
- 这是因为module对象的exports属性是exports对象的一个引用
- 也就是说 module.exports = exports = main中的bar
module.exports对象 和 exports对象的关系 :
- module.exports 和 exports 默认指向同一个对象
- 每一个js文件都是一个module对象,有个属性exports就指向exports对象
- require本质是查找的是module.exports对象
- 如果给module.exports对象赋值一个新对象,那么exports就没有什么意义了
栗子一 🌰
01 - 定义 bar.js
const userName = 'star';
const foo = () => {
console.log('foo');
};
module.exports.userName = userName;
module.exports.foo = foo;
// 四秒后获取值
setTimeout(() => {
console.log(module.exports.userName); // 冲啊!!!!!!
}, 4000);
02 - 定义 main.js
const bar = require('./bar.js');
console.log(bar); // { userName: 'star', foo: [Function: foo] }
// 两秒后设置值
setTimeout(() => {
bar.userName = '冲啊!!!!!!';
}, 2000);
栗子二 🌰
01 - 定义 bar.js
const userName = 'star';
const foo = () => {
console.log('foo');
};
// exports修改无用
exports.userName = 'coder';
// 一旦这么做了,指向了新对象
module.exports = {
userName,
foo
};
02 - 定义 main.js
const bar = require('./bar.js');
console.log(bar); // { userName: 'star', foo: [Function: foo] }
03 - 解释
5. require
require是一个函数,可以帮助我们引入一个文件(模块)中导出的对象
导入格式 => require(X)
情况一:X是一个Node核心模块,比如path、http
直接返回核心模块,并且停止查找
const path = require('path');
console.log(path);
情况二:X是以 ./ 或 ../ 或 /(根目录)开头的
第一步:将X当做一个文件在对应的目录下查找
- 如果有后缀名,按照后缀名的格式查找对应的文件
- 如果没有后缀名,会按照如下顺序
- 1. 直接查找文件X
- 2. 查找X.js文件
- 3. 查找X.json文件
- 4. 查找X.node文件
第二步:没有找到对应的文件,将X当作一个目录
查找目录下面的index文件,按照如下顺序 :
- 1. 查找X/index.js文件
- 2. 查找X/index.json文件
- 3. 查找X/index.node文件
如果还没有找到,就会报错
情况三:直接是一个X(没有路径),并且X不是一个核心模块
- 1. 寻找node_modules文件夹,
- 在当前模块寻找node_modules文件夹,若没有,在上一层文件夹中寻找,还没有,继续往上,直到找到根目录,还没有直接报错
- 2. 在node_modules文件夹中寻找该X模块
- 重复情况二,若没找到,报错
6. 模块的加载过程
结论一
模块在被第一次引入时,模块中的js代码会被运行一次
01 - 定义main.js
console.log('main start');
const coder = require('./coder');
console.log('main end');
02 - 定义coder.js
console.log('coder');
结论二
模块被多次引入时,会缓存,最终只加载(运行)一次
- 为什么只会加载运行一次
- 因为每个模块都是一个module对象,并且都有一个属性:loaded
- loaded : 值false表示还没有加载,值为true表示已经加载
01 - 定义main.js
console.log('main start');
const coder1 = require('./coder');
const coder2 = require('./coder');
const coder3 = require('./coder');
const coder4 = require('./coder');
console.log('main end');
const coder5 = require('./coder');
const coder6 = require('./coder');
02 - 效果
结论三
循环引入,使用深度优先算法来进行加载顺序
ps : 加载顺序为 => main ->aaa -> ccc -> ddd -> eee ->bbb
7. CommonJS规范缺点
CommonJS加载模块是同步的 :
- 同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行
- 这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快
如果将它应用于浏览器 :
- 浏览器加载js文件需要先从服务器将文件下载下来,之后再加载运行
- 那么采用同步的就意味着后续的js代码都无法正常运行,即使是一些简单的DOM操作
- 所以在浏览器中,通常不使用CommonJS规范
- 当然在webpack中使用CommonJS是另外一回事
- 因为它会将我们的代码转成浏览器可以直接执行的代码
在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD:
- 但是目前一方面现代的浏览器已经支持ES Modules
- 另一方面借助于webpack等工具可以实现对CommonJS或者ES Module代码的转换
- AMD和CMD已经使用非常少了
三、AMD规范 ( 不怎么使用 )
AMD主要是应用于浏览器的一种模块化规范
ps : 规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用
规范
- AMD是 Asynchronous Module Definition(异步模块定义)的缩写
- 它采用的是异步加载模块
- 事实上AMD的规范还要早于CommonJS,但是CommonJS目前依然在被使用,而AMD使用的较少了
实现
AMD实现的比较常用的库是 require.js 和 curl.js
require.js : GitHub - requirejs/requirejs: A file and module loader for JavaScript
curl.js : gitHub 打不开了~
四、CMD规范 ( 不怎么使用 )
CMD规范也是应用于浏览器的一种模块化规范
ps : 规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用
规范
- CMD 是 Common Module Definition(通用模块定义)的缩写
- 它也采用的也是异步加载模块,但是它将CommonJS的优点吸收了过来
- 但是目前CMD使用也非常少了
实现
CMD有自己比较优秀的实现方案 SeaJS
SeaJS : GitHub - seajs/seajs: A file and module loader for JavaScript
五、ES Module
1. 概念
JavaScript没有模块化一直是它的痛点,所以才会产生模块化的社区规范:CommonJS、AMD、CMD等
ES Module和CommonJS的模块化有一些不同之处 :
- 一方面它使用了import和export关键字
- 另一方面它采用编译期的静态分析,并且也加入了动态引用的方式
ES Module模块采用export和import关键字来实现模块化 :
- export 负责将模块内的内容导出
- import 负责从其他模块导入内容
- 采用ES Module将自动采用严格模式:use strict
2. 使用ES module注意事项
注意事项一
在浏览器中直接使用ES Module时, 必须在文件后加上后缀名.js,不能省略
注意事项二 JavaScript 模块 - JavaScript | MDN
在浏览器中使用ES Module,需要开启服务,本地运行不可,可使用 Live Server 插件
栗子 🌰
01 - 新建 coder.js
// 导出
export const username = 'coder';
export const age = '19';
// 也可以这么导出
const address = '北京';
export { address };
02 - 新建 main.js
// 导入,这里必须加后缀名,切记切记
import { username, age, address } from './coder.js';
console.log(username, age, address); // coder 19 北京
03 - 新建 index.html
<body>
<!-- 这里后缀名也要写完整,同时,在这里打开live serve -->
<script src="./main.js" type="module"></script>
</body>
3. export
export关键字将一个模块中的变量、函数、类等导出
方式一
方式一 : 在语句声明的前面直接加上export关键字
// 导出
export const username = 'coder';
export const age = '19';
方式二
方式二 : 将所有需要导出的标识符,放到export后面的 {}中
- 这里的 {}里面不是ES6的对象字面量的增强写法,{}也不是表示一个对象的
- export {name: name},是错误的写法
// 导出
const age = '18';
const address = '北京';
export { age, address };
方式三
方式三 : 通过as关键字导出时给标识符起一个别名
// 导出
const age = '18';
const address = '北京';
export { age as myName, address as myAddress };
4. import
import关键字负责从另外一个模块中导入内容
方式一
方式一 : import {标识符列表} from '模块'
- 这里的{}也不是一个对象,里面只是存放导入的标识符列表内容
// 导入
import { username, age, address } from './coder.js';
方式二
方式二 : 通过as关键字导入时给标识符起别名
// 导入
import { username as myName, age as myAge, address } from './coder.js';
方式三
方式三 : 通过 * 将整个模块导入,并同时取别名
// 导入
import * as coder from './coder.js';
console.log(coder.username, coder.age, coder.address);
import.meta
import.meta是一个给JavaScript模块暴露特定上下文的元数据属性的对象
ps : 包含了这个模块的信息,比如说这个模块的URL,在ES11(ES2020)中新增的特性
console.log(import.meta);
5. export和import结合使用
方便统一导出,方便阅读
01 - 通常
import { formatCount, formatDate } from './format.js'
import { parseLyric } from './parse.js'
export {
formatCount,
formatDate,
parseLyric
}
02 - 优化一
export { formatCount, formatDate } from './format.js'
export { parseLyric } from './parse.js'
03 - 优化二
export * from './format.js'
export * from './parse.js'
6. default
上面的导出功能都是有名字的导出(named exports):
- 在导出export时指定了名字
- 在导入import时需要知道具体的名字
default 导出叫做默认导出(default export):
- 默认导出export时可以不需要指定名字
- 在导入时不需要使用 {},并且可以自己来指定名字
- 也方便和现有的CommonJS等规范相互操作
- 在一个模块中,只能有一个默认导出(default export)
01 - 默认导出
// 导出变量
const username = '123';
export default username
// 导出泪
// export default class {}
// 导出函数
// export default function () {}
02 - 默认导入
// 不用加{} 可以随便取名字
import text from './text.js';
03 - 两种导出
const username = '123';
export default username;
export const age = 18
export const address = '上海'
04 - 两种导入
// 分开导入,两者不合一
import text from './text.js';
import { age, address } from './text.js';
console.log(text, age, address);
05 - 统一导出默认导出
定义 text.js 文件
const username = '123';
export default username;
定义 index.js 文件
// 中间层,取别名
export { default as text } from './text.js';
// 还有好多.......共同在这里导出
定义main.js文件
import { text,... } from './index.js';
console.log(text); // 123
定义index.html文件
<body>
<script src="./main.js" type="module"></script>
</body>
7. import函数
通过import加载一个模块,是不可以在其放到逻辑代码中的,只能放到最顶层
- 这是因为ES Module在被JS引擎解析时,就必须知道它的依赖关系
- 由于这个时候js代码没有任何的运行,所以无法在进行类似于if判断中根据代码的执行情况
- 甚至拼接路径的写法也是错误的:因为我们必须到运行时能确定path的值
// import 不能放到判断中,只能放到最顶层,这里会报错
let flag = true;
if (flag) {
import { aaa } from './aaa.js';
} else {
import { bbb } from './bbb.js';
}
某些情况下,确确实实希望动态的来加载某一个模块 :
- 根据不懂的条件,动态来选择加载模块的路径
- 可以使用 import() 函数来动态加载
- import函数返回一个Promise,可以通过then获取结果
let flag = true;
if (flag) {
import('./a.js').then((res) => {
console.log(res);
});
} else {
import('./b.js').then((res) => {
console.log(res);
});
}
8. ES Module的解析流程
ES Module 被浏览器解析并且让模块之间可以相互引用 : es-modules-deep-dive
ES Module的解析过程可以划分为三个阶段 :
- 阶段一:构建(Construction)
- 根据地址查找js文件,并且下载,将其解析成模块记录(Module Record)
- 阶段二:实例化(Instantiation)
- 对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址
- 阶段三:运行(Evaluation)
- 运行代码,计算值,并且将值填充到内存地址中