16. 模块
16.1. 概述
16.1.1. 多个命名导出
16.1.2. 单个默认导出
16.1.3. 浏览器:脚本与模块
16.2. JavaScript 中的模块
16.2.1. ECMAScript 5 模块系统
16.2.2. ECMAScript 6 模块
16.3. ES6 模块基础
16.3.1. 命名导出(每个模块多个)
16.3.2. 默认导出(每个模块一个)
16.3.3. 导入和导出必须位于顶层
16.3.4. 导入会被提升
16.3.5. 导入是对导出的只读视图
16.3.6. 支持循环依赖
16.4. 导入和导出的详细说明
16.4.1. 导入样式
16.4.2. 命名导出样式:内联与子句
16.4.3. 重新导出
16.4.4. 所有导出样式
16.4.5. 在模块中同时具有命名导出和默认导出
16.5. ECMAScript 6 模块加载器 API
16.5.1. 加载器
16.5.2. 加载器方法:导入模块
16.5.3. 更多加载器方法
16.5.4. 配置模块加载
16.6. 在浏览器中使用 ES6 模块
16.7. 详细信息:导入作为导出的视图
16.7.1. 在 CommonJS 中,导入是导出值的副本
16.7.2. 在 ES6 中,导入是对导出值的实时只读视图
16.7.3. 实现视图
16.7.4. 规范中的导入作为视图
16.8. ES6 模块的设计目标
16.8.1. 默认导出优先
16.8.2. 静态模块结构
16.8.3. 支持同步和异步加载
16.8.4. 支持模块之间的循环依赖
16.9. 常见问题解答:模块
16.9.1. 我可以使用变量来指定要从中导入的模块吗?
16.9.2. 我可以有条件地或按需导入模块吗?
16.9.3. 我可以在 import
语句中使用变量吗?
16.9.4. 我可以在 import
语句中使用解构吗?
16.9.5. 是否需要命名导出?为什么不默认导出对象?
16.9.6. 我可以 eval()
模块的代码吗?
16.10. ECMAScript 6 模块的优势
16.11. 延伸阅读
16.1 概述
JavaScript 长期以来一直都有模块。但是,它们是通过库实现的,而不是内置于语言中的。ES6 是 JavaScript 第一次拥有内置模块。
ES6 模块存储在文件中。每个文件只有一个模块,每个模块只有一个文件。您可以通过两种方式从模块中导出内容。这两种方式可以混合使用 ,但通常最好单独使用它们。
16.1.1 多个命名导出
可以有多个命名导出
//------ lib.js ------
export
const
sqrt
=
Math
.
sqrt
;
export
function
square
(
x
)
{
return
x
*
x
;
}
export
function
diag
(
x
,
y
)
{
return
sqrt
(
square
(
x
)
+
square
(
y
));
}
//------ main.js ------
import
{
square
,
diag
}
from
'lib'
;
console
.
log
(
square
(
11
));
// 121
console
.
log
(
diag
(
4
,
3
));
// 5
您也可以导入整个模块
//------ main.js ------
import
*
as
lib
from
'lib'
;
console
.
log
(
lib
.
square
(
11
));
// 121
console
.
log
(
lib
.
diag
(
4
,
3
));
// 5
16.1.2 单个默认导出
可以有一个默认导出 。例如,一个函数
//------ myFunc.js ------
export
default
function
()
{
···
}
// no semicolon!
//------ main1.js ------
import
myFunc
from
'myFunc'
;
myFunc
();
或者一个类
//------ MyClass.js ------
export
default
class
{
···
}
// no semicolon!
//------ main2.js ------
import
MyClass
from
'MyClass'
;
const
inst
=
new
MyClass
();
请注意,如果默认导出函数或类(它们是匿名声明),则末尾没有分号。
16.1.3 浏览器:脚本与模块
脚本
模块
HTML 元素
<script>
<script type="module">
默认模式
非严格模式
严格模式
顶层变量是
全局的
模块本地的
顶层 this
的值
window
undefined
执行方式
同步
异步
声明式导入 (import
语句)
否
是
程序化导入(基于 Promise 的 API)
是
是
文件扩展名
.js
.js
16.2 JavaScript 中的模块
尽管 JavaScript 从未内置模块,但社区已经融合了一种简单的模块样式,ES5 及更早版本中的库都支持这种样式。ES6 也采用了这种样式
每个模块都是一段代码,一旦加载就会执行。
在该代码中,可能存在声明(变量声明、函数声明等)。
默认情况下,这些声明对模块保持本地化。
您可以将其中一些标记为导出,然后其他模块可以导入它们。
模块可以从其他模块导入内容。它通过模块说明符 来引用这些模块,这些说明符是字符串,可以是
相对路径 ('../model/user'
):这些路径是相对于导入模块的位置进行解释的。文件扩展名 .js
通常可以省略。
绝对路径 ('/lib/js/helpers'
):直接指向要导入的模块的文件。
名称 ('util'
):模块名称所指的内容必须进行配置。
模块是单例的。即使一个模块被多次导入,也只存在它的一个“实例”。
这种模块化方法避免了全局变量,唯一全局的是模块说明符。
16.2.1 ECMAScript 5 模块系统
令人印象深刻的是,ES5 模块系统在没有语言的明确支持的情况下也能很好地工作。两个最重要(不幸的是不兼容)的标准是
CommonJS 模块: 该标准的主要实现是在 Node.js 中 (Node.js 模块具有一些超出 CommonJS 的功能)。特点
异步模块定义 (AMD): 该标准最流行的实现是RequireJS 。特点
语法稍微复杂一些,使 AMD 能够在没有 eval()(或编译步骤)的情况下工作
专为异步加载和浏览器而设计
以上只是对 ES5 模块的简化说明。如果您想了解更多深入的资料,请参阅 Addy Osmani 的“使用 AMD、CommonJS 和 ES Harmony 编写模块化 JavaScript ”。
16.2.2 ECMAScript 6 模块
ECMAScript 6 模块的目标是创建一种格式,使 CommonJS 和 AMD 的用户都满意
与 CommonJS 类似,它们具有简洁的语法、对单个导出的偏好以及对循环依赖的支持。
与 AMD 类似,它们直接支持异步加载和可配置的模块加载。
内置于语言中使 ES6 模块能够超越 CommonJS 和 AMD(详细信息将在后面解释)
它们的语法比 CommonJS 更简洁。
它们的结构可以静态分析(用于静态检查、优化等)。
它们对循环依赖的支持比 CommonJS 更好。
ES6 模块标准有两部分
声明式语法(用于导入和导出)
程序化加载器 API:用于配置模块的加载方式以及有条件地加载模块
16.3 ES6 模块基础
有两种导出:命名导出(每个模块多个)和默认导出(每个模块一个)。正如后面解释的那样 ,可以同时使用两者,但通常最好将它们分开。
16.3.1 命名导出(每个模块多个)
模块可以通过在其声明前加上关键字 export
来导出多个内容。这些导出通过其名称进行区分,称为命名导出 。
//------ lib.js ------
export
const
sqrt
=
Math
.
sqrt
;
export
function
square
(
x
)
{
return
x
*
x
;
}
export
function
diag
(
x
,
y
)
{
return
sqrt
(
square
(
x
)
+
square
(
y
));
}
//------ main.js ------
import
{
square
,
diag
}
from
'lib'
;
console
.
log
(
square
(
11
));
// 121
console
.
log
(
diag
(
4
,
3
));
// 5
还有其他方法可以指定命名导出(将在后面解释),但我发现这种方法非常方便:只需编写代码,就好像没有外部世界一样,然后用关键字标记要导出的所有内容。
如果您愿意,您也可以导入整个模块,并通过属性表示法引用其命名导出
//------ main.js ------
import
*
as
lib
from
'lib'
;
console
.
log
(
lib
.
square
(
11
));
// 121
console
.
log
(
lib
.
diag
(
4
,
3
));
// 5
CommonJS 语法中的相同代码: 有一段时间,我尝试了几种聪明的策略,以便在 Node.js 中减少模块导出的冗余。现在我更喜欢以下简单但稍微冗长的风格,它让人想起揭示模块模式
//------ lib.js ------
var
sqrt
=
Math
.
sqrt
;
function
square
(
x
)
{
return
x
*
x
;
}
function
diag
(
x
,
y
)
{
return
sqrt
(
square
(
x
)
+
square
(
y
));
}
module
.
exports
=
{
sqrt
:
sqrt
,
square
:
square
,
diag
:
diag
,
};
//------ main.js ------
var
square
=
require
(
'lib'
).
square
;
var
diag
=
require
(
'lib'
).
diag
;
console
.
log
(
square
(
11
));
// 121
console
.
log
(
diag
(
4
,
3
));
// 5
16.3.2 默认导出(每个模块一个)
只导出单个值的模块在 Node.js 社区中非常流行。但在前端开发中它们也很常见,在前端开发中,您通常拥有模型和组件的类,每个模块一个类。ES6 模块可以选择一个默认导出 ,即主要的导出值。默认导出特别容易导入。
以下 ECMAScript 6 模块“是”一个单函数
//------ myFunc.js ------
export
default
function
()
{}
// no semicolon!
//------ main1.js ------
import
myFunc
from
'myFunc'
;
myFunc
();
默认导出为类的 ECMAScript 6 模块如下所示
//------ MyClass.js ------
export
default
class
{}
// no semicolon!
//------ main2.js ------
import
MyClass
from
'MyClass'
;
const
inst
=
new
MyClass
();
默认导出有两种样式
标记声明
直接默认导出值
16.3.2.1 默认导出样式 1:标记声明
您可以在任何函数声明(或生成器函数声明)或类声明前加上关键字 export default
,使其成为默认导出
export
default
function
foo
()
{}
// no semicolon!
export
default
class
Bar
{}
// no semicolon!
在这种情况下,您也可以省略名称。这使得默认导出成为 JavaScript 中唯一具有匿名函数声明和匿名类声明的地方
export
default
function
()
{}
// no semicolon!
export
default
class
{}
// no semicolon!
16.3.2.1.1 为什么是匿名函数声明而不是匿名函数表达式?
当您查看前两行代码时,您会期望 export default
的操作数是表达式。它们只是出于一致性原因的声明:操作数可以是命名声明,将它们的匿名版本解释为表达式会令人困惑(甚至比引入新类型的声明更令人困惑)。
如果您希望将操作数解释为表达式,则需要使用括号
export
default
(
function
()
{});
export
default
(
class
{});
16.3.2.2 默认导出样式 2:直接默认导出值
这些值是通过表达式生成的
export
default
'abc'
;
export
default
foo
();
export
default
/^xyz$/
;
export
default
5
*
7
;
export
default
{
no
:
false
,
yes
:
true
};
每个默认导出都具有以下结构。
export
default
«
expression
»
;
这相当于
const
__default__
=
«
expression
»
;
export
{
__default__
as
default
};
// (A)
A 行中的语句是一个导出子句 (在后面的章节 中解释)。
16.3.2.2.1 为什么有两种默认导出样式?
引入第二种默认导出样式是因为如果变量声明声明了多个变量,则无法有意义地将它们转换为默认导出
export
default
const
foo
=
1
,
bar
=
2
,
baz
=
3
;
// not legal JavaScript!
三个变量 foo
、bar
和 baz
中的哪一个是默认导出?
16.3.3 导入和导出必须位于顶层
正如后面将更详细地解释的那样,ES6 模块的结构是静态的 ,您不能有条件地导入或导出内容。这带来各种好处。
此限制是通过语法强制执行的,仅允许在模块的顶层进行导入和导出
if
(
Math
.
random
())
{
import
'foo'
;
// SyntaxError
}
// You can’t even nest `import` and `export`
// inside a simple block:
{
import
'foo'
;
// SyntaxError
}
16.3.4 导入会被提升
模块导入会被提升(在内部移动到当前作用域的开头)。因此,你在模块中提及它们的位置并不重要,以下代码可以正常工作
foo
();
import
{
foo
}
from
'my_module'
;
16.3.5 导入是对导出的只读视图
ES6 模块的导入是对导出实体的只读视图。这意味着到模块主体内部声明的变量的连接保持活动状态,如以下代码所示。
//------ lib.js ------
export
let
counter
=
3
;
export
function
incCounter
()
{
counter
++
;
}
//------ main.js ------
import
{
counter
,
incCounter
}
from
'./lib'
;
// The imported value `counter` is live
console
.
log
(
counter
);
// 3
incCounter
();
console
.
log
(
counter
);
// 4
其工作原理将在 后面的章节 中解释。
导入作为视图具有以下优点
它们支持循环依赖,即使是非限定导入(如下一节所述)。
限定导入和非限定导入的工作方式相同(它们都是间接寻址)。
你可以将代码拆分为多个模块,它将继续工作(只要你不尝试更改导入的值)。
16.3.6 支持循环依赖
如果模块 A(可能间接/传递地)导入 B 并且 B 导入 A,则两个模块 A 和 B 循环依赖 。如果可能,应避免循环依赖,因为它们会导致 A 和 B 紧密耦合——它们只能一起使用和演进。
那么,为什么要支持循环依赖呢?有时,你无法绕过它们,这就是为什么支持它们是一项重要功能的原因。 后面的章节 有更多信息。
让我们看看 CommonJS 和 ECMAScript 6 如何处理循环依赖。
16.3.6.1 CommonJS 中的循环依赖
以下 CommonJS 代码正确处理了两个模块 a
和 b
之间的循环依赖。
//------ a.js ------
var
b
=
require
(
'b'
);
function
foo
()
{
b
.
bar
();
}
exports
.
foo
=
foo
;
//------ b.js ------
var
a
=
require
(
'a'
);
// (i)
function
bar
()
{
if
(
Math
.
random
())
{
a
.
foo
();
// (ii)
}
}
exports
.
bar
=
bar
;
如果首先导入模块 a
,则在第 i 行,模块 b
在将导出添加到 a
的导出对象之前获取它。因此,b
无法在其顶层访问 a.foo
,但在 a
的执行完成后,该属性存在。如果之后调用 bar()
,则第 ii 行中的方法调用有效。
作为一般规则,请记住,对于循环依赖,你无法在模块主体中访问导入。这是现象固有的,并且不会随着 ECMAScript 6 模块而改变。
CommonJS 方法的局限性是
这些限制意味着导出器和导入器都必须意识到循环依赖并明确支持它们。
16.3.6.2 ECMAScript 6 中的循环依赖
ES6 模块自动支持循环依赖。也就是说,它们没有上一节中提到的 CommonJS 模块的两个限制:默认导出有效,非限定命名导入也一样(以下示例中的第 i 行和第 iii 行)。因此,你可以实现如下所示的循环依赖的模块。
//------ a.js ------
import
{
bar
}
from
'b'
;
// (i)
export
function
foo
()
{
bar
();
// (ii)
}
//------ b.js ------
import
{
foo
}
from
'a'
;
// (iii)
export
function
bar
()
{
if
(
Math
.
random
())
{
foo
();
// (iv)
}
}
这段代码有效,因为如前一节所述,导入是对导出的视图。这意味着即使是非限定导入(例如第 ii 行中的 bar
和第 iv 行中的 foo
)也是引用原始数据的间接寻址。因此,面对循环依赖,你是通过非限定导入还是通过其模块访问命名导出并不重要:两种情况下都涉及间接寻址,并且它始终有效。
16.4 详细介绍导入和导出
16.4.1 导入样式
ECMAScript 6 提供了几种导入样式2
默认导入
import
localName
from
'src/my_lib'
;
命名空间导入:将模块作为对象导入(每个命名导出一个属性)。
import
*
as
my_lib
from
'src/my_lib'
;
命名导入
import
{
name1
,
name2
}
from
'src/my_lib'
;
你可以重命名命名导入
// Renaming: import `name1` as `localName1`
import
{
name1
as
localName1
,
name2
}
from
'src/my_lib'
;
// Renaming: import the default export as `foo`
import
{
default
as
foo
}
from
'src/my_lib'
;
空导入:仅加载模块,不导入任何内容。程序中的第一个此类导入会执行模块的主体。
只有两种方法可以组合这些样式,并且它们出现的顺序是固定的;默认导出始终排在第一位。
将默认导入与命名空间导入相结合
import
theDefault
,
*
as
my_lib
from
'src/my_lib'
;
将默认导入与命名导入相结合
import
theDefault
,
{
name1
,
name2
}
from
'src/my_lib'
;
16.4.2 命名导出样式:内联与子句
在模块内部导出命名内容有 两种方法 。
一方面,你可以使用关键字 export
标记声明。
export
var
myVar1
=
···
;
export
let
myVar2
=
···
;
export
const
MY_CONST
=
···
;
export
function
myFunc
()
{
···
}
export
function
*
myGeneratorFunc
()
{
···
}
export
class
MyClass
{
···
}
另一方面,你可以在模块的末尾列出你想要导出的所有内容(这在风格上类似于揭示模块模式)。
const
MY_CONST
=
···
;
function
myFunc
()
{
···
}
export
{
MY_CONST
,
myFunc
};
你也可以用不同的名称导出内容
export
{
MY_CONST
as
FOO
,
myFunc
};
16.4.3 重新导出
重新导出意味着将另一个模块的导出添加到当前模块的导出中。你可以添加其他模块的所有导出
export
*
from
'src/other_module'
;
export *
会忽略默认导出3 。
或者你可以更有选择性(可以选择重命名)
export
{
foo
,
bar
}
from
'src/other_module'
;
// Renaming: export other_module’s foo as myFoo
export
{
foo
as
myFoo
,
bar
}
from
'src/other_module'
;
16.4.3.1 将重新导出设为默认导出
以下语句将另一个模块 foo
的默认导出设为当前模块的默认导出
export
{
default
}
from
'foo'
;
以下语句将模块 foo
的命名导出 myFunc
设为当前模块的默认导出
export
{
myFunc
as
default
}
from
'foo'
;
16.4.4 所有导出样式
ECMAScript 6 提供了几种导出样式4
重新导出
重新导出所有内容(默认导出除外)
export
*
from
'src/other_module'
;
通过子句重新导出
export
{
foo
as
myFoo
,
bar
}
from
'src/other_module'
;
export
{
default
}
from
'src/other_module'
;
export
{
default
as
foo
}
from
'src/other_module'
;
export
{
foo
as
default
}
from
'src/other_module'
;
通过子句进行命名导出
export
{
MY_CONST
as
FOO
,
myFunc
};
export
{
foo
as
default
};
内联命名导出
变量声明
export
var
foo
;
export
let
foo
;
export
const
foo
;
函数声明
export
function
myFunc
()
{}
export
function
*
myGenFunc
()
{}
类声明
默认导出
函数声明(此处可以是匿名的)
export
default
function
myFunc
()
{}
export
default
function
()
{}
export
default
function
*
myGenFunc
()
{}
export
default
function
*
()
{}
类声明(此处可以是匿名的)
export
default
class
MyClass
{}
export
default
class
{}
表达式:导出值。注意末尾的分号。
export
default
foo
;
export
default
'Hello world!'
;
export
default
3
*
7
;
export
default
(
function
()
{});
16.4.5 在模块中同时具有命名导出和默认导出
以下模式在 JavaScript 中非常常见:库是一个单一函数,但通过该函数的属性提供其他服务。例如 jQuery 和 Underscore.js。以下是 Underscore 作为 CommonJS 模块的草图
//------ underscore.js ------
var
_
=
function
(
obj
)
{
···
};
var
each
=
_
.
each
=
_
.
forEach
=
function
(
obj
,
iterator
,
context
)
{
···
};
module
.
exports
=
_
;
//------ main.js ------
var
_
=
require
(
'underscore'
);
var
each
=
_
.
each
;
···
使用 ES6 的视角,函数 _
是默认导出,而 each
和 forEach
是命名导出。事实证明,你实际上可以同时拥有命名导出和默认导出。例如,之前的 CommonJS 模块,重写为 ES6 模块,如下所示
//------ underscore.js ------
export
default
function
(
obj
)
{
···
}
export
function
each
(
obj
,
iterator
,
context
)
{
···
}
export
{
each
as
forEach
};
//------ main.js ------
import
_
,
{
each
}
from
'underscore'
;
···
请注意,CommonJS 版本和 ECMAScript 6 版本只是大致相似。后者具有扁平结构,而前者是嵌套的。
16.4.5.1 建议:避免混合默认导出和命名导出
我通常建议将两种导出方式分开:每个模块,要么只有默认导出,要么只有命名导出。
然而,这不是一个非常强烈的建议;偶尔混合使用这两种方法可能是有意义的。一个例子是默认导出一个实体的模块。对于单元测试,可以通过命名导出额外提供一些内部结构。
16.4.5.2 默认导出只是另一个命名导出
默认导出实际上只是一个名为 default
的特殊命名导出。也就是说,以下两条语句是等效的
import
{
default
as
foo
}
from
'lib'
;
import
foo
from
'lib'
;
类似地,以下两个模块具有相同的默认导出
//------ module1.js ------
export
default
function
foo
()
{}
// function declaration!
//------ module2.js ------
function
foo
()
{}
export
{
foo
as
default
};
16.4.5.3 default
:可以用作导出名称,但不能用作变量名称
你不能使用保留字(例如 default
和 new
)作为变量名称,但你可以使用它们作为导出的名称(你也可以在 ECMAScript 5 中使用它们作为属性名称)。如果你想直接导入此类命名导出,则必须将它们重命名为正确的变量名称。
这意味着 default
只能出现在重命名导入的左侧
import
{
default
as
foo
}
from
'some_module'
;
并且它只能出现在重命名导出的右侧
export
{
foo
as
default
};
在重新导出中,as
的两侧都是导出名称
export
{
myFunc
as
default
}
from
'foo'
;
export
{
default
as
otherFunc
}
from
'foo'
;
// The following two statements are equivalent:
export
{
default
}
from
'foo'
;
export
{
default
as
default
}
from
'foo'
;
16.5 ECMAScript 6 模块加载器 API
除了用于处理模块的声明式语法外,还有一个编程 API。它允许你
16.5.1 加载器
加载器处理解析模块说明符(import-from
末尾的字符串 ID)、加载模块等。它们的构造函数是 Reflect.Loader
。每个平台都在全局变量 System
(系统加载器)中保留一个默认实例,该实例实现其特定的模块加载方式。
16.5.2 加载器方法:导入模块
你可以通过基于 Promises 的 API 以编程方式导入模块
System
.
import
(
'some_module'
)
.
then
(
some_module
=>
{
// Use some_module
})
.
catch
(
error
=>
{
···
});
System.import()
使你能够
在 <script>
元素中使用模块(不支持模块语法,有关详细信息,请参阅 关于模块与脚本的部分 )。
有条件地加载模块。
System.import()
检索单个模块,你可以使用 Promise.all()
导入多个模块
Promise
.
all
(
[
'module1'
,
'module2'
,
'module3'
]
.
map
(
x
=>
System
.
import
(
x
)))
.
then
(([
module1
,
module2
,
module3
])
=>
{
// Use module1, module2, module3
});
16.5.3 更多加载器方法
加载器有更多的方法。三个重要的是
System.module(source, options?)
将 source
中的 JavaScript 代码评估为模块(通过 Promise 异步传递)。
System.set(name, module)
用于注册模块(例如,你通过 System.module()
创建的模块)。
System.define(name, source, options?)
同时评估 source
中的模块代码并注册结果。
16.5.4 配置模块加载
模块加载器 API 将具有用于配置加载过程的各种钩子。用例包括
导入时对模块进行代码检查(例如,通过 JSLint 或 JSHint)。
导入时自动翻译模块(它们可以包含 CoffeeScript 或 TypeScript 代码)。
使用旧版模块(AMD、Node.js)。
可配置模块加载是 Node.js 和 CommonJS 受限的领域。
16.6 在浏览器中使用 ES6 模块
让我们看看浏览器如何支持 ES6 模块。
16.6.1 浏览器:异步模块与同步脚本
在浏览器中,有两种不同的实体:脚本和模块。它们的语法略有不同,工作方式也不同。
以下是差异概述,稍后将详细解释
脚本
模块
HTML 元素
<script>
<script type="module">
默认模式
非严格模式
严格模式
顶层变量是
全局的
模块本地的
顶层 this
的值
window
undefined
执行方式
同步
异步
声明式导入 (import
语句)
否
是
程序化导入(基于 Promise 的 API)
是
是
文件扩展名
.js
.js
16.6.1.1 脚本
脚本是嵌入 JavaScript 和引用外部 JavaScript 文件的传统浏览器方式。脚本具有 互联网媒体类型 ,用作
通过 Web 服务器传送的 JavaScript 文件的内容类型。
<script>
元素的 type
属性的值。请注意,对于 HTML5,建议如果 <script>
元素包含或引用 JavaScript,则省略 type
属性。
以下是最重要的值
text/javascript
:是一个遗留值,如果您在脚本标记中省略 type
属性,则用作默认值。对于 Internet Explorer 8 及更早版本,它是 最安全的选择 。
application/javascript
:建议 用于当前浏览器。
脚本通常是同步加载或执行的。JavaScript 线程程在代码加载或执行完毕之前会停止。
16.6.1.2 模块
为了与 JavaScript 通常的运行到完成语义保持一致,模块的主体必须在不中断的情况下执行。这为导入模块留下了两种选择
在执行主体时同步加载模块。这就是 Node.js 的做法。
在执行主体之前异步加载所有模块。这就是 AMD 模块的处理方式。它是浏览器的最佳选择,因为模块是通过互联网加载的,并且在加载模块时执行不必暂停。另一个好处是,这种方法允许并行加载多个模块。
ECMAScript 6 为您提供了两全其美的优势:Node.js 的同步语法加上 AMD 的异步加载。为了使两者成为可能,ES6 模块在语法上不如 Node.js 模块灵活:导入和导出必须发生在顶层。这意味着它们也不能是有条件的。此限制允许 ES6 模块加载器静态分析模块导入了哪些模块,并在执行其主体之前加载它们。
脚本的同步特性阻止它们成为模块。脚本甚至不能声明性地导入模块(如果您想这样做,则必须使用编程模块加载器 API)。
可以通过完全异步的 <script>
元素的新变体从浏览器使用模块
<
script
type
=
"module"
>
import
$
from
'lib/jquery'
;
var
x
=
123
;
// The current scope is not global
console
.
log
(
'$'
in
window
);
// false
console
.
log
(
'x'
in
window
);
// false
// `this` is undefined
console
.
log
(
this
===
undefined
);
// true
</
script
>
如您所见,该元素有自己的作用域,并且“内部”的变量是该作用域的局部变量。请注意,模块代码隐式处于严格模式。这是个好消息——不再需要 'use strict'
。
与普通的 <script>
元素类似,<script type="module">
也可以用于加载外部模块。例如,以下标记通过 main
模块启动 Web 应用程序(属性名 import
是我的发明,目前尚不清楚将使用什么名称)。
<
script
type
=
"module"
import
=
"impl/main"
></
script
>
通过自定义 <script>
类型在 HTML 中支持模块的优势在于,可以通过 polyfill(库)轻松地将该支持引入旧引擎。最终可能会也可能不会有一个专用于模块的元素(例如 <module>
)。
16.6.1.3 模块或脚本——上下文问题
文件是模块还是脚本仅取决于其导入或加载方式。大多数模块都有导入或导出,因此可以检测到。但是,如果模块两者都没有,则它与脚本没有区别。例如
这段代码的语义取决于它是被解释为模块还是脚本而有所不同
作为模块,变量 x
在模块作用域中创建。
作为脚本,变量 x
变为全局变量和全局对象(浏览器中的 window
)的属性。
更现实的例子是一个安装某些东西的模块,例如全局变量中的 polyfill 或全局事件侦听器。这样的模块既不导入也不导出任何东西,并且通过空导入激活
16.7 详细信息:导入作为导出的视图
导入在 CommonJS 和 ES6 中的工作方式不同
在 CommonJS 中,导入是导出值的副本。
在 ES6 中,导入是对导出值的实时只读视图。
以下部分解释了这意味着什么。
16.7.1 在 CommonJS 中,导入是导出值的副本
使用 CommonJS (Node.js) 模块,事情以相对熟悉的方式工作。
如果将值导入变量,则该值将被复制两次:一次是在导出时(A 行),一次是在导入时(B 行)。
//------ lib.js ------
var
counter
=
3
;
function
incCounter
()
{
counter
++
;
}
module
.
exports
=
{
counter
:
counter
,
// (A)
incCounter
:
incCounter
,
};
//------ main1.js ------
var
counter
=
require
(
'./lib'
).
counter
;
// (B)
var
incCounter
=
require
(
'./lib'
).
incCounter
;
// The imported value is a (disconnected) copy of a copy
console
.
log
(
counter
);
// 3
incCounter
();
console
.
log
(
counter
);
// 3
// The imported value can be changed
counter
++
;
console
.
log
(
counter
);
// 4
如果通过 exports 对象访问该值,则它在导出时仍会被复制一次
//------ main2.js ------
var
lib
=
require
(
'./lib'
);
// The imported value is a (disconnected) copy
console
.
log
(
lib
.
counter
);
// 3
lib
.
incCounter
();
console
.
log
(
lib
.
counter
);
// 3
// The imported value can be changed
lib
.
counter
++
;
console
.
log
(
lib
.
counter
);
// 4
16.7.2 在 ES6 中,导入是对导出值的实时只读视图
与 CommonJS 相反,导入是对导出值的视图。换句话说,每个导入都是与导出数据的实时连接。导入是只读的
非限定导入 (import x from 'foo'
) 类似于 const
声明的变量。
模块对象 foo
(import * as foo from 'foo'
) 的属性类似于 冻结对象 的属性。
以下代码演示了导入如何像视图一样
//------ lib.js ------
export
let
counter
=
3
;
export
function
incCounter
()
{
counter
++
;
}
//------ main1.js ------
import
{
counter
,
incCounter
}
from
'./lib'
;
// The imported value `counter` is live
console
.
log
(
counter
);
// 3
incCounter
();
console
.
log
(
counter
);
// 4
// The imported value can’t be changed
counter
++
;
// TypeError
如果通过星号 (*
) 导入模块对象,则会得到相同的结果
//------ main2.js ------
import
*
as
lib
from
'./lib'
;
// The imported value `counter` is live
console
.
log
(
lib
.
counter
);
// 3
lib
.
incCounter
();
console
.
log
(
lib
.
counter
);
// 4
// The imported value can’t be changed
lib
.
counter
++
;
// TypeError
请注意,虽然您无法更改导入的值,但可以更改它们引用的对象。例如
//------ lib.js ------
export
let
obj
=
{};
//------ main.js ------
import
{
obj
}
from
'./lib'
;
obj
.
prop
=
123
;
// OK
obj
=
{};
// TypeError
16.7.2.1 为什么要采用新的导入方法?
为什么要引入这种相对复杂的导入机制,而这种机制偏离了既定的做法?
循环依赖:主要优点是它甚至支持非限定导入的 循环依赖 。
限定导入和非限定导入的工作方式相同。在 CommonJS 中,它们不同:限定导入提供对模块导出对象的属性的直接访问,而非限定导入是它的副本。
你可以将代码拆分为多个模块,它将继续工作(只要你不尝试更改导入的值)。
另一方面,*模块折叠*,即将多个模块组合成一个模块也变得更简单了。
根据我的经验,ES6 导入就可以工作,您很少需要考虑幕后发生的事情。
16.7.3 实现视图
在幕后,导入如何作为导出的视图工作?导出通过数据结构*导出条目*进行管理。所有导出条目(重新导出除外)都有以下两个名称
本地名称:是在模块内部存储导出的名称。
导出名称:是导入模块访问导出需要使用的名称。
导入实体后,始终通过具有*模块*和*本地名称*这两个组件的指针访问该实体。换句话说,该指针引用模块内部的*绑定*(变量的存储空间)。
让我们检查一下各种导出创建的导出名称和本地名称。下表(改编自 ES6 规范 )给出了概述,后续部分有更多详细信息。
语句
本地名称
导出名称
export {v};
'v'
'v'
'v'
'v'
export {v as x};
'v'
'v'
'v'
'x'
export const v = 123;
export const v = 123;
'v'
export const v = 123;
'v'
export function f() {}
'f'
'v'
'f'
'f'
'v'
export default function f() {}
function
foo
()
{}
export
{
foo
};
function
foo
()
{}
export
{
foo
as
bar
};
'f'
export default function () {}
'*default*'
'default'
export default 123;
function
foo
()
{}
export
{
foo
};
'*default*'
'default'
16.7.3.1 导出子句
本地名称:foo
导出名称:bar
16.7.3.2 内联导出
const
*
default
*
=
123
;
// *not* legal JavaScript
export
{
*
default
*
as
default
};
这是一个内联导出
本地名称:foo
导出名称:foo
16.7.3.3 默认导出
默认导出有两种
export
default
function
foo
()
{}
16.7.3.2 内联导出
function
foo
()
{}
export
{
foo
as
default
};
*可提升声明*(函数声明、生成器函数声明)和类声明的默认导出类似于普通的内联导出,因为创建了命名的本地实体并对其进行了标记。
所有其他默认导出都是关于导出表达式结果的。
16.7.3.3.1 默认导出表达式
export
default
function
()
{}
这相当于
function
*
default
*
()
{}
// *not* legal JavaScript
export
{
*
default
*
as
default
};
*可提升声明*(函数声明、生成器函数声明)和类声明的默认导出类似于普通的内联导出,因为创建了命名的本地实体并对其进行了标记。
以下代码默认导出表达式 123
的结果
它等效于
如果默认导出表达式,则会得到
本地名称:*default*
导出名称:default
选择本地名称是为了使其不会与任何其他本地名称冲突。
请注意,默认导出仍然会导致创建绑定。但是,由于 *default*
不是合法的标识符,因此您无法从模块内部访问该绑定。
不同类型的导出创建的导出名称和本地名称,请参见“源代码模块记录 ”一节中的表 42 。“静态语义:ExportEntries ”一节提供了更多详细信息。您可以看到导出条目是静态设置的(在评估模块之前),评估导出语句在“运行时语义:评估 ”一节中进行了描述。
16.8 ES6 模块的设计目标
要理解 ECMAScript 6 模块,了解影响其设计的目标很有帮助。主要目标是
默认导出优先
静态模块结构
支持同步和异步加载
支持模块之间的循环依赖
以下小节将解释这些目标。
16.8.1 默认导出优先
模块语法建议默认导出“是”模块,这看起来可能有点奇怪,但如果您考虑到一个主要设计目标是使默认导出尽可能方便,那么这是有道理的。引用 David Herman 的话
ECMAScript 6 倾向于单一/默认导出风格,并为导入默认值提供了最简洁的语法。导入命名导出可以而且应该稍微不那么简洁。
16.8.2 静态模块结构
当前的 JavaScript 模块格式具有动态结构:导入和导出的内容可以在运行时更改。ES6 引入自己的模块格式的原因之一是实现静态结构,这有几个好处。但在我们讨论这些好处之前,让我们先来看看静态结构意味着什么。
这意味着您可以在编译时(静态地)确定导入和导出——您只需要查看源代码,而不需要执行它。ES6 在语法上强制执行这一点:您只能在顶层导入和导出(永远不能嵌套在条件语句中)。而且导入和导出语句没有动态部分(不允许使用变量等)。
以下是两个没有静态结构的 CommonJS 模块的示例。在第一个示例中,您必须运行代码才能找出它导入了什么
var
my_lib
;
if
(
Math
.
random
())
{
my_lib
=
require
(
'foo'
);
}
else
{
my_lib
=
require
(
'bar'
);
}
在第二个示例中,您必须运行代码才能找出它导出了什么
if
(
Math
.
random
())
{
exports
.
baz
=
···
;
}
ECMAScript 6 模块的灵活性较低,并强制您使用静态结构。因此,您将获得以下几个好处。
16.8.2.1 好处:打包期间消除死代码
在前端开发中,模块通常按如下方式处理
在开发过程中,代码以许多(通常很小)的模块形式存在。
为了部署,这些模块被*打包*成几个相对较大的文件。
打包的原因是
为了加载所有模块,需要检索的文件更少。
压缩打包后的文件比压缩单独的文件效率略高。
在打包过程中,可以删除未使用的导出,从而可能节省大量空间。
原因 #1 对于 HTTP/1 很重要,因为在 HTTP/1 中,请求文件的成本相对较高。这种情况将在 HTTP/2 中发生变化,这就是为什么这个原因在那里不重要。
原因 #3 将继续引人注目。只有使用具有静态结构的模块格式才能实现这一点。
模块打包器 Rollup 证明了 ES6 模块可以有效地组合在一起,因为它们都适合单个作用域(在重命名变量以消除名称冲突之后)。这得益于 ES6 模块的两个特点
它们的静态结构意味着打包格式不必考虑条件加载的模块(一种常见的做法是将模块代码放在函数中)。
导入是对导出的只读视图,这意味着您不必复制导出,可以直接引用它们。
例如,请考虑以下两个 ES6 模块。
// lib.js
export
function
foo
()
{}
export
function
bar
()
{}
// main.js
import
{
foo
}
from
'./lib.js'
;
console
.
log
(
foo
());
Rollup 可以将这两个 ES6 模块打包成以下单个 ES6 模块(注意已消除的未使用导出 bar
)
function
foo
()
{}
console
.
log
(
foo
());
Rollup 方法的另一个好处是打包没有自定义格式,它只是一个 ES6 模块。
16.8.2.3 好处:更快地查找导入
如果您在 CommonJS 中需要一个库,您将得到一个对象
var
lib
=
require
(
'lib'
);
lib
.
someFunc
();
// property lookup
因此,通过 lib.someFunc
访问命名导出意味着您必须进行属性查找,这很慢,因为它是动态的。
相反,如果您在 ES6 中导入一个库,您可以在静态时知道它的内容并优化访问
import
*
as
lib
from
'lib'
;
lib
.
someFunc
();
// statically resolved
16.8.2.4 好处:变量检查
使用静态模块结构,您始终可以在静态时知道模块内任何位置可见的变量
全局变量:越来越多的情况下,唯一完全全局的变量将来自语言本身。其他所有内容都将来自模块(包括来自标准库和浏览器的功能)。也就是说,您在静态时就知道所有全局变量。
模块导入:您在静态时也知道这些。
模块局部变量:可以通过静态检查模块来确定。
这非常有助于检查给定的标识符是否拼写正确。这种检查是 JSLint 和 JSHint 等 linter 的一个流行功能;在 ECMAScript 6 中,大部分检查都可以由 JavaScript 引擎执行。
此外,还可以静态检查对命名导入(例如 lib.foo
)的任何访问。
16.8.2.5 好处:为宏做好准备
宏仍在 JavaScript 未来的路线图上。如果 JavaScript 引擎支持宏,您可以通过库向其中添加新语法。Sweet.js 是一个用于 JavaScript 的实验性宏系统。以下是 Sweet.js 网站上的一个示例:用于类的宏。
// Define the macro
macro
class
{
rule
{
$className
{
constructor
$cparams
$cbody
$
(
$mname
$mparams
$mbody
)
...
}
}
=>
{
function
$className
$cparams
$cbody
$
(
$className
.
prototype
.
$mname
=
function
$mname
$mparams
$mbody
;
)
...
}
}
// Use the macro
class
Person
{
constructor
(
name
)
{
this
.
name
=
name
;
}
say
(
msg
)
{
console
.
log
(
this
.
name
+
" says: "
+
msg
);
}
}
var
bob
=
new
Person
(
"Bob"
);
bob
.
say
(
"Macros are sweet!"
);
对于宏,JavaScript 引擎在编译之前执行预处理步骤:如果解析器生成的标记流中的标记序列与宏的模式部分匹配,则将其替换为通过宏体生成的标记。只有当您能够静态地找到宏定义时,预处理步骤才有效。因此,如果您想通过模块导入宏,那么它们必须具有静态结构。
16.8.2.6 好处:为类型做好准备
静态类型检查施加了类似于宏的约束:只有当类型定义可以静态找到时,才能进行静态类型检查。同样,只有当类型具有静态结构时,才能从模块导入类型。
类型很有吸引力,因为它们支持 JavaScript 的静态类型化快速方言,可以在其中编写性能至关重要的代码。一种这样的方言是 低级 JavaScript (LLJS)。
16.8.2.7 好处:支持其他语言
如果您想支持将具有宏和静态类型的语言编译为 JavaScript,那么出于前两节中提到的原因,JavaScript 的模块应该具有静态结构。
16.8.2.8 本节来源
16.8.3 支持同步和异步加载
ECMAScript 6 模块必须独立于引擎是同步加载模块(例如,在服务器上)还是异步加载模块(例如,在浏览器中)。它的语法非常适合同步加载,而异步加载则通过其静态结构来实现:因为您可以在静态时确定所有导入,所以您可以在评估模块体之前加载它们(其方式类似于 AMD 模块)。
16.8.4 支持模块之间的循环依赖
支持循环依赖是 ES6 模块的一个关键目标。原因如下
循环依赖并非天生就是坏事。特别是对于对象,您有时甚至希望存在这种依赖关系。例如,在某些树(例如 DOM 文档)中,父级引用子级,子级引用父级。在库中,您通常可以通过精心设计来避免循环依赖。但是,在一个大型系统中,它们可能会发生,尤其是在重构期间。如果模块系统支持它们,那么这将非常有用,因为系统在您重构时不会崩溃。
Node.js 文档承认了循环依赖的重要性 ,Rob Sayre 提供了更多证据
数据点:我曾经为 Firefox 实现了一个类似 [ECMAScript 6 模块] 的系统。我在发布 3 周后被要求 提供循环依赖支持。
Alex Fritze 发明并由我参与开发的系统并不完美,而且语法也不是很漂亮。但是,7 年后它仍在使用 ,所以它一定有其正确之处。
16.9 常见问题解答:模块
16.9.1 我可以使用变量来指定要从哪个模块导入吗?
import
语句是完全静态的:它的模块说明符始终是固定的。如果要动态确定要加载的模块,则需要使用 编程加载器 API
const
moduleSpecifier
=
'module_'
+
Math
.
random
();
System
.
import
(
moduleSpecifier
)
.
then
(
the_module
=>
{
// Use the_module
})
16.9.2 我可以有条件地或按需导入模块吗?
导入语句必须始终位于模块的顶层。这意味着您不能将它们嵌套在 if
语句、函数等中。因此,如果要根据条件或按需加载模块,则必须使用 编程加载器 API
if
(
Math
.
random
())
{
System
.
import
(
'some_module'
)
.
then
(
some_module
=>
{
// Use some_module
})
}
16.9.3 我可以在 import
语句中使用变量吗?
不,您不能。请记住——导入的内容不得依赖于在运行时计算的任何内容。因此
// Illegal syntax:
import
foo
from
'some_module'
+
SUFFIX
;
16.9.4 我可以在 import
语句中使用解构吗?
不,您不能。import
语句看起来像解构,但完全不同(静态的、导入是视图等)。
因此,您不能在 ES6 中执行以下操作
// Illegal syntax:
import
{
foo
:
{
bar
}
}
from
'some_module'
;
16.9.5 命名导出是必需的吗?为什么不默认导出对象?
您可能想知道——如果我们可以简单地默认导出对象(如在 CommonJS 中),为什么还需要命名导出?答案是您无法通过对象强制执行静态结构,并且会失去所有相关的优势(本章 对此进行了解释)。
16.9.6 我可以 eval()
模块的代码吗?
不,您不能。对于 eval()
来说,模块是太高级的结构。模块加载器 API 提供了从字符串创建模块的方法。从语法上讲,eval()
接受脚本(不允许使用 import
和 export
),而不是模块。
16.10 ECMAScript 6 模块的优势
乍一看,ECMAScript 6 中内置的模块似乎是一个无聊的功能——毕竟,我们已经有了几个很好的模块系统。但是 ECMAScript 6 模块有几个新特性
更紧凑的语法
静态模块结构(有助于消除死代码、优化、静态检查等)
自动支持循环依赖
ES6 模块也将——希望如此——结束目前占主导地位的 CommonJS 和 AMD 标准之间的碎片化。拥有一个单一的、原生的模块标准意味着
不再需要 UMD(通用模块定义 ):UMD 是指允许同一个文件被多个模块系统(例如 CommonJS 和 AMD)使用的模式的名称。一旦 ES6 成为唯一的模块标准,UMD 就过时了。
新的浏览器 API 将成为模块,而不是全局变量或 navigator
的属性。
不再有对象作为命名空间:在 ECMAScript 5 中,诸如 Math
和 JSON
之类的对象充当函数的命名空间。将来,可以通过模块提供此类功能。
16.11 延伸阅读