16. 模块
目录
请支持本书:购买 (PDF, EPUB, MOBI)捐赠
(广告,请勿屏蔽。)

16. 模块



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 也采用了这种样式

这种模块化方法避免了全局变量,唯一全局的是模块说明符。

16.2.1 ECMAScript 5 模块系统

令人印象深刻的是,ES5 模块系统在没有语言的明确支持的情况下也能很好地工作。两个最重要(不幸的是不兼容)的标准是

以上只是对 ES5 模块的简化说明。如果您想了解更多深入的资料,请参阅 Addy Osmani 的“使用 AMD、CommonJS 和 ES Harmony 编写模块化 JavaScript”。

16.2.2 ECMAScript 6 模块

ECMAScript 6 模块的目标是创建一种格式,使 CommonJS 和 AMD 的用户都满意

内置于语言中使 ES6 模块能够超越 CommonJS 和 AMD(详细信息将在后面解释)

ES6 模块标准有两部分

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();

默认导出有两种样式

  1. 标记声明
  2. 直接默认导出值
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!

三个变量 foobarbaz 中的哪一个是默认导出?

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 代码正确处理了两个模块 ab 之间的循环依赖。

//------ 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

只有两种方法可以组合这些样式,并且它们出现的顺序是固定的;默认导出始终排在第一位。

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

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 的视角,函数 _ 是默认导出,而 eachforEach 是命名导出。事实证明,你实际上可以同时拥有命名导出和默认导出。例如,之前的 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:可以用作导出名称,但不能用作变量名称

你不能使用保留字(例如 defaultnew)作为变量名称,但你可以使用它们作为导出的名称(你也可以在 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() 使你能够

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 更多加载器方法

加载器有更多的方法。三个重要的是

16.5.4 配置模块加载

模块加载器 API 将具有用于配置加载过程的各种钩子。用例包括

  1. 导入时对模块进行代码检查(例如,通过 JSLint 或 JSHint)。
  2. 导入时自动翻译模块(它们可以包含 CoffeeScript 或 TypeScript 代码)。
  3. 使用旧版模块(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 文件的传统浏览器方式。脚本具有 互联网媒体类型,用作

以下是最重要的值

脚本通常是同步加载或执行的。JavaScript 线程程在代码加载或执行完毕之前会停止。

16.6.1.2 模块

为了与 JavaScript 通常的运行到完成语义保持一致,模块的主体必须在不中断的情况下执行。这为导入模块留下了两种选择

  1. 在执行主体时同步加载模块。这就是 Node.js 的做法。
  2. 在执行主体之前异步加载所有模块。这就是 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 模块或脚本——上下文问题

文件是模块还是脚本仅取决于其导入或加载方式。大多数模块都有导入或导出,因此可以检测到。但是,如果模块两者都没有,则它与脚本没有区别。例如

var x = 123;

这段代码的语义取决于它是被解释为模块还是脚本而有所不同

更现实的例子是一个安装某些东西的模块,例如全局变量中的 polyfill 或全局事件侦听器。这样的模块既不导入也不导出任何东西,并且通过空导入激活

import './my_module';

16.7 详细信息:导入作为导出的视图

导入在 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 相反,导入是对导出值的视图。换句话说,每个导入都是与导出数据的实时连接。导入是只读的

以下代码演示了导入如何像视图一样

//------ 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 为什么要采用新的导入方法?

为什么要引入这种相对复杂的导入机制,而这种机制偏离了既定的做法?

根据我的经验,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 };
'*default*'

'default'

export function foo() {}

export default 123;

function foo() {}
export { foo };

'*default*'

'default'

16.7.3.1 导出子句

本地名称:foo

导出名称:bar

export default 123;

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*

不同类型的导出创建的导出名称和本地名称,请参见“源代码模块记录”一节中的表 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. 为了加载所有模块,需要检索的文件更少。
  2. 压缩打包后的文件比压缩单独的文件效率略高。
  3. 在打包过程中,可以删除未使用的导出,从而可能节省大量空间。

原因 #1 对于 HTTP/1 很重要,因为在 HTTP/1 中,请求文件的成本相对较高。这种情况将在 HTTP/2 中发生变化,这就是为什么这个原因在那里不重要。

原因 #3 将继续引人注目。只有使用具有静态结构的模块格式才能实现这一点。

16.8.2.2 好处:紧凑打包,无需自定义打包格式

模块打包器 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() 接受脚本(不允许使用 importexport),而不是模块。

16.10 ECMAScript 6 模块的优势

乍一看,ECMAScript 6 中内置的模块似乎是一个无聊的功能——毕竟,我们已经有了几个很好的模块系统。但是 ECMAScript 6 模块有几个新特性

ES6 模块也将——希望如此——结束目前占主导地位的 CommonJS 和 AMD 标准之间的碎片化。拥有一个单一的、原生的模块标准意味着

16.11 延伸阅读

下一页:IV 集合