前面的话
JS
用"共享一切"的方法加载代码,这是该语言中最易出错且容易令人感到困惑的地方。在ES6
以前,在应用程序的每一个JS
中定义的一切都共享一个全局作用域。随着web
应用程序变得更加复杂,JS
代码的使用量也开始增长,这一做法会引起问题,如命名冲突和安全问题。ES6
的一个目标是解决作用域问题,也为了使JS
应用程序显得有序,于是引进了模块。本文将详细介绍ES6
中的模块
概述
模块是自动运行在严格模式下并且没有办法退出运行的
JS
代码。与共享一切架构相反的是,在模块顶部创建的变量不会自动被添加到全局共享作用域,这个变量仅在模块的顶级作用域中存在,而且模块必须导出一些外部代码可以访问的元素,如变量或函数。模块也可以从其他模块导入绑定
另外两个模块的特性与作用域关系不大,但也很重要。首先,在模块的顶部,
this
的值是undefined
;其次,模块不支持HTML
风格的代码注释,这是从早期浏览器残余下来的JS
特性
脚本,也就是任何不是模块的
JS
代码,则缺少这些特性。模块和其他JS
代码之间的差异可能乍一看不起眼,但是它们代表了JS
代码加载和求值的一个重要变化。模块真正的魔力所在是仅导出和导入需要的绑定,而不是将所用东西都放到一个文件。只有很好地理解了导出和导入才能理解模块与脚本的区别
导出
可以用
export
关键字将一部分己发布的代码暴露给其他模块,在最简单的用例中,可以将export
放在任何变量、函数或类声明的前面,以将它们从模块导出
// 导出数据
export var color = "red";
export let name = "Nicholas";
export const magicNumber = 7;// 导出函数
export function sum(num1, num2) {
return num1 + num1;
}
// 导出类
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
关键字外,每一个声明与脚本中的一模一样。因为导出的函数和类声明需要有一个名称,所以代码中的每一个函数或类也确实有这个名称。除非用default
关键字,否则不能用这个语法导出匿名函数或类
另外,在定义multiply()
函数时没有马上导出它。由于不必总是导出声明,可以导出引用,因此这段代码可以运行。此外,这个示例并未导出subtract()
函数,任何未显式导出的变量、函数或类都是模块私有的,无法从模块外部访问
导入
从模块中导出的功能可以通过
import
关键字在另一个模块中访问,import
语句的两个部分分别是要导入的标识符和标识符应当从哪个模块导入这是该语句的基本形式
import { identifier1, identifier2 } from "./example.js";
import
后面的大括号表示从给定模块导入的绑定(binding)
,关键字from
表示从哪个模块导入给定的绑定,该模块由表示模块路径的字符串指定(被称作模块说明符)。浏览器使用的路径格式与传给<script>
元素的相同,也就是说,必须把文件扩展名也加上。另一方面,Nodejs
则遵循基于文件系统前缀区分本地文件和包的惯例。例如,example
是一个包而./example.js
是一个本地文件当从模块中导入一个绑定时,它就好像使用
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
- 在这段代码中,从
example
模块导入3个绑定sum
、multiply
和magicNumber
。之后使用它们,就像它们在本地定义的一样
【导入整个模块】
特殊情况下,可以导入整个模块作为一个单一的对象。然后所有的导出都可以作为对象的属性使用
// 完全导入
import * as example from "./example.js";
console.log(example.sum(1,example.magicNumber)); // 8
console.log(example.multiply(1, 2)); // 2
在这段代码中,从
example.js
中导出的所有绑定被加载到一个被称作example
的对象中。指定的导出(sum()
函数、mutiply()
函数和magicNumber
)之后会作为example
的属性被访问。这种导入格式被称作命名空间导入(namespaceimport
)。因为example.js
文件中不存在example
对象,故而它作为example.js
中所有导出成员的命名空间对象而被创建但是,不管在
import
语句中把一个模块写了多少次,该模块将只执行一次。导入模块的代码执行后,实例化过的模块被保存在内存中,只要另一个import
语句引用它就可以重复使用它
import { sum } from "./example.js";
import { multiply } from "./example.js";
import { magicNumber } from "./example.js";
- 尽管在这个模块中有3个
import
语句,但example
加载只执行一次。如果同一个应用程序中的其他模块也从example.js
导入绑定,那么那些模块与此代码将使用相同的模块实例
【导入绑定的一个微妙怪异之处】
ES6
的import
语句为变量、函数和类创建的是只读绑定,而不是像正常变量一样简单地引用原始绑定。标识符只有在被导出的模块中可以修改,即便是导入绑定的模块也无法更改绑定的值
export var name = "huochai";
export function setName(newName) {
name = newName;
}
- 当导入这两个绑定后,
setName()
函数可以改变name
的值
import { name, setName } from "./example.js";
console.log(name); // "huochai"
setName("match");
console.log(name); // "match"
name = "huochai"; // error
- 调用
setName("match")
时会回到导出setName()
的模块中去执行,并将name
设置为"match"
。此更改会自动在导入的name
绑定上体现。其原因是,name
是导出的name
标识符的本地名称。本段代码中所使用的name
和模块中导入的name
不是同一个
重命名
有时候,从一个模块导入变量、函数或者类时,可能不希望使用它们的原始名称。幸运的是,可以在导出过程和导入过程中改变导出元素的名称
假设要使用不同的名称导出一个函数,则可以用
as
关键字来指定函数在模块外的名称
function sum(num1, num2) {
return num1 + num2;
}
export { sum as add };
- 在这里,函数
sum()
是本地名称,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()
函数,在当前模块中也没有add()
标识符
默认值
由于在诸如
CommonJS
的其他模块系统中,从模块中导出和导入默认值是一个常见的做法,该语法被进行了优化。模块的默认值指的是通过default
关键字指定的单个变量、函数或类,只能为每个模块设置一个默认的导出值,导出时多次使用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
是JS
中的默认关键字,因此不能将其用于变量、函数或类的名称;但是,可以将其用作属性名称。所以用default
来重命名模块是为了尽可能与非默认导出的定义一致。如果想在一条导出语句中同时指定多个导出(包括默认导出),这个语法非常有用
【导入默认值】
可以使用以下语法从一个模块导入一个默认值
// 导入默认值
import sum from "./example.js";
console.log(sum(1, 2)); // 3
这条
import
语句从模块example.js
中导入了默认值,请注意,这里没有使用大括号,与非默认导入的情况不同。本地名称sum
用于表示模块导出的任何默认函数,这种语法是最纯净的,ES6
的创建者希望它能够成为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"
- 在这段代码中,默认导出
(export)
值被重命名为sum
,并且还导入了color
静态加载
ES6
中的模块与node.js
中的模块加载不同,nodeJS
中的require
语句是运行时加载,而ES6
中的import
是静态加载,所以有一些语法限制
1、不能使用表达式和变量等这些只有在运行时才能得到结果的语法结构
// 报错
import { 'f' + 'oo' } from 'my_module';
// 报错
let module = 'my_module';
import { foo } from module;
2、import
和export
命令只能在模块的顶层,不能在代码块之中,如不能在if
语句和函数内使用
if (flag) {
export flag; // 语法错误
}
// 报错
if (x === 1) {
import { foo } from 'module1';
} else {
import { foo } from 'module2';
}
function tryImport() {
import flag from "./example.js"; // 语法错误
}
以上的写法会报错,是因为在静态分析阶段,这些语法都是没法得到值的
这样的设计,固然有利于编译器提高效率,但也导致无法在运行时加载模块。在语法上,条件加载就不可能实现。如果
import
命令要取代Node
的require
方法,这就形成了一个障碍。因为require
是运行时加载模块,import
命令无法取代require
的动态加载功能
const path = './' + fileName;
const myModual = require(path);
- 上面的语句就是动态加载,
require
到底加载哪一个模块,只有运行时才知道。import
语句做不到这一点
重新导出
可能需要重新导出模块已经导入的内容
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
有默认的导出值,则使用此语法时将无法定义一个新的默认导出
无绑定导入
某些模块可能不导出任何东西,相反,它们可能只修改全局作用域中的对象。尽管模块中的顶层变量、函数和类不会自动地出现在全局作用域中,但这并不意味着模块无法访问全局作用域。内建对象(如
Array
和Object
)的共享定义可以在模块中访问,对这些对象所做的更改将反映在其他模块中
例如,要向所有数组添加
pushAll()
方法,则可以定义如下所示的模块
// 没有导出与导入的模块
Array.prototype.pushAll = function(items) {
// items 必须是一个数组
if (!Array.isArray(items)) {
throw new TypeError("Argument must be an array.");
}
// 使用内置的 push() 与扩展运算符
return this.push(...items);
};
- 即使没有任何导出或导入的操作,这也是一个有效的模块。这段代码既可以用作模块也可以用作脚本。由于它不导出任何东西,因而可以使用简化的导入操作来执行模块代码,而且不导入任何的绑定
import "./example.js";
let colors = ["red", "green", "blue"];
let items = [];
items.pushAll(colors);
- 这段代码导入并执行了模块中包含的
pushAll()
方法,所以pushAll()
被添加到数组的原型,也就是说现在模块中的所有数组都可以使用pushAll()
方法了
[注意]无绑定导入最有可能被应用于创建polyfill
和Shim
加载模块
虽然
ES6
定义了模块的语法,但它并没有定义如何加载这些模块。这正是规范复杂性的一个体现,应由不同的实现环境来决定。ES6
没有尝试为所有JS
环境创建一套统一的标准,它只规定了语法,并将加载机制抽象到一个未定义的内部方法HostResolveImportedModule
中。Web
浏览器和Node.js
开发者可以通过对各自环境的认知来决定如何实现HostResolveImportedModule
【在Web浏览器中使用模块】
即使在
ES6
出现以前,Web
浏览器也有多种方式可以将JS
包含在Web
应用程序中,这些脚本加载的方法分别是
1、在<script>
元素中通过src
属性指定一个加载代码的地址来加载JS
代码文件
2、将JS
代码内嵌到没有src
属性的<script>
元素中
3、通过Web Worker
或Service Worker
的方法加载并执行JS
代码文件
为了完全支持模块功能,
Web
浏览器必须更新这些机制
在<script>中使用模块
<script>
元素的默认行为是将JS
文件作为脚本加载,而非作为模块加载,当type
属性缺失或包含一个JS
内容类型(如"text/javascript"
)时就会发生这种情况。<script>
元素可以执行内联代码或加载src
中指定的文件,当type
属性的值为"module"
时支持加载模块。将type
设置为"module"
可以让浏览器将所有内联代码或包含在src
指定的文件中的代码按照模块而非脚本的方式加载
<!-- load a module JavaScript file -->
<script type="module" src="module.js">
</script><!-- include a module inline --><script type="module">
import { sum } from "./example.js";
let result = sum(1, 2);
</script>
此示例中的第一个
<script>
元素使用src
属性加载了一个外部的模块文件,它与加载脚本之间的唯一区别是type
的值是"module"
。第二个<script>
元素包含了直接嵌入在网页中的模块。变量result
没有暴露到全局作用域,它只存在于模块中(由<script>
元素定义),因此不会被添加到window
作为它的属性在
Web
页面中引入模块的过程类似于引入脚本,相当简单。但是,模块实际的加载过程却有一些不同"module"
与"text/javascript"
这样的内容类型并不相同。JS
模块文件与JS
脚本文件具有相同的内容类型,因此无法仅根据内容类型进行区分。此外,当无法识别type
的值时,浏览器会忽略<script>
元素,因此不支持模块的浏览器将自动忽略<script type="module">
来提供良好的向后兼容性
Web浏览器中的模块加载顺序
模块与脚本不同,它是独一无二的,可以通过
import
关键字来指明其所依赖的其他文件,并且这些文件必须被加载进该模块才能正确执行。为了支持该功能,<script type="module">
执行时自动应用defer
属性
加载脚本文件时,
defer
是可选属性加载模块时,它就是必需属性。一旦HTML
解析器遇到具有src
属性的<script type="module">
,模块文件便开始下载,直到文档被完全解析模块才会执行。模块按照它们出现在HTML
文件中的顺序执行,也就是说,无论模块中包含的是内联代码还是指定src
属性,第一个<scpipt type="module">
总是在第二个之前执行
<!-- this will execute first -->
<script type="module" src="module1.js"></script>
<!-- this will execute second -->
<script type="module">
import { sum } from "./example.js";
let result = sum(1, 2);
</script>
<!-- this will execute third -->
<script type="module" src="module2.js"></script>
这3个
<script>
元素按照它们被指定的顺序执行,所以模块module1.js
保证会在内联模块前执行,而内联模块保证会在module2.js
模块之前执行每个模块都可以从一个或多个其他的模块导入,这会使问题复杂化。因此,首先解析模块以识别所有导入语句;然后,每个导入语句都触发一次获取过程(从网络或从缓存),并且在所有导入资源都被加载和执行后才会执行当前模块
用
<script type="module">
显式引入和用import
隐式导入的所有模块都是按需加载并执行的。在这个示例中,完整的加载顺序如下
1、下载并解析module1.js
2、递归下载并解析module1.js
中导入的资源
3、解析内联模块
4、递归下载并解析内联模块中导入的资源
5、下载并解析module2.js
6、递归下载并解析module2.js
中导入的资源
- 加载完成后,只有当文档完全被解析之后才会执行其他操作。文档解析完成后,会发生以下操作
1、递归执行module1.js
中导入的资源
2、执行module1.js
3、递归执行内联模块中导入的资源
4、执行内联模块
5、递归执行module2.js
中导入的资源
6、执行module2.js
- 内联模块与其他两个模块唯一的不同是,它不必先下载模块代码。否则,加载导入资源和执行模块的顺序就是一样的
[注意] <script type="module">
元素会忽略defer
属性,因为它执行时defer
属性默认是存在的
Web浏览器中的异步模块加载
<script>
元素上的async
属性应用于脚本时,脚本文件将在文件完全下载并解析后执行。但是,文档中async
脚本的顺序不会影响脚本执行的顺序,脚本在下载完成后立即执行,而不必等待包含的文档完成解析
async
属性也可以应用在模块上,在<script type="module">
元素上应用async
属性会让模块以类似于脚本的方式执行,唯一的区别是,在模块执行前,模块中所有的导入资源都必须下载下来。这可以确保只有当模块执行所需的所有资源都下载完成后才执行模块,但不能保证的是模块的执行时机
<!-- no guarantee which one of these will execute first -->
<script type="module" async src="module1.js"></script>
<script type="module" async src="module2.js"></script>
- 在这个示例中,两个模块文件被异步加载。只是简单地看这个代码判断不出哪个模块先执行,如果
module1.js
首先完成下载(包括其所有的导入资源),它将先执行;如果module2.js
首先完成下载,那么它将先执行
将模块作为Woker加载
Worker
,例如Web Worker
和Service Woker
,可以在网页上下文之外执行JS
代码。创建新Worker
的步骤包括创建一个新的Worker
实例(或其他的类),传入JS
文件的地址。默认的加载机制是按照脚本的方式加载文件
// 用脚本方式加载 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
模块不会完全受限,虽然Worker
模块具有相同的默认限制,但它们还是可以加载并访问具有适当的跨域资源共享(CORS)
头的文件;其次,尽管Worker
脚本可以使用self.importScripts()
方法将其他脚本加载到Worker
中,但self.importScripts()
却始终无法加载Worker
模块,因为应该使用import
来导入
【浏览器模块说明符解析】
浏览器要求模块说明符具有以下几种格式之一
1、以/开头的解析为从根目录开始
2、以./开头的解析为从当前目录开始
3、以../开头的解析为从父目录开始
4、URL
格式
- 例如,假设有一个模块文件位于https://www.example.com/modules/modules.js,其中包含以下代码
// 从 https://www.example.com/modules/example1.js 导入
import { first } from "./example1.js";
// 从 from https://www.example.com/example2.js 导入
import { second } from "../example2.js";
// 从 from https://www.example.com/example3.js 导入
import { third } from "/example3.js";
// 从 from https://www2.example.com/example4.js 导入
import { fourth } from "https://www2.example.com/example4.js";
此示例中的每个模块说明符都适用于浏览器,包括最后一行中的那个完整的
URL
(为了支持跨域加载,只需确保www2.example.com的CORS
头的配置是正确的)尽管尚未完成的模块加载器规范将提供解析其他格式的方法,但目前,这些是浏览器默认情况下唯一可以解析的模块说明符的格式因此,一些看起来正常的模块说明符在浏览器中实际上是无效的,并且会导致错误
// 无效:没有以 / 、 ./ 或 ../ 开始
import { first } from "example.js";
// 无效:没有以 / 、 ./ 或 ../ 开始
import { second } from "example/index.js";
- 由于这两个模块说明符的格式不正确(缺少正确的起始字符),因此它们无法被浏览器加载,即使在
<script>
标签中用作src
的值时二者都可以正常工作。<script>
标签和import
之间的这种行为差异是有意为之
总结
下面对
AMD
、CMD
、CommonJS
和ES6
的module
进行总结对比
AMD
是requireJS
在推广过程中对模块定义的规范化产出。AMD
是一个规范,只定义语法API
,而requireJS
是具体的实现。类似于ECMAScript
和javascript
的关系,由下面代码可知,AMD
的特点是依赖前置,对于依赖的模块提前执行
// AMD
define(['./a', './b'], function(a, b) {
// 依赖必须一开始就写好
a.doSomething()
// 此处略去 n 行
b.doSomething()
...
})
-
CMD
是SeaJS
在推广过程中对模块定义的规范化产出,它的特点是依赖就近,对于依赖的模块延迟执行
// CMD
define(function(require, exports, module) {
var a = require('./a')
a.doSomething()
// 此处略去 n 行
var b = require('./b')
// 依赖可以就近书写
b.doSomething()
// ...
})
-
CommonJS
规范主要在NodeJS
后端使用,前端浏览器不支持该规范
// math.js
exports.add = function () {
var sum = 0, i = 0,args = arguments, l = args.length;while (i < l) {
sum += args[i++];
}
return sum;
};
// program.js
var math = require('math');
exports.increment = function (val) {
return math.add(val, 1);
};
-
ES6
的Module
模块主要通过export
和import
来进行模块的导入和导出
//example.js
export default function(num1, num2) {
return num1 + num2;
}
// 导入默认值
import sum from "./example.js";
console.log(sum(1, 2)); // 3