我们先来看一段非常简单的JavaScript代码:
function foo() {
var a = 2;
}
foo();
console.log(a);
运行之后我们得到以下结果:变量 a 明明已经在 foo 函数中声明了,foo 也已经执行过了,为什么最后一行仍然会报错?
难道函数执行结束后,变量
a消失了?
再来看另一端代码:
var a = 1;
function foo() {
var a = 2;
function bar() {
console.log(a);
}
bar();
}
foo();
这一次,bar函数内部命名没有声明变量a,却可以正常输出:2
这两个看起来有些矛盾的现象,其实都与JavaScript中一个非常重要的概念有关——作用域
一、JavaScript 代码是怎么被执行的?
我们拿到一份 JavaScript 代码后,计算机并不能直接理解它。
计算机最终只能执行机器能够识别的指令,而我们编写的 JavaScript 代码属于高级语言。因此,在 JavaScript 代码和计算机之间,还需要一个能够“读懂”并执行 JavaScript 的工具,这就是 JavaScript 引擎。
不同的运行环境可能使用不同的 JavaScript 引擎。例如:
- Chrome、Edge 等浏览器主要使用 V8 引擎
- Firefox 使用 SpiderMonkey 引擎
- Safari 使用 JavaScriptCore 引擎
- Node.js 使用的也是 V8 引擎
JavaScript 引擎拿到代码后,并不会立刻从第一行开始直接执行,而是需要先分析代码,弄清楚代码中有哪些变量、函数以及它们之间的结构关系。
这个过程可以简单概括为:
JavaScript 源代码
↓
词法分析
↓
语法分析
↓
生成 AST
↓
生成并执行代码
1. 词法分析
首先,JavaScript 引擎会把一整段代码拆分成一个个有意义的词法单元。
例如:
var a = 10;
大致会被拆分为:
var
a
=
10
;
引擎会识别出:
var是声明变量的关键字a是变量名=是赋值运算符10是一个数值;表示一条语句结束
这个过程被称为词法分析。
2. 语法分析
仅仅识别出这些词法单元还不够,引擎还需要理解它们组合起来表达了什么。
对于下面这行代码:
var a = 10;
JavaScript 引擎需要理解:
这里声明了一个名为
a的变量,并将数值10赋给了它。
随后,引擎会根据代码的语法结构生成一棵 AST,也就是抽象语法树。
AST 的全称是:
Abstract Syntax Tree
可以把 AST 简单理解为:
JavaScript 引擎整理出来的代码结构图。
通过这棵结构图,引擎能够知道代码中声明了哪些变量、定义了哪些函数,以及这些代码分别位于什么位置。
3. 生成并执行代码
完成词法分析和语法分析后,JavaScript 引擎会根据 AST 生成可以执行的代码,并开始运行程序。
所以,JavaScript 代码的执行并不是简单地“看到一行就立刻执行一行”。
在真正执行之前,JavaScript 引擎已经先对代码进行了分析。也正是在这个过程中,引擎确定了变量声明的位置以及变量所属的作用范围。
接下来,我们就可以进一步讨论:什么是作用域,以及为什么有些变量明明已经声明,却仍然无法访问。
二、声明变量和给变量赋值是两回事
来看下面这段代码:
var a;
console.log(a);
a = 10;
它会输出:
为什么不是 10?
因为代码是按照下面的顺序执行的:
var a; // 声明变量 a
console.log(a); // 读取变量 a
a = 10; // 给变量 a 赋值
第一行只是声明了变量 a,此时还没有主动给它设置一个具体值。
使用 var 声明变量后,变量会被初始化为:undefined
所以执行下面这行代码时:console.log(a);
变量 a 已经存在,只是它的值暂时为 undefined。
直到执行:
a = 10;
变量 a 的值才会变成 10。
可以把变量声明和赋值拆开来看:
var a; // 声明
a = 10; // 赋值
也可以将它们写在一起:
var a = 10;
var、let 和 const 都可以用来声明变量,但它们在作用域和初始化规则上存在差异,后面会具体介绍。
三、var 的变量提升
来看下面的代码:
console.log(a);
var a = 10;
输出结果是:
undefined
这是因为 JavaScript 在执行代码前,会先处理 var 声明。上面的代码可以近似理解为:
var a;
console.log(a);
a = 10;
需要注意,提升的是变量声明,而不是赋值。因此,访问 a 时不会得到 10,而是得到 undefined。这种现象称为 变量提升(Hoisting) 。
四、什么是作用域?
作用域决定了一个变量可以在哪些位置被访问。
可以把作用域理解成变量的“有效活动范围”。
例如:
function foo() {
var a = 2;
}
foo();
console.log(a);
变量 a 声明在 foo 函数内部,因此它只能在 foo 函数内部被访问。
当代码执行到:
console.log(a);
此时已经位于 foo 函数之外。
外部代码无法直接访问函数内部声明的变量,所以 JavaScript 会抛出错误:
ReferenceError: a is not defined
需要注意的是,问题并不在于函数有没有执行。
即使 foo() 已经调用过,函数内部的局部变量也不会因此变成外部变量。
function foo() {
var a = 2;
}
foo();
foo();
foo();
console.log(a);
无论调用多少次,最后一行都会报错。
因为变量是否可以访问,不取决于函数执行了几次,而取决于访问代码是否处于变量所属的作用域中。
JavaScript 中常见的作用域有三种:
- 全局作用域
- 函数作用域
- 块级作用域
五、全局作用域
在函数和代码块之外声明的变量,通常位于最外层作用域中。
例如:
var a = 1;
console.log(a);
这里的变量 a 声明在最外层,因此后面的代码可以访问它。
函数内部通常也可以访问外部声明的变量:
var a = 1;
function foo() {
console.log(a);
}
foo();
输出结果为:1
foo 函数内部没有声明变量 a,于是 JavaScript 会到函数外部查找,并找到:
var a = 1;
因此,函数内部可以输出 1。
最外层声明的变量通常具有较大的可访问范围,这类变量也常被称为全局变量。
不过,全局变量并不是越多越好。
例如:
var username = "Tom";
var page = 1;
var theme = "dark";
如果项目中的许多函数都可以读取和修改这些变量,就可能出现以下问题:
- 变量被意外修改
- 不同代码使用了相同的变量名
- 很难判断变量在哪里发生了变化
- 代码之间的依赖关系越来越复杂
因此,在实际开发中,通常应该尽量缩小变量的作用范围。
六、函数作用域
每个函数都会形成自己的作用域。
在函数内部声明的变量,一般只能在这个函数内部访问。
function foo() {
var a = 2;
console.log(a);
}
foo();
这段代码可以正常输出:2
因为读取变量 a 的代码也位于 foo 函数内部。
但如果把 console.log 放到函数外面:
function foo() {
var a = 2;
}
foo();
console.log(a);
代码就会报错。
可以把函数想象成一间房间。
外部区域
└── foo 房间
└── 变量 a
变量 a 被放在 foo 这间房间里。
房间内部的代码可以使用它,但房间外部的代码不能直接进入房间拿走这个变量。
函数参数也属于函数内部
来看一个普通的加法函数:
function foo(a, b) {
return a + b;
}
foo(1, 2);
console.log(foo(2, 3));
输出结果为:5
在函数定义中:
function foo(a, b)
a 和 b 是函数的形参。
调用函数时:
foo(2, 3);
2 和 3 是传入函数的实参。
执行函数时,可以近似理解为:
function foo(a, b) {
// a 接收到 2
// b 接收到 3
return a + b;
}
形参 a 和 b 只在 foo 函数内部有效。
下面的代码无法在函数外部直接访问它们:
function foo(a, b) {
return a + b;
}
foo(1, 2);
console.log(a);
运行后会抛出:
ReferenceError: a is not defined
因为这里的 a 是 foo 函数内部的参数,不属于函数外部。
七、内层函数如何查找变量?
函数内部还可以继续定义函数:
var a = 1;
function foo() {
var a = 2;
function bar() {
console.log(a);
}
bar();
}
foo();
这段代码最终会输出:2
但是,bar 函数内部并没有声明变量 a:
function bar() {
console.log(a);
}
它为什么能够访问 a?
JavaScript 查找变量时,会先从当前所在的作用域开始。
对于 bar 函数中的这行代码:
console.log(a);
查找过程可以简单理解为:
先在 bar 中查找 a
↓
bar 中没有找到
↓
到外层的 foo 中查找
↓
找到 var a = 2
因此,最终输出的是:
2
虽然最外层还存在:
var a = 1;
但 JavaScript 已经在距离更近的 foo 函数中找到了 a,所以不会继续向外查找。
这说明 JavaScript 查找变量时遵循一个简单原则:
先查找当前作用域,如果没有找到,再向外层作用域查找。
一旦找到了变量,查找就会停止。
我们不需要在这里深入讨论更多概念,只要记住变量查找的方向是由内向外即可。
八、块级作用域
除了函数以外,由一对大括号 {} 包围的代码区域也可能限制变量的访问范围。
例如:
if (true) {
let b = 2;
}
console.log(b);
运行后会报错:
ReferenceError: b is not defined
因为 b 使用 let 声明,只能在所在的代码块中访问。
代码块内部可以正常读取:
if (true) {
let b = 2;
console.log(b);
}
输出结果为:
2
但是一旦离开大括号,变量 b 就无法继续访问。
外部作用域
└── if 代码块
└── let b = 2
使用 let 和 const 在代码块中声明变量时,会形成块级作用域。
常见的代码块包括:
if (true) {
// 代码块
}
for (let i = 0; i < 3; i++) {
// 代码块
}
while (condition) {
// 代码块
}
也可以单独写一个代码块:
{
let a = 10;
console.log(a);
}
console.log(a);
代码块内部可以输出 10,代码块外部则会报错。
九、var 为什么可以离开代码块?
var 和 let 在代码块中的表现并不相同。
来看下面的例子:
if (true) {
let b = 2;
var c = 3;
}
console.log(c);
输出结果为:3
变量 c 明明声明在 if 代码块中,为什么离开代码块后仍然可以访问?
原因是:
var没有块级作用域。
虽然 c 写在 {} 中,但这个代码块没有把 var 声明的变量限制在内部。
因此:
if (true) {
var c = 3;
}
console.log(c);
可以正常输出 3。
但是 let 不同:
if (true) {
let b = 2;
}
console.log(b);
会直接报错。
可以用一张简单的对比表来记忆:
| 声明方式 | 函数作用域 | 块级作用域 |
|---|---|---|
var | 有 | 没有 |
let | 有 | 有 |
const | 有 | 有 |
这里的“有函数作用域”表示:变量在函数内部声明后,不能被函数外部直接访问。
十、let 和 const 的暂时性死区
let 和 const 不仅具有块级作用域,在声明语句执行之前还会形成暂时性死区,英文为 Temporal Dead Zone,简称 TDZ。
来看下面的代码:
var a = 100;
if (true) {
console.log(a);
let a = 10;
}
console.log(a);
你可能会认为,执行 if 代码块中的 console.log(a) 时,内部的 a 还没有声明,因此 JavaScript 应该向外查找并输出:
100
但实际运行结果是:
ReferenceError: Cannot access 'a' before initialization
这是因为 let a = 10 会在当前代码块中创建一个新的变量绑定。这个绑定的作用范围覆盖整个 if 代码块,而不是从声明语句所在的位置才开始。
因此,进入 if 代码块后,内部的变量 a 就已经遮蔽了外层的 a:
进入 if 代码块
↓
内部的 a 已经绑定到当前作用域
↓
但 a 尚未完成初始化,处于暂时性死区
↓
执行 let a = 10
↓
暂时性死区结束,a 可以正常访问
当代码执行到:
console.log(a);
JavaScript 查找到的是当前代码块中的 a,而不是外层值为 100 的 a。但此时内部的 a 还没有完成初始化,因此会直接报错,也不会继续向外查找同名变量。
如果调整代码顺序:
var a = 100;
if (true) {
let a = 10;
console.log(a);
}
console.log(a);
输出结果为:
10
100
代码块内部访问的是由 let 声明的局部变量,离开代码块后访问的仍然是外层变量。
const 也具有相同的暂时性死区:
var a = 100;
if (true) {
console.log(a);
const a = 10;
}
这段代码同样会报错。
因此,暂时性死区的核心并不只是“不能在声明前访问变量”,还包括:
let和const会将变量绑定到当前块级作用域。在声明完成之前,该变量处于暂时性死区;即使外层存在同名变量,JavaScript 也不会转而访问外层变量。
十一、var 和 let 在声明前访问时有什么不同?
先看 var:
console.log(a);
var a = 10;
输出结果为:
undefined
再看 let:
console.log(a);
let a = 10;
运行结果是:
ReferenceError: Cannot access 'a' before initialization
二者的主要区别可以简单概括为:
var
变量在执行声明语句前已经被初始化为 undefined。
console.log(a); // undefined
var a = 10;
let
变量已经被当前作用域记录,但在声明语句执行前没有完成初始化,因此不能访问。
console.log(a); // ReferenceError
let a = 10;
因此,实际开发中不要依赖 var 在声明前返回 undefined 的行为。
更清晰的写法始终是:
let a = 10;
console.log(a);
先声明和初始化变量,再使用它。
十二、const 与 let 有什么区别?
const 和 let 都具有块级作用域,也都存在暂时性死区。
它们的主要区别在于变量能否被重新赋值。
使用 let 声明的变量可以重新赋值:
let a = 10;
a = 20;
console.log(a);
输出结果为:20
使用 const 声明的变量不能重新赋值:
const a = 10;
a = 20;
运行后会报错:
TypeError: Assignment to constant variable
此外,const 声明时必须立即提供初始值。
下面的写法不允许:
const a;
必须写成:
const a = 10;
因此可以简单记忆:
let:变量之后还需要重新赋值const:变量之后不需要重新赋值
在实际开发中,可以优先使用 const。
只有确定变量后续需要重新赋值时,再使用 let。
const 对象仍然可以修改属性
需要注意,const 限制的是变量重新赋值,并不代表对象内部的属性完全不能改变。
const user = {
name: "Tom"
};
user.name = "Jerry";
console.log(user.name);
输出结果为:
Jerry
这是允许的,因为变量 user 仍然指向原来的对象。
不允许的是让它指向另一个对象:
const user = {
name: "Tom"
};
user = {
name: "Jerry"
};
这会导致重新赋值错误。
十三、如何判断一个变量能不能访问?
遇到变量无法访问的问题时,可以按照下面几个步骤检查。
1. 变量声明在哪里?
先找到变量的声明语句:
var a = 1;
let a = 1;
const a = 1;
然后判断它位于:
- 最外层
- 某个函数中
- 某个代码块中
2. 使用变量的代码在哪里?
判断使用变量的位置是否仍在变量所属的作用域中。
function foo() {
var a = 2;
}
console.log(a);
变量声明在函数内部,使用位置在函数外部,所以不能访问。
3. 使用的是 var、let 还是 const?
如果变量声明在代码块中:
if (true) {
var a = 1;
let b = 2;
}
离开代码块后:
a仍然可能被访问b无法被访问
因为 var 没有块级作用域,而 let 有。
4. 是否在声明之前访问?
console.log(a);
let a = 10;
a 处于暂时性死区,所以无法访问。
5. 当前作用域中是否有同名变量?
var a = 100;
if (true) {
console.log(a);
let a = 10;
}
代码块内部的 a 遮蔽了外层的 a,但它还没有完成初始化,因此会报错。
十四、回到最开始的问题
文章开头的代码是:
function foo() {
var a = 2;
}
foo();
console.log(a);
变量 a 为什么访问不到?
因为它声明在 foo 函数内部,只属于 foo 的函数作用域。
函数外部无法直接访问函数内部变量。
而下面这段代码:
var a = 1;
function foo() {
var a = 2;
function bar() {
console.log(a);
}
bar();
}
foo();
为什么会输出 2?
因为 bar 中没有声明变量 a,JavaScript 会向外查找,并首先在 foo 中找到:
var a = 2;
找到后便停止查找,所以不会使用最外层值为 1 的变量。
看似神秘的变量访问问题,实际上都可以归结为几个简单问题:
变量声明在哪里?
当前代码位于哪里?
变量使用什么方式声明?
当前作用域中有没有同名变量?
变量是否已经完成初始化?
只要依次回答这些问题,就能判断一个变量为什么可以访问,或者为什么会突然报错。