Javascript中存在多种声明变量的方式,对应的作用域和机制细节也各不相同。

作用域

ALGOL 60 在1960年对作用域进行了精确定义:

the portion of source code in which a binding of a name with an entity applies

Source: Scope (computer science), Wikipedia

作用域可以分为两类:词法作用域 (lexical scope) 和动态作用域 (dynamic scope) 。虽然 ALGOL 60 所定义的实际是词法作用域,但它同样适用于定义动态作用域,不同点就在于对 portion 的理解。

在使用词法作用域的语言中,名字解析 (name resolution) 依赖于名字声明时在代码中的位置和词法上下文,关注的是在代码中距离最近的相同名字的声明。而在使用动态作用域的语言中,名字解析依赖于执行过程中遇到名字时的执行上下文 (execution context) 和调用上下文 (calling context) ,关注的是在执行过程中最近的相同名字的声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 例1 (C)

int i = 30;

int f1() {
return i;
}

int f2() {
int i = 450;
return f1();
}

f2(); // 若为词法作用域,返回30;若为动态作用域,返回450

C是词法作用域,因此例1中,声明f1时,距离最近的i声明是第一行int i = 30;,而函数f2中的i是局部变量,不影响函数外部的i,f2中调用f1也不会影响f1内部的名字解析,所以调用f2返回30。

如果是动态作用域,那么f2中调用f1时,f1内部的名字解析会优先在调用它的f2内部寻找名字声明,所以调用f2返回450。

由于词法作用域的名字解析只需要分析静态的源代码,在编译时就能完成,因此也被称为静态作用域 (static scope) ,这也便于编写程序时对名字的使用。而动态作用域通常只能在运行时才能确定名字解析,所以在编写程序时需要根据调用顺序预测名字和哪一实体绑定。

Javascript中的变量

Javascript中有多种变量声明的方式,它们的作用域也各不相同。

1. 未声明的变量

未声明的变量是指不声明而直接赋值的变量,它将会在执行时被隐式地创建为全局变量(称为global对象的property):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
i = 30;

function f1() {
console.log(i);
}

function f2() {
i = 450;
f1();
}

f1(); // 输出30
f2(); // 输出450
f1(); // 输出450
console.log(i); // 输出450

{
i = 12;
}
console.log(i); // 输出12

未声明的变量在赋值代码执行前是不存在的:

1
console.log(m); // 变量m不存在,抛出错误 Uncaught ReferenceError: m is not defined

未声明的变量是可配置的 (configurable):

1
2
n = 12;
delete n; // true

2. 使用var声明的变量

使用var声明的变量的作用域是声明时的执行上下文,在函数中声明时作用域为封闭的函数内部,在任何函数外声明时作用域为全局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var i = 30;

function f1() {
console.log(i);
}

function f2() {
var i = 450;
f1();
}

f1(); // 输出30
f2(); // 输出30
f1(); // 输出30
console.log(i); // 输出30

{
var i = 12;
}
console.log(i); // 输出12

var可以重复声明同一名字的变量:

1
2
3
var i = 30;
var i = 450;
console.log(i); // 输出450

使用var声明的变量会在作用域内任何代码之前进行声明并初始化值undefined,这被称为提升 (hoisting) :

1
2
3
4
5
6
7
8
9
10
console.log(v); // 输出undefined
var v = 30;
console.log(v); // 输出30

// 以上代码在执行时与以下代码是等价的

var v;
console.log(v); // 输出undefined
v = 30;
console.log(v); // 输出30

所以下面的c不是未声明变量,而是使用var声明的变量:

1
2
3
4
5
6
7
c = 12;
var c;

// 等价于以下代码

var c;
c = 12;

使用var声明的变量是它的执行上下文(函数或global)的不可配置属性:

1
2
var m = 30;
delete this.m; // false

3. 使用let声明的变量

使用let声明的变量的作用域是块作用域 (block-scoped) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let i = 30;

function f1() {
console.log(i);
}

function f2() {
let i = 450;
f1();
}

f1(); // 输出30
f2(); // 输出30
f1(); // 输出30
console.log(i); // 输出30

{
let i = 12;
}
console.log(i); // 输出30

let声明变量时不会创建对应的执行上下文的属性:

1
2
let x = 30;
console.log(this.x); // 输出undefined

而且let不允许在作用域内重复声明相同名字的变量:

1
2
3
let i;
let i;
// 抛出错误 Uncaught SyntaxError: Identifier 'i' has already been declared

用var也不行:

1
2
3
let i;
var i;
// 抛出错误 Uncaught SyntaxError: Identifier 'i' has already been declared

使用let声明的变量也会被提升,会在作用域内开始时进行声明。但var会初始化变量的值为undefined,而let直到定义被求值时才会进行初始化,在这之前访问变量会报错。作用域开始到let声明的变量初始化之间被称为变量的时间死区 (temporal dead zone) :

1
2
3
4
console.log(l); // 抛出错误 Uncaught ReferenceError: l is not defined
console.log(v); // 输出undefined
let l = 12;
var v = 30;

在时间死区中,对let声明的变量使用typeof会抛出错误,这和未声明的变量与使用var声明的变量不同:

1
2
3
4
5
6
console.log(typeof i1); // 输出undefined
console.log(typeof i2); // 输出undefined
console.log(typeof i3); // 提示错误 Uncaught ReferenceError: i3 is not defined

var i2;
let i3;

由于词法作用域的影响,在使用let声明并初始化变量的语句中,表达式内部也是变量的作用域,因此也存在时间死区:

1
2
3
4
5
var i = 30;

if (true) {
let i = (i + 73); // 抛出错误 Uncaught ReferenceError: i is not defined
}

在执行到let i = (i + 73)时,首先会对表达式(i + 73)求值,由于提升的作用,进入if代码块时i已经被声明,所以(i + 73)中的i指向的是if代码块内部的i。在求值完成之前依然是时间死区,所以表达式(i + 73)求值时会抛出错误。

另一种时间死区引发错误的特殊情况如下:

1
2
3
4
5
6
7
var i = {
m: [30, 35, 62]
};

for (let i of i.m) {
console.log(i); // 抛出错误 Uncaught ReferenceError: i is not defined
}

由于for的块作用域包含了条件语句部分,所以let i of i.m中of右侧的i实际指向的是of左侧的i。

4. 使用const声明的常量

const与let类似,也是块作用域。使用const声明的标识符称为常量,它不能被重新赋值,也不能再次被声明。

使用const声明常量时要求必须进行初始化,否则会抛出错误。

使用const声明常量时不会创建对应的执行上下文的属性。

使用const时和let一样,需要注意时间死区。

虽然const用于声明常量,但不代表它的值是绝对不变的。const只是不允许对常量重新赋值和再次声明,但是当常量是Object时,Object的内容是可以被修改的。

1
2
3
const obj = {};
obj.name = 'Misaka030';
console.log(obj.name); // 输出Misaka030

参考文献

https://en.wikipedia.org/wiki/Scope_(computer_science)
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const