【译】《Understanding ECMAScript6》- 第八章-Module
目录
JavaScript令人困惑并且易引发错误的特性之一是以“一切皆共享”的方式加载代码。所有文件内定义的一切代码都共享一个全局作用域,这一点是JavaScript落后于其他编程语言之处(比如Java中的package)。随着web应用变得越来越庞大复杂,“一切皆共享”的方式暴露出一系列弊端,比如命名冲突、安全性等等。ES6的目标之一便是解决这种问题,增强JavaScript代码组织的有序性。这就是Module(模块)的作用。
module是什么
Module可以简单理解为加载JavaScript文件的一种特殊方式。目前,不论是浏览器还是NodeJS,都没有实现原生ES6 Module的支持,但是我们可以期待Module作为一种默认的机制被广泛使用。模块化的代码与非模块的代码有以下区别:
- 模块化代码强制在严格模式下执行;
- 一个模块最顶层作用域中定义的变量不会暴露在共享的全局域内;
- 一个模块的最顶层作用域中的this值为undefined;
- 不支持html格式的注释语法();
- 一个模块必须导出可供模块以外代码使用的接口。
模块化JavaScript文件和常规的文件相同,都是通过文本编辑器撰写,使用.js扩展名。唯一的区别是,模块化代码使用全新的代码语法。
使用基础
export关键字用来导出一个模块暴露给外部的代码。最简单的一种使用方式是在任何变量、函数、class声明语句的前面使用export。如下:
// export data
export var color = "red";
export let name = "Nicholas";
export const magicNumber = 7;
// export function
export function sum(num1, num2) {
return num1 + num1;
}
// export class
export class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
}
// this function is private to the module
function subtract(num1, num2) {
return num1 - num2;
}
// define a function
function multiply(num1, num2) {
return num1 * num2;
}
// export later
export multiply;
需要注意以下几点:
- 不论使用export与否,声明语句的语法与常规一致;
- 被export导出的函数和class必须有明确的类名/函数名。匿名函数/类不能使用上述语法导出;
- export不仅可以在声明语句前使用,也可以用在引用前面,如上述代码中的multiply;
- 没有被明确导出的变量、函数、class被称为当前模块的私有成员,不能被外部代码访问,如上述代码中的substract()函数。
使用export的一个重要限制是,必须在当前模块的最顶层作用域使用,否则会抛出语法错误。如下:
if (flag) {
export flag; // syntax error
}
上述代码中,export在if块级域内使用会抛出语法错误。export不能以任何动态的方式导出,这样做的好处是可以令JavaScript引擎对导出的模块进行清晰地管理。因此,export只能在一个模块的最顶层作用域内使用。
某些转译器(如Babel.js)可以打破这种限制,开发者可以在任何位置使用export。但是这种模式只在代码被转译为ES5规范时能够正常工作,并不支持原生的ES6模块系统。
一旦使用export导出某个模块的功能,便可以在其他模块中通过import关键字使用它。import语句包括两部分:被导入的标识符和此标识符的源模块。如下:
import { identifier1, identifier2 } from "module";
花括号内的标识符代表的是从指定模块中导出的变量。关键字from后的模块名代表的是被导出变量的指定模块。模块名是一个字符串。截止到本书撰写日期,模块名的书写规范仍然未最终定稿。
尽管import后的花括号形式与解构Object类似,但它只是导出标识符的列表,并不是解构Object。
使用import从模块中导出的变量类似于使用const定义的常量。也就是说,在同一作用域内,不能定义与之同名的变量,不能在import之前使用它,也不能重新赋值。
本章第一个例子中的模块我们命名为“example”,你可以使用多种方式导出example模块的标识符,最简单的方式如下:
// import just one
import { sum } from "example";
console.log(sum(1, 2)); // 3
sum = 1; // error
上述代码导出了example模块的sum()函数。不论example模块export多少个接口,开发者可以根据不同的使用场景import任意个数的接口。上述代码中尝试对sum重新赋值,抛出语法错误,验证了被导入的接口变量不能被重新赋值这条规则。
如果想import多个接口变量,可以使用以下方式:
// import multiple
import { sum, multiply, magicNumber } from "example";
console.log(sum(1, magicNumber)); // 8
console.log(multiply(1, 2)); // 2
上述代码中导入了example模块的三个接口变量:sum、multiply和magicNumber。
你还可以将整个模块导出为一个独立的对象,其被export的接口变量作为这个对象的属性使用。如下:
// import everything
import * as example from "example";
console.log(example.sum(1,
example.magicNumber)); // 8
console.log(example.multiply(1, 2)); // 2
上述代码中,example模块作为一个整体被导入,以一个名为example的对象使用,example模块暴露出来的sum()、multiply()和magicNumber作为example对象的属性使用。
需要注意的是,不论使用import多次导入一个模块,被导入模块内部的代码只会被执行一次。如下:
import { sum } from "example";
import { multiply } from "example";
import { magicNumber } from "example";
上述代码中,使用import导入了3次example模块,但是example模块背部的代码钟会被执行一次。在第一次被导入后,example模块被实例化,随后此实例引用将储存在内存中。在此之后,不论import多少次,甚至被多个不同的模块import,都将使用内存中的example模块实例,而不必重复执行模块内部的代码。
接口标识符重命名
通常情况下,为了增强代码的易读性,我们往往不直接使用某个变量、函数或者class的原始名称。ES6的模块规范允许在导出或导入时修改接口标识符的名称。
比如,在导出某个函数时希望更改函数名,可以使用as关键字进行如下修改:
function sum(num1, num2) {
return num1 + num2;
}
export { sum as add };
上述代码中sum()函数在被导出时将接口函数名更改为add(),其他模块在导入此接口函数时必须使用add标识符,如下:
import { add } from "example";
同理,在导入某个模块接口函数时,也可以使用as关键字修改标识符名称:
import { add as sum } from "example";
console.log(typeof add); // "undefined"
console.log(sum(1, 2)); // 3
上述代码在导入接口函数add()时,将标识符名称修改为sum。
导入绑定
需要注意import表达式非常重要的一个细节:import的变量、函数或class并不是简单的引用关系,而是创建了一种绑定关系。换句话说,虽然不能手动修改导入的接口成员,但是可以通过源模块的逻辑进行修改。比如:
export var name = "Nicholas";
export function setName(newName) {
name = newName;
}
当在其他模块中导入name和setName()后,可以通过调用setName()修改name的值:
import { name, setName } from "example";
console.log(name); // "Nicholas"
setName("Greg");
console.log(name); // "Greg"
name = "Nicholas"; // error
调用setName("Greg")时,实际上回到了setName的源模块内执行,从而将name的值修改为“Greg”,并且修改后的结果自动映射到了导入name的模块。
缺省接口
模块export的缺省接口是由default关键字修饰的一个单独的变量、函数或者class。如下:
export default function(num1, num2) {
return num1 + num2;
}
上述代码是一个典型的export缺省接口。default关键字表明这是一个缺省接口,并且缺省接口的函数不需要指定具体的函数名,因为模块本身就代表着此接口函数。
也可以将缺省接口重命名,如下:
// equivalent to previous example
function sum(num1, num2) {
return num1 + num2;
}
export { sum as default };
上述代码等价于前例,as default表明sum函数作为缺省接口被导出。
每个模块只能被定义一个缺省接口。尝试定义多个缺省接口会引起语法错误。
导入缺省接口的语法与前文提到的导入整个模块的语法类似:
// import the default
import sum from "example";
console.log(sum(1, 2)); // 3
上述代码导入example模块的缺省接口。请注意导入的缺省接口标识符并没有包裹在花括号内。这种简洁的语法形式将成为web应用导入已存对象的常用格式:
import $ from "jquery";
如果需要导入某个模块的缺省接口和非缺省接口,可以在一个表达式中实现。比如某个模块暴露出以下接口:
export let color = "red";
export default function(num1, num2) {
return num1 + num2;
}
可以通过以下形式导入:
import sum, { color } from "example";
console.log(sum(1, 2)); // 3
console.log(color); // "red"
上述代码有两点需要注意:
- 使用逗号分隔缺省接口与非缺省接口;
- 非缺省接口包裹在花括号内。
导入缺省接口时可以重命名标识符:
// equivalent to previous example
import { default as sum, color } from "example";
console.log(sum(1, 2)); // 3
console.log(color); // "red"
上述代码中,缺省接口标识符default被重命名为sum,连同非缺省接口color一起被包裹在花括号内。
Re-exporting
某些场景下,开发者需要将导入的模块再次导出,可以使用以下模式:
import { sum } from "example";
export { sum }
除此之外,还有一种更简洁的形式:
export { sum } from "example";
上述代码将example模块的sum接口再次导出。当然,可以使用as在导出时进行重命名:
export { sum as add } from "example";
上述代码导入example模块的sum接口,随后重命名为add再次导出。
使用通配符*可以将模块作为整体导出:
export * from "example";
使用上述代码导出整个example模块时,example模块的缺省接口和非缺省接口全部包括在内,会影响当前模块的导出行为。比如,如果example模块有缺省接口,那么就不能在当前模块中另行定义缺省接口。
非绑定import
某些模块可能只是对某个全局变量进行了修改,并未导出任何接口。虽然模块内部的变量、函数和类并不暴露在全局作用域内,但并不意味着模块内部不能访问全局域的成员。在某个模块内对内置对象(比如Array或Object)进行了扩展修改,其他模块中也会受到影响。
比如,假设现在对Array对象增加一个扩展方法pushAll(),可以在某个模块内进行以下操作:
// module code without exports or imports
Array.prototype.pushAll = function(items) {
// items must be an array
if (!Array.isArray(items)) {
throw new TypeError("Argument must be an array.");
}
// use built-in push() and spread operator
return this.push(...items);
};
虽然上述模块没有导出/导入任何接口,但它本身是一个符合规范的模块。上述代码可以当作一个模块使用,也可以作为一段普通的脚本。由于模块未导出任何接口,你可以使用简化的import表达式执行模块代码,而不必创建绑定关系。如下:
import from "example";
let colors = ["red", "green", "blue"];
let items = [];
items.pushAll(colors);
上述代码将example模块导入并执行,Array的扩展方法pushAll()有效,可以在当前模块的使用。
非绑定的import通常被用来创建polyfill和shim。
译者注:shim和polyfill是JavaScript应用开发中解决兼容性的方案用语。简单来说就是使用旧环境的API实现新API。感兴趣的读者可自行查阅相关资料。
总结
ES6引入模块机制的目标是提供一种代码功能化的封装模式。模块与普通脚本的最大的不同在于其顶层作用域内的变量、函数和class并不会暴露在全局域内,而且this的值为undefined。工作原理的不同,也需要一套全然不同的载入方式支持。
如果想在模块外部使用本模块的某些功能,必须使用export关键字将其导出。任何变量、函数和class都可以被导出。此外,每个模块只能导出一个缺省接口。被导出后,其他模块便可以导入部分或者真个模块。被导入的接口标识符类似const定义的常量,拥有块级域绑定特性。
另外,没有导出任何接口的模块在被其他模块导入时不会创建绑定关系。