ES6 in Depth: Modules

ES6 In Depth: Modules

当我在2007年加入到Mozilla的JS团队时,有一笑话是经典的JS程序只有一行代码。

那时已经是Google Map开始的两年之后了。在此之前,最为卓越的JS代码就是表单的验证,那么你在<input onchange=>上的平均处理代码也就是一行,对的,已经足够了。

事情已经发生了变化。JS项目已经成长为令人惊奇的量级,同时社区中也开发了一些工具来适应规模的变化。其中一个最为基本的事情是你需要模块系统,它可以将你的工作分离到多个文件和目录中,但是它依然保证在必要时能访问其他部分,同时使得加载所有代码更为有效。所以实际上,JS已经有了模块系统,并且有几种。同时,还有些包管理工具来安装软件和复制其高层的依赖。你可能会想到ES6,它为JS带来了新的模块系统,确实有点晚了些。

好的,今天我们就开始来看ES6为这已经存在的系统增加了些什么功能,同时看一下可以使用它来做些什么标准和工具吧。但是首先,让我们开始查看ES6模块长什么样子吧。

模块基础

ES6的模块是一个包含有JS代码的模块。它没有module的关键字,一个模块几乎可以看成为一个脚本。这里有两个区别:

  • ES6模块会自动启用 严格模式,即使你没有在代码中使用”use strict”
  • 你可以在模块中使用importexport

让我们先来说一下export。在模块中的任何声明,默认都只会作用于此模块。如果你想在模块中的声明公开出去,也就是其它模块能使用它,你必须暴露这个特性(变量、函数等)。有些方式可以做到这点,最为简单的方式就是添加一个export的关键字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// kittydar.js - Find the locations of all the cats in an image.
//(Heather Arthur wrote this library for real)
// (but she didn't use modules, because it was 2013)

export function delectCats(cavas, options) {
  var kittydar = new Kittydar(options);
  return kittydar.detectCats(canvas);
}

export clas Kittydar {
  ... several methods doing image processing ...
}

// This helper function isn't exported.
function resizeCanvas() {
  ...
}

你可以export任何顶级的functionclassvarletconst

这也就是你需要知道有关模块的所有信息。你并不需要使用IIFE或者一个回调函数。你只要正常地声明你所需要的任何事情。因此,这代码就是模块,不是脚本,所有的声明的作用域都会在此模块中,并不会在全局所有的脚本和模块是可见的。将这些声明暴露出去,会使这成为此模块对外的公开API,这就是你想要的。

与暴露代码不同,在模块中的代码仅仅只是普通的代码。你可以使用全局字面量,如ObjectArray。如果你模块运行于浏览器中,你可以使用documentXMLHttpRequest

在一个分离的文件中,我们可能导入并使用detectCats()函数:

1
2
3
4
5
6
7
8
9
// domo.js - Kittydar demo program

import {detectCats} from "kittydar.js";

function go(){
  var canvas = document.getElementById("catpix");
  var cats = detectCats(canvas);
  drawRectangles(canvas, cats);
}

为了从一个模块中导入多个名称,你可以编写:

1
import {detectCats, Kittydar) from "kittydar.js";

当你运行一个含有import声明的模块时,需要导入的模块会先被加载,每个模块的代码体会根据整个依赖图进行深度遍历执行,并跳过已经执行的模块来避免循环加载。

这就是模块的基础内容了,这确实是十分简单的,;-)

导出列表

相比于在每个特性中进行导出的标记,你可以编写一个简单的列表来指出你想导出的所有名称,并放到大括号中:

1
2
3
4
5
export {detectCats, Kittydar};

// no export keyword required here
function detectCats(canvas, options) { ... }
class Kittydar { ... }

export的列表并不一定需要在文件的第一行,它可以出现在模块的顶级的任意地方。你可以拥有多个export列表,或者与其它的export混合起来用,只要保证导出的名称不超过一次就好。

对导入导出重命名

有时,一个导入的名称可能会与其它你同样需要使用的名称是一样的,所以ES6允许你对导入的名称进行重命名:

1
2
3
4
5
6
// suburbia.js

// Both these modules export something named `flip`.
// To import them both, we must rename at least one.
import {flip as flipOmelet} from "eggs.js";
import {flip as flipHouse} from "real-estate.js";

类似的,你可以对导出的名称进行重命名。有时可能会出现,你希望以不同的名称导出相同的值,如下:

1
2
3
4
5
6
7
8
9
10
11
// unlicensed_nuclear_accelerator.js - media streaming without drm
// (not a real library, but maybe it should be)

function v1() { ... }
function v2() { ... }

export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};

默认导出

设计的新标准是能与已经存在的CommonJS和AMD模块能正常溶合,所以当你一个Node项目,并且完成命令npm install lodash。你的ES6代码可以从lodash导入单独的函数:

1
2
3
import {each, map} from "lodash";

each([3,2,1], x=> console.log(x));

但是,你可能已经习惯看到_.each而不是each,并且你希望编写代码依然保持这方式。或者自从有 这个,你还想使用_作为一个函数。

为了做到这点,你可以稍微地调整为不同的语法:导入模块时不使用大括号:

1
import _ from "lodash";

这其实就是等价于import {default as _} from "lodash"。所有的CommonJS和AMD模块都会为ES6提供一个default的导出名称,这其实就类似于你require()这模块,也就是exports对象。

ES6模块被设计为可以让你导出多个事物,但在已经存在的CommonJS模块中,默认的导出是包含所有事物。例如,有名的colors包在我说之前并没有任何对ES6的支持。它就是一个CommonJS的集合,就像很多在npm的包一样。但是,你可以明确地告诉ES6代码需要导入的内容。

1
2
// ES6 equivalent of `var colors = require("colors/safte");`
import colors from "colors/safe"

如果你希望你的ES6模块有个默认的导出,那很容易。这并没有什么魔法性的操作,它就像其它的导出操作,只是将其命名为"default"。你可以使用我们之前提到的重命名的语法:

1
2
3
4
5
let myObject = {
  field1: value1,
  field2: value2
};
export {myObject as default};

或者使用缩写:

1
2
3
4
export default {
  field1: value1,
  field2: value2
}

关键字export default后面可以接任意的值:一个函数,一个类,一个你命名的对象字面量。

模块对象

十分抱歉,这部分内容有些长。但是,这并不仅仅是JS特有的。因为某些原因,所有语言中的模块系统都试图做到足够的小、方便。幸运,还剩一个特性。好吧,是两个。

1
import * as cows from "cows";

当你使用import *时,它导入的是一个模块命名对象。它的属性是这一模块的所有导出。所以,如果”cow”模块导出有一个名为moo()的函数,那么在此行代码导入”cows”之后,你可以使用cows.moo()

聚合模块

有时,主模块可能会将其它模块的包导入进来,同时会将它们又以非统一的方式导出。为了简化这一类的代码,下面有个聚合的从导入到导出的缩写:

1
2
3
4
5
6
7
8
9
10
// world-foods.js - good stuff from all over

// import "sri-lanka" and re-export some of its exports
export {Tea, Cinnamon} from "sri-lanka";

// import "equatorial-guinea" and re-export some of its exports
export {Coffee, Cocoa} from "equatorial-guinea";

// import "singapore" and export ALL of its exports
export * from "singapore";

每个这些export-from的表达式都类似于import-from,只是调整为export。并不像导入一样,这并不会重新添加导出的绑定到主模块的作用域中。所以,如果你想在world-foods.js中编写代码使用Tea,就不要使用这一缩写。你会发现找不到这字面量。

如果在导出”singapore”时会与其它的导出有冲突,会产生一个错误,所以请小心使用export *

好了,我们介绍完语法了。让我们开始些有趣的事情。

import实际上做了什么

你会相信 它没什么吗?

好吧,你不是那么好欺骗的。好的,你会相信标准委员会几乎没有对import进行说明?这是好事吗?

ES6的模块加载的整个详情可以移步到 其实现,它的实现详情可移步于 详细说明

粗略来讲,当你告诉JS引擎在运行一个模块时,它不得不进行如下四个步骤:

  • 1、解析:读取模块资源的实现代码并检查语法错误
  • 2、加载:递归地加载所有需要导入的模块,这一部分还没有标准化。
  • 3、链接:对于每个加载完成的模块,会为其创建一个作用域,并将此模块声明绑定到此作用域中,包括从其它模块导入过来的事物。

这就是import {cake} from "paleo"的部分。但是,如果”paleo”模块并没有导出任何名为”cake”的字面量,你将会得到一个错误。这是很坏的体验,因为你已经实际上运行了一些JS代码了。

  • 4、运行:最后,开始运行每个新加载的模块体。这时候,import的处理进程已经完成了,所以当代码执行到含有import声明的代码行时,没有什么会发生。

看?我之前就告诉你答案是“没什么”。我对编程语言并不会撒谎。

但是,现在我们开始接触这模块系统的有趣部分了,这是个感觉好玩的点。因为模块系统并没有指定怎么加载模块,你可以在开始时候,找出在资源代码中所有的import声明。ES6的其中一个实现方式,是将所有的工作都放到编译阶段,并将所有的模块捆绑放入一个文件中,才发送给网络上。webpack这工具实际上就是这么做的。

这是个大的话题,因为加载脚本会花费网络的时间。当你每次获取时,你会查找其import的声明,时间就会成倍增长了。比较天真的加载方式是会发送多个网络请求,但通过webpack,这不仅仅是今天使用ES6模块,你会自动得到所有软件工程所要到达的运行时优点。

ES6模块加载的详情还是与原始计划的一样,并构建起来的。其中一个原因就是因为没有统一怎么实现此特性,所以它并不是最后的标准。我希望某人能够指出来,因为就如我们看到的,模块的加载确实需要标准化,而且打包非常有用,是不能放弃的。

静态 vs 动态,或者:规则或打破规则

作为动态语言,JS已经拥有其令人惊奇的静态模块系统。

  • 所有的importexport都只允许在模块的顶级声明,导入导出没有额外的限制条件,但你不能在函数作用域中使用。

  • 所有的导出定义必须是在资源代码中存在有明确的名称。你不能通过编程来循环一个数组并导出一堆的名称。

  • 模块对象是冻结的。不能通过hack的方式来操作模块对象,polyfill方式的也不行。

  • 在任一模块代码运行之前,所有模块依赖必须加载完成、解析和关联上。按照要求,不允许import导入模块懒加载的。

  • 对于import的错误没有任何的恢复机制。一个App可能会有上百的模块,如果有任何的加载或者关联失败,所有代码将不会运行。你不能在try/catch中使用import。(这里有个优点,因为这模块系统是静态的,所以webpack可以在编译阶段检查出可能存在的错误。)

  • 在没有加载完依赖之前,不允许运行模块中的任何代码。这意味着如果依赖没有加载完成时,模块本身不知道怎么控制代码的运行。

如果你需求是静态的话,这模块系统是十分好的。但是,有时你可能需要些hack,是不是?

这就是为什么你需要编程API来处理与ES6的import/export语法相违背的模块系统加载机制。例如,webpack includes an API 你可以使用“code splitting”,按需求对某些模块进行懒加载。这个API可以让你打破上面所提到的多数规则。

ES6的模块语法是十分静态的(晕,什么叫十分静态),同时它也是好的,因为这样可以缩短其编译工具的时间。但是,通过编程后的加载API,这静态的语法已经可以动态操作了。

什么时候可以使用这ES6的模块?

现在为了使用模块,你需要一个编译器,如 Traceur 或者 Babel。早些时候,Gaston I. Silva 有一文章 来说明怎么为Web编译ES6代码。在这一文章中,Gaston 已经有一个例子是有关ES6模块的。这有个Axel Rauschmayer 编写的例子,它使用Bable和webpack。

模块系统主要由 Dave Herman 和 Sam Tobin-Hochstadt 设计,他们为此模块系统的静态化辩护,同时为此长年与包括我在内的很多人抗争着。Jon Coppeard 实现了 Firefox 中的模块。另外的JS 加载器标准化也正在进行中,人们所希望的在HTML中添加<script type=module>的特性也会随之而来。

这就是ES6。

这些实在是太有意思了,以致我并不想结束。也许,我们只是完成了部分的故事情节。我们可以讨论些ES6说明中零碎的特性,但它们又不能足够单独写成文章。也许,将来会对这些进行讨论。请在下周加入我们,一起对深入ES6进行个完美的总结吧。

Comments