深入浅出ES6(十六):模块 Modules
作者 Jason Orendorff github主页 https://github.com/jorendorff
早在2007年我刚加入Mozilla的JavaScript团队的时候广为流传一个笑话:通常来说JavaScript程序的长度只有一行。
那时候Google Maps的诞龄还只有两岁,在它诞生之前,JavaScript主要被用来验证表单,毫无疑问,每一个处理<input onchange=>
的程序真的就只有一行代码。
今 非昔比,JavaScript项目的规模已经成长到令人惊叹的地步,社区也开发了很多工具来协助构建这些规模庞大的应用。诸多工具中最核心的非模块系统莫 属,在这样一个系统中,你可以通过多个文件和目录来组织你的项目,最重要的是,所有代码都可以按需彼此访问并高效加载。所以JavaScript就这么顺 理成章地拥也了几个知名的模块系统。当然,随之而来的还有几个包管理器,可以用它们安装所有软件以及处理高层次的依赖。如此一来,你一定会觉得ES6的模 块语法真是姗姗来迟啊。
好的,那今天我们就一起看看ES6中新增的模块系统,探讨一下我们可以通过新语法打造什么样的工具并进一步推动未来标准的发展。但是首先,我们还是来了解一下ES6模块的基础知识。
模块基础知识
每一个ES6模块都是一个包含JS代码的文件,模块本质上就是一段脚本,而不是用module
关键字定义一个模块,但是模块与脚本还是有两点区别:
- 在ES6模块中,无论你是否加入“
use strict;
”语句,默认情况下模块都是在严格模式下运行。 - 在模块中你可以使
用import
和export
关键字。
我们先来讨论export
。默认情况下,你在模块中的所有声明相对于模块而言都是寄存在本地的。如果你希望公开在模块中声明的内容,并让其它模块加以使用,你一定要导出这些功能。想要导出模块的功能有很多方法,其中最简单的方式是添加export
关键字。
// kittydar.js - 找到一幅图像中所有猫的位置 // (事实上是Heather Arthur写的这个库) // (但是她没有使用ES6中新的模块特性,因为那时候是2013年) export function detectCats(canvas, options) { var kittydar = new Kittydar(options); return kittydar.detectCats(canvas); } export class Kittydar { ... 处理图片的几种方法 ... } // 这个helper函数没有被export。 function resizeCanvas() { ... } ...
你可以导出
所有的最外层函数
、类
以及var
、let
或const
声明的变量。
了解这些,你就可以编写一个简单的模块。你不需要将所有代码都放在一个IIFE或回调中,你只需要在模块中解放手脚,声明你需要的所有内容。代码就是模块,不是一段脚本,所以所有的声明都被限定在模块的作用域中,对所有脚本和模块全局不可见。你需要做的是将组成模块公共API的声明全部导出。
在模块中,除export之外的代码无异于普通代码,你可以访问类似Object
和Array
这样的全局对象。如果你在web浏览器中运行模块,你甚至可以使用document
对象和XMLHttpRequest
对象。
在一个独立文件中,我们可以导入detectCats()
函数然后用它来做点儿什么:
// demo.js - Kittydar的demo程序 import {detectCats} from "kittydar.js"; function go() { var canvas = document.getElementById("catpix"); var cats = detectCats(canvas); drawRectangles(canvas, cats); }
如果想从一个模块中导入多个名称,你可以这样写:
import {detectCats, Kittydar} from "kittydar.js";
当你运行的模块中包含一条import
声明时,首先会加载被导入的模块;然后依赖图的深度优先遍历按顺序执行每一个模块的主体代码;为了避免形成回环,所有已执行的模块都会被忽略。
这些就是模块的基本知识了,相当简单吧。;-)
Export列表
你不需要标记每一个被导出的特性,你只需要在花括号中按照列表的格式写下你想导出的所有名称:
export {detectCats, Kittydar}; // 此处不需要 `export`关键字 function detectCats(canvas, options) { ... } class Kittydar { ... }
export
列表可以在模块文件最外层作用域的每一处声明,不一定非要把它放在模块文件的首行。你也可以声明多个export
列表,甚至通过其它的export
声明打造一个混合的export
列表,只要保证每一个被导出的名称是唯一的即可。
重命名import和export
恰恰有时候,导出的名称会与你需要使用的其它名称产生冲突,ES6为你提供了重命名的方法解决这个问题,当你在导入名称时可以这样做:
// suburbia.js // 这两个模块都会导出以`flip`命名的东西。 // 要同时导入两者,我们至少要将其中一个的名称改掉。 import {flip as flipOmelet} from "eggs.js"; import {flip as flipHouse} from "real-estate.js"; ...
同样,当你在导出的时候也可以重命名。你可能会想用两个不同的名称导出相同的值,这样的情况偶尔也会遇到:
// unlicensed_nuclear_accelerator.js - 无DRM(数字版权管理)的媒体流 // (这不是一个真实存在的库,但是或许它应该被做成一个库) function v1() { ... } function v2() { ... } export { v1 as streamV1, v2 as streamV2, v2 as streamLatestVersion };
Default exports
现在广泛使用的模块系统有CommonJS、AMD两种,设计出来的新标准可以与这两种模块进行交互。所以假设你有一个Node项目,你已经执行了npm install lodash
,你的ES6模块可以从Lodash中导入独立的函数:
import {each, map} from "lodash"; each([3, 2, 1], x => console.log(x));
但是也许你已经习惯看到_.each
的书写方式而不想直接用each
函数呢?或者你就真的想导入整个_
函数呢,毕竟_对于Lodash而言至关重要。
针对这种情况,你可以换用一种稍微不太一样的方法:不用花括号来导入模块。
import _ from "lodash";
这种简略的表达方法等价于import {default as _} from "lodash";
。在ES6的模块中导入的CommonJS模块和AMD模块都有一个默认的
导出,如果你用require()
加载这些模块也会得到相同的结果——exports
对象。
ES6模块不只导出CommonJS模块,它的设计逻辑为你提供导出不同内容的多种方法,默认导出的是你得到的所有内容。举个例子,在用这种写法的时候,据我所知,著名的colors包就没有任何针对ES6的支持。像大多数npm上的包一样,它是诸多CommonJS模块的集合,但是你可以正确地将它导入到你的ES6代码中。
// `var colors = require("colors/safe");`的ES6等效代码 import colors from "colors/safe";
如果你想让自己的ES6模块有一个默认的导出,实现的方法很简单,默认导出与其它类型的导出相似,没有什么技巧可言,唯一的不同之处是它被命名为“default
”。你可以用我们刚才讨论的重命名语法来实现:
let myObject = { field1: value1, field2: value2 }; export {myObject as default};
这种简略的表达方法看起来更清爽:
export default { field1: value1, field2: value2 };
关键字export default
后可跟随任何值:一个函数、一个类、一个对象字面量,只要你能想到的都可以。
模块对象
很抱歉新特性有点儿多,但JavaScript不是唯一这样做的语言:出于某些原因,每一种语言中的模块系统都有这么一堆又独立又小,虽然无聊但是很方便的特性。不过还好,我们只剩一样东西没讲了。好吧,是两样。
import * as cows from "cows";
当你import *
时,导入的其实是一个模块命名空间对象,模块将它的所有属性都导出了。所以如果“cows”模块导出一个名为moon()
的函数,然后用上面这种方法“cows”将其全部导入后,你就可以这样调用函数了:cows.moo()
。
聚合模块
有时一个程序包中主模块的代码比较多,为了简化这样的代码,可以用一种统一的方式将其它模块中的内容聚合在一起导出,可以通过这种简单的方式将所有所需内容导入再导出:
// world-foods.js - 来自世界各地的好东西 // 导入"sri-lanka"并将它导出的内容的一部分重新导出 export {Tea, Cinnamon} from "sri-lanka"; // 导入"equatorial-guinea"并将它导出的内容的一部分重新导出 export {Coffee, Cocoa} from "equatorial-guinea"; // 导入"singapore"并将它导出的内容全部导出 export * from "singapore";
这些export-from
语句每一个都好比是在一条import-from
语句后伴随着一个export
。与真正的导入内容的方法不同的是,这些导入内容再重新导出的方法不会在作用域中绑定你导入的内容。如果你打算用world-foods.js
中的Tea
来写一些代码,可别用这种方法导入模块,你会发现当前模块作用域中根本找不到Tea
。
如果从“singapore”导出的任何名称碰巧与其它的导出冲突了,可能会触发一个错误,所以使用export *
语句的时候要格外小心。
呼!终于讲完了所有的语法!现在来讲一些有趣的内容。
import实际都做了些什么?
如果我说它什么都没做,你敢信?
哦,看来你没那么容易上当啊。好吧,你相信标准里面通常都不会规定import
的行为么?如果真是这样,那这是件好事儿么?
ES6将模块加载过程的细节完全交由最终的实现来定义,模块执行的其它部分倒是在规范中有详细定义。
粗略地讲,当你通知JS引擎运行一个模块时,它一定会按照以下四个步骤执行下去:
- 语法解析:阅读模块源代码,检查语法错误。
- 加载:递归地加载所有被导入的模块。这也正是没被标准化的部分。
- 连接:每遇到一个新加载的模块,为其创建作用域并将模块内声明的所有绑定填充到该作用域中,其中包括由其它模块导入的内容。
- 如果你的代码中有
import {cake} from "paleo"
这样的语句,而此时“paleo”模块并没有导出任何“cake
”,你就会触发一个错误。这实在是太糟糕了,你都快要运行模块中的代码了,都是cake惹的祸! - 运行时:最终,在每一个新加载的模块体内执行所有语句。此时,导入的过程就已经结束了,所以当执行到达有一行
import
声明的代码的时候……什么都没发生!
看到了嘛?我可告诉过你结果是“啥都没有”哦。事关编程语言我绝不撒谎!
但是现在我们真的要深入了解这个系统最有趣的部分了!有一个很酷的小技巧我可以教给你。系统不指定加载过程的实现方式,你也可以通过在源代码中查找import
声明提前计算出所有依赖,你可以将ES6系统实现为:在编译时计算所有依赖并将所有模块打包成一个文件,通过网络一次传输所有模块!像webpack这样的工具就实现了这个功能。
这种做法的意义非常深远,因为通过网络加载脚本需要花费时间,每当你请求到一个模块,你可能发现它里面也包含着import
声 明,这就需要你再花费一些时间加载更多的脚本。基于如此天真的思想实现的加载器需要消耗更多的网络往返时间。但是webpack就不一样啦,它所用的加载 器是经过精心设计的,吸收了软件工程领域的精华,所以你不仅可以立即开始使用ES6模块系统,还不会损耗运行时的性能。
最初的时候,标准委员会已经制定并实现了详细的ES6模块加载标准,它未成为最终的标准的原因是成员们没有就代码封包(bundle)功能的实现方式达成一 致意见。我希望有人能搞定这个问题,正如我们所见,模块加载的过程亟待被标准化;最关键的是,封包的功能实在是太好,就这样放弃对其进行标准化有些可惜 啊。
静态vs动态:论规则及破例之法
JavaScript作为一门动态语言已经得到了一个令人惊讶的静态模块系统。
- 你只可以在模块的最外层作用域使用
import
和export
,不可在条件语句中使用,也不能在函数作用域中使用import
。 - 所有导出的标识符一定要在源代码中明确地导出它们的名称,你不能通过编写代码遍历一个数组然后用数据驱动的方式导出一堆名称。
- 模块对象被冻结了,所以你无法hack模块对象并为其添加polyfill风格的新特性。
- 一个模块的所有依赖必须在模块代码运行前完全加载、解析并且及早连接,不存在一种通过
import
来按需懒加载的语法。 import
模块产生的错误没有错误恢复机制。一个app可能囊括了上百个模块,一旦有一个模块无法加载或连接,所有的模块都不会运行,而且你不能在try/catch
代码块中捕捉import
的错误信息。(上面这些描述的本意是说:系统是静态的,webpack可在编译时为你检测那些错误。)- 不支持在模块加载依赖前运行其它代码的钩子,这也意味着无法控制模块的依赖加载过程。
只要你的需求是静态的,系统就会运行良好,但是你有时可以设想下需要一点儿hack,对么?
这也就是无论你用什么模块加载系统,你都将有一个编程API来支持ES6的静态import/export
语法。举个例子,webpack中引入了一个“代码分割”API,从而可以按需懒加载一些模块的多个封包。相同的API可以帮你打破上面列举的绝大多数其它的规则。
ES6模块语法非常静态,这是很好的——它通过强有力的编译时工具的形式进行弥补。但是设计静态语法的初衷是要与丰富的动态编程加载器API一起增强ES6的模块系统。
我什么时候可以使用ES6模块?
如果你现在就想在项目中加入新的模块语法,你需要使用Babel或Traceur这样的转译器。在系列之前的文章中,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的全部啦。