模块化

什么是模块化?

image.png

模块化的历史

image.png

没有模块化带来的问题

早期没有模块化带来了很多的问题:比如命名冲突的问题

  1. var name = 'zs'
  1. var name = 'ls'
  1. console.log(name)
  1. <!DOCTYPE html>
  2. <html lang="zh_CN">
  3. <head></head>
  4. <body></body>
  5. <script src="./index1.js"></script>
  6. <script src="./index2.js"></script>
  7. <script src="./test.js"></script>
  8. </html>
  9. // index2 中的 name 覆盖了 index1 中的 name :ls

当然,我们有办法可以解决上面的问题:立即函数调用表达式(IIFE) ,因为函数是有作用域的。

  • IIFE (Immediately Invoked Function Expression) ```javascript // 立即执行函数包裹内容,并将想要暴露的部分作为函数的返回值暴露 var moduleA = (function() {

    var name = ‘zs’ // 暴露的值过多,可以返回一个复杂类型(对象) return { name: name }

})()

  1. ```javascript
  2. var moduleB = (function() {
  3. var name = 'ls'
  4. return {
  5. name: name
  6. }
  7. })()
  1. console.log(moduleA.name) // 可以精确指定需要哪个文件(函数)中的 name
  2. // zs

image.png

CommonJS 规范

CommonJS 规范和 Node 关系

image.png
总结就是一句话:Node 是 CommonJS 规范的一种落地实现。

基本使用及其原理

每个 js 文件都是一个模块 module 类的实例对象,module 中有个属性 exports ,它也是个对象,并且 exports 属性的内容会被导出,所以我们可以把要导出的属性添加进 export 对象中,也可以给 exports 属性重新赋值,然后新值就会被导出。一般重新赋值的也是一个对象。
在另一个 js 文件中进行 require 导入,其实就是获取了被导入模块中 exports 属性对象的引用。

  1. // 给 exports 对象添加 name 属性
  2. module.exports.name = 123
  3. // 直接覆写 module.exports 对象的形式
  4. // module.exports = {
  5. // name: 123
  6. // }
  7. const name = 123
  8. const age = 456
  9. // 字面量对象的增强写法
  10. module.exports = {
  11. name, // 相当于 name = name
  12. age
  13. }
  1. // 导入 index 模块,moduleIndex 接收到的就是 index 模块的 exports 对象
  2. const moduleIndex = require('./index.js')
  3. console.log(moduleIndex.name); // 123
  1. // 也支持解构赋值
  2. const { name } = require('./index.js')
  3. console.log(name); // 123

exports 和 module.exports

我们知道真正导出的对象只能是**module.exports**,那为什么exports也能导出?
其实 node 内部是这样实现 CommonJS 的:

  1. // module.exports 先是一个空对象
  2. module.exports = {}
  3. // 然后新建一个属性 exports,让它也指向了 module.exports 的空对象
  4. const exports = module.exports
  1. // 这样的 exports 导出,其实就是在给 module.exports 对象动态添加属性
  2. // 实际导出的还是 module.exports
  3. exports.name = 'zs'
  4. exports.age = 18
  5. // 和这样的效果是一样的
  6. module.exports = {
  7. name = 'zs',
  8. age = 18
  9. }

使用 exports 的时候,要时刻注意 exports 变量是否还指向 module.exports 对象,如果不再指向,那 exports 是无法导出的。

  1. // exports 变量被赋值,已经指向了新对象,所以导出失败
  2. exports = {
  3. name,
  4. age,
  5. sum
  6. }
  1. exports.name = name
  2. exports.age = age
  3. exports.sum = sum
  4. // exports 给 module.exports 对象中添加了属性
  5. // 但是 module.exports 的指向改了,所以 exports 还是导出失败
  6. module.exports = {
  7. }

都有 module.exports 了,为什么还要 exports 这个属性来恶心一下?因为 exports 导出才是 CommonJS 的设计规范。换句话说,node 没完全按规范来,为了弥补所以整了这么一个变量。但是现在 node 基本弃用了 exports ,喧宾夺主了属于是。

require 查找规则

我们现在已经知道,require 是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。
require 的查找规则是怎么样的呢?完整的查找规则

常见的规则:导入格式如下:require(X)
情况一:X 是一个 Node 原生核心模块,比如path、http、fs

  • 直接返回核心模块,并且停止查找

情况二:X是以 ./ 或 ../ 或 /(根目录)开头的
第一步:将 X 当做一个文件在对应的目录下查找;

  1. 如果有后缀名,按照后缀名的格式查找对应的文件
  2. 如果没有后缀名,会按照如下顺序:
    1. 直接查找没有后缀的文件 X
    2. 查找 X.js 文件
    3. 查找 X.json 文件
    4. 查找 X.node 文件

第二步:没有找到对应的文件,将 X 作为一个目录

  1. 查找目录下面的 index 文件
    1. 查找 X/index.js 文件
    2. 查找 X/index.json 文件
    3. 查找 X/index.node 文件

如果没有找到,那么报错:not found

情况三:直接是一个X(没有路径),并且 X 不是一个原生核心模块

  • 将 X 视为第三方包,从当前目录的 node_modules 开始层层往上,查找每一层的 node_modules

    模块的加载过程

    结论一:模块在被第一次引入时,模块中的 js 代码会被运行一次,但模块被多次引入时,会缓存,最终只加载(运行)一次。
    为什么只会加载运行一次呢?
    这是因为每个模块对象 module 都有一个属性:loaded。为 false 表示还没有加载,为 true 表示已经加载;
    1. console.log('index.js 模块代码被执行');
    ```javascript console.log(“test.js代码开始运行”)

// 加载的时候会执行一次,后续加载从缓存中加载,就不再执行 require(“./index”) require(“./index”) require(“./index”)

console.log(“test.js代码后续运行”)

// test.js代码开始运行 // index.js 模块代码被执行 // test.js代码后续运行

  1. **结论二:多个文件循环引入,模块的加载顺序采用深度优先遍历进行加载**<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 />这个其实是一种数据结构:图结构;
  2. - 图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search);
  3. Node 采用的是深度优先算法, main 左边更深,就先从左边加载到底,再加载右边没加载到的<br />结果:main -> aaa -> ccc -> ddd -> eee ->bbb
  4. <a name="l6P45"></a>
  5. ## CommonJS 规范缺点
  6. **CommonJS 加载模块是同步的。**<br />同步的意味着只有等到对应的模块加载完毕,才能继续运行当前模块中后面的内容。这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快;<br />CommonJS 如果应用于浏览器中<br />浏览器加载 js 文件需要先从服务器将文件下载下来,之后再加载运行;网络请求耗时相对较长,那么后续的 js 代码都无法正常运行,即使是一些简单的DOM操作,体验会非常的差。<br />所以在浏览器中,我们通常不使用 CommonJS 规范,但在 webpack 中会使用 CommonJSwebpack 配置的时候采用 CommonJS,但项目开发的时候就无所谓了,因为无论项目使用什么模块规范,webpack 构建时,都能将项目构建成模块函数在浏览器中运行,所以在 webpack 中可以无视 CommonJS 在浏览器中的缺陷。
  7. 在早期没有 webpack 这些构建工具,原生开发的时候,为了可以在浏览器中使用模块化,通常会采用 AMD CMD 规范:<br />但是目前一方面现代的浏览器已经支持 ES Modules,另一方面借助于 webpack 等工具可以实现对 CommonJS 或者 ES Module代码 的转换;AMD CMD 已经使用非常少了。
  8. <a name="etUr7"></a>
  9. # AMD 规范(了解)
  10. AMD 主要是应用于浏览器的一种模块化规范,AMD Asynchronous Module Definition(异步模块定义)的缩写。它采用的是异步加载模块;<br />事实上AMD的规范还要早于CommonJS,但是CommonJS目前依然在被使用,而AMD使用的较少了
  11. 规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:
  12. - AMD 规范的实现中比较常用的库是 require.js curl.js
  13. <a name="Ke8Of"></a>
  14. ## require.js 的使用
  15. ![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)
  16. <a name="AplR2"></a>
  17. # CMD 规范(了解)
  18. CMD规范也是应用于浏览器的一种模块化规范:CMD Common Module Definition(通用模块定义)的缩写;<br />它也采用了异步加载模块,但是它将CommonJS的优点吸收了过来,但是目前CMD使用也非常少了;
  19. CMD也有自己比较优秀的实现方案:
  20. - SeaJS
  21. <a name="gOJu0"></a>
  22. ## SeaJS 的使用
  23. ![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)
  24. <a name="sNAQm"></a>
  25. # ES Module
  26. <a name="rJsYk"></a>
  27. ## 认识 ES Module
  28. JavaScript 没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJSAMDCMD等,<br />所以在ES推出自己的模块化系统时,大家也是兴奋异常。
  29. **ES Module CommonJS 的模块化有一些不同之处:**
  30. - 一方面它使用了importexport关键字;
  31. - export 负责将模块内的内容导出
  32. - import 负责从其他模块导入内容
  33. - 另一方面它采用**编译期的静态分析**,并且也加入了动态引用的方式;
  34. 采用 ES Module js 文件将自动采用严格模式:use strict
  35. <a name="yYylM"></a>
  36. ## 浏览器使用 ESM
  37. ES Module 已经被浏览器环境支持,但是有几个注意点。
  38. 1. **js 文件中采用了 ES Module,导入到 html 文件中时,一定要加上**`**type = 'module'**`
  39. 否则会报`Cannot use import statement outside a module`不能在模块外部使用import语句。<br />因为 src 普通导入相当于把 js 文件代码复制了过来,而不是按模块加载。所以在 script 标签中使用 import 关键字,相当于在 html 文件中使用。不是 js 文件,报模块外部错误。
  40. ```javascript
  41. export const name = "zs"
  42. export const age = 18
  1. // 导入一定要写完整且加后缀,webpack 中可以不加,是因为webpack自动补齐了
  2. import {name, age} from './index.js'
  3. console.log(name, age);
  1. <!DOCTYPE html>
  2. <html lang="zh_CN">
  3. <head></head>
  4. <body></body>
  5. <!-- <script src="./main.js"></script> -->
  6. <!-- 指明导入类型为:module -->
  7. <script src="./main.js" type="module"></script>
  8. </html>
  1. 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。
上面列举的协议中,我们主要开启服务器,通过 HTTPHTTPS 协议打开,比如:VSCode 中有一个插件:Live Server

  1. 各种模块之间引入的时候必须写出 js 文件完整路径和后缀

比如引入 axios,不能这样引入:import axios from "axios",这样的引入的方式能成功是构建工具的作用。
浏览器的引入方式:import axios from "/node_module/axios/dist/axios.js"

  1. 浏览器并不支持 CommonJS,所以引入第三方包的时候一定需要下载 ESM 版本

比如 lodash-es。如果是 CMJ 构成的包,则会无法识别 require 函数报错。
并且浏览器引入第三方包,会有个弊端:浏览器没有 tree shaking,它会引入库中所有可达的包,发送大量的网络请求。

export 关键字

export 关键字将一个模块中的变量、函数、类等导出;

  1. // 1.第一种方式: export 声明语句
  2. export const name = "zs"
  3. export const age = 18
  4. export function foo() {
  5. console.log("foo function")
  6. }
  7. export class Person {
  8. }
  9. // 2.第二种方式: export 导出 和 声明分开
  10. const name = "why"
  11. const age = 18
  12. function foo() {
  13. console.log("foo function")
  14. }
  15. // 注意:这个花括号是个固定结构,不是表示对象,
  16. // 里面也不是ES6对象字面量的增强写法,所以里面不能写键值对
  17. export {
  18. name,
  19. age,
  20. foo
  21. }
  22. // 3.第三种方式: 第二种导出时起别名
  23. export {
  24. name as fName,
  25. age as fAge,
  26. foo as fFoo
  27. }

import 关键字

import 关键字负责从另外一个模块中导入内容

  1. // 1.方式一: import {标识符列表} from '模块';
  2. // 注意1:这里的{}也不是一个对象,里面只是存放导入的标识符列表内容;
  3. // 注意2:import 里面的标识符和 export 的标识符要完全一致,包括别名
  4. import { name, age, foo } from "./foo.js"
  5. import { fName, fAge, fFoo } from './foo.js' // export 导出的有别名,import 就得用别名
  6. // 2.方式二: 导入的时候起别名
  7. import { name as fName, age as fAge, foo as fFoo } from './foo.js'
  8. // 3.方式三: 将导出的所有内容放到一个标识符中
  9. // 通过通配符 * 接收所有导出,
  10. // 并将模块功能放到一个模块功能对象(a module object)上,并给对象起个别名作为引用
  11. import * as foo from './foo.js'
  12. console.log(foo.name) // export 导出的内容可以和属性一样调用了
  13. console.log(foo.age)
  14. foo.foo()

export 和 import 结合使用

一般在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中;这样方便指定统一的接口规范,也方便阅读;
这个时候,我们就可以使用export和import结合使用;

  1. // 1.出口方式一: import export 分开
  2. import { add, sub } from './math.js'
  3. import { timeFormat, priceFormat } from './format.js'
  4. export {
  5. add,
  6. sub,
  7. timeFormat,
  8. priceFormat
  9. }
  10. // 2.导出方式二: import 后直接 export 导出
  11. export { add, sub } from './math.js'
  12. export { timeFormat, priceFormat } from './format.js'
  13. // 3.导出方式三: 如果其他具体实现模块的 export 需要全部导出,用通配符 * 全部接收后直接导出
  14. export * from './math.js'
  15. export * from './format.js'

default 用法

之前的导出功能都是有名字的导出,命名导出(named exports):

  • 在导出export时指定了名字;
  • 在导入import时需要知道具体的名字;

还有一种导出叫做默认导出(default export)

  • 有两种导出方式
  • 并且 import 导入时不需要使用 {},并且可以自己来指定名字;
  • 它也方便我们和现有的 CommonJS 等规范相互操作;

注意: 默认导出只能有一个

  1. const name = "why"
  2. const age = 18
  3. const foo = "foo value"
  4. // 1.默认导出的方式一:
  5. export {
  6. name,
  7. age,
  8. foo as default
  9. }
  10. // 2.默认导出的方式二: 常见
  11. export default foo
  1. // index.js 中存在默认导出,import 可以不用花括号并自己命名标识符
  2. import hhh from './index.js'

import( ) 函数

import 加载模块默认也是同步的,它也会阻塞后续的代码运行。并且通过 import 加载一个模块,是不可以在其放到逻辑代码中的。
因为ES Module在被JS引擎解析时,引擎就必须明确各种依赖关系;可是这个时候 js 代码没有任何的运行,所以无法进行类似于 if 这种逻辑判断,从而无法明确是否应该导入这个模块。

  1. if (true) {
  2. import hhh from './index.js' // 错误
  3. }

**import()**函数是异步的,它可以让我们动态的来加载某一个模块。

  • 异步加载和异步请求不是异曲同工吗,所以 import() 函数的返回值也是一个 promise ```javascript // import函数返回的结果是一个Promise // 加载成功,相当于 resolve,可以调用 then 对导入结果进行处理 import(“./foo.js”).then(res => { console.log(“res:”, res.name) })

console.log(“后续的代码不会被阻塞正常运行~”)

  1. <a name="VVqiY"></a>
  2. ## import meta
  3. import.meta 是在ES11(ES2020)中新增的特性,是一个给 JavaScript 模块暴露特定上下文的元数据属性的对象。它包含了当前模块的信息,比如说这个模块的 URL;
  4. ```javascript
  5. console.log(import.meta)
  6. // {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),运行代码,计算值,并且将值填充到内存地址中;
image.png

阶段一:构建阶段

ES Module 会层层往下构建,先下载,然后解析成模块记录,然后才能知道模块依赖了哪些模块,再去下载,解析,依次获取所有依赖的模块。期间所有的代码都不会运行,只会解析 import 后面的 url
所有的下载都是异步的,也就是说设置了 type=module 的代码,相当于在script标签上也加上了 async 属性;
image.png
因为代码不会执行,所以 url 中通过变量的方式进行动态加载是无法生效,比如:
import {count} from${path}/count.js`<br />path 中没有值,错误的 url,无法建立依赖。如果想要动态的引入,就要使用import()`函数。动态导入的模块将启动一个新的依赖图起点(实例化后),该图形将单独处理,
image.png
ES Module 构建依赖图谱的阶段和实例化各个模块的阶段分离是与 CommonJS 的重要区别之一。
因为 CommonJS 是加载本地文件,速度很快,所以可以同步,让主线程等待,加载文件然后解析实例化一起执行。ES Module 在浏览器中,js 文件是从网络下载的,这个等待时间,主线程等不起。

ES Module 是异步下载,对于那些下载完的 js 文件,静态分析会维护一个 Map。模块被解析创建模块记录后,将其放置在模块映射中。之后再次请求这个 url 时,加载器都可以从 Map 中直接获取该模块记录。
image.png

阶段二和三:实例化阶段 – 求值阶段

在构建过程结束后,我们从只有一个入口模块文件变成了拥有一堆模块记录。实例化就是建立这些模块记录间的联系。开始执行 import 和 export 语句,其他代码依然不执行。
JS 引擎会先在内存中创建模块环境记录(Module Environment Record),并在内存中开辟一个空间管理了模块环境记录中的变量,并且让环境记录,也就是 js 文件中 export 的部分指向内存空间中对应的变量。
环境记录中的变量暂时没有值,为 undefined,因为还没执行相关代码。
另外也会让 import 了该变量的模块环境记录指向内存空间中的对应变量。
导出和导入都指向内存中的同一位置。首先连接出口可以保证所有进口都可以连接到匹配的出口。
image.png
为了实例化模块图,引擎将执行深度优先后序遍历。

实例化与 CommonJS 的不同

在 CommonJS 中,整个导出对象在导出时被复制。这意味着导出的任何值(如数字)都是副本。
所以如果在导出模块稍后更改该值,则导入模块不会看到之后的更改。
image.png

  1. let a = 123
  2. setTimeout(() => {
  3. a = 456
  4. }, 100)
  5. module.exports = a
  1. const a = require('./foo.js')
  2. console.log(a); // 123
  3. setTimeout(() => {
  4. console.log(a) // 123 没有发现导出值的变化
  5. }, 200)

相比之下,ES Module 相当于实时绑定。两个模块都指向内存中的相同位置。这意味着,当导出模块更改值后,导入模块稍后能获取到该更改。

  1. let a = 123
  2. setTimeout(() => {
  3. a = 456
  4. }, 100)
  5. export { a }
  6. // 这样默认导出,import 也获取不到变化,很奇怪
  7. // export default a
  1. import { a } from './foo.js'
  2. console.log(a) // 123
  3. setTimeout(() => {
  4. console.log(a) // 456 获取到变化
  5. }, 300)

注意:导出值的模块可以随时这些值,但导入模块不能更改其导入的值。

image.png

  1. import a from './index.js'
  2. a = 456
  3. // 报错:a 是一个常量,无法更改

阶段三,就是执行 export 和 import 中间的代码,给内存空间的变量赋值了。

Node 中使用 ES Module

方式一:在package.json中配置 type: module
方式二:文件以 .mjs 结尾,表示使用的是ES Module;

ES Module 和 CommonJS 的交互混用

  1. 浏览器环境中,压根不支持 CommonJS,所以不能混用
  2. 开发环境中,如构建工具 webpack 中,它对两个都有很好的支持,所以能完全混用。

node 环境:

  • CommonJS 不能 require 加载 ES Module
    • 因为 CommonJS 是同步加载的,但是 ES Module 必须经过静态分析等,无法在这个时候执行 JavaScript 代码;
  • ES Module 可以 import 加载 CommonJS
    • ES Module 在加载 CommonJS 时,会将其 module.exports 导出的内容作为 default 导出方式来使用