似乎许多开发人员认为 ES6
模块只不过是export
、import
关键字。事实上,它更加多样化。它拥有强大的功能和鲜为人知的问题。在本文中,我们将使用一些示例来了解这些内容。
// index.mjs
import { default } from './module.mjs';
console.log(default);
// module.mjs
export default 'bar';
首先,让我们记住各种导入和导出语法:
如果我们对照上面的import
语法,会看到没有与我们的代码匹配的语法:
import { default } from ‘./module.mjs’;
因为这个语法是禁止的。测试代码抛出以下错误:
SyntaxError: Unexpected reserved word
在import { default } from ‘./module.mjs’;
这张代码中,导出的名字是default
,在这个上下文中的变量名也是default
,但是default
是个保留词,不能这样使用。解决这个问题很容易:
import { default as foo } from ‘./module.mjs’;
现在,导出的名字是default
,上下文中的变量名改成foo
。换句话说,如果我们想为默认导出使用指定的导入语法,则必须重命名它。
// index.js
console.log('index.js');
import { sum } from './helper.js';
console.log(sum(1, 2));
// help.js
console.log('helper.js');
export const sum = (x, y) => x + y;
很少有开发者知道的重要细微之处是import
被提升了。也就是说,当引擎正在解析代码时,它们就会上升。在代码运行之前,将加载所有依赖项。
打印的日志为:
helper.js
index.js
3
如果我们希望在导入声明之前执行一些代码,请考虑将其移到一个单独的文件中:
import './logs.js';
import { sum } from './helper.js';
console.log(sum(1, 2));
// logs.js
console.log('index.js');
打印顺序为:
index.js
helper.js
3
// index.mjs
import './module.mjs';
import { num } from './counter.mjs';
console.log('index num =', num);
// index num = 1;
// module.mjs
import { num } from './counter.mjs';
console.log('module num =', num);
// module num = 1;
// counter.mjs
if (!globalThis.num) {
globalThis.num = 0;
}
export const num = ++globalThis.num;
模块是单例的:
无论我们从同一文件或不同文件导入模块多少次,该模块只能执行和加载一次。换句话说,只有一个模块实例。
// index.mjs
import './module.mjs?param=5;'
// module.mjs
console.log(import.meta.url);
根据MDN :
重要的是。元对象将上下文特定的元数据公开给一个
JavaScript
模块。它包含关于模块的信息。
它返回一个具有URL
属性的对象,该属性指示URL
模块的。这将是获取脚本的URL
,用于外部脚本,或者是包含文档的文档URL
,用于内联脚本。
请注意,这将包括查询参数和/
或#
(即跟着?
或者#
)。
import myCounter from './counter';
myCounter += 1;
console.log(myCounter);
// counter.js
let counter = 5;
export default counter;
大多数开发人员忽略的另一个极其重要部分是导入像常量这样变量的时候,不能直接修改这个变量值(修改的值只在当前文件生效,不会更新到模块中),为了使代码有效,可以导出对象并更改其属性。
// index.mjs
import foo from './module.mjs';
console.log(typeof foo);
// number
// module.mjs
foo = 25;
export default function foo() {}
首先,这个
export default function foo() {}
等于
function foo() {}
export { foo as default }
也等于
function foo() {}
export default foo
但一开始的打印并不是function
而是number
。别惊讶,继续读。我们下面会继续讲。
现在是时候记住了,函数声明会被提升,变量的初始化总是在函数/变量声明之后进行的。
引擎处理完模块代码后,看起来是这样的:
function foo() {}
foo = 25;
export { foo as default }
因此,打印的结果是number
。
// index.mjs
import defaultFoo, { foo } from './module.mjs';
setTimeout(() => {
console.log(foo);
console.log(defaultFoo);
}, 2000);
// module.mjs
let foo = 'bar';
export { foo };
export default foo;
setTimeout(() => {
foo = 'baz';
}, 1000);
在大多数情况下,export
数据是活的。也就是说,如果导出值发生了变化,则此变化会反映在导入变量中。
但对export default
而言并非如此:
export default foo;
当使用此语法时,不是导出变量,而是它的值。我们可以使用以下语法导出默认值:
export default ‘hello’;
export default 42;
如果我们查看示例一的export
语法,会发现export default function () {}
在另一个列(Default export
)而不是export default foo
(Export of values
).
这是因为它们的行为不同,函数仍然作为实时引用传递:
// module.mjs
export { foo };
export default function foo() {};
setTimeout(() => {
foo = 'baz';
}, 1000);
// index.mjs
import defaultFoo, { foo } from './module.mjs';
setTimeout(() => {
console.log(foo); // baz
console.log(defaultFoo); //baz
}, 2000);
让我们再看一下示例一中的export
表。
export { foo as default };
是在Named Export
内,跟之前示例中不同。但对我们来说,唯一重要的是Export of values
部分。因此,这意味着当我们以这种方式导出数据时,它将是对导入值的活绑定。
// index.mjs
import { shouldLoad } from './module1.mjs';
let num = 0;
if (shouldLoad) {
import { num } from './module2.mjs';
}
console.log(num);
// module1.mjs
export const shouldLoad = true;
//module2.mjs
export const num = 1;
import { num } from ‘./module2.mjs’;
这行代码会抛出一个错误,因为import
构造必须在脚本的最高层:
SyntaxError: Unexpected token ‘{‘
这是一个重要的限制,它加上在文件路径中使用变量的限制,使得ES6
模块成为静态的。这意味着我们不需要执行代码来找出所有模块之间的依赖关系,这一点与普通模块不同。
在这个例子中,使用common.js
模块,找出哪个模块 a
或b
将加载,需要运行以下代码:
let result;
if (foo()) {
result = require('a');
} else {
result = require('b');
}
模块的静态特性有很多好处,其中一些是:
在ES6
中,如果需要有条件地加载模块,可以使用import()
功能式结构。
// index.mjs
import { double, square } from './module.mjs';
export function calculate(value) {
return value % 2 ? square(value) : double(value);
}
// module.mjs
import { calculate } from './index.mjs';
export function double(num) {
return num * 2;
}
export function square(num) {
return num * num;
}
console.log(calculate(3));
在上面的代码中,我们可以看到循环依赖关系:index.mjs
引入 module.mjs
模块的double
和square
方法 ,而module.mjs
又引入了index.mjs
模块的calculation
方法。
这个代码之所以有效,是因为ES6
模块本质上支持循环依赖关系。例如,如果我们使用cjs
来改写这个代码它将不再工作:
// index.js
const helpers = require('./module.js');
function calculate(value) {
return value % 2 ? helpers.square(value) : helpers.double(value);
}
module.exports = {
calculate
}
// module.js
const actions = require('./index.js');
function double(num) {
return num * 2;
}
function square(num) {
return num * num;
}
console.log(actions.calculate(3));
// TypeError: actions.calculate is not a function
module.exports = {
double,
square
}
这是nodejs
中的常见问题,让我们看看这个代码是如何工作的:
index.js
module.js
时中断const helpers = require(‘./module.js’);
module.js
console.log(actions.calculate(3));
这行代码引发错误是因为actions.calculate
没有定义。这是因为JS
同步加载模块。index.js
它的导出对象目前是空的。如果延迟调用导入函数,则index.js
模块将有时间加载:
// module.js
const actions = require('./index.js');
function double(num) {
return num * 2;
}
function square(num) {
return num * num;
}
function someFunctionToCallLater() {
console.log(actions.calculate(3)); // Works
}
module.exports = {
double,
square
}
正如我们在上一示例中所知道的,es6
模块支持循环依赖关系,因为它们是静态的–模块的依赖关系在代码执行之前就被加载了。
另一个使上述代码工作的东西是函数提升。当calculate
函数被调用时,我们还没有到calculate
函数定义的那一行:
以下是在模块打包后代码的样子:
function double(num) {
return num * 2;
}
function square(num) {
return num * num;
}
console.log(calculate(3));
function calculate(value) {
return value % 2 ? square(value) : double(value);
}
如果没有函数提升这段代码就不能正常工作。
如果我们将函数声明改为函数表达式:
export let calculate = function(value) {
return value % 2 ? square(value) : double(value);
}
它会造成以下错误:
ReferenceError: Cannot access ‘calculate’ before initialization
// index.mjs
import { num } from './module.mjs';
console.log(num);
export let num = 0;
num = await new Promise((resolve) => {
setTimeout(() => resolve(1), 1000);
});
Top-level await
这是一个非常有用的功能,许多开发人员不知道,也许是因为它是最近在ECMASKIPT2022
引入的。
根据 tc39 top-level await proposal :
Top-level await
使得模块可以在异步函数中发挥非常大的作用,在Top-level await
的情况下,ECMAScript
模块(esm
)可以等待资源,导致其他导入模块的部分需要等待完成后才能进行解析。
模块的标准行为是:在它导入的所有模块都被加载并执行它们的代码之前,模块中的代码不会被执行(参考示例二)。事实上,Top-level await
也没有改变这个行为。模块中的代码在导入模块中的所有代码被执行之前才会执行,只是现在包括等待模块中所有期待的promise
被解决。
console.log('index.js');
import { num } from './module.js';
console.log('num = ', num);
export let num = 5;
console.log('module.js');
await new Promise((resolve) => {
setTimeout(() => {
console.log('module.js: promise 1');
num = 10;
resolve();
}, 1000);
});
await new Promise((resolve) => {
setTimeout(() => {
console.log('module.js: promise 2');
num = 20;
resolve();
}, 2000);
});
打印:
module.js
module.js: promise 1
module.js: promise 2
index.js
num = 20
如果我们在module.js
中删除第5和第13行,并在index.js
文件中添加timeout
,像这样:
console.log('index.js');
import { num } from './module.js';
console.log('num = ', num);
setTimeout(() => {
console.log('timeout num = ', num);
}, 1000);
setTimeout(() => {
console.log('timeout num = ', num);
}, 2000);
打印:
module.js
index.js
num = 5
module.js: promise 1
timeout num = 10
module.js: promise 2
timeout num = 20
import { shouldLoad } from './module1.mjs';
let num = 0;
if (shouldLoad) {
({ num } = import('./module2.mjs'));
}
console.log(num);
// module1.mjs
export const shouldLoad = true;
//module2.mjs
export const num = 1;
根据MDN :
调用
import()
,通常称为动态导入,是一个类似函数的表达式。允许异步和动态加载一个ECMAScript
模块。它允许规避导入声明的语法刚度,并有条件地或根据需要加载模块。
这一功能是在ES2020
中引入的。
import(module)
返回实现包含模块所有导出的对象的一个Promise
。
在import
调用之前添加await
关键词
if (shouldLoad) {
({ num } = await import('./module2.mjs'));
}
在这里我们再次使用Top-level await
。
在尝试从全局范围调用异步函数时经常会发生下面这种情况:
SyntaxError: await is only valid in async functions
为了解决这个问题,我们可以使用:
(async () => {
await [someAsyncFunc]();
})();
这段代码看起来就丑,而且在异步中使用此模式加载模块时可能会导致错误。例如:
// module1.mjs
let num;
(async () => {
({ num } = await import(‘./module2.mjs’));
})();
export { num };
// module2.mjs
export const num = 5;
当导入module1.mjs
模块时num
的值是什么,是来自module2
还是undefined
?这将取决于变量何时被访问:
import { num } from './module1.mjs';
console.log(num); // undefined
setTimeout(() => console.log(num), 100); // 5
当我们导入一个具有Top-level await
的模块时(module1
),num
的值永远都不会是undefined
:
let { num } = await import('./module2.mjs');
export { num };
import { num } from './module1.mjs';
console.log(num); // 5
const module1 = await import('./module1.mjs');
const module2 = await import('./module2.mjs');
console.log(module1, module2);
function multiply(num1, num2) { return num1 * num2; }
console.log(multiply(module1, module2));
// module1.mjs
export default await new Promise((resolve) => resolve(1));
// module2.mjs
export default await new Promise((resolve) => resolve(2));
上面的代码会造成一个错误:
TypeError: Cannot convert object to primitive value
让我们找出这个错误的来源。
在这段代码中,我们使用了一个动态导入,我们在前面的示例中已经遇到了它。为了理解代码中的问题,我们需要更仔细地研究import()
。
变量module1
和module2
不是我们期望那样的值。import()
返回一个具备和命名导入相同字段的已解决的promise
:
import * as name from moduleName
default
导出一个键名为default
的对象。
所以返回值不是1 和2,而是{ default: 1 }
和{ default: 2 }
。
为什么我们在用两个对象相乘时会有这样一个奇怪的错误,而不是NaN
这是因为返回的对象有一个null
原型。因此,它没有toString()
方法(用于将对象转换为字符)。如果这个对象有Object
原型,我们才会看到NaN
。
为了修正测试代码,我们需要做以下修改:
console.log(multiply(module1.default, module2.default));
或者
const { default: module1 } = await import('./module1.mjs');
const { default: module2 } = await import('./module2.mjs');
// index.js
import * as values from './intermediate.js';
console.log(values.x);
// module1.js
export const x = 1;
// module2.js
export const x = 2;
// intermediate.js
export * from './module1.js';
export * from './module2.js';
export * from ‘module’
会重新导出所有从module
模块导出name exports
并作为当前文件的name exports
,如果有重名的情况,那么都不是重导出。
所以运行这个代码在控制台里会打印undefined
。
另外,如果在同样的情况下引入x
,如预期的那样,我们会有一个错误:
import { x } from ‘./intermediate.js’;
SyntaxError: The requested module ‘./intermediate.js’ contains conflicting star exports for name ‘x’