全面了解 JavaScript 中的模块机制-Module
为什么需要模块-Module
历史上,JavaScript 一直没有模块 Module 体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。
为什么要拆分?
如果将所有前端代码维护在一个文件中,那么基本没有可读性和可维护性可言。
Module 是什么
那么很快地就可以讲出 Module 的定义:
它的出现就是为了拆分一个大文件,那么每个 Module 就是一个小文件,可以相互依赖,最终结果是拼装成一个大文件。
这样模块化的思想就很简单:把逻辑分块,各自封装,相互独立,每个块自行决定对外暴露什么,这个思想是所有 JavaScript Module 的基础。
早期的几种实现
- 全局
Function模式:将不同的功能封装成不同的函数
function foo() {
console.log("foo");
}
function bar() {
console.log("bar");
}
- 全局
Namespace模式,解决了命名冲突的问题,但是存在数据安全的问题,外部可以直接修改模块内部数据
window.__Module = {
foo: function() {
console.log("foo");
}
bar: function () {
console.log("bar");
}
}
const module = window.__Module
module.foo() // foo
IIFE模式,通过匿名立即执行函数来隔离代码,解决私有化问题
(function (window) {
foo: function() {
console.log("foo");
}
bar: function () {
console.log("bar");
}
window.__Module = {
foo,
bar
}
})();
const module = window.__Module
module.foo() // foo
目前有哪几种常用的 Module
各种
Module出现的原因
由于在ECMAScript(ESM)模块规范出现之前,原生浏览器并不支持Module的行为,而开发者们迫切的需要这样的行为。因此希望使用模块模式的库或代码库基于JavaScript的语法和词法特点“伪造”除了各种类似Module的行为。
下面的图表可以简单概括下各种常用 Module 实现的适用场景和特点:
| Module | 适用场景 | 特点 |
|---|---|---|
| CommonJs(CJS) | 服务器,Node.js 使用了轻微修改版本的 CommonJs | 以服务器端为目标环境,不考虑网络延迟,一次性将所有模块都加载到内存中 |
| AMD | 浏览器 | 以浏览器为目标环境,考虑网络延迟 |
| 通用模块定义 UMD | 创建 (AMD,CJS) 都可以使用的模块代码 | 启动时检测使用哪个模块系统,返回需要的逻辑包装。 |
| ECMAScript(ESM) | 原生浏览器支持的模块加载方式 | 原生浏览器支持,同时吸收了 AMD 和 CJS 的一些优点,是集大成者 |
CommonJs(CJS)
Node.js 的模块系统,就是参照 CommonJS 规范实现的,CommonJS 对模块的加载时同步的。同步就意味着有一个模块需要请求等待的话,那么整个程序都要被阻塞住(因为 JavaScript 是单线程语言)。
2009 年,美国程序员 Ryan Dahl 创造了 node.js 项目,将 javascript 语言用于服务器端编程。 这标志”Javascript 模块化编程”正式诞生。前端的复杂程度有限,没有模块也是可以的,但是在服务器端,一定要有模块,与操作系统和其他应用程序互动,否则根本没法编程。
常用写法
var moduleB = require('./moduleB');
module.exports = {
stuff: moduleB.doStuff();
}
源码解读
精简后的源码大致如下:
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(requset, 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,
module.require,
module,
filename,
module.path
);
// 将该模块标识为加载完成
module.loaded = true;
// 最后返回执行完该模块内代码后的模块到处内容
return module.exports;
};
// 模块原型上的require方法
Module.prototype.require = function (id) {
return Module._load(id, this);
};
特性
1. 运行时加载
通过require() 在运行时才执行模块内的代码,并加载至本文件中。
2. CommonJS 对模块的加载是同步的。
如果模块加载需要的时间很长,将会阻塞页面的渲染,使整个应用陷入卡顿状态。因此,浏览器端的模块,只能采用异步加载。这就是 AMD 规范诞生的背景。
3. 缓存机制
由上面代码可知,在执行 require() 方法时,会判断是否已经加载过相同模块,如果已经加载完成将直接返回缓存内容
4. 循环引用
同样的,在上面代码可知,只有在第一加载该模块时才会执行一次代码,其他时候直接返回模块中的代码。
5. 模块是值拷贝
在引入模块的文件中修改原始值,将不会印象原模块中的私有值。引用类型由于使用的是值拷贝,因此在修改引用类型时,将会影响原模块中的私有值。
// a.js
let num = 1;
let user = { name: "LH" };
exports.num = num;
exports.user = user;
exports.addNum = () => {
num += 1;
}; // 修改变量 num 的值
exports.getNum = () => {
console.log("模块内部 num 变量:", num);
};
exports.setName = () => {
user.name = "op_chen";
}; // 修改变量 user 中的属性
exports.getName = () => {
console.log("模块内部 user 变量中的 name:", user.name);
};
// b.js
const a = require("./a.cjs");
console.log("A 模块 num:", a.num); // 打印, A 模块 num: 1
console.log("A 模块 user.name:", a.user.name); // 打印, A 模块 user.name: LH
a.addNum(); // 修改模块内部 num 变量的值
a.setName(); // 修改模块内部 user 的 name 属性值
console.log("A 模块 num:", a.num); // 打印, A 模块 num: 1
console.log("A 模块 user.name:", a.user.name); // 打印, A 模块 user.name: clj
a.getNum(); // 打印, 模块内部 num 变量: 2
a.getName(); // 打印, 模块内部user 变量中的 name: clj
AMD
AMD 的一般策略是让模块声明自己的依赖,而运行在浏览器中的模块系统会按需依赖,并在依赖加载完成后立即执行依赖他们的函数。
AMD 实现的核心是用函数包装模块定义。这样可以防止声明全局变量,并允许加载器控制何时加载模块。
// ID为 'moduleA'的模块定义。modulesA依赖moduleB
// moduleB会异步加载
definde("moduleA", ["moduleB"], function (moduleB) {
return {
stuff: moduleB.doStuff(),
};
});
UMD
为了统一 CommonJS 和 AMD 生态系统,通用模块定义规范应运而生。 UMD 可用于创建这个两个系统都可以使用的模块代码。
本质上,UMD 定义的模块会在启动时检查要使用哪个模块,然后适当配置,并把所有逻辑包装在有一个立即调用的函数式里。
下面是一个依赖的 UMD 模块定义的示例:
(function (root, factory) {
if (typeof define === "function" && define.amd) {
// amd 注册为匿名模块
define(["moduleB"], factory);
} else if (typeof module === "object" && module.exports) {
// Node。不支持严格CommonJS
module.exports === factory(require("moduleB"));
} else {
// 浏览器全局上下文
window.returnExports = factory(window.moduleB);
}
})(this, function () {
// 以魔种方式使用moduleB
return {};
});
UMD 应用由构建工具自动生成。开发者只要专注于模块的内容,而不必关系这些样板代码。
ECMAScript(ESM)
ECMAScript6 吸收了 AMD 和 CJS 的许多优秀特性,随着 ECMAScript6 模块规范得到越来越广泛的支持,其他模块引入模式可能会走向没落。
常用写法
import { foo } from "./js/export.js";
function foo2() {
console.log("foo");
}
export default foo2;
原理解读
- 构造:从入口文件出发,根据
import一层层解析,每个模块都会生成一个模块记录Module Record。
- 首先会查找相关依赖,形成一个依赖关系树
(AST)。 - 其次还需要加载
import对应模块的资源,而为了尽量不阻塞线程,这一步实际上又是异步进行加载的。 - 当然
ESM也 存在缓存,他会被记录在模块映射Module Map中。这点和CJS同理。
- 实例化: 在内存中存储所有
export出来的数据,同时将所有import指向对应的内存块中。 - 求值:执行所有模块的最外层代码。
特性
1. 运行前加载(编译时加载)
在构造和实例化阶段就开始解析模块,因此可以支持 Webpack 实现 Tree Shaking
2. 缓存机制
同样的和CJS一样有缓存机制,一个模块只会加载一次
3. 导出的是引用地址
// a-es.mjs
export let num = 1;
export const addNum = () => {
num += 1;
};
export const getNum = () => {
console.log("getNum:", num);
};
// b-es.mjs
import { num, addNum, getNum } from "./a-es.mjs";
console.log(num); //1
addNum();
console.log(num); //2
getNum(); //2
4. 导出的值不允许被修改
import a from "./a.mjs";
a = {}; // 报错, 无法赋值
a.age = 1; // 这个是可以的,对对象的属性进行修改会影响整个使用此模块的文件