1、浏览器加载
(1)传统方法
在 HTML 网页中,浏览器通过 <script>
加载 JavaScript 脚本。
<script type="text/javascript">
// module code
<script>
<script type="text/javascript" src="path/test.js"></script>
上述代码中,由于浏览器脚本的默认语言是 JavaScript,因此 type="text/javascript"
可以省略。
默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到 <script>
标签就会停下来,执行完脚本,再继续向下渲染。如果是外部脚本,还必须加入脚本下载的时间。
同步可能会造成浏览器堵塞,所以浏览器允许脚本异步加载,如下
<script src="test.js" defer></script>
<script src="test.js" async></script>
在 <script>
标签中使用 defer
或者 async
属性,脚本就会异步加载。渲染引擎遇到这一行命令,就会开始下载外部脚本,但不会等它下载和执行,而是直接执行后面的命令。
defer : 要等到整个页面正常渲染结束才会执行,“渲染完再执行”。
async : 一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染,“下载完就执行”。
(2)模块加载规则
浏览器加载 ES6 模块,也使用 <script>
标签,但是要加入 type="module"
属性。
<script type="module" src="foo.js"></script>
<script type="module">
import utils from "./util.js";
// module code
<script>
浏览器对于带有 type="module"
的 <script>
都是异步加载的,不会造成浏览器堵塞,默认使用 defer
属性,即等到整个页面渲染完,再执行模块脚本。如果想使用 asyn
则需要显式给出。
<script type="module" src="foo.js" async></script>
对于外部的模块脚本(如上面的 foo.js ),需注意:
(1)代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
(2)模块脚本自动采用严格模式,不管有没有声明 use static
。参见严格模式限制
(3)模块之中,可以使用 import
命令加载其他模块(.js
后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用 export
命令输出对外接口。
(4)模块之中,顶层的 this
关键字返回 undefined
,而不是指向 window
。也就是说,在模块的顶层使用 this
关键字,无意义。
(5)同一个模块如果加载多次,只执行一次。
2、ES6 模块与 CommonJS 模块的差异
ES6 模块与 CommonJS 模块完全不同:
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用(不可修改)。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
- CommonJS 模块的顶层
this
指向当前模块,ES6 模块的顶层this
指向undefined
。
第二个差异是因为 CommonJS 加载的是一个对象(即 module.exports
属性),该对象只有在脚本运行完成才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
CommonJS 模块输出的是值的拷贝,会被缓存。也就是说,一旦输出一个值,模块内部的变化不会影响到该值。
// libComjs.js
var counter = 3;
var colors = ['red'];
function incCounter (){
counter++;
colors = ['green'];
// 给 colors 重新赋值,colors 所代表的地址内容发生变化,
// 指向了 ['green'] 数组。
return counter;
}
module.exports = {
counter: counter,
colors: colors,
incCounter: incCounter
}
//main.js
var mod = require('./libComjs.js');
console.log(mod.counter); // 3
console.log(mod.colors); // ['red']
console.log(mod.incCounter()); // 4
console.log(mod.counter); // 3
console.log(mod.colors); // ['red']
如果想取到变化后的值,应输出取值器函数,如将模块输出代码改为:
module.exports = {
get counter(){
return counter;
},
get color(){
return color;
},
incCounter: incCounter
}
改为上述代码后,在运行 main.js 就能得到内部变动后的值。
import 生成的是一个只读的引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面取值。换句话说,import 加载的值会跟着原始值变。因此,ES6模块是动态引用,并且不会缓存值。模块里面的变量绑定其所有的模块。
// libES6.js
var counter = 3;
var color = ['red'];
function incCounter(){
counter++;
color = ['green'];
return counter;
}
export {counter, color, incCounter};
//main.js
var mod = require('./libES6.js');
console.log(counter); // 3
console.log(colors); // ['red']
console.log(incCounter()); // 4
console.log(counter); // 4
console.log(colors); // ['green']
注意:
(1)ES6 输入模块不会缓存运行结果,而是动态地去被加载模块取值,并且变量总是绑定其所在的模块。
(2)ES6 输入的模块变量,只是一个“符号连接符”,所以这个变量是只读的,对它进行重新赋值会报错。如果是引用类型,变量指向的地址是只读的,但是可以为其添加属性或成员。
color.push('blue'); // 正确,可以为其添加成员
color = ['blue']; // 报错
(3)export 通过接口,输出的是同一个值,不同的脚本加载这个接口,得到的都是同样的实例。
3、Node 加载
(1)概述
Node 中 CommonJS 模块与 ES6 模块不兼容,所以需要将两者分开各自加载。
在静态分析阶段,一个模块脚本只要有一行 import 或 export 语句,Node 就会认为该脚本为 ES6 模块,否则就为 CommonJS 模块。如果不输出任何接口,但是希望被 Node 认为是 ES6 模块,可以在脚本中输出一个空对象,export {};
如果不指定绝对路径,Node 加载 ES6 模块会依次寻找以下脚本,与 require()
规则一致。
import './foo';
// 依次寻找
// ./foo.js
// ./foo/package.json
// ./foo/index.js
import 'baz';
// 依次寻找
// ./node_modules/baz.js
// ./node_modules/baz/package.json
// ./node_modules/baz/index.js
// 寻找上一级目录
// ../node_modules/baz.js
// ../node_modules/baz/package.json
// ../node_modules/baz/index.js
// 再上一级目录
(2)import 命令加载 CommonJS 模块
Node 采用 CommonJS 模块格式,模块的输出都定义在 module.exports
这个属性上面。在 Node 环境中,使用 import
命令加载 CommonJS 模块,Node 会自动将 module.exports
属性,当作模块的默认输出,即等同于 export default
。
// a.js
module.exports = {
foo: 'hello',
bar: 'world'
}
// 等同于
export default {
foo: 'hello',
bar: 'world'
}
// main.js
import baz from './a';
或 import {default as baz} from './a';
或 import * as baz from './a';
注意:
1)CommonJS 模块的缓存机制,在 ES6 加载方式下依然有效。
2)由于 ES6 模块是编译时确定输出接口,而 CommonJS 模块是运行时确定输出接口,所以采用 import 命令加载 CommonJS 模块时,不允许使用以下写法:
import {readfile} from 'fs'; // 错误
(3)require 命令加载 ES6 模块
采用 require 命令加载 ES6 模块时,ES6 模块的所有输出接口,会成为输入对象的属性,default 接口会变成 default 属性,其余的接口属性名为原来变量名(或函数名)。并且,依然存在缓存机制。
// default.js
let foo = {bar: 'my-default'};
export default foo;
foo = null;
// main.js
const default_prop = requrie('./default.js');
console.log(default_prop.default); // {bar: 'my-default'}
// es.js
export let foo = {bar:'my-default'};
export {foo as bar};
export function f() {};
export class c {};
// cjs.js
const es_namespace = require('./es');
// es_namespace = {
// get foo() {return foo;}
// get bar() {return foo;}
// get f() {return f;}
// get c() {return c;}
// }
4、循环加载
循环加载指的是 A 脚本的执行依赖 B 脚本,而 B 脚本的执行又依赖 A 脚本。通常,循环加载表示存在强耦合,如果处理不好,还可能导致递归加载,使程序无法执行,因此避免出现。
(1)CommonJS 模块的循环加载
CommonJS 的一个模块,就是一个脚本文件。 require 命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。
{
id:
exports:
loaded:
....
}
上面代码是 Node 内部加载模块后生成的一个对象,id 属性为模块名,exports 属性是模块输出的各个接口,loaded 属性是个布尔值,表示该模块的脚本是否执行完毕,其他属性此处省略。以后需要用到这个模块时,就会到 export 属性上面取值。即使再次执行 require 命令,也不会再次执行该模块,而是到缓存中取值。
CommonJS 模块的重要特性是加载时执行,即脚本代码在 require 时就会全部执行。一旦出现某个模块被循环加载,就只输出已经执行的部分,还未执行的部分不会输出。
// a.js
exports.done = false; // 2
var b = require('./b.js'); // 3
console.log('在 a.js 之中,b.done = %j', b.done); // 9
exports.done = true; // 10
console.log('a.js 执行完毕'); // 11
// b.js
exports.done = false; // 4
var a = require('./a.js'); // 5
// 此时 a.js 只执行了 exports.done = false;
// 所以后面输出的是 a.done = false
console.log('在 b.js 之中,a.done = %j', a.done); // 6
exports.done = true; // 7
console.log('b.js 执行完毕'); // 8
// main.js
var a = require('./a.js'); // 1
var b = require('./b.js'); // 12
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done); // 13
// 输出
在 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 的执行结果,即 b.js 的第四行。
注意:因为 CommonJS 遇到循环加载时,返回当前已经执行的部分值,因此,以下两种写法在遇到循环加载时会有差异。
var a = require('a'); // 安全写法
var foo = require('a');.foo; // 危险写法
(2)ES6 模块的循环加载
ES6 模块是动态引用,如果使用 import 从一个模块加载变量,那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。
// a.js如下
import {bar} from './b.js';
console.log('a.js');
console.log(bar);
export let foo = 'foo';
// b.js
import {foo} from './a.js';
console.log('b.js');
console.log(foo);
export let bar = 'bar';
// main.js
import * as all from './a.js';
// 输出
b.js
undefined
a.js
bar
上述代码中,由于 a.js
第一行加载 b.js
,所以先执行的是 b.js
,而 b.js
的第一行又是 a.js
,这时由于 a.js
已经开始执行了,所以不会重复执行,而是继续往下执行 b.js
,所以第一个输出的是 b.js
。
5、ES6 模块的转码
浏览器目前还不支持 ES6 模块,为了现在就能使用,可以将其转为 ES5 的写法。可以用 Babel 、 ES6 module transpiler 和 SystemJS来转码。