谈谈ES6的模块机制
在ES6
之前,JavaScript中并没有在语言标准中提供模块定义规范,这对开发规模较大、较复杂的应用造成一定的影响。而在非语言层面,一些开源社区制定了模块定义规范,主要有CommonJS
和AMD
两种。在ES6
中,定义了import
和export
两种语法声明,从而在语言面实现了模块功能。
1.ECMAScript 6
的模块化
1.1ECMAScript 6
之前的模块化
JavaScript最初被设计时并不是用来大型应用的,所以在其设计中也并没有模块化标准。随着其应用越来越广泛,一些开源社区和开发者提出了一些模块标准,如:CommoneJS
模块化、异步模块定义(AMD)等。这些规范在提出后得到了广泛关注,并逐步被应用到了一些企业级的大型应用。
CommoneJS模块化:该标准最成功的应用是Node.js
(Node.js
在CommoneJS
的基础上就进行了一定的扩展)。其主要特点是语法简洁,模块使用同步加载机制,主要使用在服务器端。
异步模块定义(AMD):该标准的典型应该是RequireJS
。其主要特点是模块使用异步加载,主要使用在浏览器端。
1.2ES6
模块机制
ECMAScript 6
基于export
和import
,定义了模块的导出和导入规范,在语言标准层面实现了模块机制。该标准的目标是创建一种能够兼容CommoneJS
和AMD
两标准的规范,即可以像CommoneJS
一样语法简洁、使用单一的接口且支持循环依赖,又可以像AMD
支持异步加载和可配置的模块加载。
ES6
定义的模块标准由两部分组成:
- 声明语法(定义引入与导出)
- 编程式加载接口(API):用于配置如何加载模块和按条件加载模块
ES6
的模块机制具有以下特点:
- 简洁的语法。语法将比
CommoneJS
更简单,只使用export
和import
实现模块的导出和导入- 使用
export
关键字定义导出对象,这个关键字可以无限次使用 - 使用
import
关键字引入导入对象,这个关键字可导入任意数量的模块
- 使用
- 模块结构可以做静态分析。这使得在编译时就能确定模块的依赖关系,以及输入和输出的变量
- 模块支持异步加载
- 为加载模块提供编程支持,可以按需加载
- 比
CommonJS
更优秀的循环依赖处理
1.3 支持状况
ES6
为JavaScript带来了模块机制,但ES6
的模块机制在当前所有的浏览器及Node.js中均不受支持。但我们可以通过一些编译器来对ES6
语法进行转换,从而利用这些新特性给我们项目带来便利:
Babel
-Babel
是一个ES6
语法转换为ES5
语法的转换器,其支持对ES6
模块语法的转换,包括:异步加载、状态隔离、顶级命名空间隔离等。- es6-module-transpiler-将
ES6
模块编译为AMD
规范或者CommonJS
规范的模块 - ES6 module loader-能支持动态加载
ES6
风格的模块 - Traceur-Google开发的JS转换编译器,目的在于支持更多的JavaScript特性包括
ES6
模块
2.模块基础
一个 ES6 的模块是一个包含了 JS 代码的文件。ES6 里没有所谓的module
关键字。一个模块看起来就和一个普通的脚本文件一样,除了以下两个区别:
ES6 的模块自动开启严格模式,即使你没有写
'use strict'
。你可以在模块中使用
import
和export
。
让我们先来看看export
。在模块中声明的任何东西都是默认私有的,如果你想对其他模块 Public,你必须export
那部分代码。我们有几种实现方法,最简单的方式是添加一个export
关键字。
// 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 detectCats(canvas, options) {
var kittydar = new Kittydar(options);
return kittydar.detectCats(canvas);
}
export class Kittydar {
... several methods doing image processing ...
}
// This helper function isn't exported.
function resizeCanvas() {
...
}
...
你可以在function
、class
、var
、let
或const
前添加export
。
如果你想写一个模块,有这些就够了!再也不用把代码放在 IIFE 或者一个回调函数里了。既然你的代码是一个模块,而非脚本文件,那么你生命的一切都会被封装进模块的作用域,不再会有跨模块或跨文件的全局变量。你导出的声明部分则会成为这个模块的 Public API。
除此之外,模块里的代码和普通代码没啥大区别。它可以访问一些基本的全局变量,比如Object
和Array
。如果你的模块跑在浏览器里,它将可以访问document
和XMLHttpRequest
。
在另外一个文件中,我们可以导入这个模块并且使用detectCats()
函数:
// demo.js - Kittydar demo program
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
声明的模块,被引入的模块会先被导入并加载,然后根据依赖关系,每一个模块的内容会使用深度优先的原则进行遍历。跳过已经执行过的模块,以此避免依赖循环。
这便是模块的基础部分,挺简单的。
3.export
与模块导出
3.1export
概述
export
语法声明用于导出函数、对象、指定文件(或模块)的原始值。export
有两种模块导出方式:命名式导出(名称导出)和定义式导出(默认导出),命名式导出每个模块可以多个,而默认导出每个模块仅一个。
export
可能会有以下几种形式的导出语法:
export { name1, name2, …, nameN };
export { variable1 as name1, variable2 as name2, …, nameN };
export let name1, name2, …, nameN; // 也可以是 var
export let name1 = …, name2 = …, …, nameN; // 也可以是 var, const
export default expression;
export default function (…) { … } // 也可以是 class, function*
export default function name1(…) { … } // 也可以是 class, function*
export { name1 as default, … };
export * from …;
export { name1, name2, …, nameN } from …;
export { import1 as name1, import2 as name2, …, nameN } from …;
name1… nameN
-导出的“标识符”。导出后,可以通过这个“标识符”在另一个模块中使用import
引用default
-设置模块的默认导出。设置后import
不通过“标识符”而直接引用默认导入*
-继承模块并导出继承模块所有的方法和属性as
-重命名导出“标识符”from
-从已经存在的模块、脚本文件…导出
3.2 模块导出相关介绍
命名式导出
模块可以通过export
前缀关键词声明导出对象,导出对象可以是多个。这些导出对象用名称进行区分,称之为命名式导出
。
export { myFunction }; // 导出一个已定义的函数
export const foo = Math.sqrt(2); // 导出一个常量
我们可以使用*
和from
关键字来实现的模块的继承:
export * from 'article';
模块导出时,可以指定模块的导出成员。导出成员可以认为是类中的公有对象,而非导出成员可以认为是类中的私有对象:
var name = 'IT笔录';
var domain = 'http://itbilu.com';
export {name, domain};
模块导出时,我们可以使用as
关键字对导出成员进行重命名:
var name = 'IT笔录';
var domain = 'http://itbilu.com';
export {name as siteName, domain};
默认导出
默认导出
也被称做定义式导出
。命名式导出可以导出多个值,但在在import
引用时,也要使用相同的名称来引用相应的值。而默认导出每个导出只有一个单一值,这个输出可以是一个函数、类或其它类型的值,这样在模块import
导入时也会很容易引用。
export default function() {}; // 可以导出一个函数
export default class(){}; // 也可以出一个类
命名式导出与默认导出
默认导出可以理解为另一种形式的命名导出,默认导出可以认为是使用了default
名称的命名导出。
下面两种导出方式是等价的:
const D = 123;
export default D;
export { D as default };
3.3export
使用示例
使用名称导出一个模块时:
// "my-module.js" 模块
export function cube(x) {
return x * x * x;
}
const foo = Math.PI + Math.SQRT2;
export { foo };
在另一个模块(脚本文件)中,我们可以像下面这样引用:
import { cube, foo } from 'my-module';
console.log(cube(3)); // 27
console.log(foo); // 4.555806215962888
使用默认导出一个模块时:
// "my-module.js"模块
export default function (x) {
return x * x * x;
}
在另一个模块(脚本文件)中,我们可以像下面这样引用,相对名称导出来说使用更为简单:
// 引用 "my-module.js"模块
import cube from 'my-module';
console.log(cube(3)); // 27
4.import
4.1import
概述
import
语法声明用于从已导出的模块、脚本中导入函数、对象、指定文件(或模块)的原始值。import
模块导入与export
模块导出功能相对应,也存在两种模块导入方式:命名式导入(名称导入)和定义式导入(默认导入)。
import
可能会有以下几种形式的导入语法:
import defaultMember from "module-name";
import * as name from "module-name";
import { member } from "module-name";
import { member as alias } from "module-name";
import { member1 , member2 } from "module-name";
import { member1 , member2 as alias2 , [...] } from "module-name";
import defaultMember, { member [ , [...] ] } from "module-name";
import defaultMember, * as name from "module-name";
import "module-name";
name
-从将要导入模块中收到的导出值的名称member, memberN
-从导出模块,导入指定名称的多个成员defaultMember
-从导出模块,导入默认导出成员alias, aliasN
-别名,对指定导入成员进行的重命名module-name
-要导入的模块。是一个文件名as
-重命名导入成员名称(“标识符”)from
-从已经存在的模块、脚本文件等导入
4.2 模块导入相关介绍
命名式导入
我们可以通过指定名称,就是将这些成员插入到当作用域中。导出时,可以导入单个成员或多个成员:
import {myMember} from "my-module";
import {foo, bar} from "my-module";
模块对象
通过*
符号,我们可以导入模块中的全部属性和方法。当导入模块全部导出内容时,就是将导出模块('my-module.js')所有的导出绑定内容,插入到当前模块('myModule')的作用域中:
import * as myModule from "my-module";
import * as cows from "cows";
当你import *
,被引入进来的是一个module namespace object
。它的属性是那个模块的导出,所以如果 “myModule” 模块导出了一个名为moo()
的函数,当你像这样引入了 “myModule” 之后,你可以这样写myModule.moo()
。
导入模块对象时,也可以使用as
对导入成员重命名,以方便在当前模块内使用:
import {reallyReallyLongModuleMemberName as shortName} from "my-module";
导入多个成员时,同样可以使用别名:
import {reallyReallyLongModuleMemberName as shortName, anotherLongModuleName as short} from "my-module";
导入一个模块,但不进行任何绑定:
import "my-module";
默认导入
在模块导出时,可能会存在默认导出。同样的,在导入时可以使用import
指令导出这些默认值。
直接导入默认值:
import myDefault from "my-module";
也可以在命名空间导入和名称导入中,同时使用默认导入:
import myDefault, * as myModule from "my-module"; // myModule 做为命名空间使用
或
import myDefault, {foo, bar} from "my-module"; // 指定成员导入
4.3import
使用示例
导入一个二级文件,用于在当前模块中进行AJAX JSON请求:
// --file.js--
function getJSON(url, callback) {
let xhr = new XMLHttpRequest();
xhr.onload = function () {
callback(this.responseText)
};
xhr.open("GET", url, true);
xhr.send();
}
export function getUsefulContents(url, callback) {
getJSON(url, data => callback(JSON.parse(data)));
}
// --main.js--
import { getUsefulContents } from "file";
getUsefulContents("http://itbilu.com", data => {
doSomethingUseful(data);
});
5.聚合模块
有时候一个包的主模块会引入许多其他模块,然后再将它们以一个统一的方式导出。为了简化这样的代码,我们有一个 import-and-export 的简写方法:
// 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`的表达式和后面跟了一个`export`的`import-from`表达式类似。但和真正的导入不同,它并不会在你的作用域中加入二次导出的变量绑定。所以如果你打算在`world-foods.js`写用到了`Tea`的代码,就别使用这个简写形式。
如果 "singapore" 导出的某一个变量恰巧和其他的导出变量名冲突了,那么这里就会出现一个错误。所以你应该谨慎使用`export *`。
import
到底干了啥
ES6 将模块的加载细节完全交给了实现,其余的执行部分则规定得非常详细。
大致来说,当 JS 引擎运行一个模块的时候,它的行为大致可归纳为以下四步:
解析:引擎实现会阅读模块的源码,并且检查是否有语法错误。
加载:引擎实现会(递归地)加载所有被引入的模块。这部分咱还没标准化。
链接:引擎实现会为每个新加载的模块创建一个作用域,并且将模块中的声明绑定填入其中,包括从其他模块中引入的
执行:终于,JS 引擎开始执行刚加载进来的模块中的代码。到这个时候,
import
的处理过程已经完成,因此当 JS 引擎执行到一行import
声明的时候,它啥也不会干。
因为ES6体系并没有指定加载的细节,也因为你只需要看一眼源码中的import
声明就可以在运行前搞清楚模块的依赖,某些 ES6 的实现甚至可以通过预处理就完成所有的工作,然后将模块全部打包成一个文件,最后通过网络分发。像webpack这样的工具就是做这个事情的。
这非常的了不起,因为通过网络加载资源是非常耗时的。假设你请求一个资源,接着发现里面有import
声明,然后你又得请求更多的资源,这又会耗费更多的时间。一个 naive 的 loader 实现可能会发起许多次网络请求。但有了 webpack,你不仅可以在今天就开始使用 ES6,还可以得到一切模块化的好处并且不向运行时性能妥协。
原先我们计划过一个详细定义的 ES6 模块加载规范,而且我们做出来了。它没有成为最终标准的原因之一是它无法与打包这一特性调和。模块系统需要被标准化,打包也不应该被放弃,因为它太好了。
动态 VS 静态,或者说:规矩和如何打破规矩
作为一门动态编程语言,JavaScript 令人惊讶地拥有一个静态的模块系统。
import
和export
只能写在顶级作用域中。你无法在条件语句中使用引入和导出,你也不能在你写的函数作用域中使用import
。所有的导出必须显示地指定一个变量名,你也无法通过一个循环动态地引入一堆变量。
模块对象被封装起来了,我们无法通过 polyfill 去 hack 一个新 feature。
在模块代码运行之前,所有的模块都必须经历加载,解析,链接的过程。没有可以延迟加载,惰性
import
的语法。对于
import
错误,你无法在运行时进行 recovery。一个应用可能包含了几百个模块,其中的任何一个加载失败或链接失败,这个应用就不会运行。你无法在try/catch
语句中import
。(不过正因为 ES6 的模块系统是如此地静态,webpack 可以在预处理时就为你检测出这些错误)。你没办法 hook 一个模块,然后在它被加载之前运行你的一些代码。这意味着模块无法控制它的依赖是如何被加载的。
只要你的需求都是静态的话,这个模块系统还是很 nice 的。但你还是想 hack 一下,是吗?
这就是为啥你使用的模块加载系统可能会提供 API。举个栗子,webpack 有一个 API,允许你 “code splitting”,按照你的需求去惰性加载模块。这个 API 也能帮你打破上面列出的所有规矩。
ES6 的模块是非常静态的,这很好——许多强大的编译器工具因此收益。而且,静态的语法已经被设计成可以和动态的,可编程的 loader API 协同工作。
我何时能开始使用 ES6 模块?
如果你今天就要开始使用,你需要诸如Traceur和Babel这样的预处理工具。这个系列专题之前也有文章介绍了如何使用 Babel 和 Broccoli去生成可用于 Web 的 ES6 代码。那篇文章的栗子也被开源在了 GitHub 上。这篇文章也介绍了如何使用 Babel 和 webpack。
ES6 模块系统的主要设计者是 Dave Herman 和 Sam Tobin-Hochstadt,此二人不顾包括笔者在内的数位委员的反对,始终坚持如今你见到的 ES6 模块系统的静态部分,争论长达数年。Jon Coppeard 正在火狐浏览器上实现 ES6 的模块。之后包括JavaScript Loader 规范在内的工作已经在进行中。HTML 中类似<script type=module>
这样的东西之后也会和大家见面。