早期的 JavaScript 大多用来执行独立的脚本任务,在web页面必要的地方引入,提供一定的交互。但随着JavaScript脚本逐渐复杂以及应用其他的一些运行环境下(如 Node.js)。所以,近年来开始考虑提供将 JavaScript 程序拆分为可按需导入的单独模块的机制。例如:
- AMD —— 最古老的模块系统之一,最初由 require.js 库实现。
- CommonJS —— 为 Node.js 服务器创建的模块系统。 ECMAScript6 中提出模块规范后,最新的浏览器也开始原生支持模块功能了。本文主要介绍一下JavaScript中模块系统的发展,以及如何使用。
什么是模块
就像一个好的作者将书分成多个章节一样,好的程序员将程序分成多个模块。JavaScript 模块允许将代码分解为单独的文件。简单来说,模块就是程序中的一段代码,这段代码保存在一个单独的文件(或脚本)中。
一个好的模块应该具有以下特一个模块必须尽可能地与其他依赖项分离点:
- 独立的:一个模块必须尽可能地与其他依赖项分离
- 具体的:一个模块需要能够执行一个或一组相关的任务。创建它们的核心本质首先是要创建独立的功能。一个模块,一个(种)任务
- 可重用:一个模块必须易于集成到各种程序中以执行其任务
为什么要使用模块
1) 可维护性: 一个模块是自包含的。一个设计良好的模块旨在尽可能减少对部分代码库的依赖,使其能够独立优化和改进。当模块与其他代码片段分离时,更新单个模块会容易得多。
2) 命名空间: 在 JavaScript 中,顶级函数范围之外的变量是全局的(意味着每个人都可以访问它们)。正因为如此,“命名空间污染”很常见,即完全不相关的代码共享全局变量。
在不相关的代码之间共享全局变量是开发中的一大禁忌。
3) 可重用性: 如不使用模块,当我们想重用之前编写的代码时,通常是将之前编写的代码复制到新项目中。但这样我们必须记住这段代码的来源,并且修改时要保证两个地方代码一致。这显然会浪费时间,如果使用模块会更加方便。
模块模式
把逻辑分块,各自封装,相互独立,每个块自行决定对外暴露什么,同时自行决定引入那些外部代码。
一、CommonJS 规范
CommonJS 规范概述了同步声明依赖的模块定义。这个规范主要用于在服务器端实现模块化代码组织,但也可用于定义在浏览器中使用的模块依赖。
注意:CommonJS 模块语法不能在浏览器中直接运行。
Node.js采用了这个规范。在 Node.js 模块系统中,每个文件都被视为独立的模块。
CommonJS 模块定义使用 require()
指定依赖,使用 exports
对象定义自己公共的 API。 如下代码:
// 加载模块
var moduleB = require('./moduleB);
// 导出公共 API
module.exports = {
...
}
2.1 访问主模块
当文件直接从 Node.js 运行时,则 require.main 被设置为其 module。 这意味着可以通过 require.main === module
来确定文件是否被直接运行。
对于文件 foo.js:
- 如果通过 node foo.js 运行,则
require.main === module
为true
- 如果通过 require('./foo') 运行,则
require.main === module
为false
当入口点不是 CommonJS 模块时,则 require.main
为 undefined
,且主模块不可达。
2.2 缓存
模块在第一次加载后被缓存。 这意味着(类似其他缓存)每次调用 require('foo')
都会返回完全相同的对象(如果解析为相同的文件)。
如果 require.cache
没有被修改,则多次调用 require('foo')
不会导致模块代码被多次执行。 这是重要的特征。 有了它,可以返回“部分完成”的对象,从而允许加载传递依赖项,即使它们会导致循环。如下代码中,require('foo')
只会执行一次。
var foo = require('foo');
var foo = require('foo');
var foo = require('foo');
var foo = require('foo');
需要注意的是:
- 模块根据其解析的文件名进行缓存。由于模块可能会根据调用模块的位置(从
node_modules
文件夹加载)解析为不同的文件名,因此如果解析为不同的文件,则不能保证require('foo')
始终返回完全相同的对象。 - 在不区分大小写的文件系统或操作系统上,不同的解析文件名可以指向同一个文件,但缓存仍会将它们视为不同的模块,并将多次重新加载文件。例如,
require('./foo')
和require('./FOO')
返回两个不同的对象,无论 和 是否./foo
是./FOO
同一个文件。
2.3 循环依赖
当存在require()
循环调用时,模块返回时可能尚未完成执行。例如:
a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);
当 main.js
加载 a.js
时,a.js
会去加载 b.js
,b.js
中又会尝试去加载 a.js
。为了防止无限循环,会将a.js
exports 对象的未完成副本将返回到 b.js 模块。然后 b.js
完成加载,它的 exports
对象会提供给 a.js
模块。
当 main.js 加载这两个模块时,它们都已完成。所以程序输出如下:
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true
main.js
中的 require('./b.js')
并没有加载 b.js
,这是因为 a.js
中已经加载过 b.js
了,这时 require.cache
就会记录 b.js
,所以 main.js
中执行require('./b.js')
就直接从缓存中获取了。
2.3 模块依赖
(1)文件模块
对于传入require()
的字符串,Node.js 通常按以下方式进行解析:
针对于文件名
-
如果找不到确切的文件名,Node.js 将尝试加载所需的文件名,并添加扩展名:
.js
、.json
,最后是.node
。(所以如果是js文件,后缀名可以省略) -
当加载具有不同扩展名的文件(例如 .cjs)时,则必须将其全名传给 require(),包括其文件扩展名(例如 require('./file.cjs'))。
-
json 文件被解析为 JSON 文本文件,.node 文件被解释为加载了 process.dlopen() 的已编译插件模块。 使用任何其他扩展名(或根本没有扩展名)的文件被解析为 JavaScript 文本文件。
针对于路径
-
以
/
为前缀的表示绝对路径。 例如,require('/home/marco/foo.js') 将在 /home/marco/foo.js 加载文件。 -
以
./
、../
等表示相对路径,也就是说,circle.js 必须和 foo.js 在同一个目录下,require('./circle') 才能找到它。 -
如果没有前导 '/'、'./' 或 '../' 来指示文件,则该模块必须是核心模块或从 node_modules 文件夹加载。
-
如果给定路径不存在,则 require() 将抛出 MODULE_NOT_FOUND 错误。
(2)文件夹模块
可以通过三种方式将文件夹作为参数传给require()
:
- 当文件夹的根目录下存在
package.json
文件,并且指定了main
字段,例如文件夹 "some-library" 中存在如下文件:
// package.json
{
"name": "some-library",
"main": "./lib/main.js"
}
那么 则 require('./some-library')
将尝试加载 ./some-library/lib/main.js
。
- 如果目录中不存在
package.json
文件,或者"main"
字段丢失或无法解析,则 Node.js 将尝试从该目录中加载index.js
或index.node
文件。
如果上例中没有package.json
文件,require('./some-library')
将尝试加载:
./some-library/index.js
./some-library/index.node
- 如果上述尝试都失败,Node.js 将报告整个模块丢失,并显示默认错误:
Error: Cannot find module 'some-library'
(3)从node_modules 目录加载
如果传给 require() 的模块标识符不是核心模块,并且不以 '/'、'../' 或 './' 开头,则 Node.js 从当前模块的目录开始,并添加 /node_modules,并尝试从该位置加载模块。 Node.js 不会将 node_modules 附加到已经以 node_modules 结尾的路径。
如果在那里找不到它,则它移动到父目录,依此类推,直到到达文件系统的根目录。
例如,如果 '/home/ry/projects/foo.js' 处的文件调用 require('bar.js'),则 Node.js 将按以下顺序查找以下位置:
- /home/ry/projects/node_modules/bar.js
- /home/ry/node_modules/bar.js
- /home/node_modules/bar.js
- /node_modules/bar.js
这允许程序本地化它们的依赖项,这样它们就不会发生冲突。
(4)从全局目录加载
如果 NODE_PATH 环境变量设置为以冒号分隔的绝对路径列表,则 Node.js 将在这些路径中搜索模块(如果它们在其他地方找不到)。
Node.js 将在以下 GLOBAL_FOLDERS 列表中搜索:
- 1:
$HOME/.node_modules
- 2:
$HOME/.node_libraries
- 3:
$PREFIX/lib/node
其中 $HOME
是用户的主目录,$PREFIX
是 Node.js 配置的 node_prefix
。
2.4 模块封装
在执行模块代码之前,Node.js 将使用如下所示的函数封装器对其进行封装:
(function(exports, require, module, __filename, __dirname) {
// 模块代码实际存在于此处
});
通过这样做,Node.js 实现了如下几点:
-
它将顶层变量(使用
var
、const
或let
定义)保持在模块而不是全局对象的范围内。 -
它有助于提供一些实际特定于模块的全局变量
__dirname
:当前模块的目录名。这与path.dirname(__filename)
相同__filename
:当前模块的文件名。这是当前模块文件的已解析符号链接的绝对路径。exports
:对module.exports
的引用moudle
:对当前模块的引用require
:导入模块
2.5 require
(1)require(id)
id <string>
:模块名称或路径返回值 <any>
导出的模块内容
用于导入模块、JSON
和本地文件。
- 模块可以从
node_modules
导入。 - 可以使用相对路径(例如
./
、./foo
、./bar/baz
、../foo
)导入本地模块和 JSON 文件,该路径将根据__dirname
(如果有定义)命名的目录或当前工作目录进行解析
(2)require.cache
模块在需要时缓存在此对象中。通过从此对象中删除键值,下一次 require
将重新加载模块。
(3)require.main
Module
对象代表 Node.js 进程启动时加载的入口脚本,如果程序的入口点不是 CommonJS 模块,则为 undefined
。
假设 a.js
中代码如下:
require.main === module
如果您是直接运行 a.js
文件,例如命令行执行 node a.js
,那么上述输出为true
。如果是从另外某个文件通过 require('a.js')
加载 a.js
,那么输出为 false
。
(3)require.resolve(request[,options])
require.resolve(request[, options])
#
版本历史
`` | |
-
request
<string> :要解析的模块路径。 -
options
<Object>:paths
<string[]>]:从中解析模块位置的路径。如果存在,将使用这些路径而不是默认解析路径,但 GLOBAL_FOLDERS 和$HOME/.node_modules
除外,它们始终包含在内。这些路径中的每一个都用作模块解析算法的起点,这意味着从此位置检查node_modules
层级。
-
返回:<string>
使用内部的 require()
工具查找模块的位置,但不加载模块,只返回解析的文件名。如果找不到模块,则会抛出 MODULE_NOT_FOUND
错误。
(3)require.resolve.paths(requet)
request
<string>正在检索其查找路径的模块路径。- 返回:<string[]> | <null>
如果 request
字符串引用核心模块,例如 http
或 fs
,则返回包含在解析 request
或 null
期间搜索的路径的数组。
2.5 module 对象
在每个模块中,module 自由变量是对代表当前模块的对象的引用。module 实际上不是全局的,而是每个模块本地的。
module
对象中通常会包含如下属性:
module.filename
:模块的完全解析文件名。module.id
:模块的标识符。通常这是完全解析的文件名。module.path
:模块的目录名称。这通常与module.id
的path.dirname()
相同。module.paths
:模块的搜索路径。module.children
:这个模块首次需要的对象。module.isPreloading
:如果模块在 Node.js 预加载阶段运行,则为true
。module.loaded
:模块是否已完成加载,或正在加载。
Module {
'9': [Function: internalRequire],
id: '.',
path: 'c:\\Users\\dali\\Desktop\\test\\modules',
exports: {},
filename: 'c:\\Users\\dali\\Desktop\\test\\modules\\a.js',
loaded: false,
children: [],
paths: [
'c:\\Users\\dali\\Desktop\\test\\modules\\node_modules',
'c:\\Users\\dali\\Desktop\\test\\node_modules',
'c:\\Users\\dali\\Desktop\\node_modules',
'c:\\Users\\dali\\node_modules',
'c:\\Users\\node_modules',
'c:\\node_modules'
]
}
module.exports
module.exports 对象由 Module 系统创建。将所需的导出对象赋值给 module.exports 赋值给 module.exports 必须立即完成。 不能在任何回调中完成。
module.exports 和 exports 的区别
为方便起见,module.exports 也可通过 exports 模块全局访问。 exports 变量在模块的文件级作用域内可用,并在评估模块之前被分配 module.exports 的值。
- 它允许一个快捷方式,以
module.exports.f = ...
可以更简洁地写成exports.f = ...
。 但是,请注意,与任何变量一样,如果将新值分配给 exports,则它就不再绑定到 module.exports:
module.exports.hello = true; // 从模块的 require 中导出
exports = { hello: false }; // 未导出,仅在模块中可用
- 当 module.exports 属性被新对象完全替换时,通常也会重新分配 exports:
module.exports = exports = function Constructor() {
// ... 等等。
};
简单来说,module.exports
和 exports
保存指向同一个对象的引用,所以通过module.exports
和 exports
操作的是同一对象。但是 exports = { hello: false };
相当于给变量 export
赋予了一个指向新的对象的引用,与 module.exports
引用所指向的对象不同,所以不会导出。
二、AMD 规范
如上所述,CommonJS 采用服务器优先的方法并同步加载模块。如果我们需要require
其他三个模块,它会一个一个地加载它们,必须等待模块加载完才能执行后面的代码。
这在服务器上工作得很好,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但对于浏览器来说,这是个大问题,因为模块存放在服务器端,web端需要发送远程网络请求读取,所以从 Web 读取模块比从磁盘读取要花费更长的时间。只要加载模块的脚本正在运行,它就会阻止浏览器运行其他任何东西,直到它完成加载。造成浏览器处于"假死"状态。
如下例子:
var math = require('math');
math.add(2, 3);
第二行math.add(2, 3),在第一行require('math')之后运行,因此必须等math.js加载完成。也就是说,如果加载时间很长,整个应用就会停在那里等。
2.1 AMD规范概述
AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
AMD也采用require()语句加载模块,但是不同于CommonJS:
require([module], callback);
- 第一个参数[module],是一个数组,里面的成员就是要加载的模块;
- 第二个参数callback,则是加载成功之后的回调函数。
对于上述的例子,我们改用 AMD 规范加载模块。如下代码中,math.add()
与 math
模块加载是异步的,浏览器不会发生假死。所以很显然,AMD比较适合浏览器环境。
require(['math'], function (math) {
math.add(2, 3);
});
除了异步之外,AMD 的另一个好处是你的模块可以是对象、函数、构造函数、字符串、JSON 和许多其他类型,而 CommonJS 只支持将对象作为模块。
目前,主要有两个Javascript库实现了AMD规范:require.js和curl.js
三、UMD 规范
对于需要同时支持 AMD 和 CommonJS 功能的项目,还有另一种格式:通用模块定义 (UMD)。
UMD 本质上创建了一种使用两者中的任何一个的方法,同时还支持全局变量定义。因此,UMD 模块能够在客户端和服务器上工作。
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['myModule', 'myOtherModule'], factory);
} else if (typeof exports === 'object') {
// CommonJS
module.exports = factory(require('myModule'), require('myOtherModule'));
} else {
// Browser globals (Note: root is window)
root.returnExports = factory(root.myModule, root.myOtherModule);
}
}(this, function (myModule, myOtherModule) {
// Methods
function notHelloOrGoodbye(){}; // A private method
function hello(){}; // A public method because it's returned (see below)
function goodbye(){}; // A public method because it's returned (see below)
// Exposed public methods
return {
hello: hello,
goodbye: goodbye
}
}));
五、ECMAScript 模块
ECMAScript 模块是 官方标准格式,用于打包 JavaScript 代码以供重用。在 ES 模块中,允许使用 export
关键字导出几乎所有内容,使用 import
关键字从另一个模块中导入模块。
Node.js 完全支持当前指定的 ECMAScript 模块,并提供它们与其原始模块格式 CommonJS 之间的互操作性。
5.1 模块说明符
import
语句的说明符是 from
关键字之后的字符串,例如 import { sep } from 'node:path'
中的 'node:path'
。
有三种类型的说明符:
- 相对说明符:如
'./startup.js'
或'../config.mjs'
。它们指的是相对于导入文件位置的路径。这些文件扩展名始终是必需的。 - 纯说明符:如
'some-package'
或'some-package/shuffle'
。它们可以通过包名称来引用包的主要入口点,或者根据示例分别以包名称为前缀的包中的特定功能模块。只有没有"exports"
字段的包才需要包含文件扩展名。纯说明符解析由 Node.js 模块解析和加载算法 处理。 - 绝对说明符:如
'file:///opt/nodejs/config.js'
。它们直接且明确地引用完整的路径。
5.2 模块导出(export)
在创建JavaScript模块时,export 语句用于从模块中导出实时绑定的函数、对象或原始值,以便其他程序可以通过 import 语句使用它们。
被导出的绑定值依然可以在本地进行修改。在使用import进行导入时,这些绑定值只能被导入模块所读取,但在export导出模块中对这些绑定值进行修改,所修改的值也会实时地更新。
无论是否声明,导出的模块都处于严格模式。 export语句不能用在嵌入式脚本中。
// 导出单个特性
export let name1, name2, …, nameN; // also var, const
export let name1 = …, name2 = …, …, nameN; // also var, const
export function FunctionName(){...}
export class ClassName {...}
// 导出列表
export { name1, name2, …, nameN };
// 重命名导出
export { variable1 as name1, variable2 as name2, …, nameN };
// 解构导出并重命名
export const { name1, name2: bar } = o;
// 默认导出
export default expression;
export default function (…) { … } // also class, function*
export default function name1(…) { … } // also class, function*
export { name1 as default, … };
// 导出模块合集
export * from …; // does not set the default export
export * as name1 from …; // Draft ECMAScript® 2O21
export { name1, name2, …, nameN } from …;
export { import1 as name1, import2 as name2, …, nameN } from …;
export { default } from …;
(1)命名导出(每个模块包含任意数量)
// 导出事先定义的特性
export { myFunction,myVariable };
// 导出单个特性(可以导出var,let,const,function,class)
export let myVariable = Math.sqrt(2);
export function myFunction() { ... };
(2)默认导出(每个模块包含一个)
默认导出只能导出一次,如下写了多个 export default 的话,最后一个 export default 会替换前面的
// 导出事先定义的特性作为默认值
export { myFunction as default };
// 导出单个特性作为默认值
export default function () { ... }
export default class { .. }
// 每个导出都覆盖前一个导出
(3)重导出 / 聚合
为了使模块导入变得可用,在一个父模块中“导入/导出”这些不同模块也是可行的。也就是说,你可以创建单个模块,集中多个模块的多个导出
export { default as function1, function2 } from 'bar.js';
注意:
下面的写法是无效的:
import DefaultExport from 'bar.js'; // 有效的
export DefaultExport from 'bar.js'; // 无效的
正确的写法如下:
export { default as DefaultExport } from 'bar.js';
5.3 模块导入(import)
静态的import 语句用于导入由另一个模块导出的绑定。无论是否声明了 strict mode ,导入的模块都运行在严格模式下。
在浏览器中,import 语句只能在声明了 type="module"
的 script 的标签中使用。
此外,还有一个类似函数的动态 import(),它不需要依赖 type="module" 的script标签。
在 script 标签中使用 nomodule 属性,可以确保向后兼容。
(1)导入整个模块的内容
这将myModule插入当前作用域,其中包含来自位于/modules/my-module.js文件中导出的所有接口。
import * as myModule from '/modules/my-module.js';
(2)导入单个接口(对应export命名导出)
给定一个名为myExport的对象或值,它已经从模块my-module导出(因为整个模块被导出)或显式地导出(使用export语句),将myExport插入当前作用域。
import {myExport} from '/modules/my-module.js';
(3)导入多个接口
这将foo和bar插入当前作用域。
import {foo, bar} from '/modules/my-module.js';
(4)导入带有别名的接口
你可以在导入时重命名接口。例如,将shortName插入当前作用域。
import {reallyReallyLongModuleExportName as shortName} from '/modules/my-module.js';
(5)导入时重命名多个接口
使用别名导入模块的多个接口。
import { reallyReallyLongModuleMemberName as shortName, anotherLongModuleName as short } from '/modules/my-module.js';
(6)仅为副作用而导入一个模块
整个模块仅为副作用(中性词,无贬义含义)而导入,而不导入模块中的任何内容(接口)。 这将运行模块中的全局代码, 但实际上不导入任何值。
import '/modules/my-module.js';
(7)导入默认值 —— 对应export默认导出
引入模块可能有一个defaultexport(无论它是对象,函数,类等)可用。然后可以使用import语句来导入这样的默认接口。
最简单的用法是直接导入默认值:
import myDefault from '/modules/my-module.js';
(8)同时导入默认模块和命名模块
也可以同时将default语法与上述用法(命名空间导入或命名导入)一起使用。在这种情况下,default导入必须首先声明。 例如:
import myDefault, * as myModule from '/modules/my-module.js';
// myModule used as a namespace
或者
import myDefault, {foo, bar} from '/modules/my-module.js';
// specific, named imports
5.4 动态import
标准用法的import导入的模块是静态的,会使所有被导入的模块,在加载时就被编译(无法做到按需编译,降低首页加载速度)。有些场景中,你可能希望根据条件导入模块或者按需导入模块,这时你可以使用动态导入代替静态导入。下面的是你可能会需要动态导入的场景:
- 当静态导入的模块很明显的降低了代码的加载速度且被使用的可能性很低,或者并不需要马上使用它。
- 当静态导入的模块很明显的占用了大量系统内存且被使用的可能性很低。
- 当被导入的模块,在加载时并不存在,需要异步获取
- 当导入模块的说明符,需要动态构建。(静态导入只能使用静态说明符)
- 当被导入的模块有副作用(这里说的副作用,可以理解为模块中会直接运行的代码),这些副作用只有在触发了某些条件才被需要时。(原则上来说,模块不能有副作用,但是很多时候,你无法控制你所依赖的模块的内容)
请不要滥用动态导入(只有在必要情况下采用)。静态框架能更好的初始化依赖,而且更有利于静态分析工具和tree shaking发挥作用
关键字import可以像调用函数一样来动态的导入模块。以这种方式调用,将返回一个 promise。
import('/modules/my-module.js')
.then((module) => {
// Do something with the module.
});
这种使用方式也支持 await 关键字。
let module = await import('/modules/my-module.js');
5.5 浏览器中使用 JS 模块
在 Web 中,可以通过将 <script>
标签的 type
属性设置为 module
来告诉浏览器将<script>
元素视为模块.
<script type="module" src="main.mjs"></script>
浏览器加载模块脚本与加载传统脚本不同:
- 模块脚本和它们的依赖关系是用 CORS 获取的。这意味着,任何跨源模块的脚本都必须使用适当的头信息,如
Access-Control-Allow-Origin: *
。 但这对经典脚本来说是不正确的。 - 模块脚本在下载时不会阻塞HTML解析器,但它也会尽快执行脚本,不保证顺序,也不等待HTML解析完成。(类似于设置了
async
属性)
- V8 官方文档建议模块脚本文件扩展名使用
.mjs
模块与传统脚本的区别
- 模块中默认启动严格模式
- 模块中不支持 HTML 样式的注释语法,尽管它适用于传统脚本。
- 模块有一个词法顶级作用域。这意味着,例如,
var foo = 42;
在模块中运行不会创建名为的全局变量foo
,不能通过window.foo
访问该变量。 - 模块内部
this
不引用全局this
,而是引用undefined
- 新的静态
import
和export
语法仅在模块中可用——它不适用于传统脚本 - 模块默认异步加载
- 顶级
await
在模块中可用,但在经典脚本中不可用。相关地,await
不能在模块中的任何地方用作变量名。
5.6 import.meta
对象
import.meta
元属性是包含以下属性的 Object
。
import.meta.dirname
:当前模块的目录名import.meta.filename
:当前模块的完整绝对路径和文件名import.meat.url
:模块的绝对urlimport.meta.resolve(specifier)
specifier <string>
相对于当前模块解析的模块说明符。- 返回值:说明符将解析为的绝对 URL 字符串
六、CommonJS 模块 和 ECMAScript 模块
6.1 互操作性
-
import
语句可以引用 ES 模块或 CommonJS 模块。import
语句只允许在 ES 模块中使用,但 CommonJS 支持动态import()
表达式来加载 ES 模块。 -
CommonJS 模块
require
总是将它引用的文件视为 CommonJS。不支持使用require
加载 ES 模块,因为 ES 模块具有异步执行。而是,使用import()
从 CommonJS 模块加载 ES 模块。
6.2 模块加载区别
CommonJS 模块加载器:
- 它是完全同步的。
- 它负责处理
require()
调用。 - 它是可修补的。
- 它支持文件夹作为模块。
- 当解析说明符时,如果没有找到完全的匹配,则它将尝试添加扩展名(.js、.json,最后是 .node),然后尝试将文件夹作为模块解析。
- 它将 .json 视为 JSON 文本文件。
- .node 文件被解释为加载了 process.dlopen() 的编译插件模块。
- 它将所有缺少 .json 或 .node 扩展名的文件视为 JavaScript 文本文件。
- 它不能用于加载 ECMAScript 模块(尽管可以从 CommonJS 模块加载 ECMASCript 模块)。 当用于加载不是 ECMAScript 模块的 JavaScript 文本文件时,则它将作为 CommonJS 模块加载。
ECMAScript 模块加载器:
- 它是异步的。
- 负责处理 import 语句和 import() 表达式。
- 它不是可修补的,可以使用加载器钩子自定义。
- 它不支持文件夹作为模块,必须完全指定目录索引(例如 './startup/index.js')。
- 它不进行扩展名搜索。 当说明符是相对或绝对的文件 URL 时,必须提供文件扩展名。
- 它可以加载 JSON 模块,但需要导入断言(在 --experimental-json-modules 标志后面)。
- 它只接受 JavaScript 文本文件的 .js、.mjs 和 .cjs 扩展名。
- 它可以用来加载 JavaScript CommonJS 模块。 这样的模块通过 es-module-lexer 来尝试识别命名的导出,如果可以通过静态分析确定的话是可用的。 导入的 CommonJS 模块将其 URL 转换为绝对路径,然后通过 CommonJS 模块加载器加载。
调用 require() 始终使用 CommonJS 模块加载器。 调用 import() 始终使用 ECMAScript 模块加载器。
6.3 require 和 import 的区别
(1)不同端使用限制
require/exports | import/export | |
---|---|---|
Node.js | 所有版本 | Node 9.0+(启动需加上 flag --experimental-modules)Node 13.2+(直接启动) |
Chrome | 不支持 | 61+ |
Firefox | 不支持 | 60+ |
Safari | 不支持 | 10.1+ |
Edge | 不支持 | 16+ |
(2)require/exports 是运行时动态加载,import/export 是静态编译
CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。
ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
(3)require/exports 输出的是一个值的拷贝,import/export 模块输出的是值的引用
- require/exports 输出的是值的 浅拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
// 模块A (a.mjs)
let counter = 0;
exports.counter = counter;
exports.increment = function() {
counter++;
}
// 模块B (b.mjs)
const { counter, increment } = require('./a.js');
console.log(counter); // 输出 0
increment();
console.log(counter); // 输出 0
- import/export 模块输出的是值的 引用。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
// 模块A (a.mjs)
export let counter = 0;
export function increment() {
counter++;
}
// 模块B (b.mjs)
import { counter, increment } from './a.mjs';
console.log(counter); // 输出 0
increment();
console.log(counter); // 输出 1
若文件引用的模块值改变,require 引入的模块值不会改变,而 import 引入的模块值会改变
(4)用法不同
- import/export 不能对引入模块重新赋值/定义
- ES6 模块可以在 import 引用语句前使用模块,CommonJS 则需要先引用后使用
- import/export 只能在模块顶层使用,不能在函数、判断语句等代码块之中引用;require/exports 可以。
(5)是否采用严格模式
- import/export 导出的模块默认调用严格模式。
- require/exports 默认不使用严格模式,可以自定义是否使用严格模式
6.4 确定模块系统
以下情况会被视为 ES 模块
- 扩展名为
.mjs
的文件 - 当最近的父
package.json
文件包含值为"module"
的顶层"type"
字段时,扩展名为.js
的文件。 - 字符串作为参数传入
--eval
,或通过STDIN
管道传输到node
,带有标志--input-type=module
。
以下情况会被视为 CommonJS 模块
- 扩展名为
.cjs
的文件。 - 当最近的父
package.json
文件包含值为"commonjs"
的顶层字段"type"
时,则扩展名为.js
的文件。 - 字符串作为参数传入
--eval
或--print
,或通过STDIN
管道传输到node
,带有标志--input-type=commonjs
。 - 默认情况下
本文到这里就结束啦!
参考
[1] JavaScript Modules: A Beginner’s Guide
[2] JavaScript modules
[3] 《JavaScript 高级程序设计(第四版)》
[4] JavaScript modules 模块
[5] import
[6] export