关于js中的作用域与变量

前言

说实话,虽然一直知道js中有全局作用域、函数作用域和块级作用域这几种作用域,但一直说不清到底怎样才算是块级作用域以及作用域到底用来干啥的。还有就是虽然知道var声明和letconst声明的一些表现差异,但根本无法解释这种差异性为何发生以及变量提升的准确定义是什么。总而言之,在实际使用中将js与浏览器绑定得过于紧密,而忽略了js作为一种独立的编程语言的基本特性了!

作用域

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。

LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层楼),最后抵达全局作用域(顶层),无论找到或没找到都将停止。
——《你不知道的js(上卷)》

作用域是在js编译过阶段中产生的,然后在运行阶段发挥作用的。而js的编译过程发生在运行前不久(尽管编译时间很短,且编译完后立即执行,还是属于先编译然后执行,而非实时运行的),编译过程大概经历分词 → 解析 → 代码生成这几个阶段:

  • 分词:将代码字符串划分成具有js语法意义的代码块(词法单元);
  • 解析:将分词过程中产生的代码块列表转化成具有js程序语法结构的树(抽象语法树AST);
  • 代码生成:将AST转化成可执行代码;

函数作用域:即函数在声明时的词法作用域。

块级作用域一对大括号内(不包括function声明后的大括号)的词法作用域,或标记块声明后的大括号内的词法作用域。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
statement
}

if(...){

}else{

}

for(...;...;...){

}

while(...){

}

switch(...){

}

label: {
...
}

变量提升与函数提升

所谓的提升指的就是在代码编译阶段声明语法单独提前到该作用域的顶部,也就是说每个作用域内都有提升,但是根据声明类型作用域类型的不同提升的方式也会有所区别。

变量提升

虽然在一般的变量声明赋值操作中,我们觉得好像写的就只有一条语句,然而编译器并不是这么认为,编译器会将诸如var a = 1这样的语句拆分成两部分理解:var a(声明语句)和a = 1(赋值语句);然后在编译的时候将声明语句单独提升到该作用域的顶部,而赋值语句则留在原地不动(当然,如果仅仅只有变量声明,那就相当于没有赋值)!

举个例子:

1
2
3
a = 1
var a
console.log(a) // 1

在不了解变量提升之前,大多数人都会觉得打印出来的a应该是undefined(我之前就是这么觉得的╥﹏╥),然而在编译的时候有了变量提升,实际上执行的代码是这样的:

1
2
3
var a // ←变量声明提升到作用域顶部了
a = 1
console.log(a) // 1

函数作用域内也一样:

1
2
3
4
5
6
7
8
9
10
11
// 编译前
function foo(){
console.log(a)
var a = 123
}
// 编译后
function foo(){
var a // ←变量声明提升到该函数作用域顶部了
console.log(a)
a = 123 // 赋值语句则留在原地!
}

然而并非所有的变量声明都有提升ES6新增的letconst声明就不会有提升:

1
2
console.log(a) // ReferenceError: a is not defined
let a = 1

除此之外,var声明会自动忽略块级作用域,也就是说如果var声明所在的作用域为块级作用域时会自动向上找到最近的非块级作用域(函数作用域或全局作用域),再进行提升;最著名的例子就是这个:

1
2
3
4
5
6
7
8
9
10
for(var i = 0; i < 10; i++){
...
}
console.log(i) // 10
// 因为实际上编译后时是这样:
var i // ←直接跳过块级作用域,提升到上一层作用域
for(i = 0; i < 10; i++){
...
}
console.log(i)

函数提升

实际上函数声明没有赋值这一说法,因此是整体提升到作用域的顶部的:

1
2
3
4
5
6
7
8
9
10
11
foo()

function foo(){
...
}
// 编译后:
function foo(){
...
} // ←整体提升到顶部

foo()

然而如果是函数表达式,就不存在函数提升了!如果是将函数表达式作为值进行变量赋值,实际上也只是变量提升罢了:

1
2
3
4
5
6
7
8
9
10
11
12
13
foo() // TypeError: foo is not a function

var foo = function(){
...
}
// 编译后:
var foo // 只是普通的变量提升而已

foo() // 实际上此时foo等于undefined,所以执行会报错

foo = function(){
...
}

提升的优先级

varfunction在同一作用域下出现重复声明(即标识符一样)的情况下,优先进行函数提升!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
foo() // function

var foo = function(){
console.log('var')
}

function foo(){
console.log('function')
}
// 编译后:
function foo(){
console.log('function')
} // ←函数被优先提升声明!

foo()

foo = function(){
console.log('var')
}

let、const 和 var

虽然在感觉上知道letconstvar在声明上有一些差异,但是总是说不出来具体差异有哪些,希望在这里详细记录这3种声明的差异性。

差异1:变量提升与暂时性死区

  1. var存在变量提升,而letconst则不存在变量提升;

  2. letconst存在暂时性死区(temporal dead zone,简称TDZ);

暂时性死区:指的是letconst在其声明的作用域内会该标识符强制绑定到该作用域,即该标识符不受外层作用域的影响(外层作用域有同名标识符时无法访问到外层作用域的同名标识符变量,相当于与外层作用域隔离),且无法在声明语句之前对其进行访问!

1
2
3
4
5
let tmp = 123
{ // TDZ starts
console.log(tmp) // ReferenceError: tmp is not defined
let tmp = 666 // TDZ ends
}

差异2:重复声明

var可以在同一作用域对同一变量进行多次声明,而letconst则不允许在同一作用域下对同一变量进行多次声明!

例1:

1
2
3
var a = 1
var a = 666
console.log(a) // 666

例2:

1
2
3
let a = 1
let a = 666 // SyntaxError: Identifier 'a' has already been declared
console.log(a)

例3:(同一作用域下的let声明特性优于var声明特性执行?)

1
2
3
let a = 1
var a = 666 // SyntaxError: Identifier 'a' has already been declared
console.log(a)

差异3:const的独特性

const相比其他两种声明又多了一些独特性质:

  1. 声明时必须赋值
1
2
const a // SyntaxError: Missing initializer in const declaration
a = 123
  1. 值(引用指向)不可修改。当const声明的变量赋值对象是基本类型时,就是值不可更改;,如果赋值对象是引用类型,那就是引用的指向不可修改(实际上引用对象的属性值依然可以更改)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 例1:
const a = 123
a = 666 // TypeError: Assignment to constant variable.

// 例2:
const a = {
name: 'foo',
some: 'bar'
}
a = {
name: 'nothing'
} // TypeError: Assignment to constant variable.

// 例3:
const a = {
name: 'foo',
some: 'bar'
}
a.name = 'foobar' // 引用类型的属性值可以更改
console.log(a) // { name: 'foobar', some: 'bar' }

const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。
——《ECMAScript 6 入门》

参考文档

  1. block | MDN
  2. 《你不知道的JavaScript(上卷)》
  3. 9.4 暂时性死区(temporal dead zone)
  4. let 和 const 命令 - ECMAScript 6入门