JS用"共享一切"的方法加载代码,这是该语言中最易出错且容易令人感到困惑的地方。在ES6以前,在应用程序的每一个JS中定义的一切都共享一个全局作用域。随着web应用程序变得更加复杂,JS代码的使用量也开始增长,这一做法会引起问题,如命名冲突和安全问题。ES6的一个目标是解决作用域问题,也为了使JS应用程序显得有序,于是引进了模块。
一、概述
模块是自动运行在严格模式下并且没有办法退出运行的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语句前使用标识符或改变绑定的值
1、导入单个绑定
假设前面的示例在一个名为"example.js"的模块中,我们可以导入并以多种方式使用这个模块中的绑定
// 单个导入 import { sum } from "./example.js"; console.log(sum(1, 2)); // 3 sum = 1; // 出错
尽管example.js导出的函数不止一个,但这个示例导入的却只有sum()函数。如果尝试给sum赋新值,结果是抛出一个错误,因为不能给导入的绑定重新赋值
为了最好地兼容多个浏览器和Node.js环境,一定要在字符串之前包含/、./或../来表示要导入的文件
2、导入多个绑定
如果想从示例模块导入多个绑定,则可以明确地将它们列出如下
// 多个导入 import { sum, multiply, magicNumber } from "./example.js"; console.log(sum(1, magicNumber)); // 8 console.log(multiply(1, 2)); // 2
在这段代码中,从example模块导入3个绑定sum、multiply和magicNumber。之后使用它们,就像它们在本地定义的一样
3、导入整个模块
特殊情况下,可以导入整个模块作为一个单一的对象。然后所有的导出都可以作为对象的属性使用
// 完全导入 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导入绑定,那么那些模块与此代码将使用相同的模块实例
4、导入绑定的一个微妙怪异之处
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关键字来指定函数在模块外的名称(类似于sql的语法了)
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关键字是一个语法错误
1、导出默认值
下面是一个使用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来重命名模块是为了尽可能与非默认导出的定义一致。如果想在一条导出语句中同时指定多个导出(包括默认导出),这个语法非常有用
2、导入默认值
可以使用以下语法从一个模块导入一个默认值
// 导入默认值 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