一、脚本和模块

​ JavaScript 有 2 种源文件,一种叫做脚本,一种叫做模块。这个区分是在 ES6 引入了模块机制开始的,在 ES5 和之前版本中,就只有一种源文件格式,即脚本。

​ 脚本是可以由浏览器或者 node 环境引入执行的,而模块智能有 JavaScript 代码用 import 引入执行。

​ 脚本具有主动性的 JavaScript 代码段,是控制宿主完成一定任务的代码;而模块是被动行的 JavaScript 代码段,是等待被调用的库。

​ 实际上,模块和脚本之间的区别仅仅在于是否包含import和export

​ 脚本是一种兼容之前的版本的定义,在这个模式下,没有 import 就不需要处理加载.js 文件问题。

​ 现代浏览器可以支持用 script 标签引入模块或者脚本,如果需要引入模块,必须给 script 标签添加 type=”module”。如果引入脚本,则不需要 type。

1
<script type="module" scr="XXX.js"></script>

​ script 标签如果不加 type=“module”,默认认为我们加载的文件是脚本而非模块,如果我们在脚本中写了 export,当然会抛错。

​ 脚本可以包含语句。模块包含 3 中内容:import 声明、export 声明、语句。

import声明用法有 2 种:一个是直接import一个模块、另一个是``带from的import`(能引入模块里的一些信息)

1
2
import "module"; // 引入一个模块
import m from "module"; // 把模块默认的导出值放入变量m

​ 用法 1 直接 import 一个模块,只是保证了这个模块代码被执行,引用它的模块是无法获得它的任何信息的。

​ 用法 2 带 from 的 import 意思是引入模块中的一部分信息,可以把他们变成本地的变量。

​ 带 from 的 import 又分为 3 种用法:

1
2
3
4
5
6
7
import x from "./a.js"; // 引入模块中导出的默认值
import { a as x, modify } from "./a.js"; // 引入模块中的变量
import * as x from "./a.js"; // 把模块中所有的变量以类型对象属性的方式引入

// 第一种方式还可以跟后两种组合使用
import d, { a as x, modify } from "./a.js";
import d, * as x from "./a.js";

​ 语法要求不带 as 的默认值永远在最前。注意,这里的变量实际上仍然可以受到原来模块的控制。

示例代码:

模块a

1
2
3
4
export var a = 1;
export function modify() {
a = 2;
}

模块b

1
2
3
4
import { a, modify } from "./a.js";
console.log(a);
modify();
console.log(a);

​ 当我们调用修改变量的函数后,b 模块变量也跟着发生了改变。这说明导入与一般的赋值不同,导入后的变量只是改变了名字,它仍然与原来的变量是同一个。

export声明:承担的是导出的任务。

​ 模块中导出变量的方式有两种,一种是独立使用 export 声明,一种是直接在声明型语句前添加 export 关键字。

​ 独立使用 export 声明就是一个 export 关键字加上变量名列表。

1
export { a, b, c };

​ 我们也可以直接在声明型语句前添加 export 关键字,这里的 export 可以加在任何声明性质的语句之前

1
2
3
4
5
var
function(含async 和generator)
class
let
const

​ export 还有一种特殊的用法,就是跟 default 联合使用。export default 表示到处一个默认变量值,它可以用于 function 和 class。这里导出的变量是没有名称的,可以使用 import x from ‘./a.js’ 这样的语法,在模块中引入。

​ export default 还支持一种语法,后面跟一个表达式

1
2
var a = {};
export default a;

​ 但是,这里的行为跟导出变量是不一致,这里导出的是值,导出的就是普通变量a的值,以后a的变化与导出的值就无关了,修改变量 a,不会使得其他模块中引入的 default 值发生改变。

​ 在 import 语句前无法加入 export,但是我们可以直接使用 export from 语法

1
export a from "a.js";

​ JavaScript 引擎除了执行脚本和模块之外,还可以执行函数。而函数体跟脚本和模块有一定的相似之处。

二、函数体

执行函数的行为通常是在 JavaScript 代码执行时,注册宿主环境的某些事件触发的,而执行的过程,就是执行函数体(函数的花括号中间的部分)。

下面的 setTimeout 函数注册了一个函数给宿主,当一定时间之后,宿主就会执行这个函数。

1
2
3
setTimeout(function () {
console.log("go");
}, 10000);

宿主会为这样的函数创建宏任务。宏任务中可能会执行的代码包括脚本、模块、函数体。

函数体其实也是一个语句的列表。跟脚本和模块比起来,函数体中的语句列表中多了 return 语句可以用。

普通函数体

1
2
3
function foo() {
//function body
}

异步函数体

1
2
3
async function foo() {
// function body
}

生成器函数体

1
2
3
function* foo() {
// function body
}

异步生成器函数体

1
2
3
async function* foo() {
// function body
}

上面的 4 种函数体的区别在于:能否使用 await 或者 yield

类型 yield await return import&export

普通函数体 X X √ X

异步函数体 X √ √ X

生成器函数体 √ X √ X

异步生成器函数体 √ √ √ X

脚本 X X X X

模块 X X X √

三、预处理机制

JavaScript 语法的全局机制:预处理和指令序言。这 2 个机制对于我们解释一些 JavaScript 的语法现象非常重要。预处理机制可以理解 var 等声明类语句行为,指令序言可以解释严格模式。

预处理:JavaScript 执行前,会对脚本、模块和函数体中的语句进行预处理。预处理过程将会提前处理 var、函数声明、class、const 和 let 这些语句,以确定其中变量的意义。

var声明:var 声明永远作用于脚本、模块和函数体,在预处理阶段,不关心赋值的部分,只管在当前作用域声明这个变量。

1
2
3
4
5
6
var a = 1;
function foo() {
console.log(a); // undefined
var a = 2;
}
foo();

上面的代码声明了一个脚本级别的 a,又声明了 foo 函数体级别的 a,函数体级的 var 出现在 console.log 语句之后。

预处理过程在执行前,所以有函数体级的变量 a,就不会去访问外层作用域中的变量 a 了,而函数体级的变量 a 此时还没有复制,所以是 undefined。

1
2
3
4
5
6
7
8
var a = 1;
function foo() {
console.log(a);
if (false) {
var a = 2;
}
}
foo();

这段代码比上一段代码在 var a = 2 之外多了一段 if,我们知道 if(false) 中的代码永远不会被执行,但是预处理阶段并不管这个,var 的作用能够穿透一切语句结构,它只认脚本、模块和函数体三种语法结构。所以这里结果跟前一段代码完全一样,我们会得到 undefined。

1
2
3
4
5
6
7
8
9
10
11
12
var a = 1;

function foo() {
var o = { a: 3 };
with (o) {
var a = 2;
}
console.log(o.a);
console.log(a);
}

foo();

引入 with 语句,with(o)创建了一个作用域,并把 o 对象加入词法环境,在其中使用了 var a = 2 语句。

在预处理阶段,只认 var 中声明的变量,所以同样为 foo 的作用域创建了 a 这个变量,但是没有赋值。在执行阶段,当执行到 var a = 2 时,作用域变成了 with 语句内,这时候的 a 被认为访问到了对象 o 的属性 a,所以最终执行的结果,我们得到了 2 和 undefined。

function声明:function 声明的行为,在全局(脚本、模块和函数体),function 声明表现跟 var 相似,不同之处在于,function 声明不但在作用域中加入变量,还会给它赋值。

1
2
console.log(foo);
function foo() {}

在声明函数 foo 之前,打印函数 foo,我们可以发现,已经是函数 foo 的值了。

下面示例不再被提前赋值:

1
2
3
4
console.log(foo); // undefined
if (true) {
function foo() {}
}

function 声明出现在 if 等语句中,它仍然作用于脚本、模块和函数体级别,在预处理阶段,仍然会产生变量,它不再被提前赋值。

上述打印结果为 undefined,声明 function 在预处理阶段仍然发生了作用,在作用域中产生了变量,没有产生赋值,赋值行为发生在了执行阶段。

出现在 if 等语句中的 function,在 if 创建的作用域中仍然会被提前,产生赋值效果。

class声明:在全局的行为跟 function 和 var 都不一样。

在 class 声明前使用 class 类名,会抛出错误:

1
2
console.log(c); // c is not defined
class c {}

这个抛错很像是 class 没有预处理,但实际上并非如此。

复杂一点的例子:

1
2
3
4
5
6
var c = 1;
function foo() {
console.log(c);
class c {}
}
foo();

上面的代码,把 class 放进了一个函数体中,在外层作用域中有变量 c。

执行后,我们看到,仍然抛出了错误,如果去掉 class 声明,则会正常打印出 1,也就是说,出现在后面的 class 声明影响了前面语句的结果。这说明,class 声明也是会被预处理的,它会在作用域中创建变量,并且要求访问它时抛出错误。

class 的声明作用不会穿透 if 等语句结构,所以只有写在全局环境才会有声明作用。这样的 class 设计比 function 和 var 更符合直觉,而且在遇到一些比较奇怪的用法时,倾向于抛出错误。

四、指令序言机制

脚本和模块都支持一种特别的语法,指令序言最早是为了 use strict 设计的,它规定了一种给 JavaScript 代码添加元信息的方式。

1
2
3
4
5
"use strict";
function f() {
console.log(this);
}
f.call(null);

null 原封不动地被当做 this 值打印了出来,这是严格模式的特征。

去掉严格模式,打印结果会变成 global。

‘use strict’是 JavaScript 标准中规定的唯一一种指令序言,但是设计指令序言的目的是留给 JavaScript 的引擎和实现者一些统一的表达式,在静态规定扫描时指定 JavaScript 代码的一些特性。

JavaScript 的指令序言是只有一个字符串直接量的表达式语句,它只能出现在脚本、模块和函数体的最前面。

1
2
3
4
5
6
function doSth() {
//
}
("use strict");
var a = 1;
// .....

‘use strct’没有出现在最前,所以不是指令序言。

1
2
3
4
"use strict"; // 单引号也是指令序言
function doSth() {
// ....
}