彻底理解前端模块化
你会发光哎u 2024-10-23 17:03:02 阅读 75
目录
引入历史问题
CommonJSexports导出module.exports导出require导入加载过程缺点
AMD规范(基本不用)`require.js`使⽤
CMD规范(基本不用)SeaJS的使⽤
ES Module简单使用export关键字import关键字export和import结合default⽤法import函数import metaES Module的解析
模块化是一种处理复杂系统分解成为更好的可管理模块的方式,模块化开发最终的目的是将程序划分成一个个小的结构
这个结构中编写属于自己的逻辑代码,有自己的作用域,定义变量名词时不会影响到其他的结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用可以通过某种方式,导入另外结构中的变量、函数、对象等
引入
历史
在网页开发的早期,<code>Brendan Eich开发JavaScript
仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的,只需要将JavaScript
代码写到<script>
标签即可,没有必要放到多个文件中来编写
随着前端和JavaScript
的快速发展,JavaScript
代码变得越来越复杂了
ajax
的出现,前后端开发分离,意味着后端返回数据后,我们需要通过JavaScript
进行前端页面的渲染
SPA
的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过JavaScript
来实现
包括Node
的实现,JavaScript
编写复杂的后端程序,没有模块化是致命的硬伤
所以,模块化已经是JavaScript
一个非常迫切的需求:
但是JavaScript
本身,直到ES6(2015)
才推出了自己的模块化方案
在此之前,为了让JavaScript
支持模块化,社区出了很多不同的模块化规范:AMD、CMD、CommonJS
等
问题
早期没有模块化带来了很多的问题:比如命名冲突的问题,我们是使用 立即函数调用表达式(IIFE
) 来解决的,但也会有其他问题:
必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用
代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写
在没有合适的规范情况下,每个人都可能会任意命名、甚至出现模块名称相同的情况
需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码,JavaScript
社区为了解决上面的问题,涌现出一系列好用的规范,接下来我们一一学习
CommonJS
CommonJS
是一种模块系统规范,主要用于在服务器端环境(如 Node.js
)中管理模块。它提供了模块的定义、加载、导出机制,允许开发者在不同模块之间共享代码。在 Node.js
中,CommonJS
是默认的模块系统,虽然现在 Node.js
也支持 ECMAScript
模块,但 CommonJS
仍然广泛使用
最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS
,后来为了体现它 的广泛性,修改为CommonJS
,平时也会简称为CJS
Node
是CommonJS
在服务器端一个具有代表性的实现,Node
中对CommonJS
进行了支持和实现
Browserify
库是CommonJS
在浏览器中的一种实现
webpack
打包工具具备对CommonJS
的支持和转换
在Node
中每一个js
文件都是一个单独的模块
这个模块中包括CommonJS
规范的核心变量:exports、module.exports、require
,可以使用这些变量来方便的进行模块化开发
exports
和module.exports
可以负责对模块中的内容进行导出
require
函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容
exports导出
exports
是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会导出
// a.js
function add(num1, num2) {
return num1 + num2;
}
const message = "hello world";
console.log(exports); // {}
exports.add = add;
exports.message = message;
// main.js
// const { add, message } = require("./a"); // 可以拿到文件中导出的exports对象,相当于引用赋值
// console.log(add(10, 30)); // 40
// console.log(message); // hello world
const a = require("./a");
console.log(a.add(10, 30)); // 40
console.log(a.message); // hello world
上面代码原理:
<code>exports是一个对象,在内存中就会有个对象地址比如是0x100
,那么exports
就指向这个引用地址
当执行const a = require("./a")
时require
就会找到a
模块导出的exports
,把exports
的引用地址赋值给a
,a
和exports
指向了同一个对象
也就意味着你在main.js
两秒后修改了message
的值,两秒后在a.js
中获取时会变成你新改的值
module.exports导出
// b.js
function add(num1, num2) {
return num1 + num2;
}
const message = "hello world";
// 方式一
// module.exports.add = add;
// module.exports.message = message;
// console.log(module.exports === exports); // true
// 方式二:开发中常用,module.exports赋值新对象更灵活方便
module.exports = {
add,
message,
};
// main.js
const b = require("./b");
console.log(b.add(10, 20)); // 30
console.log(b.message); // hello world
上面代码原理:
<code>module.exports和exports
有什么关系呢?
exports
是 module.exports
的简写,起初它们都指向同一个对象的引用地址
module.exports = exports = main.js中引入的变量
我们开发中常用的是module.exports
,而且他俩还相等,那有exports
还有什么意义那?
CommonJS
中是没有module.exports
的概念的,但Node
要实现commonjs
标准所以有exports
,并且让exports = module.exports
但为了实现模块的导出,Node
中使用的是Module
的类,每⼀个模块都是Module
的⼀个实例也就是module
所以在Node
中真正⽤于导出的其实不是exports
,使用require
导入时查找的本质也不是exports
,而是module.exports
也就是说module.exports
可以通过赋值为一个新对象导出,但exports
不行,因为改变了exports
的引用没有用,node
中找的还是module.exports
require导入
我们已经知道,require
是⼀个函数,可以帮助我们引⼊⼀个⽂件(模块)中导出的对象
require
的查找规则是怎么样的呢?导⼊格式如下:require(X)
X
是⼀个Node
核⼼内置模块,⽐如path、http
:直接返回核⼼模块,并且停⽌查找
console.log("path:", require("path"));
console.log("http:", require("http"));
<code>X是以 ./
或 ../
或 /
(根⽬录)开头的:
第⼀步:将X
当做⼀个⽂件在对应的⽬录下查找
直接查找⽂件X
查找X.js
⽂件
查找X.json
⽂件
查找X.node
⽂件
第⼆步:没有找到对应的⽂件,将X
作为⼀个⽬录:查找⽬录下⾯的index
⽂件
查找X/index.js
⽂件
查找X/index.json
⽂件
查找X/index.node
⽂件
如果没有找到,那么报错:not found
直接是⼀个<code>X(没有路径),并且X
不是⼀个核⼼模块:
我们可以看到它是会报错的
引入的<code>hello我们可以在目录下建个node_modules
里面再建个hello
文件夹并包含index.js
入口文件,这时require
就可以找到了,这也是npm install 依赖
下载依赖的原理,那么axios
我们就可以用npn install
下载
那么它的查找规律就是会先在当前目录的<code>node_modules文件夹(必须有入口文件)中寻找
没有找到的话,会再到上一级目录的node_modules
文件夹中寻找,直到找到根目录还没有就会报错
加载过程
模块在被第一次引入时,模块中的js代码会被运行一次,这个我们在上面的演习中就能发现
模块被多次引入时,会缓存,最终只加载(运行)一次
这是因为每个模块对象module
都有一个属性loaded
记录是否被加载过,默认为false
如果有循环引入,那么加载顺序是什么?
这个其实是一种数据结构:图结构
图结构在遍历的过程中,有深度优先搜索(DFS, depth first search
)和广度优先搜索(BFS, breadth first search
)
Node
采用的是深度优先算法(在一层里面走到底):main -> aaa -> ccc -> ddd -> eee ->bbb
缺点
意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运⾏
在服务器不会有什么问题,因为服务器加载的js
⽂件都是本地⽂件,加载速度⾮常快
如果将它⽤于浏览器呢?
浏览器加载js
⽂件需要先从服务器将⽂件下载下来,之后再加载运⾏
那么采⽤同步的就意味着后续的js
代码都⽆法正常运⾏,即使是⼀些简单的DOM
操作
在浏览器中,我们通常不使⽤CommonJS
规范,在webpack
中使⽤CommonJS
是另外⼀回事
在早期为了可以在浏览器中使⽤模块化,通常会采⽤AMD
或CMD
:
⽬前⼀⽅⾯现代的浏览器已经支持ES Modules
另⼀⽅⾯借助于webpack
等⼯具可以实现对CommonJS
或者ES Module
代码的转换
AMD
和CMD
已经使⽤⾮常少了
AMD规范(基本不用)
AMD
主要是⽤于浏览器的⼀种模块化规范:
AMD
是Asynchronous Module Definition`(异步模块定义)的缩写,⽤的是异步加载模块
事实上 AMD
的规范还要早于CommonJS
,但是CommonJS
⽬前依然在被使⽤,⽽AMD
使⽤的较少了
规范只是定义代码的应该如何去编写,需有了具体的实现才能被应⽤,AMD
实现的⽐较常⽤的库是require.js
和curl.js
require.js
使⽤
下载require.js
:下载地址:https://github.com/requirejs/requirejs 找到其中的require.js
⽂件
定义HTML
的script
标签引⼊require.js
和定义⼊⼝⽂件:
<script src="./lib/require.js" data-main="./index.js"></script>code>
data-main
属性的作⽤是在加载完src
的⽂件后会加载执⾏该⽂件
CMD规范(基本不用)
CMD
规范也是⽤于浏览器的⼀种模块化规范:
CMD
是Common Module Definition
(通⽤模块定义)的缩写
⽤的也是异步加载模块,但是它将CommonJS
的优点吸收了过来,但是⽬前CMD
使⽤也⾮常少了
CMD
也有⾃⼰⽐较优秀的实现⽅案:SeaJS
SeaJS的使⽤
下载SeaJS
:下载地址:https://github.com/seajs/seajs 找到dist
⽂件夹下的sea.js
引⼊sea.js
和使⽤主⼊⼝⽂件:seajs
是指定主⼊⼝⽂件的
ES Module
JavaScript
没有模块化⼀直是它的痛点,所以才会产⽣我们前⾯学习的社区规范:CommonJS、AMD、CMD
等,所以在 ECMA
推出⾃⼰的模块化系统时,⼤家也是很兴奋
ES Module
模块采用 export
负责将模块内的内容导出和import负责从其他模块导入内容来实现模块化
ES Module
模块允许编译器在编译时进行静态分析,也加入了动态引用的方式
使用ES Module
将自动采用严格模式:use strict
简单使用
在浏览器中,ES Modules
通过 <script type="module">code> 标签引入,来声明这个脚本是一个模块
<!DOCTYPE html>
<html lang="en">code>
<head>
<meta charset="UTF-8" />code>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />code>
<title>Document</title>
</head>
<body>
<script src="./main.js" type="module"></script>code>
</body>
</html>
但浏览器打开本地文件会报错,这个在MDN上面有给出解释:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules#其他模块与标准脚本的不同
需要注意本地测试:如果你通过本地加载 <code>HTML 文件(比如一个 file://
路径的文件),你将会遇到 CORS 错误,因为 JavaScript 模块安全性需要。你需要通过一个服务器来测试
这里使用的VSCode
插件:Live Server
,可以执行并打印main.js
代码
在 Node.js
中使用 ES Modules(ESM)
可以通过以下几种方式实现:
如果你的模块文件使用 .mjs
扩展名,Node.js
会将其识别为 ES Module
// example.mjs
export const greeting = "Hello, World!";
export function sayHello() {
console.log(greeting);
}
// main.mjs
import { sayHello } from './example.mjs';
sayHello(); // 输出: Hello, World!
如果你希望在整个项目中使用 ES Modules
,而不仅仅是单个文件,可以在 package.json
文件中添加 "type": "module"
,所有 .js
文件都将被视为 ES Modules
// package.json
{
"name": "my-project",
"version": "1.0.0",
"type": "module"
}
// example.js
export const greeting = "Hello, World!";
export function sayHello() {
console.log(greeting);
}
// main.js
import { sayHello } from './example.js';
sayHello(); // 输出: Hello, World!
export关键字
export
关键字将一个模块中的变量、函数、类等导出
方式一:想导出谁就在语句声明的前面直接加上export
关键字
方式二:想导出谁则将需要导出的标识符,放到export
后面的 {}
中
注意:这里的 {}
里面不是ES6
的对象字面量的增强写法,{}
也不是表示一个对象, export { message: message }
是错误的写法;
方式三:在方式二导出时给标识符起一个别名
// 方式一
export const message1 = "hello world1";
export function add1(num1, num2) {
return num1 + num2;
}
export class Person1 {
constructor(name) {
this.name = name;
}
}
// 方式二
const message2 = "hello world2";
function add2(num1, num2) {
return num1 + num2;
}
class Person2 {
constructor(name) {
this.name = name;
}
}
export { message2, add2, Person2 };
// 方式三
const message3 = "hello world3";
function add3(num1, num2) {
return num1 + num2;
}
class Person3 {
constructor(name) {
this.name = name;
}
}
export { message3, add3 as add0, Person3 as Person0 };
import关键字
import
关键字负责从另外一个模块中导入内容
方式一:import { 标识符列表 } from '模块'
注意:这里的 {}
也不是一个对象,里面只是存放导入的标识符列表内容
方式二:通过as
关键字在导入时给标识符起别名
方式三:通过 * as 自己名字
将模块功能放到一个模块功能对象上
// 结合export中的代码学习
import {
message1, // 方式一
message2,
message3,
add0 as add3, // 方式二
add1,
add2,
Person0 as Person3,
Person1,
Person2,
} from "./a.js";
import * as a from "./a.js"; // 方式三
console.log(
message1,
message2,
message3,
add1,
add2,
add3,
Person1,
Person2,
Person3,
a.message1,
a.message2,
a.message3,
a.add1,
a.add2,
a.add0,
a.Person1,
a.Person2,
a.Person0
);
export和import结合
在开发和封装一个功能库时,通常希望将暴露的所有接口放到一个文件中,这样方便指定统一的接口规范也方便阅读,这个时候就可以使用export
和import
结合使用
/* util/index 通常是不编写逻辑的,在这里统一导入并导出 */
// 方式一
import {
message1,
message2,
message3,
add0 as add3,
add1,
add2,
Person0 as Person3,
Person1,
Person2,
} from "./a.js";
import { getData } from "./b.js";
export {
message1,
message2,
message3,
add3,
add1,
add2,
Person3,
Person1,
Person2,
getData,
};
// 方式二:结合
export {
message1,
message2,
message3,
add0 as add3,
add1,
add2,
Person0 as Person3,
Person1,
Person2,
} from "./a.js";
export { getData } from "./b.js";
// 方式三:建议当有相应的文档时再这样写
export * from "./a.js";
export * from "./b.js";
default⽤法
前面学习的导出都是有名字的导出(named exports
):在导出export
时指定了名字,在导入import
时需要知道具体的名字,文件只有一个想要导出的内容并且文件名已经概括时就可以使用默认导出(default export
)
默认导出:在一个模块中,只能有一个默认导出
默认导出export
时可以不指定名字
在导入时不需要使用 {}
,并且可以自己来指定名字
也方便我们和现有的CommonJS
等规范相互操作
/* validMobile.js */
// 方式一
// function validMobile(str) {
// const reg = /^1[3-9]\d{9}$/;
// return reg.test(str);
// }
// export default validMobile;
// 方式二
export default (str) => {
const reg = /^1[3-9]\d{9}$/;
return reg.test(str);
};
/* main.js */
import validMobile from "./validMobile.js";
console.log(validMobile("12345678910")); // false
import函数
import
是 静态的,这意味着在编译或打包阶段,模块依赖关系就已经确定了。JavaScript
引擎需要在脚本开始执行之前分析所有的模块和依赖项,以便优化打包、代码分割、死代码消除等操作。如果需要根据不同的条件,动态来选择加载模块,这个时候我们需要使⽤ import()
函数
import()
是异步的,返回的是个promise
结合 export default
使用时,要用.default
取值
/* a.js */
export function add(num1, num2) {
return num1 + num2;
}
/* validMobile.js */
export default (str) => {
const reg = /^1[3-9]\d{9}$/;
return reg.test(str);
};
/* main.js */
import validMobile from "./validMobile.js";
console.log(validMobile("12345678910")); // false
if (validMobile("13626068779")) {
// 结合 export
import("./a.js").then((a) => {
console.log(a.add(10, 20)); // 30
});
} else {
// 结合 export default
import("./validMobile.js").then((v) => {
console.log(v.default("13626068779")); // true
});
}
import meta
import.meta
是⼀个给JavaScript
模块暴露特定上下⽂的元数据属性的对象,它包含了这个模块的信息,⽐如说这个模块的URL
<code>url:模块的完整 URL
,包括查询参数和/或哈希(位于?
或之后#
)。在浏览器中,这是获取脚本的 URL(对于外部脚本)或包含文档的 URL(对于内联脚本)。在 Node.js
中,这是文件路径(包括file://
协议)
resolve
:使用当前模块的 URL
作为基础,将模块说明符解析为 URL
ES Module的解析
ES Module
是如何被浏览器解析并且让模块之间可以相互引⽤的呢?
可以看下这篇文章:https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
ES Module
的解析过程可以划分为三个阶段:
阶段⼀:构建(Construction
),根据地址查找js⽂件,并且下载,将其解析成模块记录(Module Record
)
当浏览器(或 JavaScript
引擎)遇到 <script type="module">code> 或
import
语句时,它会识别这是一个 ESM
模块
解析器会识别所有的 import
语句,并在构建依赖图的同时请求这些模块文件的下载
在依赖图构建的过程中,浏览器会为每个模块分配一个唯一的模块记录,这个模块记录保留了模块的状态和依赖关系
模块环境记录可参考学习这篇文章:https://blog.csdn.net/qq_45730399/article/details/141196562?spm=1001.2014.3001.5501
阶段⼆:实例化(<code>Instantiation),对模块记录进⾏实例化,并且分配内存空间,解析模块的导⼊和导出语句,把模块指向对应的内存地址
在所有依赖项(模块)都下载完成后,浏览器会执行模块链接。在此阶段模块的依赖关系会被处理,导入的模块和导出的符号都会被绑定起来
在这个过程中,浏览器不会执行模块的代码,只是检查模块之间的依赖关系,并将导入和导出的值关联起来
阶段三:运⾏(Evaluation
),运⾏代码,计算值,并且将值填充到内存地址中
关联完成后才会开始逐个执行模块的代码,模块执行后,模块的状态会更新,表明它已经执行完毕,之后如果再次请求该模块,执行的结果会直接从缓存中获取
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。