ES6 Modules(模块)系统及语法详解

10年服务1亿前端开发工程师

在2014年7月底,TC39 [1]又召开了一次会议,在此期间ECMAScript 6(ES6)模块语法的最后细节被最终确定。这篇博客文章概述了完整的 ES6 模块系统。

当前的 JavaScript 模块系统

注:原文日期为 2014-09-07 ,请用当时的环境浏览本段内容

JavaScript 目前没有内置支持模块化,但社区创造了令人印象深刻的解决方案。两个最重要的(不幸的是互不兼容)标准是:

  • CommonJS 模块:这个标准的主要实现在Node.js中(Node.js模块有一些超越 CommonJS 的功能)。特点:
    • 语法简洁
    • 为同步加载设计
    • 主要用途:服务器端
  • 异步模块定义(AMD):这个标准最流行的实现是 RequireJS。 特点:
    • 稍微复杂的语法,使AMD能够在没有 eval()(或编译步骤)的情况下工作。
    • 专为异步加载而设计
    • 主要用途:浏览器

以上只是对当前状况的大致的解释一下。如果你想要更深入的资料,可以看看Addy Osmani的文章 使用AMD编写模块化JavaScript,CommonJS & ES Harmony

ECMAScript 6 模块

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

  • 与 CommonJS 类似,简洁的语法,倾向于单一的接口并且支持循环依赖。
  • 与AMD类似,直接支持异步加载和可配置的模块加载。

内置语言允许ES6模块超越 CommonJS 和 AMD(细节将在后面解释):

  • 他们的语法比 CommonJS 更简洁。
  • 他们的结构可以静态分析(用于静态检查,优化等)。
  • 他们支持的循环依赖性优于 CommonJS。

ES6模块标准有两个部分:

  • 声明语法(用于导入和导出)
  • 编程式加载器(loader)API:配置如何加载模块以及有条件地加载模块

ES6模块语法概述

有两种导出方式:命名的导出(每个模块可以导出多个)和 默认的导出(每个模块仅导出一个)。

命名的导出(每个模块多个)

模块可以通过使用前缀关键词 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

还有其他方法来指定命名的导出(稍后解释),但我觉得这个方式很方便:如果没有外层环境,你可以只关注你的代码,然后以你想要的关键词来标识所有的东西。

如果你需要,您还可以 导入(import) 整个模块,并通过属性符号引用其命名的导出(export) :

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

默认的导出(每个模块一个)

在 Node.js 社区中,只导出单个值的模块非常受欢迎。但是它们在前端开发中也很常见,你经常用 构造函数 / 类 来创建模型,每个模块有一个模型。ECMAScript 6模块可以选择默认的导出方式,导出最主要的值。默认的导出方式特别容易导入。

以下 ECMAScript 6模块 单个函数:

//------ myFunc.js ------
export default function () { ... };

//------ main1.js ------
import myFunc from 'myFunc';
myFunc();

默认导出一个类的 ECMAScript 6 模块如下所示:

//------ MyClass.js ------
export default class { ... };

//------ main2.js ------
import MyClass from 'MyClass';
let inst = new MyClass();

注意:定义式导出声明的运算对象是一个表达式,往往不需要名字。相反,它将通过其模块的名称来标识。

在一个模块中同时具有命名的导出和默认的导出

以下模式在 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 版本只是大致相似。后者具有扁平式结构,而前者是嵌套式结构。喜欢哪一种风格有自己决定,但是扁平式结构具有静态可分析的优点(为什么这样好下面会解释)。CommonJS风格看起来为了满足对象的需要部分被作为命名空间,这种需求通常可以通过 ES6 模块和命名导出来实现。

需要注意的是 CommonJS 版本和 ECMAScript 6 版本仅仅是大致相同。后者为扁平式结构,而前者为嵌套式结构。喜欢哪一种风格有自己决定,不过扁平式结构具有做静态分析的优势(下面会提到优点)。CommonJS 风格看起来为了满足对象的需要部分被作为命名空间,可以通过 ES6 模块来实现这种需求并且导出实现。

默认导出只是另一个命名导出

默认导出实际上只是一个具有特殊名称default的命名导出。也就是说,以下两个语句是是等价的:

import { default as foo } from 'lib';
import foo from 'lib';

类似地,以下两个模块也是等价的默认导出:

//------ module1.js ------
export default 123;

//------ module2.js ------
const D = 123;
export { D as default };
为什么我们需要命名导出?

你可能想知道 – 如果我们可以简单地默认导出对象(如CommonJS),那为什么我们需要命名导出?答案是,你不能通过对象强制实施一个静态结构,并且会失去所有相关的优点(在下一节中描述)。

设计目标

如果你想了解 ECMAScript 6 模块设计理念,那么首先需要了解什么目标影响了他的设计。主要是以下几点:

  • 默认导出是有利的
  • 静态模块结构
  • 同时支持同步和异步加载
  • 支持模块之间的循环依赖性

以下小节解释这些目标。

默认导出是有利的

模块语法显示,默认导出可能导致模块看起来有点奇怪,但它是有道理的,你考虑一个主要的设计目标是使默认导出尽可能方便。引用David Herman的话说:

ECMAScript 6 支持单个/默认导出风格,并提供默认导入语法糖。导入命名的导出显得不太简洁。

静态模块结构

注:关于 静态模块结构 可以看看这篇文章 webpack 2中的Tree Shaking,有助于更好的理解。

在当前的JavaScript模块系统中,你必须执行代码,来找出什么是 导入 和 什么是 导出。这是 ECMAScript 6 与这些模块系统(注:指 CMD,AMD)决裂的主要原因: 通过将模块系统构建到JavaScript语言中,您可以在语法上强制执行静态模块结构。让我们先来看看这意味着什么,带来什么好处。

模块的静态结构,意味着您可以在编译时确定导入和导出(静态) – 你只需要看看源代码,你不必执行它。下面是两个 CommonJS 模块的例子,告诉你为什么 CommonJS 模块在编译时确定导入和导出是不可能的。在第一示例中,你必须运行代码才可以找出它导入的是什么:

var mylib;
if (Math.random()) {
    mylib = require('foo');
} else {
    mylib = require('bar');
}

在第二个示例中,您必须运行代码才可以找出它导出的内容:

if (Math.random()) {
    exports.baz = ...;
}

ECMAScript 6 模块的灵活性不如 CommonJS 模块,强迫使用静态结构。但却使你得到几个好处(参考引用 David Herman 的“Static module resolution”),下面描述。

好处1:更快的查找

如果你在 CommonJS 中 require 一个库,你会得到一个对象:

var lib = require('lib');
lib.someFunc(); // 属性查找

因此,通过lib.someFunc 访问命名导出意味着您必须进行属性查找,这是很慢,因为它是动态的。

相反,如果您在 ES6 中导入一个库,您可以静态地了解其内容并可以优化访问:

import * as lib from 'lib';
lib.someFunc(); // 静态解析
好处2:变量检查

利用静态模块结构,你总是静态地知道哪些变量在模块内的任何位置是可见的:

  • 全局变量:越来越多,唯一完全的全局变量将将来自适当的语言。一切都将来自模块(包括来自标准库和浏览器的功能)。也就是说,你静态地知道所有的全局变量。
  • 模块导入:你也能静态地知道。
  • 模块局部变量:可以通过静态检查模块来确定。

这有助于检查给定的标识符是否拼写正确。这种检查是程序检测器中一个受欢迎的特性,如JSLint和JSHint; 而在 ECMAScript 6 中,大多数可以由 JavaScript 引擎执行。

此外,还可以静态检查命名导入(例如lib.foo)的任何访问。

好处3:为宏命令做准备

宏命令仍然是JavaScript未来的未来。如果JavaScript引擎支持宏命令,你可以通过一个库添加新的语法。Sweet.js是JavaScript一个实验性的宏系统。下面是Sweet.js网站的一个例子:一个类的宏。

macro class {
    rule {
        $className {
                constructor $cparams $cbody
                $($mname $mparams $mbody) ...
        }
    } => {
        function $className $cparams $cbody
        $($className.prototype.$mname
            = function $mname $mparams $mbody; ) ...
    }
}

// 使用宏
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引擎在编译之前执行预处理步骤:如果由解析器产生的token流中的token序列与宏的模式部分匹配,它被由宏的主体生成的token替换。只有当您能够静态地找到宏定义时,预处理步骤才有效。 因此,如果你想通过模块导入宏,那么它们必须有一个静态结构。

好处4:为类型做准备

静态类型检查强加类似于宏的约束:它只能在可以静态找到类型定义时才能完成。同样,只有当模块具有静态结构时,才能从模块导入类型。

类型是吸引人的,因为它们支持静态类型的JavaScript的快速dialect,其中可以编写性能关键代码。一种这样的dialect是低级JavaScript(LLJS)。它目前编译为asm.js.

好处5:支持其他语言

如果你想支持编译语言的宏和静态类型的JavaScript,JavaScript的模块应该有一个静态结构,因为前两节提到的原因。

同时支持同步和异步加载

ECMAScript 6 模块必须能独立于引擎加载模块,不论是否同步地(例如在服务器上)或异步地(例如在浏览器中)。它的语法非常适合于同步加载,通过其静态结构启用异步加载:因为你可以静态确定所有导入,您可以在评估模块的主体之前加载它们(这种让人联想到AMD模块的方式)。

支持模块之间的循环依赖性

如果两个模块 A 和 B ,A(可能间接)导入 B,并且 B 导入 A ,那么模块 A 和 B 相互依赖。如果可能,应避免循环依赖,因为这样会导致 A 和 B 紧密耦合 – 它们只能一起使用和改进。

为什么支持循环依赖?

循环依赖不是天生就是邪恶的。特别是对于对象来说,你有时甚至想要这种依赖。例如,在一些树(例如DOM文档)中,父元素引用子元素,并且子元素引用回父元素。在库中,通常可以通过仔细设计避免循环依赖。但在大型系统中,它们可能发生,特别是在重构过程中。然后,如果模块系统支持它们是非常有用的,因为当你重构时,系统不会中断。

Node.js文档承认循环依赖的重要性(查看 Node.js API 文档中的“Modules: Cycles”),并且Rob Sayre提供了额外的证据:

数据点:我曾经为Firefox实现了一个类似[ECMAScript 6 modules]的系统。我被要求循环依赖支持3周后发布。

Alex Fritze 发明的系统,我工作起来不完美,并且语法不是很漂亮。但它仍然被使用7年后,所以它必须得到解决。

让我们看看 CommonJS 和 ECMAScript 6 如何处理循环依赖。

CommonJS中的循环依赖

在CommonJS中,如果模块 B require 主体当前正在被评估的模块 A,它会回到其当前状态下模块 A的出口对象(以下示例中的行#1)。这使得 B 能够引用该对象内部导出(行#2)的属性。在 B 的评估完成后填充属性,此时 B 的导出能工作正常。

//------ a.js ------
var b = require('b');
exports.foo = function () { ... };

//------ b.js ------
var a = require('a'); // (1)
// Can’t use a.foo in module body,
// but it will be filled in later
exports.bar = function () {
    a.foo(); // OK (2)
};

//------ main.js ------
var a = require('a');

作为基本规则,请记住,使用循环依赖关系,您无法访问模块主体中的导入。这是现象固有的,并且不随 ECMAScript 6 模块而改变。

CommonJS方法的局限性是:

  • Node.js风格的单值导出不能工作。 在Node.js中,您可以导出单个值而不是对象,如下所示:
    module.exports = function(){...}
    如果你在模块A中这么做了,你将无法使用模块B中的导出函数,因为B中的变量 a 仍然引用A的原始导出对象。
  • 您不能直接使用命名导出。 也就是说,模块B不能像这样导入a.foo
    var foo = require('a').foo;
    foo 将简单地未定义。 换句话说,你别无选择,只能通过导出对象 a 引用 foo

CommonJS有一个独特的功能:您可以在导入之前导出。这样的导出保证可以在导入模块的主体中访问。也就是说,如果A这样做,他们可以在B的主体中访问。但是,在导入之前导出很少有用。

ECMAScript 6中的循环依赖

为了消除上述两个限制,ECMAScript 6 模块导出绑定,而不是值。也就是说,在模块体内声明的变量是保持活动的。这可以通过以下代码演示。

//------ lib.js ------
export let counter = 0;
export function inc() {
    counter++;
}

//------ main.js ------
import { inc, counter } from 'lib';
console.log(counter); // 0
inc();
console.log(counter); // 1

因此,面对循环依赖,无论是直接访问命名导出还是通过其模块访问命名导出:在这2种情况下,只要有一个间接引用,它总是能正常工作。

更多关于导入和导出

导入

ECMAScript 6 提供了以下的导入方式(参见 ECMAScript 6 规范中的 Imports):

// 默认导出和命名导出
import theDefault, { named1, named2 } from 'src/mylib';
import theDefault from 'src/mylib';
import { named1, named2 } from 'src/mylib';

// 重命名: 导入 named1 作为 myNamed1
import { named1 as myNamed1, named2 } from 'src/mylib';

// 导入模块作为一个对象
// (每个命名导出都作为一个属性)
import * as mylib from 'src/mylib';

// 只加载模块,不导入任何东西
import 'src/mylib';

导出

有两种方法可以导出当前模块中的内容(参见 ECMAScript 6 规范中的 Exports)。 第一种是,您可以使用关键字 export 来声明。

export var myVar1 = ...;
export let myVar2 = ...;
export const MY_CONST = ...;

export function myFunc() {
    ...
}
export function* myGeneratorFunc() {
    ...
}
export class MyClass {
    ...
}

默认导出(注:通过关键字default声明)的运算对象是一个表达式(包括函数表达式和类表达式)。 例如:

export default 123;
export default function (x) {
    return x
};
export default x => x;
export default class {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
};

第二种是,您可以在模块的末尾列出要导出的所有内容(风格上与模块模式类似)。

const MY_CONST = ...;
function myFunc() {
    ...
}

export { MY_CONST, myFunc };

您也可以使用不同的名称导出:

export { MY_CONST as THE_CONST, myFunc as theFunc };

请注意,您不能使用保留字(如defaultnew)作为变量名称,但您可以将其用作导出的名称(在 ECMAScript 5 中,您也可以将它们用作属性名称)。如果要直接导入此类命名的导出,那么你必须将它们重命名为正确的变量名称。

重新导出

重新导出意味着将另一个模块的导出添加到当前模块的导出。 你可以添加所有其他模块的导出:

export * from 'src/other_module';

或者你可以有更多选择性(随意地重命名):

export { foo, bar } from 'src/other_module';
    
// 导出其他模块的 foo 作为 myFoo
export { foo as myFoo, bar } from 'src/other_module';

eval() 和 模块

eval() 不支持模块语法。它根据脚本语法规则解析其参数,而脚本不支持模块语法(原因稍后解释)。如果要评估模块代码, 您可以使用模块加载器API(如下所述)。

ECMAScript 6 模块加载器 API

除了使用模块的声明性的语法外,还有一个编程式的API。 它允许您:

  • 以编程方式使用模块和脚本
  • 配置模块加载

加载器处理解析 模块说明符(在 import...from 后面的字符串 ID)加载模块,等。他们的构造函数是Reflect.Loader。每个平台在全局变量 System 中保留自定义实例(系统加载器),实现其平台特定的模块加载方式。

导入模块并加载脚本

您可以通过基于 ES6 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
});

更多加载器方法:

配置模块加载

模块加载器 API 具有用于配置的各种 hook(钩子) 。目前它仍在发展中。用于浏览器的第一系统加载器正在实施和测试。目标是找出如何最好地配置模块加载。

加载器API将允许在很大程度上定制加载过程。例如:

  • Lint模块导入(例如通过JSLint或JSHint)。
  • 在导入时自动转译模块(它们可能包含 CoffeeScript 或 TypeScript 代码)。
  • 使用旧版模块( AMD,Node.js )。

可配置模块加载是 Node.js 和 CommonJS 受限的一个领域。

更多信息

以下内容回答与 ECMAScript 6 模块相关的两个重要问题:现在我如何使用他们?如何将它们嵌入到HTML中?

注:

由于作者写这篇文章的时间较早,有些观点和资源现在已经不适用了。

比如,现在流行的 ES6 转换器有 BabelBuble , Rollup.js 等等。

现在很多框架已经支持 ES6 语法,项目一般都通过打包工具(例如:webpack )打包发布。

所以现在使用 ES6 已经非常普遍简单了,不需要有太多顾虑。

  • 今天开始使用 ECMAScript 6” 概述了 ECMAScript 6 的使用,并解释如何将其编译为 ECMAScript 5 。如果你对后者感兴趣,请先阅读Sect.2。 一个简便使用的解决方案是使用ES6模块转换器(注:该项目已经废弃)将 ES6 模块语法添加到 ES5 并将其编译为 AMD 或 CommonJS 。
  • 将ES6模块嵌入HTML中<script>元素中的代码不支持模块语法,因为元素的同步性质与模块的异步性不兼容。相反,您需要使用新的<module>元素。博客文章“ECMAScript 6 模块在未来的浏览器”解释了<module>是如何工作的。它与比<script>相比有几个显着的优点,可以在其替代版本<script type="module">中进行 polyfill。
  • CommonJS vs. ES6:“JavaScript模块”(作者Yehuda Katz)是 ECMAScript 6 模块的快速介绍。特别有趣的是第二页,CommonJ S模块与 ECMAScript 6 版本并排显示。

ECMAScript 6模块的优点

乍一看,ECMAScript 6 中内置的模块看起来可能是一个无聊的功能 – 毕竟我们已经有了几个好的模块系统。但 ECMAScript 6 模块具有无法通过库添加的功能,例如非常紧凑的语法和静态模块结构(这有助于优化,静态检查等)。他们还有望结束当前主流标准 CommonJS 和 AMD 之间的分裂。

具有独立的原生标准对于模块来说意味着:

  • 无需更多UMD(通用模块定义):UMD是模式的名称,即使得相同的文件能够被多个模块系统(例如 CommonJS 和 AMD )使用。一旦ES6是唯一模块成为标准,UMD已过时。
  • 新的浏览器API成为模块,代替全局变量或引导属性。
  • 没有更多的对象命名空间:对象如Math和JSON作为ECMAScript 5中函数的命名空间。在将来,这样的功能可以由模块提供。

推荐阅读最新关于 ECMAScript 的文章

英文原文:《ECMAScript 6 模块:最终语法

赞(0) 打赏
未经允许不得转载:WEB前端开发 » ES6 Modules(模块)系统及语法详解

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

前端开发相关广告投放 更专业 更精准

联系我们

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏