本文全部内容来自ES6入门教程,这里只做笔记,请直接参考阮一峰老师《ES6入门教程》
介绍
在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
CommonJS 的模块本质就是一个对象,当我们reuqire
一个模块的时候,会引入整个模块,也就是把整个对象给搬了过来,然后访问对象上的属性。这种模块加载方式我们称之为动态加载
,因为只有在运行时才能得到这个对象。动态加载的好处是灵活方便,可以根据条件判断选择性地加载模块;坏处是要么不加载,要加载就是整个模块,没办法只加载用到部分(因此没法做 Tree-Shaking
),而且没法在编译阶段做“静态优化”(实现 Tree-Shaking
的另一个阻碍)。
代码清单11
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后面的大括号,并不是对象,更不是对象的简写
代码清单21
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语句,那么只会执行一次,而不会执行多次
模块整体加载所在的那个对象,应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。
代码清单31
2
3
4
5import * as circle from './circle';
// 下面两行都是不允许的
circle.foo = 'hello';
circle.area = function () {};
export和 import的复合写法
如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。
代码清单31
2
3
4
5
6
7
8
9
10
11
12
13
14export { 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实现。
代码清单51
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) 加载的。
代码清单61
<script type="module" src="./foo.js"></script>
浏览器对于type=module
的标签都是进行异步加载,等到页面渲染结束,再执行模块代码,如果页面有多个type=module
模块,会按照出现的顺序依次执行。
代码清单71
2
3<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>
当然也可以开启async
,这样只要模块下载完成,就执行该模块。
不支持模块的浏览器怎么办?结合使用 type=module
和 nomodule
,通过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
这个属性上面。Node
的import
命令加载 CommonJS
模块,Node
会自动将module.exports
属性,当作模块的默认输出,即等同于export default xxx
。
代码清单81
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输出的代码。
代码清单91
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 加载方式下依然有效。
代码清单101
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
模块的所有输出接口,会成为输入对象的属性。
代码清单111
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属性。
下面是另一个例子。
代码清单121
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
指向undefined
;CommonJS
模块的顶层this
指向当前模块,这是两者的一个重大差异。
其次,以下这些顶层变量在 ES6 模块之中都是不存在的。
- arguments
- require
- module
- exports
- __filename
- __dirname
循环加载
CommonJS 模块的循环加载
CommonJS
模块的重要特性是加载时执行,即脚本代码在require
的时候,就会全部执行。一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。
让我们来看,Node
官方文档里面的例子。脚本文件a.js
代码如下。
代码清单131
2
3
4
5exports.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
代码就停在这里,此时缓存中的a
的exports
只有done=false
,等待b.js
执行完毕,再往下执行。
再看b.js
的代码。
代码清单141
2
3
4
5exports.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
已经执行的部分,只有一行。
代码清单151
exports.done = false;
因此,对于b.js
来说,它从a.js
只输入一个变量done
,值为false
。
然后,b.js
接着往下执行,等到全部执行完毕,再把执行权交还给a.js
。于是,a.js
接着往下执行,直到执行完毕。我们写一个脚本main.js
,验证这个过程。
代码清单161
2
3var 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,运行结果如下。
代码清单171
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
上面的代码证明了两件事:
- 在
b.js
之中,a.js
没有执行完毕,只执行了第一行。 main.js
执行到第二行时,不会再次执行b.js
,而是输出缓存的b.js
的执行结果,即它的第四行。1
exports.done = true;
总之,CommonJS
输入的是被输出值的拷贝,不是引用。
ES6 模块的循环加载
ES6
处理“循环加载”与 CommonJS
有本质的不同。ES6
模块是动态引用,如果使用import
从一个模块加载变量(即import foo from 'foo'
),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。
请看下面这个例子。
代码清单181
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.mjs
,b.mjs
又加载a.mjs
,构成循环加载。执行a.mjs
,结果如下。
代码清单191
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
写成函数来解决。
代码清单201
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就可以得到预期结果。
代码清单211
2
3
4
5$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar
这是因为函数具有提升作用,在执行import {bar} from './b'
时,函数foo
就已经有定义了,所以b.mjs
加载的时候不会报错。这也意味着,如果把函数foo
改写成函数表达式,也会报错。
代码清单221
2
3
4
5
6// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
const foo = () => 'foo';
export {foo};
上面代码的第四行,改成了函数表达式,就不具有提升作用,执行就会报错。
总结
完!