前端模块化的必要性
为什么需要模块化
当前端工程到达一定规模后,就会出现下面的问题:
-
全局变量污染
-
依赖混乱
模块化就是为了解决这些问题而出现的
模块化出现后,我们就可以把复杂的代码细分到各个文件中,便于后期的维护和管理
前端模块化标准
前端主要有两大模块化标准:
- CommonJS,简称CJS,这是一个社区规范,出现时间较早,目前仅Node环境支持
- ES Module,简称ESM,这是随着ES6发布的官方模块化标准,目前浏览器和新版本Node环境均支持(但仅限于在.mjs文件中使用)
CommonJS
CommonJS,简称CJS,这是一个社区规范,出现时间较早,目前仅Node环境支持
CommonJS使用module.exports来导出模块,使用require来导入模块
nodejs天生就支持CommonJS规范:
-
node将每个js文件都视为一个模块
-
node每次只能运行一个js文件,该js文件称为入口文件(入口模块)
-
模块中所有在全局定义的变量、函数,均不会在全局对象上形成属性,自然也不会污染到其他模块
-
使用module.exports来导出模块,使用require来导入模块
module.exports默认为一个空对象,因此如果没有在模块中导出任何内容,其它模块所接收到的就是一个空对象
// other.js module.exports = { a: 1, b: function(){} }// index.js var res = require("./other.js"); console.log(res); /** res = { a: 1, b: function(){} } */ -
使用require导入自定义模块时,若传入的是相对路径,则必须以
./或../开头,相对路径是相对于该require函数所在模块的路径若相对路径省略了
./或../开头,则nodejs就会到当前模块所在目录中的node_modules目录中查找模块,若没有就返回上一级目录,去查找该目录中的node_modules目录查找node_modules目录时,会按照第6点的① ~ ⑤的顺序进行查找
-
导入模块时,可以省略.js后缀
var a = require("./a");以上面的导入语句为例,nodejs会按照下面的规则查找模块:
① 查看模块所在目录中是否有a文件,若有就执行该文件,否则进入下一步
② 查看模块所在目录中是否有a.js文件,若有就执行该文件,否则进入下一步
③ 查看模块所在目录中是否有a.json文件,若有就执行该文件,否则进入下一步
④ 查看模块所在目录中是否有a.node文件,若有就执行该文件,否则进入下一步
⑤ 查看模块所在目录中是否有a目录
若有就将其视为一个包,并读取其package.json文件,然后读取其中main配置来找到包的入口文件,如果存在main配置所对应的入口文件,则执行该文件
若a目录中不是一个包,或者没有main字段所指定的入口文件,则查找目录下的index.js、index.json、index.node文件,若有文件存在就执行该文件,否则进入下一步
⑥ 抛出
Cannot find module './a'错误
细节
-
require可以在任何地方出现,包括判断或函数中,没有强制规定必须书写在模块的开头
-
require是同步执行的,只有当运行到require时,才会去加载并执行该模块
模块加载并执行完成后,require函数才算执行结束,而模块导出的内容就会作为require的返回值返回,之后nodejs便继续执行require函数后面的代码
-
require可以出现在判断中,导入的路径字符串也可以动态改变,因此require是动态的,只有代码实际运行后才能推断出require究竟导入的是哪个模块
-
require导入一个模块时,会通过模块路径找到本机文件,然后读取文件内容并执行,由于模块就在本机,因此读文件的速度比较快,就算require是同步执行的,也不会造成卡顿
-
为了避免重复加载同一个模块,nodejs默认开启了模块缓存(可以手动关闭),如果要导入的模块之前已经被导入过了,则会自动使用之前的导出结果,而不会重复导入
var util1 = require("./utils.js"); // 加载本地文件并执行其中的代码 var util2 = require("./utils.js"); // 不加载也不执行 console.log(util1 === util2); // true -
当nodejs准备执行一个模块时,会自动对该模块进行如下处理:
① 将模块中的代码放入到一个函数环境中,以保证模块中的“全局变量”或“全局函数”不会污染全局
② 初始化module.exports = {}
③ 将全局的this绑定为module.exports
④ 声明了一个变量exports,并让exports = module.exports
⑤ 将module.exports作为函数的返回值返回,这也将作为该模块所导出的结果
function(module){ module.exports = {}; this = module.exports; var exports = module.exports; // 模块中的代码 return module.exports; } // 当该模块被另一个模块require时,实际上就是在另一个模块中执行这里的这个函数由于模块导出的是module.exports,因此对module.exports重新赋值,导出结果就变为了重新赋的值,而对exports重新赋值,导出结果则不受影响
更详细的代码说明参见CommonJS的实现原理
-
当require导入的是json文件时,会自动将文件中的json内容解析为js对象,并作为函数返回值返回
ES Module
ES Module,简称ESM,这是伴随着ES6发布的官方模块化标准,目前浏览器和新版本Node环境均支持
在浏览器环境下,html文件中使用script元素引入的JS文件默认不是一个模块,要想将其作为模块使用,需要在script元素上添加type="module"属性
<script type="module" src="./index.js"></script>
这种方式引入的js文件,就成为了一个模块,模块中所有顶级变量、顶级函数均不会成为window对象的属性
在HTML页面中引入的模块,称为入口模块
导入和导出
导入使用import,导出使用export
注意:只有在模块中才允许使用import和export
ESM的导入导出分为两种:
- 具名导入导出
- 默认导入导出
具名导出
具名导出可以导出多个内容,并且每个内容都必须要有名称
export 声明表达式 // 这里声明的内容在模块的其他地方也能正常使用
export { 标识符 } // 这里的{}可不是对象
export { 原始名称 as 新名称 } // 其它模块在导入时,就需要使用新名称来对应这里的内容
例:
export var a = 10;
export let b = 20;
export const c = 30;
export function d(){}
export class E {}
var f = 40;
var g = 50;
export { f, g as temp }
默认导出
默认导出只能导出一个内容,因此不需要为其命名
export default 内容
export { 标识符 as default }
例:
export default 1;
export default function (){};
var a = 2;
export default a;
export { a as default }
默认导出和具名导出可以在同一条导出语句中使用:
var a = 10;
var b = 20;
export { a, b as default }
// a为具名导出的内容,b为默认导出的内容
具名导入
具名导入用于导入其他模块具名导出的内容
import { 标识符列表 } from "路径" // 这里的{}可不是对象,也不是解构符号
import { 标识符 as 新名称 } from "路径" // 使用时,应该使用新名称
例:
import { a, b, temp as g } from "/module.js"
默认导入
默认导入用于导入其他模块默认导出的内容
import 接收名称 from "/module.js"
import { default as 接收名称 } from "/module.js"
例:
import a from "/module.js"
import { default as a } from "/module.js"
默认导入可以和具名导入在同一个导入语句中使用:
import a, { b, c } from "/module.js"
import { default as a, b, c } from "/module.js"
// a为默认导入的内容,b、c为具名导入的内容
导入所有内容
import * as obj from "./module.js"
将模块中导出的所有内容(包括具名导出和默认导出),并用obj接收,此时接收的所有内容会聚合为一个Module对象,具名导出的内容会成为该对象中的同名属性,默认导出的内容会成为该对象的default属性
仅运行模块
import "./module.js"
仅仅运行一遍module.js,而不使用它所导出的任何内容
这种方式导入的模块同样会被缓存
动态导入
import("./module.js")
将import作为一个函数使用,传入模块的路径,即可动态导入该模块,该函数的返回值为一个Promise,当模块加载并执行结束后Promise完成,相关数据是模块所导出的所有内容所形成的Module对象(类似于* as obj)
动态导入可以出现在模块中的任何地方,包括判断或函数体中
细节
-
import的导入路径如果是相对路径,则必须以
./或../开头如果是不完整的绝对路径,则必须以
/开头相对路径相对的是模块的路径,不是HTML页面的路径
-
文件名必须加上后缀
.js -
ES Module有模块缓存,不会再次执行同一个模块
-
静态导入所接收的符号为常量,不可以被重新赋值
-
静态的导入导出语句,必须为顶级代码,不能出现在
{}中 -
静态导入的语句,建议书写在模块的开头
即使没有书写在模块开头,浏览器也会在代码编译时自动将它们移动到开头
-
静态导入的模块路径必须是一个字符串常量,不可以在此放一个标识符
-
带有type="module"的script,同带有defer一样,其引用的js文件会在HTML解析完毕后执行
如果网页有多个
<script type="module">,它们会按照在页面出现的顺序依次执行 -
模块内部使用静态导入的其他模块是同步加载的,当静态导入的模块加载并执行完成后,其后面的代码才能够执行
-
模块内部的代码是在严格模式下执行的
-
Module对象中的属性是只读的,不允许更改,也不允许向Module对象中添加或删除属性
模块化中的更多细节
CommonJS的实现原理
function require(path) {
if(该模块有缓存) {
return 缓存结果;
}
function _run(exports, require, module, __filename, __dirname) {
// 模块代码会被放到这里
}
var module = {
exports: {}
};
_run.call(
module.exports,
module.exports,
require,
module,
模块的完整绝对路径,
模块所在目录的完整绝对路径
);
把 module.exports 加入到缓存中;
return module.exports;
}
ES Module的符号绑定
符号绑定是指两个模块之间使用的变量使用的是同一个内存空间(址传递),一个模块对变量的值进行变化时,另一个模块的变量值也会发生变化
// a.js
export let count = 0;
export function increase() {
count++;
}
// index.js
import { count, increase } from "/a.js";
console.log(count); // 0
increase();
console.log(count); // 1