【译】《Understanding ECMAScript6》- 第八章-Module

目录

JavaScript令人困惑并且易引发错误的特性之一是以“一切皆共享”的方式加载代码。所有文件内定义的一切代码都共享一个全局作用域,这一点是JavaScript落后于其他编程语言之处(比如Java中的package)。随着web应用变得越来越庞大复杂,“一切皆共享”的方式暴露出一系列弊端,比如命名冲突、安全性等等。ES6的目标之一便是解决这种问题,增强JavaScript代码组织的有序性。这就是Module(模块)的作用。

module是什么

Module可以简单理解为加载JavaScript文件的一种特殊方式。目前,不论是浏览器还是NodeJS,都没有实现原生ES6 Module的支持,但是我们可以期待Module作为一种默认的机制被广泛使用。模块化的代码与非模块的代码有以下区别:

  1. 模块化代码强制在严格模式下执行;
  2. 一个模块最顶层作用域中定义的变量不会暴露在共享的全局域内;
  3. 一个模块的最顶层作用域中的this值为undefined;
  4. 不支持html格式的注释语法();
  5. 一个模块必须导出可供模块以外代码使用的接口。

模块化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;

需要注意以下几点:

  1. 不论使用export与否,声明语句的语法与常规一致;
  2. 被export导出的函数和class必须有明确的类名/函数名。匿名函数/类不能使用上述语法导出;
  3. export不仅可以在声明语句前使用,也可以用在引用前面,如上述代码中的multiply;
  4. 没有被明确导出的变量、函数、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"

上述代码有两点需要注意:

  1. 使用逗号分隔缺省接口与非缺省接口;
  2. 非缺省接口包裹在花括号内。

导入缺省接口时可以重命名标识符:

// 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定义的常量,拥有块级域绑定特性。

另外,没有导出任何接口的模块在被其他模块导入时不会创建绑定关系。

posted @ 2016-03-18 20:01  JunpengZ  阅读(257)  评论(0编辑  收藏  举报