模块化
什么是模块化?
模块化的历史
没有模块化带来的问题
早期没有模块化带来了很多的问题:比如命名冲突的问题
var name = 'zs'
var name = 'ls'
console.log(name)
<!DOCTYPE html>
<html lang="zh_CN">
<head></head>
<body></body>
<script src="./index1.js"></script>
<script src="./index2.js"></script>
<script src="./test.js"></script>
</html>
// index2 中的 name 覆盖了 index1 中的 name :ls
当然,我们有办法可以解决上面的问题:立即函数调用表达式(IIFE) ,因为函数是有作用域的。
IIFE (Immediately Invoked Function Expression) ```javascript // 立即执行函数包裹内容,并将想要暴露的部分作为函数的返回值暴露 var moduleA = (function() {
var name = ‘zs’ // 暴露的值过多,可以返回一个复杂类型(对象) return { name: name }
})()
```javascript
var moduleB = (function() {
var name = 'ls'
return {
name: name
}
})()
console.log(moduleA.name) // 可以精确指定需要哪个文件(函数)中的 name
// zs
CommonJS 规范
CommonJS 规范和 Node 关系
总结就是一句话:Node 是 CommonJS 规范的一种落地实现。
基本使用及其原理
每个 js 文件都是一个模块 module 类的实例对象,module 中有个属性 exports ,它也是个对象,并且 exports 属性的内容会被导出,所以我们可以把要导出的属性添加进 export 对象中,也可以给 exports 属性重新赋值,然后新值就会被导出。一般重新赋值的也是一个对象。
在另一个 js 文件中进行 require 导入,其实就是获取了被导入模块中 exports 属性对象的引用。
// 给 exports 对象添加 name 属性
module.exports.name = 123
// 直接覆写 module.exports 对象的形式
// module.exports = {
// name: 123
// }
const name = 123
const age = 456
// 字面量对象的增强写法
module.exports = {
name, // 相当于 name = name
age
}
// 导入 index 模块,moduleIndex 接收到的就是 index 模块的 exports 对象
const moduleIndex = require('./index.js')
console.log(moduleIndex.name); // 123
// 也支持解构赋值
const { name } = require('./index.js')
console.log(name); // 123
exports 和 module.exports
我们知道真正导出的对象只能是**module.exports**
,那为什么exports
也能导出?
其实 node 内部是这样实现 CommonJS 的:
// module.exports 先是一个空对象
module.exports = {}
// 然后新建一个属性 exports,让它也指向了 module.exports 的空对象
const exports = module.exports
// 这样的 exports 导出,其实就是在给 module.exports 对象动态添加属性
// 实际导出的还是 module.exports
exports.name = 'zs'
exports.age = 18
// 和这样的效果是一样的
module.exports = {
name = 'zs',
age = 18
}
使用 exports 的时候,要时刻注意 exports 变量是否还指向 module.exports 对象,如果不再指向,那 exports 是无法导出的。
// exports 变量被赋值,已经指向了新对象,所以导出失败
exports = {
name,
age,
sum
}
exports.name = name
exports.age = age
exports.sum = sum
// exports 给 module.exports 对象中添加了属性
// 但是 module.exports 的指向改了,所以 exports 还是导出失败
module.exports = {
}
都有 module.exports 了,为什么还要 exports 这个属性来恶心一下?因为 exports 导出才是 CommonJS 的设计规范。换句话说,node 没完全按规范来,为了弥补所以整了这么一个变量。但是现在 node 基本弃用了 exports ,喧宾夺主了属于是。
require 查找规则
我们现在已经知道,require 是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。
require 的查找规则是怎么样的呢?完整的查找规则
常见的规则:导入格式如下:require(X)
情况一:X 是一个 Node 原生核心模块,比如path、http、fs
- 直接返回核心模块,并且停止查找
情况二:X是以 ./ 或 ../ 或 /(根目录)开头的
第一步:将 X 当做一个文件在对应的目录下查找;
- 如果有后缀名,按照后缀名的格式查找对应的文件
- 如果没有后缀名,会按照如下顺序:
- 直接查找没有后缀的文件 X
- 查找 X.js 文件
- 查找 X.json 文件
- 查找 X.node 文件
第二步:没有找到对应的文件,将 X 作为一个目录
- 查找目录下面的 index 文件
- 查找 X/index.js 文件
- 查找 X/index.json 文件
- 查找 X/index.node 文件
如果没有找到,那么报错:not found
情况三:直接是一个X(没有路径),并且 X 不是一个原生核心模块
- 将 X 视为第三方包,从当前目录的 node_modules 开始层层往上,查找每一层的 node_modules
模块的加载过程
结论一:模块在被第一次引入时,模块中的 js 代码会被运行一次,但模块被多次引入时,会缓存,最终只加载(运行)一次。
为什么只会加载运行一次呢?
这是因为每个模块对象 module 都有一个属性:loaded。为 false 表示还没有加载,为 true 表示已经加载;
```javascript console.log(“test.js代码开始运行”)console.log('index.js 模块代码被执行');
// 加载的时候会执行一次,后续加载从缓存中加载,就不再执行 require(“./index”) require(“./index”) require(“./index”)
console.log(“test.js代码后续运行”)
// test.js代码开始运行 // index.js 模块代码被执行 // test.js代码后续运行
**结论二:多个文件循环引入,模块的加载顺序采用深度优先遍历进行加载**<br />比如:出现如下模块的引用关系,那么加载顺序是什么呢?<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22919157/1650556376526-5c01092c-9ce3-41da-ade9-705ad07c572d.png#clientId=u6298a95a-8a01-4&crop=0&crop=0&crop=1&crop=1&errorMessage=unknown%20error&from=paste&height=273&id=u56015669&margin=%5Bobject%20Object%5D&name=image.png&originHeight=341&originWidth=466&originalType=binary&ratio=1&rotation=0&showTitle=false&size=89335&status=error&style=none&taskId=u29ee86ed-ce9b-4dc7-aaef-33695195eeb&title=&width=372.8)<br />这个其实是一种数据结构:图结构;
- 图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search);
Node 采用的是深度优先算法, main 左边更深,就先从左边加载到底,再加载右边没加载到的<br />结果:main -> aaa -> ccc -> ddd -> eee ->bbb
<a name="l6P45"></a>
## CommonJS 规范缺点
**CommonJS 加载模块是同步的。**<br />同步的意味着只有等到对应的模块加载完毕,才能继续运行当前模块中后面的内容。这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快;<br />CommonJS 如果应用于浏览器中<br />浏览器加载 js 文件需要先从服务器将文件下载下来,之后再加载运行;网络请求耗时相对较长,那么后续的 js 代码都无法正常运行,即使是一些简单的DOM操作,体验会非常的差。<br />所以在浏览器中,我们通常不使用 CommonJS 规范,但在 webpack 中会使用 CommonJS;webpack 配置的时候采用 CommonJS,但项目开发的时候就无所谓了,因为无论项目使用什么模块规范,webpack 构建时,都能将项目构建成模块函数在浏览器中运行,所以在 webpack 中可以无视 CommonJS 在浏览器中的缺陷。
在早期没有 webpack 这些构建工具,原生开发的时候,为了可以在浏览器中使用模块化,通常会采用 AMD 或 CMD 规范:<br />但是目前一方面现代的浏览器已经支持 ES Modules,另一方面借助于 webpack 等工具可以实现对 CommonJS 或者 ES Module代码 的转换;AMD 和 CMD 已经使用非常少了。
<a name="etUr7"></a>
# AMD 规范(了解)
AMD 主要是应用于浏览器的一种模块化规范,AMD是 Asynchronous Module Definition(异步模块定义)的缩写。它采用的是异步加载模块;<br />事实上AMD的规范还要早于CommonJS,但是CommonJS目前依然在被使用,而AMD使用的较少了
规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:
- AMD 规范的实现中比较常用的库是 require.js 和 curl.js;
<a name="Ke8Of"></a>
## require.js 的使用
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22919157/1650558174874-24ad61e6-1c33-4aab-af2a-d32bafe84fe5.png#clientId=u6298a95a-8a01-4&crop=0&crop=0&crop=1&crop=1&errorMessage=unknown%20error&from=paste&height=443&id=u41956653&margin=%5Bobject%20Object%5D&name=image.png&originHeight=554&originWidth=1180&originalType=binary&ratio=1&rotation=0&showTitle=false&size=249810&status=error&style=none&taskId=u23988323-ee59-499f-a471-4c2de5a359f&title=&width=944)
<a name="AplR2"></a>
# CMD 规范(了解)
CMD规范也是应用于浏览器的一种模块化规范:CMD 是Common Module Definition(通用模块定义)的缩写;<br />它也采用了异步加载模块,但是它将CommonJS的优点吸收了过来,但是目前CMD使用也非常少了;
CMD也有自己比较优秀的实现方案:
- SeaJS
<a name="gOJu0"></a>
## SeaJS 的使用
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22919157/1650558203157-e3375290-dc78-4c96-b637-8c41ef30e068.png#clientId=u6298a95a-8a01-4&crop=0&crop=0&crop=1&crop=1&errorMessage=unknown%20error&from=paste&height=448&id=u4aeae62e&margin=%5Bobject%20Object%5D&name=image.png&originHeight=560&originWidth=1062&originalType=binary&ratio=1&rotation=0&showTitle=false&size=268176&status=error&style=none&taskId=u84165445-ef1d-4bbf-bab6-a0dbe13ab25&title=&width=849.6)
<a name="sNAQm"></a>
# ES Module
<a name="rJsYk"></a>
## 认识 ES Module
JavaScript 没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJS、AMD、CMD等,<br />所以在ES推出自己的模块化系统时,大家也是兴奋异常。
**ES Module和 CommonJS 的模块化有一些不同之处:**
- 一方面它使用了import和export关键字;
- export 负责将模块内的内容导出
- import 负责从其他模块导入内容
- 另一方面它采用**编译期的静态分析**,并且也加入了动态引用的方式;
采用 ES Module 的 js 文件将自动采用严格模式:use strict
<a name="yYylM"></a>
## 浏览器使用 ESM
ES Module 已经被浏览器环境支持,但是有几个注意点。
1. **js 文件中采用了 ES Module,导入到 html 文件中时,一定要加上**`**type = 'module'**`
否则会报`Cannot use import statement outside a module`不能在模块外部使用import语句。<br />因为 src 普通导入相当于把 js 文件代码复制了过来,而不是按模块加载。所以在 script 标签中使用 import 关键字,相当于在 html 文件中使用。不是 js 文件,报模块外部错误。
```javascript
export const name = "zs"
export const age = 18
// 导入一定要写完整且加后缀,webpack 中可以不加,是因为webpack自动补齐了
import {name, age} from './index.js'
console.log(name, age);
<!DOCTYPE html>
<html lang="zh_CN">
<head></head>
<body></body>
<!-- <script src="./main.js"></script> -->
<!-- 指明导入类型为:module -->
<script src="./main.js" type="module"></script>
</html>
- html 引入了采用 ES Module 的 js 文件,则该 html 文件不能通过
**file://**
协议在浏览器中打开。
因为安全性的需要,会被 CORS 同源策略禁止Cross origin requests are only supported for protocol schemes: http, data, chrome-extension, edge, https, chrome-untrusted.
跨源请求只支持协议方案:http, data, chrome-extension, edge, https, chrome-untrusted。
上面列举的协议中,我们主要开启服务器,通过 HTTP
或 HTTPS
协议打开,比如:VSCode 中有一个插件:Live Server
- 各种模块之间引入的时候必须写出 js 文件完整路径和后缀
比如引入 axios,不能这样引入:import axios from "axios"
,这样的引入的方式能成功是构建工具的作用。
浏览器的引入方式:import axios from "/node_module/axios/dist/axios.js"
- 浏览器并不支持 CommonJS,所以引入第三方包的时候一定需要下载 ESM 版本
比如 lodash-es。如果是 CMJ 构成的包,则会无法识别 require 函数报错。
并且浏览器引入第三方包,会有个弊端:浏览器没有 tree shaking,它会引入库中所有可达的包,发送大量的网络请求。
export 关键字
export 关键字将一个模块中的变量、函数、类等导出;
// 1.第一种方式: export 声明语句
export const name = "zs"
export const age = 18
export function foo() {
console.log("foo function")
}
export class Person {
}
// 2.第二种方式: export 导出 和 声明分开
const name = "why"
const age = 18
function foo() {
console.log("foo function")
}
// 注意:这个花括号是个固定结构,不是表示对象,
// 里面也不是ES6对象字面量的增强写法,所以里面不能写键值对
export {
name,
age,
foo
}
// 3.第三种方式: 第二种导出时起别名
export {
name as fName,
age as fAge,
foo as fFoo
}
import 关键字
import 关键字负责从另外一个模块中导入内容
// 1.方式一: import {标识符列表} from '模块';
// 注意1:这里的{}也不是一个对象,里面只是存放导入的标识符列表内容;
// 注意2:import 里面的标识符和 export 的标识符要完全一致,包括别名
import { name, age, foo } from "./foo.js"
import { fName, fAge, fFoo } from './foo.js' // export 导出的有别名,import 就得用别名
// 2.方式二: 导入的时候起别名
import { name as fName, age as fAge, foo as fFoo } from './foo.js'
// 3.方式三: 将导出的所有内容放到一个标识符中
// 通过通配符 * 接收所有导出,
// 并将模块功能放到一个模块功能对象(a module object)上,并给对象起个别名作为引用
import * as foo from './foo.js'
console.log(foo.name) // export 导出的内容可以和属性一样调用了
console.log(foo.age)
foo.foo()
export 和 import 结合使用
一般在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中;这样方便指定统一的接口规范,也方便阅读;
这个时候,我们就可以使用export和import结合使用;
// 1.出口方式一: import export 分开
import { add, sub } from './math.js'
import { timeFormat, priceFormat } from './format.js'
export {
add,
sub,
timeFormat,
priceFormat
}
// 2.导出方式二: import 后直接 export 导出
export { add, sub } from './math.js'
export { timeFormat, priceFormat } from './format.js'
// 3.导出方式三: 如果其他具体实现模块的 export 需要全部导出,用通配符 * 全部接收后直接导出
export * from './math.js'
export * from './format.js'
default 用法
之前的导出功能都是有名字的导出,命名导出(named exports):
- 在导出export时指定了名字;
- 在导入import时需要知道具体的名字;
还有一种导出叫做默认导出(default export)
- 有两种导出方式
- 并且 import 导入时不需要使用
{}
,并且可以自己来指定名字; - 它也方便我们和现有的 CommonJS 等规范相互操作;
注意: 默认导出只能有一个
const name = "why"
const age = 18
const foo = "foo value"
// 1.默认导出的方式一:
export {
name,
age,
foo as default
}
// 2.默认导出的方式二: 常见
export default foo
// index.js 中存在默认导出,import 可以不用花括号并自己命名标识符
import hhh from './index.js'
import( ) 函数
import 加载模块默认也是同步的,它也会阻塞后续的代码运行。并且通过 import 加载一个模块,是不可以在其放到逻辑代码中的。
因为ES Module在被JS引擎解析时,引擎就必须明确各种依赖关系;可是这个时候 js 代码没有任何的运行,所以无法进行类似于 if 这种逻辑判断,从而无法明确是否应该导入这个模块。
if (true) {
import hhh from './index.js' // 错误
}
**import()**
函数是异步的,它可以让我们动态的来加载某一个模块。
- 异步加载和异步请求不是异曲同工吗,所以 import() 函数的返回值也是一个 promise ```javascript // import函数返回的结果是一个Promise // 加载成功,相当于 resolve,可以调用 then 对导入结果进行处理 import(“./foo.js”).then(res => { console.log(“res:”, res.name) })
console.log(“后续的代码不会被阻塞正常运行~”)
<a name="VVqiY"></a>
## import meta
import.meta 是在ES11(ES2020)中新增的特性,是一个给 JavaScript 模块暴露特定上下文的元数据属性的对象。它包含了当前模块的信息,比如说这个模块的 URL;
```javascript
console.log(import.meta)
// {url: 'http://127.0.0.1:5500/main.js'}
ES Module 的工作原理
ES Module是如何被浏览器解析并且让模块之间可以相互引用的呢?
https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
ES Module的解析过程可以划分为三个阶段:
阶段一:构建(Construction),根据地址查找 js 文件,并且异步下载,并将其解析成模块记录(Module Record);
阶段二:实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址。
阶段三:运行(Evaluation),运行代码,计算值,并且将值填充到内存地址中;
阶段一:构建阶段
ES Module 会层层往下构建,先下载,然后解析成模块记录,然后才能知道模块依赖了哪些模块,再去下载,解析,依次获取所有依赖的模块。期间所有的代码都不会运行,只会解析 import 后面的 url。
所有的下载都是异步的,也就是说设置了 type=module 的代码,相当于在script标签上也加上了 async 属性;
因为代码不会执行,所以 url 中通过变量的方式进行动态加载是无法生效,比如:import {count} from
${path}/count.js`<br />path 中没有值,错误的 url,无法建立依赖。如果想要动态的引入,就要使用
import()`函数。动态导入的模块将启动一个新的依赖图起点(实例化后),该图形将单独处理,
ES Module 构建依赖图谱的阶段和实例化各个模块的阶段分离是与 CommonJS 的重要区别之一。
因为 CommonJS 是加载本地文件,速度很快,所以可以同步,让主线程等待,加载文件然后解析实例化一起执行。ES Module 在浏览器中,js 文件是从网络下载的,这个等待时间,主线程等不起。
ES Module 是异步下载,对于那些下载完的 js 文件,静态分析会维护一个 Map。模块被解析创建模块记录后,将其放置在模块映射中。之后再次请求这个 url 时,加载器都可以从 Map 中直接获取该模块记录。
阶段二和三:实例化阶段 – 求值阶段
在构建过程结束后,我们从只有一个入口模块文件变成了拥有一堆模块记录。实例化就是建立这些模块记录间的联系。开始执行 import 和 export 语句,其他代码依然不执行。
JS 引擎会先在内存中创建模块环境记录(Module Environment Record),并在内存中开辟一个空间管理了模块环境记录中的变量,并且让环境记录,也就是 js 文件中 export 的部分指向内存空间中对应的变量。
环境记录中的变量暂时没有值,为 undefined,因为还没执行相关代码。
另外也会让 import 了该变量的模块环境记录指向内存空间中的对应变量。
导出和导入都指向内存中的同一位置。首先连接出口可以保证所有进口都可以连接到匹配的出口。
为了实例化模块图,引擎将执行深度优先后序遍历。
实例化与 CommonJS 的不同
在 CommonJS 中,整个导出对象在导出时被复制。这意味着导出的任何值(如数字)都是副本。
所以如果在导出模块稍后更改该值,则导入模块不会看到之后的更改。
let a = 123
setTimeout(() => {
a = 456
}, 100)
module.exports = a
const a = require('./foo.js')
console.log(a); // 123
setTimeout(() => {
console.log(a) // 123 没有发现导出值的变化
}, 200)
相比之下,ES Module 相当于实时绑定。两个模块都指向内存中的相同位置。这意味着,当导出模块更改值后,导入模块稍后能获取到该更改。
let a = 123
setTimeout(() => {
a = 456
}, 100)
export { a }
// 这样默认导出,import 也获取不到变化,很奇怪
// export default a
import { a } from './foo.js'
console.log(a) // 123
setTimeout(() => {
console.log(a) // 456 获取到变化
}, 300)
注意:导出值的模块可以随时这些值,但导入模块不能更改其导入的值。
import a from './index.js'
a = 456
// 报错:a 是一个常量,无法更改
阶段三,就是执行 export 和 import 中间的代码,给内存空间的变量赋值了。
Node 中使用 ES Module
方式一:在package.json中配置 type: module
方式二:文件以 .mjs 结尾,表示使用的是ES Module;
ES Module 和 CommonJS 的交互混用
- 浏览器环境中,压根不支持 CommonJS,所以不能混用
- 开发环境中,如构建工具 webpack 中,它对两个都有很好的支持,所以能完全混用。
node 环境:
- CommonJS 不能 require 加载 ES Module
- 因为 CommonJS 是同步加载的,但是 ES Module 必须经过静态分析等,无法在这个时候执行 JavaScript 代码;
- ES Module 可以 import 加载 CommonJS
- ES Module 在加载 CommonJS 时,会将其 module.exports 导出的内容作为 default 导出方式来使用;