什么是模块
- 将一个复杂的程序依据一定的规则封装成几个块,并进行组合在一起
- 块的内部数据和实现是私有的,只是向外部暴露一些接口与外部其他模块通信
模块化演变过程
文件划分方式
最原始的模块系统,具体做法是每个功能及其相关的状态数据存在不同的文件当中,约定每个文件就是一个独立的模块.使用时将模块引入到对应的 html 文件当中,每个 script 标签对应一个模块,在代码当中直接调用模块中的全局成员
缺点:
- 污染全局作用域
- 命名冲突问题
- 模块成员之间看不出直接关系
命名空间方式
每个模块只暴露一个全局对象,所有的成员都挂载在这个对象上面
缺点
- 仍然没有私有空间,数据依旧可以在外部被修改
IIFE
将数据和行为封装到一个函数内部, 通过给window添加属性来向外暴露接口,当模块依赖另一个模块时,通过引入依赖就好,可以保证模块的独立性,还使得模块之间的依赖关系变得明显
模块化规范
CommonJS
Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类等都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。
//基本使用
//导出
function add (x,y) {
return x+y
}
module.exports = {
add,
}
//导入
const {add} = require(./add.js)
console.log(add(1,1)) // 2
特点
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次,但是只会在第一次加载时运行一次,然后运行结果就被缓存了。想要让模块再次运行,必须清掉缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
module属性
- 任意 JS 文件就是一个模块,可以直接使用 module 属性
- id:返回模块标识符,一般是一个绝对路径
- filename:返回文件模块的绝对路径
- loaded:返回布尔值,标识模块是否完成加载
- parent:返回对象,存放调用当前模块的模块
- children:返回数组,存放当前模块调用的其他模块
- exports:返回当前模块需要暴露的内容
- paths:返回数组,存放不同目录下的 node_modules 位置
为什么任意JS文件就是一个模块?
来看一下精简过后的源码
function Module(id = '', parent) {
// 模块文件的绝对路径
this.id = id;
// 模块所在的目录
this.path = path.dirname(id);
// 导出内容
this.exports = {};
// 将该模块添加到父模块的children数组中
updateChildren(parent, this, false);
this.filename = null;
// 是否加载完成
this.loaded = false;
// 引用的子模块,即在当前模块中通过require加载的模块
this.children = [];
}
// 用于包裹模块代码
Module.wrap = function (script) {
return (
'(function (exports, require, module, __filename, __dirname) {' +
script +
'\n})'
);
};
// 模块缓存
Module._cache = Object.create(null);
Module._load = function (request, parent) {
// Module._resolveFilename 用于将 require 的模块地址解析成能定位到模块的绝对路径
const filename = Module._resolveFilename(request, parent);
const cachedModule = Module._cache[filename];
// 命中缓存直接返回
if (cachedModule) {
return cachedModule.exports;
}
// 未命中缓存,则创建一个新的模块实例
const module = new Module(filename, parent);
// 将该模块记录到模块缓存中
Module._cache[filename] = module;
// 获取到模块代码内容
const content = fs.readFileSync(filename, 'utf8');
// 将参数传入并执行模块代码,可以理解为eval,但源码中的实现并不是eval,而是更安全的执行方式
runInThisContext(Module.wrap(content))(
module.exports, // 对应包裹函数中接收的 exports 形参
module.require, // 对应包裹函数中接收的 require 形参
module, // 对应包裹函数中接收的 module 形参
filename, // 对应包裹函数中接收的 __filename 形参
module.path // 对应包裹函数中接收的 __dirname 形参
);
// 将该模块标识为加载完成
module.loaded = true;
// 最后返回执行完该模块内代码后的模块导出内容
return module.exports;
};
// 模块原型上的require方法
Module.prototype.require = function (id) {
// ...
return Module._load(id, this);
};
可以看到,在 _load 方法中,module 是通过 new Module() 实例化得到的一个 CommonJS 模块实例,它包含了之前提到过那些属性,我们看到 filename 属性,最后是不是 xx.js ?所以才说任意 JS 文件就是一个模块。
为啥可以直接使用属性?
准确来说,是可以直接使用:
- exports,等同于 module.exports,作为 module.exports 的快捷访问;
- require,通常为模块原型上的 require 方法,用于加载其他模块;
- module,当前模块对象,通过 new Module() 实例化创建的;
- __filename,当前模块的绝对路径文件名;
- __dirname,当前模块所在目录的绝对路径;
看源码可得知是因为在模块加载过程中 CommonJS 模块系统帮我们包裹了一层匿名函数,并在执行该函数时传入了上述5个参数,所以我们编写的代码实际上是在该函数内部执行的,我们使用的module、exports、require等其实都是该函数的形参,并不是什么全局变量。通过上面的源码我们还可以知道,除了module、module.exports、exports和require,还有__filename和__dirname这两个变量我们也是可以直接在模块代码中使用的。也正是因为模块代码是放在一个匿名函数内部执行的,所以每个模块都有单独的作用域,不会污染全局变量。
注意:正如上文提到过的,任意 JS 文件可以直接使用 module 属性,那是不是可以直接用 exports 导出呢?答案是可以的,module.exports === exports,但是值得注意的是 exports 不能使用赋值的方式进行导出,因为 exports 是指向 module.exports 对象的引用,而且最终导出的结果以 module.exports 为准,所以直接赋值的话会改变 exports 的指向,所以会拿到一个空对象。使用 exports 的话应该是 exports.xx = xxx 的形式
require属性
- 基本功能是读入并且执行一个模块文件
- resolve:返回模块文件绝对路径
- extensions:依据不同后缀名执行解析操作
- main:返回主模块对象
加载流程
- 路径分析:依据标识符确定模块位置
- 文件定位:确定目标模块中具体的文件及文件类型
- 编译执行:采用对应的方式完成文件的编译执行
路径分析中,将模块标识解析(通过Module._resolveFilename())成能定位到模块文件的一个 filename 字符串,依据不同的标识符分为路径标识符跟非路径标识符(即核心模块或第三方模块,核心模块如 fs、http、path 等,第三方模块如 koa 等)
完成了路径分析后,就要开始文件定位,通常情况如上所说,有三种:
- 加载相对路径或绝对路径文件模块,如 ./xxx.xxx
- 加载核心模块,如 fs、http
- 加载第三方模块,如 koa
第一种情况下,如果我们传入的标识符不带扩展名,像 require(m1),就会按照 .js -> .json -> .node 的顺序来补足扩展名去寻找,如果以上三者都没查到,就会尝试将其当作是目录来处理,查找 package.json 文件,看看是否指定了main字段,按照上述的三个顺序去补足查找,如果补足查找没找到,或者不存在 package.json 文件,就会将 index 作为目标模块中的具体文件名称去补足查找,如果找不到,则根据 module 属性中的 paths 逐级向上重复步骤查找,直到找到或者找不到时抛出错误。
在第二种情况下,则会判断Module_cache[‘fs’]是否命中缓存,如果缓存命中,则返回该缓存模块的module.exports,require结束;若未命中缓存则加载该原生模块,然后返回其module.exports,require结束。
在第三种情况下,则模块系统将从当前目录下的node_modules目录中开始,查找是否存在该第三方模块标识符对应的文件或者目录,如果找不到,则继续往上一级的node_modules目录找,一直到根目录下的node_modules目录,如果根目录下的node_modules中也没找到,则抛出MODULE_NOT_FOUND的错误。
ESM
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
- 提前加载并执行模块文件
- 在预处理阶段分析依赖关系,在执行阶段执行模块(深度优先遍历,执行顺序先子后父)
- ES6模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块
与 CommonJS 的差异
- CommonJS 的导出是拷贝一份变量出来,ES6 Module 导出的是变量是引用类型的,不能随意更改
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
- CommonJS 只能导出单个值,ES6 Module 导出的值数量不限制
结语
其实还有 AMD、CMD 这两个模块化规范可以说,不过就前端开发而言,commonJS 跟 ESM 就完全足够了,感兴趣的可以去自己去搜一下,文章可能会有错漏的地方,烦请不吝指出,共同进步。