深入 ES6 是一个系列文章,介绍了 ECMAScript 标准第 6 版(简称 ES6)中添加的 JavaScript 编程语言的新特性。
当我于 2007 年加入 Mozilla 的 JavaScript 团队时,大家都在开玩笑说,一个典型的 JavaScript 程序只有一行代码。
那是 Google 地图发布两年后。在那之前不久,JavaScript 的主要用途是表单验证,事实证明,你的平均 <input onchange=>
处理程序也只有一行代码。
情况已经改变。JavaScript 项目已经发展到令人瞠目结舌的规模,并且社区已经开发了用于大规模工作的工具。你需要做的一件最基本的事情是使用一个模块系统,它可以将你的工作分散到多个文件和目录中,但同时也要确保你所有的代码片段都能根据需要相互访问,并且能够高效地加载所有这些代码。因此,JavaScript 自然而然地具有一个模块系统,甚至有几个。还有几个包管理器,这些工具可以用来安装所有这些软件并处理高级依赖关系。你可能认为 ES6 的新模块语法有点晚。
今天,我们将看看 ES6 是否为这些现有系统添加了任何内容,以及未来的标准和工具是否能够在它之上构建。但首先,让我们直接深入了解一下 ES6 模块的外观。
模块基础
ES6 模块是一个包含 JS 代码的文件。没有特殊的 module
关键字;模块看起来就像一个脚本。有两个区别。
-
ES6 模块是自动严格模式代码,即使你没有在其中写入
"use strict";
。 -
你可以在模块中使用
import
和export
。
让我们先谈谈 export
。默认情况下,模块内部声明的所有内容对模块都是局部的。如果你希望模块中声明的内容是公开的,以便其他模块可以使用它,你必须导出该功能。有几种方法可以做到这一点。最简单的方法是添加 export
关键字。
<pre>
// kittydar.js - 在图像中查找所有猫的位置。
// (<a href="https://harthur.github.io/kittydar/" target="_blank">Heather Arthur 编写了这个真实库</a>)
// (但她没有使用模块,因为它是在 2013 年)
<strong>export</strong> function detectCats(canvas, options) {
var kittydar = new Kittydar(options);
return kittydar.detectCats(canvas);
}
<strong>export</strong> class Kittydar {
... 做图像处理的几种方法 ...
}
// 此辅助函数未导出。
function resizeCanvas() {
...
}
...
</pre>
你可以 export
任何顶层 function
、class
、var
、let
或 const
。
实际上,这就是你需要了解的所有内容来编写模块!你无需将所有内容都放入 IIFE 或回调中。只需声明所有你需要的东西即可。因为代码是一个模块,而不是一个脚本,所以所有声明都将作用域到该模块,而不是全局可见于所有脚本和模块。导出构成模块的公开 API 的声明,你就完成了。
除了导出之外,模块中的代码基本上就是正常的代码。它可以使用像 Object
和 Array
这样的全局变量。如果你的模块在 Web 浏览器中运行,它可以使用 document
和 XMLHttpRequest
。
在另一个文件中,我们可以导入并使用 detectCats()
函数
<pre>
// demo.js - Kittydar 演示程序
import {detectCats} from "kittydar.js";
function go() {
var canvas = document.getElementById("catpix");
var cats = detectCats(canvas);
drawRectangles(canvas, cats);
}
</pre>
要从一个模块中导入多个名称,你可以这样写
<pre>
import {detectCats, Kittydar} from "kittydar.js";
</pre>
当运行包含 import
声明的模块时,首先会加载它导入的模块,然后以深度优先遍历依赖关系图的方式执行每个模块主体,通过跳过已执行的内容来避免循环。
这就是模块的基础知识。真的很简单。 ;-)
导出列表
与其为每个导出的功能添加标签,你可以写出一个包含所有你想要导出的名称的单一列表,并将其括在花括号中
<pre>
export {detectCats, Kittydar};
// 此处不需要 `export` 关键字
function detectCats(canvas, options) { ... }
class Kittydar { ... }
</pre>
export
列表不必是文件中第一项;它可以出现在模块文件顶层作用域的任何地方。你可以在多个 export
列表中混合使用,或将 export
列表与其他 export
声明混合使用,只要没有名称被导出多次即可。
重命名导入和导出
有时,导入的名称恰好与你需要使用的其他名称发生冲突。因此,ES6 允许你在导入时重命名它们
<pre>
// suburbia.js
// 这两个模块都导出了一个名为 `flip` 的东西。
// 要导入它们,我们必须至少重命名其中一个。
import {flip as flipOmelet} from "eggs.js";
import {flip as flipHouse} from "real-estate.js";
...
</pre>
类似地,你可以在导出时重命名它们。如果你希望以两个不同的名称导出相同的值,这会很方便,这种情况偶尔会发生
<pre>
// unlicensed_nuclear_accelerator.js - 无 DRM 的媒体流
// (不是一个真正的库,但也许应该有一个)
function v1() { ... }
function v2() { ... }
export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};
</pre>
默认导出
新的标准旨在与现有的 CommonJS 和 AMD 模块互操作。所以,假设你有一个 Node 项目,你已经执行了 npm install lodash
。你的 ES6 代码可以从 Lodash 导入单个函数
<pre>
import {each, map} from "lodash";
each([3, 2, 1], x => console.log(x));
</pre>
但也许你已经习惯了看到 _.each
而不是 each
,你仍然想要以这种方式编写代码。或者也许你想将 _
作为函数使用,因为 在 Lodash 中这样做很有用。
为此,你可以使用稍微不同的语法:在没有花括号的情况下导入模块。
<pre>
import _ from "lodash";
</pre>
此简写等效于 import {default as _} from "lodash";
。所有 CommonJS 和 AMD 模块都被呈现给 ES6,它们具有一个 default
导出,它与你要求 require()
获取该模块时得到的结果相同,也就是说,exports
对象。
ES6 模块旨在让你导出多个内容,但对于现有的 CommonJS 模块而言,default
导出是你唯一可以获得的东西。例如,在撰写本文时,著名的 colors 包据我所知还没有任何特殊的 ES6 支持。它是一组 CommonJS 模块,就像 npm 上的大多数包一样。但你可以在你的 ES6 代码中直接导入它。
<pre>
// ES6 等效于 `var colors = require("colors/safe");`
import colors from "colors/safe";
</pre>
如果你希望你自己的 ES6 模块具有 default
导出,这很容易做到。default
导出并没有什么神奇之处;它与其他任何导出都一样,只是它被命名为 "default"
。你可以使用我们之前讨论过的重命名语法
<pre>
let myObject = {
field1: value1,
field2: value2
};
export {myObject as default};
</pre>
或者更好的是,使用此简写
<pre>
<strong>export default</strong> {
field1: value1,
field2: value2
};
</pre>
关键字 export default
后面可以跟任何值:函数、类、对象字面量,任你所选。
模块对象
抱歉,这篇文章太长了。但 JavaScript 并非孤例:出于某种原因,所有语言的模块系统都倾向于拥有大量单独的小而无聊的便利功能。幸运的是,只剩下一个了。好吧,两个。
<pre>
import * as cows from "cows";
</pre>
当你 import *
时,导入的是一个 模块命名空间对象。它的属性是模块的导出。因此,如果“cows”模块导出了一个名为 moo()
的函数,那么以这种方式导入“cows”后,你可以这样写:cows.moo()
。
聚合模块
有时,包的主模块仅仅是导入包的其他所有模块并将它们以统一的方式导出。为了简化这种代码,有一个一键式导入和导出简写
<pre>
// world-foods.js - 来自世界各地的美味佳肴
// 导入 "sri-lanka" 并重新导出其部分导出内容
export {Tea, Cinnamon} from "sri-lanka";
// 导入 "equatorial-guinea" 并重新导出其部分导出内容
export {Coffee, Cocoa} from "equatorial-guinea";
// 导入 "singapore" 并导出其所有导出内容
export * from "singapore";
</pre>
每个 export-from
语句类似于一个 import-from
语句,后面跟着一个 export
。与真实的导入不同,这不会将重新导出的绑定添加到你的作用域中。所以,如果你打算在 world-foods.js
中编写一些使用 Tea
的代码,请不要使用此简写。你会发现它不在那里。
如果“singapore”导出的任何名称恰好与其他导出内容发生冲突,那将是一个错误,因此请谨慎使用 export *
。
哇!我们完成了语法!接下来是更有趣的部分。
import
到底做了什么?
你能相信吗?……什么都没做!
哦,你没那么容易上当。好吧,你能相信标准基本上没有说明 import
做了什么吗?这其实是一件好事吗?
ES6 将模块加载的细节完全留给实现决定。模块执行的其余部分在详细说明。
简单来说,当你告诉 JS 引擎运行一个模块时,它必须表现得像在执行以下四个步骤一样。
-
解析:实现会读取模块的源代码并检查语法错误。
-
加载:实现会加载所有导入的模块(递归地)。这是目前尚未标准化的一部分。
-
链接:对于每个新加载的模块,实现都会创建一个模块作用域,并用该模块中声明的所有绑定填充它,包括从其他模块导入的内容。
如果尝试运行 `import {cake} from "paleo"`,但“paleo”模块实际上没有导出名为 `cake` 的任何内容,就会出现错误。这太可惜了,因为你已经非常接近实际运行一些 JS 代码了。而且还有蛋糕!
-
运行时:最后,实现会运行每个新加载模块主体中的语句。到此时,`import` 处理已经完成,所以当执行到达代码行,并且该行包含 `import` 声明时……什么都不会发生!
看到了吧?我告诉你答案是“什么都不会发生”。我不会说谎关于编程语言。
但是现在我们来到了这个系统有趣的部分。有一个很酷的技巧。因为系统没有指定加载是如何工作的,而且你可以通过查看源代码中的 `import` 声明提前找出所有依赖关系,所以 ES6 的实现可以自由地在编译时完成所有工作,并将所有模块捆绑到一个文件中以便通过网络传输!像 webpack 这样的工具实际上就是这样做的。
这是一件大事,因为通过网络加载脚本需要时间,而且每次你获取一个脚本时,你可能会发现它包含需要你加载数十个其他脚本的 `import` 声明。一个简单的加载器需要很多网络往返。但是使用 webpack,你不仅可以在今天使用带有模块的 ES6,而且还可以在没有任何运行时性能损失的情况下获得所有软件工程优势。
ES6 中模块加载的详细规范最初是计划的——并且已经构建。它没有出现在最终标准中的原因之一是,关于如何实现这种捆绑功能没有达成共识。我希望有人能解决这个问题,因为正如我们所见,模块加载确实应该标准化。捆绑太棒了,不能放弃。
静态与动态,或者:规则和如何打破它们
对于一门动态语言来说,JavaScript 却为自己创造了一个出奇静态的模块系统。
-
所有类型的 `import` 和 `export` 只能在模块的顶层使用。没有条件导入或导出,你不能在函数作用域中使用 `import`。
-
所有导出的标识符都必须在源代码中显式地按名称导出。你不能以数据驱动的方式编程地循环遍历一个数组并导出大量名称。
-
模块对象是冻结的。没有办法以类似于 polyfill 的方式将新功能添加到模块对象中。
-
模块的所有依赖关系都必须在任何模块代码运行之前被 eagerly 加载、解析和链接。没有语法用于按需延迟加载的 `import`。
-
对于 `import` 错误没有错误恢复机制。一个应用程序可能包含数百个模块,如果任何模块加载或链接失败,就什么也无法运行。你不能在 `try/catch` 块中使用 `import`。(这里的好处是,由于系统非常静态,webpack 可以帮助你在编译时检测这些错误。)
-
没有允许模块在依赖关系加载之前运行一些代码的钩子。这意味着模块无法控制其依赖关系的加载方式。
只要你的需求是静态的,这个系统就相当不错。但是你可以想象有时需要一些小 hack,对吧?
这就是为什么无论你使用哪种模块加载系统,它都将有一个编程 API 与 ES6 静态 `import/export` 语法一起使用。例如,webpack 包含一个 API,你可以用它来进行“代码分割”,按需延迟加载一些模块捆绑包。同一个 API 可以帮助你打破上面列出的其他大多数规则。
ES6 模块 *语法* 非常静态,这很好——它以强大编译时工具的形式得到回报。但是静态语法被设计为与一个丰富的动态编程加载器 API 一起工作。
我什么时候可以使用 ES6 模块?
若要使用今天的模块,你需要一个编译器,例如 Traceur 或 Babel。在本系列的早期,Gastón I. Silva 展示了如何使用 Babel 和 Broccoli 为 Web 编译 ES6 代码;在本文的基础上,Gastón 提供了一个支持 ES6 模块的示例。这篇 由 Axel Rauschmayer 撰写的文章 包含一个使用 Babel 和 webpack 的示例。
ES6 模块系统主要由 Dave Herman 和 Sam Tobin-Hochstadt 设计,他们在多年的争议中,针对所有反对者(包括我)捍卫了该系统静态部分。Jon Coppeard 正在 Firefox 中实现模块。关于 JavaScript 加载器标准 的额外工作正在进行中。在 HTML 中添加类似 `<script type=module>` 的内容的工作预计会紧随其后。
这就是 ES6。
这段旅程太有趣了,我不想让它结束。也许我们应该再做一期。我们可以谈谈 ES6 规范中一些不够重要而没有单独文章的零散内容。也许还可以谈谈未来会发生什么。请下周加入我,欣赏 ES6 深入解析的精彩结局。
33条评论