传统加载
HTML 网页中,浏览器通过<script>标签加载 JavaScript 脚本。
页面内嵌脚本:
<script type="application/javascript">
// module code
</script>
由于浏览器脚本的默认语言是 JavaScript,因此type="application/javascript"可以省略。
外部脚本:
<script type="application/javascript" src="path/to/myModule.js"></script>
异步脚本:
<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>
defer与async有什么区别?
defer:要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行(通俗来讲就是:(顺序加载,渲染完再执行)。
多个defer:会按照它们在页面出现的顺序加载。
async:一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染(通俗来讲就是:乱序加载,下载完就执行)。
多个async:脚本是不能保证加载顺序的。
浏览器是如何加载js脚本的?
默认情况下:浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染(如果是外部脚本,还必须加入脚本下载的时间)。
所以我们经常把<script>标签放在后面。
如果脚本体积很大,下载和执行的时间就会很长,因此造成浏览器堵塞,用户会感觉到浏览器“卡死”了,没有任何响应。
这显然是很不好的体验,所以浏览器允许脚本异步加载,下面就是两种异步加载的语法:
<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>
上面代码中,<script>标签打开defer或async属性,脚本就会异步加载。渲染引擎遇到这一行命令,就会开始下载外部脚本,但不会等它下载和执行,而是直接执行后面的命令。
模块化加载
为什么要使用模块化?
- 解决命名冲突
- 提供复用性
- 提高代码可维护性
外部脚本:
type属性设为module,所以浏览器知道这是一个 ES6 模块。
<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>
如果网页有多个<script type="module">,它们会按照在页面出现的顺序依次执行。
也可以设置成async,这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后,再恢复渲染。
<script type="module" src="./foo.js" async></script>
页面内嵌脚本:
<script type="module">
import utils from "./utils.js";
// other code
</script>
注意点:
- 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
- 模块脚本自动采用严格模式,不管有没有声明use strict。
- 模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。
- 模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。
- 同一个模块如果加载多次,将只执行一次。
AMD 和 CMD
鉴于目前这两种实现方式已经很少见到,所以不再对具体特性细聊,只需要了解这两者是如何使用的。
AMD:
define(['./a', './b'], function(a, b) {
// 加载模块完毕可以使用
a.do()
b.do()
})
CMD:
define(function(require, exports, module) {
// 加载模块
// 可以把 require 写在函数体的任意地方实现延迟加载
var a = require('./a')
a.doSomething()
})
CommonJS
CommonJS 最早是 Node 在使用,目前也仍然广泛使用,比如在 Webpack 中你就能见到它,当然目前在 Node 中的模块管理已经和 CommonJS 有一些区别了。
模块导出:
关键字:module.exports、exports
// foo.js
// 一个一个 导出
module.exports.age = 1
module.exports.foo = function() {}
exports.a = 'hello'
// 整体导出
module.exports = {
age: 1,
a: 'hello',
foo:function(){}
}
// 整体导出不能用`exports` 用exports不能在导入的时候使用
exports = {
age: 1,
a: 'hello',
foo:function() {}
}
// 这里需要注意 exports 不能被赋值,可以理解为在模块开始前exports = module.exports, 因为赋值之后exports失去了 对module.exports的引用,成为了一个模块内的局部变量。
模块导入:
关键字:require
const foo = require('./foo.js')
console.log(foo.age) //1
ES6 Module
由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
模块导出:
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。
关键字:export
// a.js
// 声明命名导出
export var name= 'an';
export var year = 1998;
// 函数导出
export function multiply(x, y) {
return x * y;
};
// 命名导出
var name= 'an';
var year = 1958;
export { name, year };
// 重命名导出
export { name as names, year as years };
需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
// 报错
export 1;
// 报错
var m = 1;
export m;
// 分割线
// 写法一
export var m = 1;
// 写法二
var m = 1;
export {m};
// 写法三
var n = 1;
export {n as m};
// 分割线
// 报错
function f() {}
export f;
// 正确
export function f() {};
// 正确
function f() {}
export {f};
另外,export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);
这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新。
注意点:
export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,import命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。
模块导入:
关键字:import
// main.js
import { name, year } from './a.js';
// 重命名
import { name as n, year as y } from './a.js';
import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。
import {a} from './xxx.js'
a = {}; // Syntax Error : 'a' is read-only;
上面代码中,脚本加载了变量a,对其重新赋值就会报错,因为a是一个只读的接口。但是,如果a是一个对象,改写a的属性是允许的。
import {a} from './xxx.js'
a.foo = 'hello'; // 合法操作
import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js后缀可以省略。
注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。
foo();
import { foo } from 'my_module';
上面的代码不会报错,因为import的执行早于foo的调用。这种行为的本质是,import命令是编译阶段执行的,在代码运行之前。
由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。
// 报错
import { 'f' + 'oo' } from 'my_module';
// 报错
let module = 'my_module';
import { foo } from module;
// 报错
if (x === 1) {
import { foo } from 'module1';
} else {
import { foo } from 'module2';
}
上面三种写法都会报错,因为它们用到了表达式、变量和if结构。在静态分析阶段,这些语法都是没法得到值的。
最后,import语句会执行所加载的模块,因此可以有下面的写法。
import 'xxx.js';
如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。
import 'xxx.js';
import 'xxx.js';
整体加载
导出:
// a.js
export function area(radius) {
return Math.PI * radius * radius;
}
export function circumference(radius) {
return 2 * Math.PI * radius;
}
导入:
import * as circle from './a.js';
console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));
ES Module与CommonJS区别
- CommonJS 支持动态导入,也就是 require(${path}/xx.js),后者目前不支持,但是已有提案
- CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
- CommonJS 在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。但是 ES Module 采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
- ES Module 会编译成 require/exports 来执行的
参考:
https://es6.ruanyifeng.com/#docs/module
https://segmentfault.com/a/1190000017878394?utm_source=tag-newest
https://juejin.im/book/5bdc715fe51d454e755f75ef/section/5bdd0d83f265da615f76ba57#heading-6