javascript笔记之ES6模块

本文全部内容来自ES6入门教程,这里只做笔记,请直接参考阮一峰老师《ES6入门教程》

介绍

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

CommonJS 的模块本质就是一个对象,当我们reuqire一个模块的时候,会引入整个模块,也就是把整个对象给搬了过来,然后访问对象上的属性。这种模块加载方式我们称之为动态加载,因为只有在运行时才能得到这个对象。动态加载的好处是灵活方便,可以根据条件判断选择性地加载模块;坏处是要么不加载,要加载就是整个模块,没办法只加载用到部分(因此没法做 Tree-Shaking),而且没法在编译阶段做“静态优化”(实现 Tree-Shaking的另一个阻碍)。
代码清单1

1
2
3
4
5
6
7
8
9
10
11
// CommonJS模块,这段代码的实质是在`程序运行时`整体加载fs模块所有的方法到_fs对象,然后结构这个对象获得需要的3的方法
let { stat, exists, readFile } = require('fs');

// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

// ES6模块, 这段代码的实质是在程序编译时,从fs模块加载三个方法,其他的不加载
import { stat, exists, readFile } from 'fs';

ESM 采用静态加载的方式,牺牲一部分灵活性(Dynamic Import 提案弥补了这一缺陷,现已进入 Stage-3,主流环境都已经支持,可以期待在 ES2019 中见到它),换取“静态优化”的可能性,大家熟悉的 Tree-Shaking、类型检测等功能都要归功于此。

严格模式

es的模块自动采用严格模式,不管有没有使用use strict。严格模式的部分内容。严格模式本身属于 ES5 的内容,ES6 并没有对其做修改,只是强制启用。换个角度理解,未来其实也就没有严格模式一说了,所有 JavaScript 代码都必须遵守严格模式的规则。

  • 变量必须声明后再使用
  • 函数的参数不能有同名属性,否则报错
  • 不能使用with语句
  • 不能对只读属性赋值,否则报错
  • 不能使用前缀 0 表示八进制数,否则报错
  • 不能删除不可删除的属性,否则报错
  • 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
  • eval不会在它的外层作用域引入变量
  • eval和arguments不能被重新赋值
  • arguments不会自动反映函数参数的变化
  • 不能使用arguments.callee
  • 不能使用arguments.caller
  • 禁止this指向全局对象
  • 不能使用fn.caller和fn.arguments获取函数调用的堆栈
  • 增加了保留字(比如protected、static和interface)

语法要点

export命令

export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,

export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。注意export后面的大括号,并不是对象,更不是对象的简写
代码清单2

1
2
3
4
5
6
7
8
9
// 报错
export 1;

// 报错
var m = 1;
export m;
// 报错
let bb = {}
export bb;

正确的写法

1
2
3
4
5
6
7
8
9
10
// 写法一
export var m = 1;

// 写法二
var m = 1;
export {m};

// 写法三
var n = 1;
export {n as m};

import命令

import命令具有提升效果,会提升到整个模块的头部,首先执行。

由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次

模块整体加载所在的那个对象,应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。
代码清单3

1
2
3
4
5
import * as circle from './circle';

// 下面两行都是不允许的
circle.foo = 'hello';
circle.area = function () {};

export和 import的复合写法

如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。
代码清单3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export { foo, bar } from 'my_module';

// 可以简单理解为
import { foo, bar } from 'my_module';
export { foo, bar };

// 接口改名
export { foo as myFoo } from 'my_module';

// 整体输出,这种写法不包括default,import的时候包括default
export * from 'my_module';
// 导出default
export { default } from "my_module";
export { default as a } from "my_module";

es6模块和commonjs模块的差异

它们有两个重大差异。

  • CommonJS 模块输出的是一个值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。ES6 模块里的导出变量是和模块绑定在一起的,输出的是一个只读引用,只有需要的时候才会去到自身的模块去取值。恩,ES6的模块是单例的
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。因为commonjs加载的是一个脚本运行完成时生成的对象,而es6的对外接口是一种静态定义,在代码解析阶段就会生成。

CommonJS 模块的加载原理

CommonJS 的一个模块,就是一个脚本文件。。

1
2
3
4
5
6
{
id: "...",
exports: { ... },
loaded: true,
...
}

上面代码就是 Node 内部加载模块后生成的一个对象。该对象的id属性是模块名,exports属性是模块输出的各个接口,loaded属性是一个布尔值,表示该模块的脚本是否执行完毕。其他还有很多属性,这里都省略了。

以后需要用到这个模块的时候,就会到exports属性上面取值。即使再次执行require命令,也不会再次执行该模块,而是到缓存之中取值。也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。

module的加载实现

浏览器加载

传统加载

默认情况下,浏览器是同步加载的javascript脚本,即渲染引擎遇到script标签就会停下来下载脚本然后执行,执行完成后,再继续渲染。

浏览器也允许异步下载脚本,通过在script标签中添加defer和async实现。

代码清单5

1
2
<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>

defer要等待整个页面这场渲染完成后,才会去执行脚本;async在下载完成后,中断渲染,立即执行
如果有多个defer,会按照出现在的顺序执行;async无法保证顺序。

ES6模块的加载规则

浏览器要加载es6模块,也需要使用script标签,但是要加入type=module属性来标示加载的是一个es6模块。任何使用 type=”module” 加载的脚本都是以 严格模式(strict mode) 加载的。

代码清单6

1
<script type="module" src="./foo.js"></script>

浏览器对于type=module的标签都是进行异步加载,等到页面渲染结束,再执行模块代码,如果页面有多个type=module模块,会按照出现的顺序依次执行。

代码清单7

1
2
3
<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>

当然也可以开启async,这样只要模块下载完成,就执行该模块。

不支持模块的浏览器怎么办?结合使用 type=modulenomodule,通过nomodule为不支持的浏览器添加专门的代码。

1
2
<script type="module" src="module.js"></script>
<script nomodule src="fallback.js"></script>

新问题不支持nomodule的浏览器怎么办? 凉拌。。。

node加载

Node 要求 ES6 模块采用.mjs后缀文件名。也就是说,只要脚本文件里面使用import或者export命令,那么就必须采用.mjs后缀名。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import。Node 的import命令是异步加载,这一点与浏览器的处理方法相同。

ES6 模块加载 CommonJS 模块

CommonJS 模块的输出都定义在module.exports这个属性上面。Nodeimport命令加载 CommonJS 模块,Node 会自动将module.exports属性,当作模块的默认输出,即等同于export default xxx

代码清单8

1
2
3
4
5
6
7
8
9
10
11
// a.js
module.exports = {
foo: 'hello',
bar: 'world'
};

// 等同于
export default {
foo: 'hello',
bar: 'world'
};

import命令加载上面的模块,module.exports会被视为默认输出,即import命令实际上输入的是这样一个对象{ default: module.exports }。下面是获取module.exports输出的代码。
代码清单9

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 写法一
import baz from './a';
// baz = {foo: 'hello', bar: 'world'};

// 写法二
import {default as baz} from './a';
// baz = {foo: 'hello', bar: 'world'};

// 写法三
import * as baz from './a';
// baz = {
// get default() {return module.exports;},
// get foo() {return this.default.foo}.bind(baz),
// get bar() {return this.default.bar}.bind(baz)
// }

// 不正确, 因为fs是 CommonJS 格式,只有在运行时才能确定readFile接口,而import命令要求编译时就确定这个接口。解决方法就是改为整体输入
import { static } from 'express';
// 正确的写法一
import * as express from 'express';
const app = express.default();

// 正确的写法二
import express from 'express';
const app = express();

CommonJS 模块的输出缓存机制,在 ES6 加载方式下依然有效。
代码清单10

1
2
3
// foo.js
module.exports = 123;
setTimeout(_ => module.exports = null);

上面代码中,对于加载foo.js的脚本,module.exports将一直是123,而不会变成null

Commonjs加载es6模块

import()是用来实现动态import,类似require的用法,弥补了ESM纯静态方案在动态加载上方案上的缺陷。import()根据传入的参数按需加载模块,并在加载完成时返回一个 Promise 对象,后续就可以在then()里访问获取到的模块。

CommonJS 模块加载 ES6 模块,不能使用require命令,而要使用import()函数。ES6 模块的所有输出接口,会成为输入对象的属性。
代码清单11

1
2
3
4
5
6
7
8
9
10
11
12
13
// es.mjs
let foo = { bar: 'my-default' };
export default foo;

// cjs.js
const es_namespace = await import('./es.mjs');
// es_namespace = {
// get default() {
// ...
// }
// }
console.log(es_namespace.default);
// { bar:'my-default' }

上面代码中,default接口变成了es_namespace.default属性。

下面是另一个例子。
代码清单12

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// es.js
export let foo = { bar:'my-default' };
export { foo as bar };
export function f() {};
export class c {};

// cjs.js
const es_namespace = await import('./es');
// es_namespace = {
// get foo() {return foo;}
// get bar() {return foo;}
// get f() {return f;}
// get c() {return c;}
// }

内部变量

ES6 模块应该是通用的,同一个模块不用修改,就可以用在浏览器环境和服务器环境。为了达到这个目标,Node 规定 ES6 模块之中不能使用 CommonJS 模块的特有的一些内部变量。

首先,就是this关键字。ES6 模块之中,顶层的this指向undefinedCommonJS 模块的顶层this指向当前模块,这是两者的一个重大差异。

其次,以下这些顶层变量在 ES6 模块之中都是不存在的。

  • arguments
  • require
  • module
  • exports
  • __filename
  • __dirname

循环加载

CommonJS 模块的循环加载

CommonJS 模块的重要特性是加载时执行,即脚本代码在require的时候,就会全部执行。一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。

让我们来看,Node 官方文档里面的例子。脚本文件a.js代码如下。
代码清单13

1
2
3
4
5
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');

上面代码之中,a.js脚本先输出一个done变量,然后加载另一个脚本文件b.js。注意,此时a.js代码就停在这里,此时缓存中的aexports只有done=false,等待b.js执行完毕,再往下执行。

再看b.js的代码。
代码清单14

1
2
3
4
5
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');

上面代码之中,b.js执行到第二行,就会去加载a.js,这时,就发生了“循环加载”。系统会去a.js模块对应对象的exports属性取值,可是因为a.js还没有执行完,从exports属性只能取回已经执行的部分,而不是最后的值。

a.js已经执行的部分,只有一行。
代码清单15

1
exports.done = false;

因此,对于b.js来说,它从a.js只输入一个变量done,值为false

然后,b.js接着往下执行,等到全部执行完毕,再把执行权交还给a.js。于是,a.js接着往下执行,直到执行完毕。我们写一个脚本main.js,验证这个过程。
代码清单16

1
2
3
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

执行main.js,运行结果如下。
代码清单17

1
2
3
4
5
6
7
$ node main.js

在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true

上面的代码证明了两件事:

  1. b.js之中,a.js没有执行完毕,只执行了第一行。
  2. main.js执行到第二行时,不会再次执行b.js,而是输出缓存的b.js的执行结果,即它的第四行。
    1
    exports.done = true;

总之,CommonJS 输入的是被输出值的拷贝,不是引用。

ES6 模块的循环加载

ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import从一个模块加载变量(即import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

请看下面这个例子。
代码清单18

1
2
3
4
5
6
7
8
9
10
11
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';

上面代码中,a.mjs加载b.mjsb.mjs又加载a.mjs,构成循环加载。执行a.mjs,结果如下。
代码清单19

1
2
3
$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined

上面代码中,执行a.mjs以后会报错,foo变量未定义,这是为什么?

让我们一行行来看,ES6 循环加载是怎么处理的。首先,执行a.mjs以后,引擎发现它加载了b.mjs因此会优先执行b.mjs,然后再执行a.mjs。接着,执行b.mjs的时候,已知它从a.mjs输入了foo接口,这时不会去执行a.mjs,而是认为这个接口已经存在了,继续往下执行。执行到第三行console.log(foo)的时候,才发现这个接口根本没定义,因此报错。

解决这个问题的方法,就是让b.mjs运行的时候,foo已经有定义了。这可以通过将foo写成函数来解决。
代码清单20

1
2
3
4
5
6
7
8
9
10
11
12
13
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};

这时再执行a.mjs就可以得到预期结果。
代码清单21

1
2
3
4
5
$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar

这是因为函数具有提升作用,在执行import {bar} from './b'时,函数foo就已经有定义了,所以b.mjs加载的时候不会报错。这也意味着,如果把函数foo改写成函数表达式,也会报错。
代码清单22

1
2
3
4
5
6
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
const foo = () => 'foo';
export {foo};

上面代码的第四行,改成了函数表达式,就不具有提升作用,执行就会报错。

总结

完!

参考

http://es6.ruanyifeng.com/#docs/module-loader