众所周知,ES6引入了官方的模块化方案import
和export
。在此之前,js想要实现模块化开发,需要依赖于非官方方案。
模块化历史
最早期:IIFE
最开始为了避免在全局作用域执行代码,开发者使用IIFE将代码封装在函数作用域内。
var mathToolModule = (function () {
var pi = 3.141592653
function add (a, b) {
return a + b
}
return {
pi: pi,
add: add
}
})()
mathToolModule.add(1, 2)
CommonJs
比较常用的模块化方案,使用require
和module.exports
来导入/导出模块。现今的Node.js仍然使用该模块化标准。
// toolsModule.js
module.exports = {
add: function (a, b) {
return a + b
}
}
// main.js
const mathToolModule = require('./toolsModule.js');
mathToolModule.add(1, 2)
缺点很明显,因为是同步加载,所以在浏览器上网络差的情况下会比较慢影响性能。而Node服务端因为都在本地所以不影响。
AMD(Async Module Definition)
CommonJs
实现不了异步加载模块,所以出现了AMD标准。RequireJs
是AMD的一种实现:
// 定义一个mathTools模块
define('mathTools', [], function () {
var add = function (a, b) {
return a + b;
};
return {
add: add,
};
});
// 引入math作为依赖模块
require(['mathTools'], function (math) {
math.add(1, 2)
})
通过引入依赖数组,并异步加载全部的模块,等待加载完成后再运行内部代码。缺点是依赖数组前置了,所以用或者不用都会加载这些模块。没有按需加载。
CMD(Common Module Definition))
阿里巴巴提出的一种前端模块化规范。CMD规范主要用于Sea.js
模块加载器。特点是按需加载,依赖就近(依赖可以写在代码任意地方,只有使用时才去解析依赖)。
// 定义math模块
define(function (require, exports, module) {
exports.add = function (a, b) {
return a + b;
}
})
// 使用math模块
define(function (require, exports, module) {
var mathTools = require('./math.js');
mathTools.add(1, 2)
})
require
和exports
都作为define
内函数的参数。分别代表自己要使用什么模块,以及导出哪些变量。
UMD(Universal Module Definition)
UMD规范的目的是让一个模块能同时在不同环境下运行。不论是浏览器、Node.js还是其他环境。它兼容了AMD、CommonJs以及全局变量。
// mathTools.js
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define([], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS
module.exports = factory();
} else {
// 浏览器全局变量
root.mathTools = factory();
}
}(this, function () {
// 主体代码
return {
add: function (a, b) {
return a + b
}
}
}));
// main.js
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['mathTools'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS
module.exports = factory(require('mathTools'));
} else {
// 浏览器全局变量
root.myModule = factory(root.mathTools);
}
}(this, function (mathTools) {
// 主体代码
mathTools.add(1, 2)
}));
可以看到每个模块的主体代码都在factory函数
里。模块执行时使用IFFE:判断当前使用哪种模块规范,并把自己导出为当前的模块规范。从而让每个模块都能运行在各种模块规范下。
ESM(EMCAScript Module)
ES6从语言层面实现了模块化。旨在统一标准。使用import
导入模块,export
导出模块。
// mathTools.js
export default {
add: (a, b) => a + b
}
// main.js
import add from 'mathTools'
add(1, 2)
并且ESM慢慢成为浏览器的标配。使用<script type="module">
就可以定义一个脚本使用该模块规范。在node端也支持ESM。通过在package.json里配置type: "module"
或者使用.mjs
后缀,就可以使用ESM。
ESM的加载运行机制
ESM在浏览器和Node中的加载运行机制,被设计为三个阶段:
-
构建阶段 - 加载并解析模块代码,生成一堆模块记录。
-
实例化阶段 - 模块将导出指向内存,此时的变量仅是声明。然后其他模块的导入也指向同一位置。这个过程称为“连接”。
-
执行阶段 - 运行代码,变量的值将填充内存。
参考mozilla上的ESM详细解析。
构建阶段
首先,所有的工作会从入口模块开始。加载器会负责加载模块(从URL下载或文件系统加载)。加载完成后解析器会解析代码,生成模块记录。
模块记录包含AST的模块代码、请求模块、导入实体对象、本地导出变量等。如果一个模块代码中使用到了import
语句。那么也会进入上述重复的过程。所以这里的请求模块即一个模块依赖的其他模块。
模块记录会存到一个使用地址作为key的模块映射map中。这样就不会重复的加载和解析。
我们知道非ESM情况下,解析器也会解析js代码为AST。ESM下由于多了模块所以如此复杂。
这一阶段完成后,我们就从入口文件,得到了一堆模块记录。
实例化阶段
实例化阶段主要就是将模块“连接”在一起,并将变量都指向相同的内存位置。
每个模块有自己的环境对象而相互隔离,因此各个模块可以存在相同的命名。对于模块记录中的所有导出,在内存中创建空间,并指向内存。由于此时代码还未执行,如果是变量,此时还是undefined。比如export var count = 1
,此时count指向的内存的值为undefined。
如果是函数,指向的就是实际的函数内存区域,并不是undefined。
随后,引擎将根据模块记录,进行深度优先后序遍历(即最深的最先执行)。并将模块的导入也指向同一内存位置。这样的话,导入和导出指向同一内存。
这就是我们经常说的:ESM导出的是引用,而commonjs导出的是副本。
这一阶段完成后,我们就将所有模块,及导入/导出变量的内存地址都连接起来了。
执行阶段
最后一步就是执行代码,引擎将执行所有顶层代码(函数之外的代码)。并将变量的值填充到内存里。即上面的例子,count
被设置为1。
要注意的是,正是由于模块映射的存在。能保证每个模块的代码,只执行一次,即使是在循环依赖的情况下。为什么模块代码必须只执行一次?这是因为执行代码是有副作用的。比如count++
,每次执行都会变化。
与实例化阶段一样,这个阶段也是深度优先后序遍历的。这是合理的,正因如此,前一个模块使用到的导入变量才会获取到正确的值。比如import val from "modA.js"
,modA.js
先执行,所以val
能获取到正确的值。
ESM和CommonJs的差异
接着对比一下ESM和CJS的模块加载机制带来的一些差异:
-
esm是静态的,即模块的依赖关系和引用在代码运行前就已经确定。而commonjs是动态的,模块在代码运行时解析,依赖关系无法提前确定。(所以
import
语句只能写在(top-leve)顶层作用域,不可在{}块级作用域中。而require
则没有限制。这也导致了只有esm可以做treeshaking)。 -
esm可以认为是异步的,因为它整体分为了三阶段,并且每个阶段都可以单独完成。而commonjs则顺序的、同步的加载和运行模块,遇到
require
就去加载和运行新的模块,整个过程无法中断。 -
esm导出的是引用,而commonjs导出的是副本。(看下面的示例)
-
esm代码执行是深度优先后序遍历执行的,而commonjs则是按代码顺序执行的。
比如以下情况:
// index.js
console.log("1");
import "./esm-a.js";
console.log("2");
import "./esm-b.js";
console.log("3");
// esm-a.js
console.log("a");
export default {}
// esm-b.js
console.log("b");
export default {}
输出的结果为:a => b => 1 => 2 => 3
如果在esm-a.js
内部加上import "./esm-b.js"
,那输出的结果就变为:b => a => 1 => 2 => 3
同比之下,commonjs因为是同步顺序执行的,结果会比较符合常规。还是上面的例子,改为commonjs:
// index.js
console.log("1");
require("./esm-a.js");
console.log("2");
require("./esm-b.js");
console.log("3");
输出结果为:1 => a => 2 => b => 3
此外,esm支持动态导入模块,使用import()
函数,可以在任意位置调用,无需top-level。比如import("./esm-a.js")
会返回一个Promise,在Promise里可以获取到模块导出的内容。动态导入的模块只在调用import()
函数时,才被解析、加载和执行。
// index.js
console.log("1");
import("./esm-a.js")
console.log("2");
import("./esm-b.js")
console.log("3");
输出结果为:1 => 2 => 3 => a => b
浏览器中ESM的特殊之处
浏览器中esm的加载一定是defer
异步的。也就是说下面的代码是等同的:
<script type="module" src="./index.js"></script>
<script type="module" src="./index.js" defer></script>
BTW:defer
属性的脚本会在html解析完成后执行并且保证defer脚本的执行顺序。而async
脚本会在该脚本下载完成后立即执行,执行顺序不保证,执行时间也不保证。所以async
适合与dom无关,独立运行的脚本。而defer
适合不需要阻塞html解析的异步脚本。
执行顺序:html开始解析 => 同步script按顺序执行 => html解析完成 => defer脚本按顺序执行 => DOMContentLoaded事件 => 其他资源加载(link外部样式表,img的图片资源) => 全部完成触发onload事件。
Node中ESM的特殊之处
Node支持通过裸模块名加载本地模块。比如import fs from 'fs'
。它会通过不断往上查找node_modules
目录来加载对应模块。
Webpack上编译ESM和CommonJs
Webpack可以通过配置output.type将代码打包成各种模块规范的格式,我们可以根据库的目标运行环境来更改模块类型。
前面所讲的都是浏览器或Node对ESM模块的原生加载机制,而我们常常会使用Webpack这样的打包工具,对前端应用进行构建打包。Webpack目前对ESM支持还有限,它在构建ESM时,并不会保留ESM,而是转译成自己的实现,并最大限度的对齐ESM的规则和表现。
我们看一下Webpack在development模式下是如何实现esm模块的。(production模式下看不出来,因为做了代码压缩、treeshaking、模块合并等)。
拿下面的例子来看esm静态导入和动态导入模块,webpack编译后是什么样子:
// ========== index.js - 编译前
console.log("1");
{
import("./esm-a.js").then(() => {
console.log("esm-a loaded");
})
}
console.log("2");
import "./esm-b.js"
console.log("3");
// ========== index.js - 编译后
__webpack_require__.r(__webpack_exports__);
var _esm_b_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./esm-b.js");
console.log("1");
{
__webpack_require__.e("esm-a_js").then(__webpack_require__.bind(__webpack_require__, "./esm-a.js")).then(() => {
console.log("esm-a loaded");
})
}
console.log("2");
console.log("3");
可以看到静态import的模块,被提到了代码顶部,并通过__webpack_require__()
函数加载模块。动态import()的模块还在原来的位置,通过__webpack_require__.e
Promise化后再加载对应模块,并在模块加载后执行回调函数。
webpack处理CommonJs的就比较简单了。直接使用__webpack_require__()
加载模块即可。
// ========== index.js - 编译前
console.log("1");
require("./esm-b.js");
console.log("2");
// ========== index.js - 编译后
console.log("1");
__webpack_require__("./esm-b.js");
console.log("2");
webpack_require
也顺便看看webpack的__webpack_require__()
方法做了什么。
// __webpack_modules__是一个对象。
var __webpack_modules__ = ({
"./esm-b.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval(/** code省略 */);
}),
"./index.js": ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
eval("console.log(\"1\");\n__webpack_require__(/*! ./esm-b.js */ \"./esm-b.js\");\nconsole.log(\"2\");\n\n//# sourceURL=webpack://esm/./index.js?");
})
});
// 模块缓存
var __webpack_module_cache__ = {};
// require函数实现,用来加载模块
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId]
if (cachedModule !== undefined) {
return cachedModule.exports
}
var module = __webpack_module_cache__[moduleId] = {exports: {}}
__webpack_modules__[moduleId](module, module.exports, __webpack_require__)
return module.exports
}
// 执行导入入口文件
__webpack_require__("./index.js");
可以看到,__webpack_modules__
用来存储所有的模块(不包含异步模块)。以模块路径为key,值为对应模块的工厂函数。
__webpack_require__
函数用来加载模块。它会首先检查缓存,如果有缓存就直接返回缓存。否则调用__webpack_modules__
中对应模块的工厂函数。
工厂函数内,直接eval
执行了编译后的模块代码。模块代码的作用域限定在函数内,工厂函数提供的三个参数即模块代码的执行上下文。执行模块代码时:使用__webpack_require__
导入其他模块。导出内容输出到__webpack_exports__
上。
最后执行入口文件的require。从而形成了整个闭环。
其他
-
ESM的特性之一就是导出的是引用,假如值被某模块修改了,其他模块获取的仍是最新值。ESM的实现导入和导出都指向同一块内存地址。Webpack在实现这个特性时,是通过
Object.definedProperty
设置get()函数。原始值始终是在原模块内,其他模块通过get()取到的始终是原模块内的值。// esm-b.js - 编译前 export var a = 1 export function setA() { a++ } // esm-b.js - 编译后并展开__webpack_require__.d Object.defineProperty(__webpack_exports__, 'a', { get: () => (a), }); Object.defineProperty(__webpack_exports__, 'setA', { get: () => (setA) }); var a = 1 function setA() { a++ }
-
对于使用动态加载语法的模块,前面我们看到了是通过
__webpack_require__.e
处理的。简单说一下:-
动态模块会被单独提到一个chunk中,生成对应的bundle.js。动态模块不会出现在初始的
__webpack_modules__
里。 -
生成的bundle.js里,会把自己执行代码和模块id都push到
chunkLoadingGlobal
里 -
__webpack_require__.e
会执行一系列promise。 -
加载的promise函数内,会通过script标签加载bundle.js。
-
加载成功后会执行
webpackJsonpCallback
函数,将该模块的id和函数代码添加到__webpack_modules__
上。 -
promise执行下一步。调用
__webpack_require__
执行模块代码。
详细参考https://blog.youkuaiyun.com/letterTiger/article/details/136977101
-