Skip to content

Latest commit

 

History

History
524 lines (345 loc) · 26 KB

13-模块.md

File metadata and controls

524 lines (345 loc) · 26 KB

用模块封装代码

JavaScript的“共享一切”方法来加载代码是语言中最容易出错和混乱的方面之一。其他语言使用诸如包之类的概念来定义代码作用域,但是在ECMAScript 6之前,应用中每个文件定义的内容共享一个全局作用域。随着web应用程序变得越来越复杂,并且开始使用更多的JavaScript代码,那种方式会导致一些问题,如命名冲突和安全隐患。ECMAScript 6的一个目标就是解决作用域问题,并且为JavaScript应用带来一些秩序。这就是为什么引入模块。

模块是什么?

模块是以不同模式加载的JavaScript文件(和scripts相反,它们以JavaScript工作的原始方式加载)。这种不同的模式是有必要的,因为模块具有与scripts非常不同的语义:

  1. 在严格模式下,模块代码自动运行,而且没有办法选择退出严格模式。
  2. 在模块的顶层创建变量不会自动添加到共享全局作用域。它们只存在于模块的顶级作用域。
  3. 模块顶级的thisundefined
  4. 模块代码里面不允许HTML样式的注释(JavaScript的早期浏览器遗留的功能)。
  5. 模块必须将任何可用于代码的东西导出到模块之外。
  6. 模块可以导入其他模块的绑定。

这些差异咋一看似乎很小,但是它们在加载和评估JavaScript代码方面代表着重要的变化,我将在本章讨论这些。模块的实际功能是仅导出和导入所需的绑定,而不是文件中的所有内容。

理解导入和导出是理解模块和scripts差异的基础。

基本导出(Exporting)

你可以使用export关键字对其他模块暴露要发布的代码部分。在最简单的场景里,你可以把export放在任何变量、函数或者class声明前面,从模块里导出它,比如:

// 导出数据
export var color = "red";
export let name = "Nicholas";
export const magicNumber = 7;

// 导出函数
export function sum(num1, num2) {
    return num1 + num1;
}

// 导出class
export class Rectangle {
    constructor(length, width) {
        this.length = length;
        this.width = width;
    }
}

// 这是个函数是模块的私有函数
function subtract(num1, num2) {
    return num1 - num2;
}

// 定义一个函数...
function multiply(num1, num2) {
    return num1 * num2;
}

// ...之后导出它
export { multiply };

这个示例中有一些地方需要注意。首先,除了export关键字,每个声明和原有声明完全一样。每个导出的函数或者class也有名称;这是因为导出的函数和类声明需要名称。你不能使用这个语法导出匿名函数或者classes,除非你使用default关键字(在模块章节的默认值部分详细讨论)。

接着,讨论下multiply()函数,定义它时没有导出。这样是可行的,因为你没必要总是导出声明:你也可以导出引用。最后,注意这个示例没有导出subtract()函数。在模块之外不能访问这个函数,因为未明确导出的任何变量、函数或者classes保持私有。

基本导出(Importing)

一旦你有导出的模块,你可以在另外的模块中使用import关键字访问功能。import语句的两部分是你要导入的标识符以及要导入标识符的模块。这是语句的基本形式:

import { identifier1, identifier2 } from "./example.js";

import之后的大括号表示从给定模块导入的绑定。关键字from表示导入给定绑定的模块。模块由表示模块路径的字符串指定(成为模块指定符)。浏览器使用同样的路径格式,你可以传递给<script>元素,这意味着你必须包含文件扩展名。另外一方面,Node.js遵循它的传统约定,基于文件系统的前缀区分本地文件和包。比如,example是一个包,而./example.js是一个本地文件。

import的绑定列表看起来和结构对象类似,但它们不是同一个概念。

从模块中导入绑定时,绑定的行为就像使用const定义一样。这意味着,你不能使用相同名称定义另外一个变量(包括导入相同名称的另一个绑定),在import语句之前使用该标识符,或者改变它的值。

导入单个绑定

假设基础模块部分的第一个示例是在文件名为example.js的模块内。你可以以多种方式导入和使用那个模块。比如,你可以只导入一个标识符:

// 只导入一个
import { sum } from "./example.js";

console.log(sum(1, 2));     // 3

sum = 1;        // 错误

尽管example.js导出不止一个函数,但是这个示例只导入sum()函数。如果你想给sum赋新值,结果会报错,因为你不能对导入绑定重新赋值。

确保在你导入文件的开头包含/./或者../,以便在浏览器和Node.js之间实现最佳兼容性。

导入多个绑定

如果你想从示例模块中导入多个绑定,你可以明确列出它们,如下:

// 导入多个模块
import { sum, multiply, magicNumber } from "./example.js";
console.log(sum(1, magicNumber));   // 8
console.log(multiply(1, 2));        // 2

这里,从示例模块中导入三个绑定:summultiplemagicNumber。然后使用它们,就像它们在本地定义一样。

导入整个模块

还有一个特殊情况,允许你将整个模块作为单个对象导入。所有的导出都可以作为该对象的属性使用。比如:

// 导入所有内容
import * as example from "./example.js";
console.log(example.sum(1,
        example.magicNumber));          // 8
console.log(example.multiply(1, 2));    // 2

在这段代码中,example.js模块的所有导出绑定加载到一个称之为example的对象上。这个命名的exports(sum()函数,multiple()函数和magicNumber)作为example的属性被访问。这种导入形式称之为命名空间导入,因为example对象在example.js文件内不存在,而是创建作为example.js的所有导出成员的命名空间对象。

然而,请记住,无论你在import语句中使用一个模块多少次,这个模块只会执行一次。导入模块的代码执行之后,将实例化的模块保存在内存中,无论什么时候另一个import语句引用它,就会重复利用这个实例化的模块。思考以下代码:

import { sum } from "./example.js";
import { multiply } from "./example.js";
import { magicNumber } from "./example.js";

即使在这个模块里有三个import声明,example.js只会执行一次。如果同一个应用中的其他模块想从example.js导入绑定,那些模块将会使用这段代码使用的模块实例。

模块语法限制

exportimort的一个重要限制是必须在其他语句和函数之外使用。比如,这段代码将会报语法错误:

if (flag) {
    export flag;    // 语法错误
}

export语句在if语句内,这是不允许的。导出不能以任何方式有条件地或者动态地完成。模块语法存在的一个原因是为了让JavaScript引擎静态地判断要导出的内容。因此,你只能在模块的顶部使用export

同样地,你不能在语句内使用import;你只能在顶部使用它。这意味着这段代码也会报语法错误:

function tryImport() {
    import flag from "./example.js";    // 语法错误
}

你不能动态地导入绑定,因为你不能动态导出绑定。exportimport关键字设计为静态的,因此像文本编辑器这样的工具可以很容易地从模块中得知哪些信息可用。

导入绑定的微妙怪癖

ECMAScript 6的import语句对变量,函数和classes创建了只读绑定,而不是想普通变量一样简单地引用原始绑定。即使导入绑定的模块不能改变绑定的值,但是导出那个标识的模块可以。比如,假设你想使用这个模块:

export var name = "Nicholas";
export function setName(newName) {
    name = newName;
}

当你导入这两个绑定时,setName()函数可以改变name的值:

import { name, setName } from "./example.js";

console.log(name);       // "Nicholas"
setName("Greg");
console.log(name);       // "Greg"

name = "Nicholas";       // error

setName("Greg")调用将会返回导出setName()的模块,并在这个模块中执行,将name设置为 "Greg"。请注意,这个更改会自动反映在导入的name绑定上。因为对于导出的name标识,name是局部名称。上面代码使用的name和导入的模块中使用的name不是同一个。

重命名导出和导入

有时候,你可能不想使用导入的模块中变量,函数或class的原始名称。幸运的是,你可以在导出和导入阶段更改一个导出的名称。

在第一种情况下,假设你有一个函数,你想使用不同的名称导出。你可以使用as关键字指定该函数在模块外部被称为的名称:

function sum(num1, num2) {
    return num1 + num2;
}

export { sum as add };

这里,sum()函数(sum局部名称)作为add()add导出名称)导出。这意味着当另一个模块想导入这个函数时,它将必须使用名称add

import { add } from "./example.js";

如果模块导入这个函数想使用不同的名称,它可以使用as:

import { add as sum } from "./example.js";
console.log(typeof add);            // "undefined"
console.log(sum(1, 2));             // 3

这段代码导入add()函数,使用导入名称,并且重命名为sum()(本地名称)。这意味在这个模块中没有命名为add的标识。

模块中默认值

对于从模块中导出和导入默认值,模块语法是有优化的,这种模式在其他模块系统中也十分常见,比如CommonJS(在浏览器之外使用的另一种JavaScript规范)。模块的默认值是使用default关键字指定的单一变量,函数或者class,而且每个模块你只能设置一个默认导出。对多个导出使用default关键字是语法错误。

导出默认值

这里有一个简单的示例,使用default关键字:

export default function(num1, num2) {
    return num1 + num2;
}

这个模块导出一个函数作为它的默认值。default关键字表示这是一个默认导出。这个函数不要求名称,因为模块本身代表这个函数。

你也可以指定一个标识作为默认导出,通过在export default之后放置它,比如:

function sum(num1, num2) {
    return num1 + num2;
}

export default sum;

这里,首先定义sum()函数,之后作为模块的默认值导出。如果需要计算默认值,你可能想选择这种方法。

第三种指定标识符作为默认导出的方法是通过使用重命名语法,如下:

function sum(num1, num2) {
    return num1 + num2;
}

export { sum as default };

在重命名的导出中,标识符default具有特殊意义,表示值因为模块的默认值。因为default是JavaScript的关键字,他不能用作于变量,函数或者类名(它可以用作属性名)。所以使用default重命名导出是一个特殊情况,用于创建与定义非默认导出的一致性。如果你想使用单个export语句一次性指定多个导出,包括默认值,这种语法是有用的。

导入默认值

你可以使用如下语法,从模块中导入默认值:

// 导入默认值default
import sum from "./example.js";

console.log(sum(1, 2));     // 3

这个import语句从example.js模块导入默认值。注意没有使用大括号,不像你看到的非默认导入。本地名称sum表示模块导出的默认函数。这种语法是最简洁的,而且ECMAScript 6的创建者期待他成为Web上导入的主要形式,这允许你使用已经存在的对象。

对于同时导出一个默认绑定和一个或多个非默认绑定的模块,你可以使用一个语句导入所有的导出绑定。比如,假设你有这个模块:

export let color = "red";

export default function(num1, num2) {
    return num1 + num2;
}

你可以使用如下import语句,导入color和默认函数:

import sum, { color } from "./example.js";

console.log(sum(1, 2));     // 3
console.log(color);         // "red"

逗号分隔默认的本地名称与非默认值,非默认值也由大括号括起来。请记住,默认值必须在import语句中的非默认值之前。

与导出默认值一样,你也可以使用重命名语法导入默认值:

// 等效于前一个例子
import { default as sum, color } from "example";

console.log(sum(1, 2));     // 3
console.log(color);         // "red"

在这段代码中,默认导出(default)重命名为sum,而且还会导入额外的color。此示例与上述示例相当。

再次导出绑定

你可能需要再次导出模块导入的内容(比如,如果你正在从几个小模块创建一个库文件)。你可以使用这章已经讨论的模式,再次导出一个导入的值。如下:

import { sum } from "./example.js";
export { sum }

这是有效的,但是单一的语句也可以做同样的事情:

export { sum } from "./example.js";

这种形式的export查找指定模块的sum声明,然后导出它。当然,你也可以选择给相同的值导出不同的名称:

export { sum as add } from "./example.js";

这里,sum是从"./example.js"导入,然后导出为add。 如果你想导出另外一个模块的所有内容,你可以使用*模式:

export * from "./example.js";

通过导出所有内容,这包括默认值以及命名的导出,这可能影响你能从你的模块导出的内容。比如,如果example.js有一个默认导出,当使用这个语法,你不能定义新的默认导出。

没有绑定的导入

一些模块可能不会导出任何内容,相反,只会修改全局作用域的对象。即使在模块内的顶级变量,函数和classes不会终止于全局作用域,这表示模块能访问全局作用域。内置的对象分享定义,比如在一个模块内可以访问ArrayObject,而且这些对象的更改将会反映到其他模块中。

比如,如果你想添加一个pushAll()方法到所有数组中,你可能定义一个像这样的模块:

// 模块代码没有exports或者imports
Array.prototype.pushAll = function(items) {

    // items必须是一个数组
    if (!Array.isArray(items)) {
        throw new TypeError("Argument must be an array.");
    }

    // 使用内置的push()和spread操作符
    return this.push(...items);
};

这是一个有效的模块,即使没有导出和导入。这段代码即可以用作一个模块,也可以用作一个脚本。因为它不会导出任何内容,你可以使用简单的导入去执行这段模块代码,不需要导入任何绑定:

import "./example.js";

let colors = ["red", "green", "blue"];
let items = [];

items.pushAll(colors);

这段代码导入和执行模块包含的pushAll()方法,所以将pushAll()添加到数据原型中。这表示这个模块内的所有数组上可以使用pushAll()

没有绑定的导入最有可能用于创建polyfills和shims。

加载模块

当ECMAScript 6定义模块的语法时,没有定义如何加载它们。这是对实施环境不可预知的规范的复杂性的一部分。而不是试图创建可以适应于所有JavaScript环境的单一规范, ECMAScript 6仅指定语法,并将加载机制抽象为未定义的内部操作,称之为HostResolveImportedModule。Web浏览器和Node.js可以决定使用对各自环境有意义的方式实现HostResolveImportedModule

在浏览器中使用模块

甚至在ECMAScript 6之前,web浏览器有多种方式将JavaScript包含在web应用中。这些脚本加载选项是:

  1. 使用带有src属性的<script>元素加载JavaScript代码文件,src属性指定加载代码的资源地址。
  2. 使用不带src属性的<script>元素内嵌JavaScript代码。
  3. 加载JavaScript代码文件作为workers执行(比如web worker或者service worker)。

为了完全支持模块,web浏览器比如更新这些机制。这些细节定义在HTML规范中,我将在本节总结一下。

使用模块和<script>

<script>元素的默认行为是把JavaScript文件作为脚本加载(不是modules)。这种情况发生在type属性丢失或者type属性包含JavaScript内容类型(比如"text/javascript")时。<script>元素可以执行内联代码或者加载在src中指定的文件。为了支持模块,type选项添加了"module"值。设置type"module"告诉浏览器把任何内联代码或者由src指定的文件中包含的代码作为模块,而不是脚本。这里有一个简单的例子:

<!-- 加载JavaScript文件模块 -->
<script type="module" src="module.js"></script>

<!-- 包含内联模块 -->
<script type="module">

import { sum } from "./example.js";

let result = sum(1, 2);

</script>

这个示例中的第一个<script>元素使用src属性加载一个外部的模块文件。相比加载脚本的唯一区别是type选项的值为"module"。第二个<script>元素包含一个直接嵌入网页中的页面。变量result不会全局暴露,因为它只存在与这个模块内(正如<script>元素定义),因此不会作为属性添加到window

如你所见,在网页中包含模块十分简单,而且和包含脚本类似。然后,在如何加载脚本方面有一些区别。

你可能注意到,"module"不是像"text/javascript"一样的内容类型。模块JavaScript文件和脚本JavaScript文件具有相同的内容类型,所以不可能基于文件内容进行区分。此外,当type未识别时,浏览器会忽视<script>元素,所以不支持模块的浏览器将会自动忽视<script type="module">行,提供了很好的向后兼容性。

在Web浏览器中模块加载顺序

模块是独一无二的,不想脚本,他们可以使用import去指定必须加载的其他文件以便正确地执行。为了支持这个功能,<script type="module">总是想运用defer属性一样运作。

defer属性对于加载脚本文件是可选的,但是通常运用在加载模块文件。只要HTML分析器遇到带有src属性的<script type="module">,模块文件就会开始下载,但是直到document完全解析之后才会执行。模块也是按照它们在HTML文件中的出现顺序执行。这表示第一个<script type="module">通常保证在第二个之前执行,即使一个模块包含内联代码,而不是指定src。比如:

<!-- 第一个执行 -->
<script type="module" src="module1.js"></script>

<!-- 第二个执行 -->
<script type="module">
import { sum } from "./example.js";

let result = sum(1, 2);
</script>

<!-- 第三个执行 -->
<script type="module" src="module2.js"></script>

这三个<script>元素根据它们指定的顺序执行,所以module1.js保证在内联模块之前执行,而且内联模块保证在module2.js之前执行。

每个模块可能import一个或者多个其他模块,这让问题变得复杂。这也是什么模块首先被解析以识别所有的import语句。每个import语句然后触发一个fetch(要么从网络要么从内存),而且直到所有的import资源首先加载和执行,没有模块会执行。

所有模块,包括使用<script type="module>"明确包含的模块和使用import隐式包含的模块,它们都按顺序加载和执行。在前一个实例中,完整的加载顺序是:

  1. 下载和解析module1.js
  2. 递归下载和解析module1.js中的import资源。
  3. 解析内联模块。
  4. 递归下载和解析内联模块中的import资源。
  5. 下载和解析module2.js
  6. 递归下载和解析module2.js中的import资源。

一旦加载完成,在文件完全解析后,才会执行内容。在文件解析完成之后,将会发生如下操作:

  1. 递归执行module1.js中的import资源。
  2. 执行module1.js
  3. 递归执行内联模块内的import资源。
  4. 执行内联模块。
  5. 递归执行module2.js中的import资源。
  6. 执行module2.js

注意,内联模块的行为和其他两个模块的行为一样,除了它的代码不需要下载。除此之外,加载import资源和执行模块的顺序是完全一样的。

<script type="module">上的defer属性会被忽视,因为它已经表现和使用defer属性一样。

Web浏览器中异步模块加载

你可能已经熟悉<script>元素中的async属性。当与脚本一起使用时,async属性将导致脚本文件完全下载并解析后立即执行。文档中async脚本的顺序不会影响脚本执行的顺序。脚本通常在完成下载后就会执行,不需要等待包含的文档来完成解析。

async属性也可以应用于模块。在<script type="module">上使用async让模块以类似执行脚本的方式执行。唯一的区别是,模块所有的import资源在模块自身执行之前已经下载完毕。这保证模块在模块执行之前所需的所有资源都将被下载。你只是不能保证什么时候该模块将会执行。看如下代码:

<!-- 不能保证哪个模块将会先执行 -->
<script type="module" async src="module1.js"></script>
<script type="module" async src="module2.js"></script>

在这个示例中,有两个文件被异步加载。简单地通过看这段代码,不可能知道哪个模块将会执行。如果module1.js先完成下载(包括它的所有import资源),那么它将先执行。如果module2.js先完成下载,那么该模块将会先执行。

作为Workers加载模块

Workers,比如web workers和 service workers,可以在web网页上下文之外执行JavaScript代码。创建一个新的worker涉及创建一个新的Worker实例(或者另外一个class)以及传入JavaScript文件的路径。默认加载机制是将文件作为脚本加载,比如:

// 像脚本一样加载script.js
let worker = new Worker("script.js");

为了支持加载模块,HTML标准的开发者给这些构造函数添加了第二个参数。第二个参数是一个带有type属性默认值为"script"的对象。为了加载模块文件,你可以设置type"module"

// 想模块一样加载module.js
let worker = new Worker("module.js", { type: "module" });

通过传入第二个参数type属性值为"module",此示例将 module.js加载为模块而不是脚本(type属性旨在模仿<script>type属性如何区分模块和脚本)。第二个参数支持浏览器中的所有worker类型。

Worker模块通常和worker脚本一样,但是有几个例外。首先,worker脚本限制为网页同源加载,但是模块不是很受限制。尽管worker模块有相同的默认限制,他们也可以加载具有适当的夸源资源共享(CORS)报文头来允许访问的文件。其次,worker脚本可以使用self.importScripts()方法把额外的脚本加载到worker,self.importScripts()总是失败加载worker模块,因为你应该使用import

浏览器模块说明符Resolution

这章的所有示例使用相对模块路径说明符路径,比如./example.js。浏览器要求模块说明符是如下格式之一:

  • /开头,从根目录去解析
  • ./开头,从当前目录去解析
  • ../开头,从父级目录去解析
  • URL格式

比如,假设你有一个模块文件位于https://www.example.com/modules/module.js,它包含如下代码:

// 从https://www.example.com/modules/example1.js导入
import { first } from "./example1.js";

// 从https://www.example.com/example2.js导入
import { second } from "../example2.js";

// 从https://www.example.com/example3.js导入
import { third } from "/example3.js";

// 从https://www2.example.com/example4.js导入
import { fourth } from "https://www2.example.com/example4.js";

这示例中的每个模块说明符在浏览器中使用都是有效的,包括最后一行完整的URL(你不需要确保ww2.example.com已经正确地配置它的跨源资源共享(CORS)报文头去允许跨域加载)。这些是默认情况下浏览器可以解析的唯一模块说明符(尽管尚未完成的模块加载器规范将提供解析其他格式的方式)。这表示一些看似正常的模块说明符实际上在浏览器中是无效的,而且将会导致错误,比如:

// 无效的 - 不是以 /, ./, or ../开头
import { first } from "example.js";

// 无效的 - 不是以 /, ./, or ../开头
import { second } from "example/index.js";

这些模块说明符都不能被浏览器解析。这两个模块说明符都是无效的格式(缺少正确的开头字符),即使当它们用作为<script>标签的src的值时,都能正常工作。这是<script>import之间在行为上的内在差异。

概括

ECMAScript 6将模块添加到语言中,作为打包和封装功能的一部分。模块的行为和脚本不同,因为他们不会使用顶级变量,函数和class修改全局作用域 ,而且thisundefined。为实现这种行为,使用不同模式加载模块。

你必须到导出任何你想要想模块消费者提供的功能。变量,函数全部能导出,但是每个模块只允许一个默认导出。在导出之后,另一个模块可以导入所有或者部分导出的名称。这名称就像使用let定义,像块级绑定一样操作,不能在同一个模块中再次声明。

如果模块是操作全局的作用域的内容,不需要导出任何内容。你实际上可以从这个模块导入,不需要引入任何绑定到这模块作用域。

因为模块必须以不同的模块运行,浏览器引入<script type="module">去标示资源文件或者内联代码应该像模块一样执行。模块文件使用<script type="module">加载就像运用defer属性一样。一旦文档全部解析,模块也会按照它们出现在包含文档中的顺序执行。