1. AMD&&CMD 异步模块
本质上为了解决 commonJS 同步阻塞加载执行模块
- 多个 js 文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
- js 加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长
CMD
SeaJS 要解决的问题和 requireJS 一样,只不过在模块定义方式和模块加载(可以说运行、解析)时机上有所不同
// AMD
require(['a', 'b'], function(math) {
a.doSomething();
b.doSomething();
});
// CMD
define(function(require, exports, module) {
var a = require('./a')
a.doSomething();
...
var b = require('./b') // 依赖可以就近书写
b.doSomething();
})
- AMD 推崇依赖前置,在定义模块的时候就要声明其依赖的模块,AMD 在加载模块完成后就会执行该模块,所有模块都加载执行完后会进入 require 的回调函数,执行主逻辑,这样的效果就是依赖模块的执行顺序和书写顺序不一定一致,看网络速度,哪个先下载下来,哪个先执行,但是主逻辑一定在所有依赖加载完成后才执行。
- CMD 推崇就近依赖,CMD 加载完某个依赖模块后并不执行,只是下载,遇到 require 语句的时候才执行对应的模块。这样模块的执行顺序和书写顺序是完全一致的。
AMD 和 CMD 最大的区别是对依赖模块的执行时机处理不同,注意不是加载的时机或者方式不同
这也是很多人说 AMD 用户体验好,因为没有延迟,依赖模块提前执行了,CMD 性能好(按需加载),因为只有用户需要的时候才执行的原因
2. UMD
UMD(Universal Module Definition - 通用模块定义)模式,该模式主要用来解决 CommonJS 模式和 AMD 模式代码不能通用的问题,并同时还支持老式的全局变量规范。
// bundle.js
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory())
: typeof define === "function" && define.amd
? define(factory)
: ((global = global || self), (global.myBundle = factory()));
})(this, function () {
"use strict";
var main = () => {
return "hello world";
};
return main;
});
- 判断
module是否为一个对象,并且是否存在module.exports来判断是否为CommonJS规范 - 判断
define为函数,并且是否存在define.amd,来判断是否为 AMD 规范, - 如果以上两种都没有,设定为原始的代码规范。
3. CommonJS
早期 JavaScript 开发很容易存在全局污染和依赖管理混乱问题
比如如何让先加载的模块调用后加载的模块
场景
Node是 CommonJS 在服务器端一个具有代表性的实现;webpack打包工具对 CommonJS 的支持和转换;也就是前端应用也可以在编译之前,尽情使用 CommonJS 进行开发。
使用
- 在
commonjs中每一个 js 文件都是一个单独的模块,我们可以称之为 module; - 该模块中,包含 CommonJS 规范的核心变量: exports、module.exports、require;
- exports 和 module.exports 可以负责对模块中的内容进行导出;
- require 函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;
实现原理
利用 wrapper 函数注入 require,export,module
第一步利用 wrapper 函数包装模块为新的函数字符串
function wrapper(script) {
return (
"(function (exports, require, module, __filename, __dirname) {" +
script +
"\n})"
);
}
const modulefunction = wrapper(`
const sayName = require('./hello.js')
module.exports = function say(){
return {
name:sayName(),
author:'dd'
}
}
`);
第二步利用runInThisContext可理解为 eval,执行 modulefunction
runInThisContext(modulefunction)(
module.exports,
require,
module,
__filename,
__dirname
);
- 在模块加载的时候,会通过 runInThisContext (可以理解成 eval ) 执行
modulefunction,传入require,exports,module等参数。最终我们写的 nodejs 文件就这么执行了。
首先从上述得知每个模块文件上存在 module,exports,require三个变量,然而这三个变量是没有被定义的,但是我们可以在 Commonjs 规范下每一个 js 模块上直接使用它们。在 nodejs 中还存在 __filename 和 __dirname 变量。
如上每一个变量代表什么意思呢:
module记录当前模块信息。require引入模块的方法。exports当前模块导出的属性
require 模块的引入和处理
CommonJS 模块同步加载并执行模块文件,CommonJS 模块在执行阶段分析模块依赖,采用深度优先后序遍历(depth-first traversal)
// a.js
const getMes = require("./b");
console.log("我是 a 文件");
exports.say = function () {
const message = getMes();
console.log(message);
};
// b.js
const say = require("./a");
const object = {
name: "《b文件》",
author: "Tommy",
};
console.log("我是 b 文件");
module.exports = function () {
return object;
};
// index.js
const a = require("./a");
const b = require("./b");
console.log("node 入口文件");
// output
// 我是文件b
// 我是文件a
// node入口文件
main.js和a.js模块都引用了b.js模块,但是b.js模块只执行了一次。a.js模块 和b.js模块互相引用,但是没有造成循环引用的情况。- 执行顺序是父 -> 子 -> 父; 参考下面 require 源码
require("./a.js"); // Module._cache[id] = module cache 模块a
// a.js 里面又去 第一行 require b.js
// 如果这个时候 b 又去循环引用 a.js
// return cachedModule.exports 返回a的 cache 只是空对象 因为a这时没有真正loaded
首先为了弄清楚上述两个问题。我们要明白两个感念,那就是 module 和 Module。
module :在 Node 中每一个 js 文件都是一个 module ,module 上保存了 exports 等信息之外,还有一个 loaded 表示该模块是否被加载。
- 为
false表示还没有加载; - 为
true表示已经加载
Module :以 nodejs 为例,整个系统运行之后,会用 Module 缓存每一个模块加载的信息。
require 的源码
runInThisContext(modulefunction)(
module.exports,
require,
module,
__filename,
__dirname
);
// id 为路径标识符__dirname
function require(id) {
/* 查找 Module 上有没有已经加载的 js 对象*/
const cachedModule = Module._cache[id]
/* 如果已经加载了那么直接取走缓存的 exports 对象 */
if(cachedModule){
return cachedModule.exports
}ry
/* 创建当前模块的 module */
const module = { exports: {} ,loaded: false , ...}
/* 将 module 缓存到 Module 的缓存属性中,路径标识符作为 id */
/* a.js require b.js, b.js 又require a.js拿到 cachedModule.exports的 {} */
Module._cache[id] = module
/* 先创建再加载文件 */
runInThisContext(wrapper('module.exports = "123"'))(module.exports, require, module, __filename, __dirname)
/* 加载完成 *//
module.loaded = true
/* 返回值 */
return module.exports
}
require 避免重复加载
将 module 缓存到 Module 上如果另外一个模块再次引用 a ,那么会直接读取缓存值 module ,所以无需再次执行模块。
require 避免循环引用
也是利用 Module 缓存,如果模块 loaded 了返回 loaded 的模块,否则返回创建了的临时空模块,先创建module再加载文件解决a.js 没有laoded但被b.js引用的问题
require 动态加载
console.log("我是 a 文件");
exports.say = function () {
const getMes = require("./b");
const message = getMes();
console.log(message);
};
require 本质上就是一个函数,那么函数可以在任意上下文中执行,来自由地加载其他模块的属性方法。
exports 和 module.exports
module.exports 本质上就是 exports ,我们用 module.exports 来实现如上的导出。
为什么 exports={} 直接赋值一个对象就不可以呢,只能通过exports.xx
答 :exports 本质上是 module.exports; 传递给函数runInThisContext型参数,直接赋值相当于修改了函数参数指向的对象 ,wrapper 函数的参数不再指向 module.exports 而是 {}
既然有了 exports,为何又出了 module.exports ?
答: 如上我们知道 exports 会被初始化成一个对象,也就是我们只能在对象上绑定属性,但是我们可以通过 module.exports 自定义导出出对象外的其他类型元素
CommonJS 真的输出的是值的拷贝嘛?
答:commonjs的导出是值拷贝这句话是错误的,commonjs导出的是module.exports,commonjs的导入就是变量赋值。当module.exports的值是字符串、数字等原始类型时,赋值是值拷贝才会产生导出值改变不会导致导入值改变的现象,如果是引用类型其实是同一个引用
ES Modules
Common.js 不同的是 ,CommonJS 模块同步加载并执行模块文件,ES6 模块提前加载并执行模块文件,ES6 模块在预处理阶段分析模块依赖,在执行阶段执行模块,两个阶段都采用深度优先遍历
对于 ES 模块,这分三个步骤进行。
- 构建——查找、下载并将所有文件解析为模块记录。
- 实例化——在内存中找到用于存放所有导出值的盒子(但暂时不要填充值)。然后使导出和导入都指向内存中的这些盒子。这称为链接。
- 执行——运行代码,用变量的实际值填充框。
JavaScript 的执行过程
接下来我们要讲解 ESM 的模块导入,为了方便理解 ESM 的模块导入,这里需要补充一个知识点 —— JavaScript 的执行过程。
JavaScript 执行过程分为两个阶段:
- 编译阶段
- 执行阶段
编译阶段
在编译阶段 JS 引擎主要做了三件事:
- 词法分析
- 语法分析
- 字节码生成
这里不详情讲这三件事的具体细节,感兴趣的读者可以阅读 the-super-tiny-compiler 这个仓库,它通过几百行的代码实现了一个微形编译器,并详细讲了这三个过程的具体细节。
执行阶段
在执行阶段,会分情况创建各种类型的执行上下文,例如:全局执行上下文 (只有一个)、函数执行上下文。而执行上下文的创建分为两个阶段:
- 创建阶段
- 执行阶段
在创建阶段会做如下事情:
- 绑定 this
- 为函数和变量分配内存空间
- 初始化相关变量为 undefined
我们日常提到的 变量提升 和 函数提升 就是在 创建阶段 做的,所以下面的写法并不会报错:
console.log(msg);
add(1, 2);
var msg = "hello";
function add(a, b) {
return a + b;
}
JavaScript 常见错误类型
1. RangeError
function a() {
b();
}
function b() {
a();
}
a();
// out:
// RangeError: Maximum call stack size exceeded
2. ReferenceError
hello;
// out:
// ReferenceError: hello is not defined
3.SyntaxError
console.log(1));
// out:
// console.log(1));
// ^
// SyntaxError: Unexpected token ')'
4. TypeError
var a = 1;
a();
// out:
// TypeError: a is not a function
上面的各种 Error 类型中,SyntaxError 最为特殊,因为它是 编译阶段 抛出来的错误,如果发生语法错误,JS 代码一行都不会执行。而其他类型的异常都是 执行阶段 的错误,就算报错,也会执行异常之前的脚本。
什么叫 编译时输出接口? 什么叫 运行时加载?
ESM 之所以被称为 编译时输出接口,是因为它的模块解析是发生在 编译阶段。
也就是说,import 和 export 这些关键字是在编译阶段就做了模块解析,这些关键字的使用如果不符合语法规范,在编译阶段就会抛出语法错误。
console.log("hello world"); // 根本不会打印到不了执行阶段
if (true) {
import { resolve } from "path";
}
// out:
// import { resolve } from 'path';
// ^
// SyntaxError: Unexpected token '{'
与此对应的 CommonJS,它的模块解析发生在 执行阶段,因为 require 和 module 本质上就是个函数或者对象,只有在 执行阶段 运行时,这些函数或者对象才会被实例化。因此被称为 运行时加载。
import 和 export 的用法很像导入一个对象或者导出一个对象,但这和对象完全没有关系。他们的用法是 ECMAScript 语言层面的设计的,并且“恰巧”的对象的使用类似。
所以在编译阶段,import 模块中引入的值就指向了 export 中导出的值。如果读者了解 linux,这就有点像 linux 中的硬链接,指向同一个 inode。或者拿栈和堆来比喻,这就像两个指针指向了同一个栈。
ES Modules 如何解决循环依赖
它其实和前面提到的 CommonJS 的 Module._load 函数做的事情有些类似
为不同module创建上下文,分配内存,创建变量(export的总是变量)解决
- 检查缓存,如果缓存存在且已经加载,则直接从缓存模块中提取相应的值,不做下面的处理
- 如果缓存不存在,新建一个 Module 实例
- 将这个 Module 实例放到缓存中
- 通过这个 Module 实例来加载文件
- 加载文件后到全局执行上下文时,会有创建阶段和执行阶段,在创建阶段做函数和变量提升(var 先提升定义,后赋值),接着执行代码。
- 返回这个 Module 实例的 exports
在创建阶段 就去在缓存中 去初始化好变量和内存空间,但没有执行,被循环引用也没问题 拿到undefined
Tree Shaking
ES Modules 之所以能 Tree-shaking 主要为以下四个原因(摘自尤雨溪在知乎的回答):
import只能作为模块顶层的语句出现,不能出现在 function 里面或是 if 里面。import的模块名只能是字符串常量。- 不管
import的语句出现的位置在哪里,在模块初始化的时候所有的import都必须已经导入完成。 import binding是immutable的,类似 const。比如说你不能 import { a } from ‘./a’ 然后给 a 赋值个其他什么东西。
副作用
// effect.js
console.log(unused());
export function unused() {
console.log(1);
}
// index.js
import { unused } from "./effect";
console.log(42);
此例子中 console.log(unused()); 就是副作用。在 index.js 中并不需要这一句 console.log。而 rollup 并不知道这个全局的函数去除是否安全。因此在打包地时候你可以显示地指定treeshake.moduleSideEffects 为 false,可以显示地告诉 rollup 外部依赖项没有其他副作用。
动态导出和静态编译区别?
动态导出,只有当模块运行后,才能知道导出的模块是什么。
var test = "hello";
module.exports = {
[test + "1"]: "world",
};
静态编译, 在编译阶段就能知道导出什么模块。
export function hello() {
return "world";
}
关于 ES6 模块编译时执行会导致有以下两个特点:
- import 命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行。
- export 命令会有变量声明提前的效果。
CommonJS VS ES Module
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。(js编译时)
- CommonJS 模块的
require()是同步加载,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段(编译时)。
兼容性与混合使用
原生支持情况
-
浏览器: 所有主流现代浏览器(Chrome, Firefox, Safari, Edge)都已原生支持ESM。
-
Node.js:
-
Node.js
v13.2.0开始正式支持ESM(需开启flag)。 -
从
v14.x及以后版本,ESM的支持已非常稳定,无需flag。 -
NodeJS 有了这样的规矩
.mjs必定是 esm 模块.cjs必定是 cjs 模块.js则由离他最近的pkg.type决定
-
1. 在ESM中导入CJS模块
这相对简单。ESM的import语句可以直接导入CJS模块。
// main.mjs
import _ from 'lodash'; // lodash是CJS模块
import myCjsModule from './legacy.cjs';
console.log(_.chunk([1, 2, 3, 4], 2));
myCjsModule.doSomething();
注意:CJS模块的module.exports会被当作ESM的export default来处理。
2. 在CJS中导入ESM模块
这是更具挑战性的场景,因为require()是同步的,而ESM是异步加载的。不能使用 require() 直接导入ESM模块。
解决方案:使用动态import()函数。import()返回一个Promise。
// main.js (CJS)
async function loadEsmModule() {
try {
const { someExport } = await import('./my-module.mjs');
someExport();
} catch (err) {
console.error('Failed to load ESM module:', err);
}
}
loadEsmModule();
前端💩史学:.esm.js
这段过渡期,有些工具支持了 .mjs,有一些还只认 .js(.esm.js 也是 .js)。为了兼容尽可能多的工具,很多包开发者就会选择这么写:
{
"main": "index.js", // cjs
"module": "index.esm.js" // esm,前面说过这个 module 也是共识产物,没纳入 npm
}
到后来有了 pkg.exports,但为了兼容起见,还是有人会保留 .esm.js:
{
"main": "index.js",
"module": "index.esm.js",
"exports": {
".": {
"import": "./dist/index.mjs",
"module": "./dist/index.esm.js",
"require": "./dist/index.js"
}
}
}