ES6 Module的语法

注:这是一篇学习笔记,记录自己在ES6学习过程中按照自己的思路觉得应该记录的一些要点,方便以后查看和复习。参考:

阮一峰ES6的书籍中Module 的语法
我的ES6入门学习规划

ES6推出了标准的module语法。在没有这个之前,以前都是amd或cmd的方式来写js的模块,ES6的模块要解决的问题跟它们是一样的,只不过在使用上各有特点。

理解es6的模块特点

nodejs中的模块规范cmd,是一种动态加载的方式,它只有在模块完整地加载、编译、执行完毕,才能拿到一个模块的输出对象,最后通过require这个对象去使用模块对外暴露的接口。

1
2
let fs = require('fs');
fs.readFile(...);

es6的模块不需要使用module.exports或require这些全局变量,而是通过export和import命令来完成模块接口的定义和引用:export命令用于对外提供接口,import命令用于引用其它接口。 先简单了解一个例子:

1
2
3
4
5
6
7
8
9
10
//es6_module_04.js
export let value = undefined;

export function getValue() {
return value;
}

setTimeout(() => {
value = 'not empty';
}, 1000);

上面这个模块定义两个对外接口,分别是value变量和getValue函数,初始化的时候value变量为未定义,但是这个模块执行完之后1.5s,value会被赋值。 接下来看如何引用这个模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html>
<head>
<title></title>
<script type="module">
import {value, getValue} from './es6_module_04.js';

// 在es6_module_04加载&执行完以后,立即访问val和getVal接口
console.log('check immediately', value);// check immediately undefined
console.log('check immediately', getValue());// check immediately undefined

// 1500ms以后再访问一次
setTimeout(()=>{
console.log('check after 1.5s', value);// check immediately not empty
console.log('check after 1.5s', getValue());// check immediately not empty
}, 1500);
</script>
</head>
<body>
</body>
</html>

上面还包含了es6的模块如何在html中使用的知识点,这个会在下一篇笔记中记录,此处只是先借助它来运行es6的模块。在上面的html中,通过import引入前面定义的es6_module_04.js这个模块,并明确地引用了它对外暴露的两个接口:value和getValue;然后立即访问了一下这两个接口,由于es6_module_04刚执行完时,value的值就是undefined,所以第一次访问这两个接口的结果都是“check immediately undefined”;接着在1.5s以后,再次访问了两个接口,此时es6_module_04执行时设定的定时器早已执行,导致它内部的value已经被赋值,外部再重新访问接口时,成功地拿到了内部value变量的新值,最后两次打印都是“check immediately not empty”。

先把这个例子放到一边,等会再来说明为什么第二次访问module的接口,还能访问到内部变量的新值。 接下来把这个例子,放到node.js里面去试验一下,看看是什么效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//module.js
let value = undefined;

function getValue() {
return value;
}

setTimeout(() => {
value = 'not empty';
}, 1000);

module.exports = {
value: value,
getValue: getValue
};

1
2
3
4
5
6
7
8
9
10
11
12
// main.js
let m = require('./module.js');

// 在module加载&执行完以后,立即访问val和getVal接口
console.log('check immediately', m.value);// check immediately undefined
console.log('check immediately', m.getValue());// check immediately undefined

// 1500ms以后再访问一次
setTimeout(()=>{
console.log('check after 1.5s', m.value);// check immediately undefined
console.log('check after 1.5s', m.getValue());// check immediately not empty
}, 1500);

运行main.js:

1
node main.js

最后的运行结果已经写在上面的注释里面,这个结果跟es6的模块唯一的区别就在于main.js里面1.5s以后直接访问模块内部value变量时的打印。 ES6的模块输出的是value最后的值,但是node里面输出的还是value的初始值。

node的结果是很好理解的。 因为它是动态加载,并且最后得到是一个模块的对象,用来访问模块内部的东西,所以外部最后在使用模块时,完全等同于使用模块的对象,也就是说外部使用的m.value以及m.getValue都是在使用m这个对象上的属性,而不是直接访问模块内部的value和getValue变量。 模块内部定时器给value赋值的时候,是给value变量赋值,而不是给模块对象的value属性赋值,所以定时器结束后,模块对象的value属性值并没有改变。模块对象的getValue属性,最终指向的也是模块内部的getValue函数,调用模块对象的getValue方法,等同于调用模块内部的getValue函数,在这个函数内部,始终能够访问到模块内部value变量的最终值,所以在main.js里面,通过m.getValue能取到模块内部value的最新状态。

通过这个例子可以看到,node的模块,本质是把模块内部的接口复制到模块对象上面,外部是通过模块对象才能使用内部的接口,如果模块的接口在模块被使用的过程中,有这种赋值更换的行为,很有可能就会出现像上面那样的不一致结果。 明白了这一点,在排查node模块使用时一些意外的不一致状态问题时,就会容易一些。 另外也有一些办法可以尽量去减少此类问题,最重要的是就是提前就想好该不该直接把属性部署在模块对象上,尤其是基础类型值,要特别谨慎;如果非要部署的话,最好改用setter或getter等行为方法来处理。

es6的模块跟node的比起来,形式上更简洁一些,而且看起来做的更好。 它的核心要点在于:

  1. 它是静态加载的方式来加载模块,它只需要在模块加载、编译完成时,就能确定这个模块有哪些对外接口

大概地原理就是在模块编译完成以后、模块执行前,通过模块内的所有export关键字,分析出这个模块对外定义的接口。 注意,在这个过程里面,它只是确定有哪些接口,核心点在于确定接口的名称,而不包含接口的值。 所以在它确定完一个模块的所有接口以后,这些接口是什么值,此时是完全未知的,这些值最起码要等到模块执行完以后才能绑定到接口上。

  1. 外部在引用一个模块时,不是引用模块的对象,es6的模块根本没有返回值,它是直接引用模块内部的接口,这是一种类似引用传递的机制

这就是为啥es6的模块里面,当定时器改变了value变量的值,外部模块能够实时拿到模块内接口最新值的根本原因,它们俩等同于就是同一个变量!

es6为啥要在node的模块规范这么普遍地适用之后再推出一个新的规范呢,在我看来,es6现在的module方式,除了最基本的作用外,还有2个突出的价值:

  1. 静态分析

因为现在es6的模块都是在编译后就能知道有哪些接口了,所以如果拿到一堆es6的模块,不就不需要执行,就能知道它有哪些api了吗?这对于使用js编程的ide来说,应该是很有用的,比如改进代码提示的功能。 利用静态分析地能力,ide能够更快更准确地帮助我们提示模块有哪些api可用。 这也是其他强类型语言,比如java这种,在ide里面很强大的能力。 另外静态分析也能帮助我们快速检查一遍代码的问题,比如一个模块引用了一个别的模块并没有暴露的接口,这个肯定是一个问题,不用等到部署到线上运行,在编译打包期间就能知道代码有错了。比如说前面的例子,如果把import简单改一下,后面随便加个不存在接口:

1
import {value, getValue, setValue} from './es6_module_04.js';

在浏览器里面执行以下,就会抛出以下错误:

1
Uncaught SyntaxError: The requested module './es6_module_04.js' does not provide an export named 'setValue'

相反,node里面访问一个模块对象不存在的接口,最多也就是因为undefined而报错,而不会明确地跟你说是这个模块内部未定义相关的接口,这样的错误是非得代码真正运行起来才能发现的。 这样一个错误检查机制有意义吗?很有意义,有的时候开发人员一个不小心,把代码多加了一个字符,真的是能够产生很严重的bug,所以越早地发现代码不应该的错误,越有好处。

  1. 更好地处理循环依赖

这个点的研究暂时只是引入,详细地介绍放在下一篇笔记。

以上关于es6的模块的基本内容,我认为是es6的核心,后面虽然也要详细地总结export import等等一些语法的规则,但都是一些语言上的规定,不是这个语法的重点。 掌握es6是静态加载的这一个要点,特别特别重要。

export语法

export是定义接口的关键字,它的语法只有几种正确的形式,编写模块时,按照这几种写法去写即可:

  1. 声明时export,可作用于let const function 以及class声明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 输出变量声明
export var a = 1;
export let b = 2;
export const c = 3;

// 输出函数声明
export function hello() {
console.log('hello');
}

// 输出类声明
export class Person {
constructor(name) {
this.name = name;
}

sayYes() {
console.log(this.name)
}
}

注意,输出声明的时候不允许使用匿名函数或者匿名类。

  1. 引用已声明的变量进行export
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 输出变量
var a = 1;
let b = 2;
const c = 3;
export {a, b, c};

// 输出函数
let hello = function () {
console.log('hello');
};
export {hello};

// 输出类
class Person {
constructor(name) {
this.name = name;
}

sayYes() {
console.log(this.name)
}
}

export {Person};

借助大括号完成已声明的变量输出。 export在一个模块里面,应该放置在顶层位置,不能放在块级作用域当中,从阅读性的角度,最好是都放在模块的末尾,这样别人看的时候一目了然。 但是并没有强制一定是这样的,export可以有任意次数的使用,且上面两种方式可以并存。

  1. export的时候改名
    静态加载时最终确定的是模块对外的接口列表,最重要的就是一个接口的名称,默认情况接口名称都直接是变量或函数的名称,但也可以进行改名。
1
2
3
var a = 1;
var foo = ()=>{};
export {a as b, foo as bar};

这个点看情况,有作用,但也不是那么有用。

import语法

import帮助我们引用其它模块接口。

  1. 引入某个模块对外的一组接口
    1
    import {firstName, lastName, year} from './profile.js';

大括号中的变量名必须与被加载的模块对外输出的接口名称相同。

  1. 引入时改名

    1
    import {firstName as name1, lastName as name2, year} from './profile.js';
  2. 仅仅执行某个模块

    1
    import 'lodash';

仅仅执行某个模块,但是不引入它的接口。 es6的模块仅会执行一次,重复引入,不会多次执行。

  1. 模块的整体引入
    1
    2
    3
    4
    import * as sth from './circle';

    console.log(sth.area(4));
    console.log(sth.circumference(14));

这个用法只是为了帮助我们简化模块引入,但绝对不是说把一个模块作为对象返回给sth这个变量。es6规定可以利用星号指定一个对象,保存模块对外的所有接口;此方式引入时,sth对象不允许修改,不允许重新给它上面的模块接口赋值,不允许增删属性。

  1. 其它要点

import引入的接口是只读的,类似const的效果
import后面的from指定模块的文件位置,允许相对路径、绝对路径、可以省略.js后缀
import命令具有提升效果,会提升到整个文件头部优先执行
import引入接口,不能在接口名称和模块位置上使用表达式,不能位于块级作用域当中,因为它是静态分析
cmd的require引入与import引入最好不要同时使用。

default接口

为了方便使用,可以使用export default来输出模块的默认接口

1
2
3
export default class {
// ...
}

其它模块引入这个模块的默认接口时,不需要使用大括号,并且可以用任意名字引入:

1
import someName from './module';

这种方式可能更具备适用性。 利用default,可以实现已存在的变量直接通过export来输出,不需要大括号:

1
2
3
4
5
6
7
8
9
10
11
12
13
export default function() {

}

export default class {

}

export default 1;

export default 'hello';

export default new Object();

注意,没有default,以上输出都会报错。一个模块只能有一个默认输出,export default只能使用一次,否则报错。本质上,export default 就是输出一个叫做default的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法是有效的。

1
2
3
4
5
6
7
8
9
10
11
12
// modules.js
function add(x, y) {
return x * y;
}
export {add as default};
// 等同于
// export default add;

// app.js
import { default as foo } from 'modules';
// 等同于
// import foo from 'modules';

相反export default 后面不能接变量、函数或class声明,所以要注意default有无对export的作用区别。

如果一个模块同时有default接口,外部可以同时import默认接口和其它接口:

1
import _, {each, forEach} from 'lodash';

复合写法

  1. 接口转发

    1
    2
    export {foo, bar} from 'module';
    //从module中引入名为foo,bar的接口,然后对外又输出名为foo,bar的接口
  2. 接口改名

    1
    2
    export { foo as myFoo } from 'my_module';
    //改名后,外部引入当前模块只能使用myFoo接口,foo接口无效
  3. 整体输出

    1
    2
    export * from 'my_module';
    //这种方式会忽略掉my_module中的default接口
  4. 具名接口改为默认接口的写法如下

    1
    export { es6 as default } from './someModule';
  5. 默认接口也可以改名为具名接口

    1
    export { default as es6 } from './someModule';

模块的继承

通过前面的复合写法,可以实现模块的继承。

1
2
export * from 'circle';
export var e = 2.71828182846;

这个模块会包含circle定义的行为和自己额外定义的行为。

export * 会忽略加载模块的default接口,当前模块可重新定义自己的default接口

1
2
3
4
5
export * from 'circle';
export var e = 2.71828182846;
export default function(x) {
return Math.exp(x);
}

假如circle里面有area方法,当前模块可重新输出一个area方法,达到覆盖的目的

1
2
3
4
5
6
7
8
export * from 'circle';
export var e = 2.71828182846;
export default function(x) {
return Math.exp(x);
}
export var area = function() {
//...
}

跨模块的常量

这个点是有用的,尤其在项目里面有很多需要常量的场景时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// constants.js 模块
export const A = 1;
export const B = 3;
export const C = 4;

// test1.js 模块
import * as constants from './constants';
console.log(constants.A); // 1
console.log(constants.B); // 3

// test2.js 模块
import {A, B} from './constants';
console.log(A); // 1
console.log(B); // 3

import()函数

ES6现在模块,缺乏动态加载的能力,现在有提案加入import()函数来实现动态加载,它接收一个模块路径作为参数,返回一个Promise. import()加载模块成功以后,这个模块会作为一个对象,当作then方法的参数。因此,可以使用对象解构赋值的语法,获取输出接口。

1
2
3
4
import('./myModule.js')
.then(({export1, export2}) => {
// ...·
});

这个import函数,通过babel转码是可用的,不过我目前也怎么用到需要它的场景。 对了,import()函数现在是动态加载用的,所以它的参数不再要求是固定字符串了,可以使用表达式。

如果模块有default输出接口,可以用参数直接获得。

1
2
3
4
5
6
7
8
9
import('./myModule.js')
.then(myModule => {
console.log(myModule.default);
});
//或者使用解构
import('./myModule.js')
.then(({default: theDefault}) => {
console.log(theDefault);
});

同时加载多个模块

1
2
3
4
5
6
7
8
Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
])
.then(([module1, module2, module3]) => {
···
});

补充

es6的模块自动开启严格模式,无法关闭,要注意严格模式的一些特性。