变量明明定义了,为什么访问不到?深入理解 JavaScript 作用域

0 阅读15分钟

我们先来看一段非常简单的JavaScript代码:

function foo() {
    var a = 2;
}
foo();
console.log(a);

运行之后我们得到以下结果:变量 a 明明已经在 foo 函数中声明了,foo 也已经执行过了,为什么最后一行仍然会报错?

image.png 难道函数执行结束后,变量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;

varletconst 都可以用来声明变量,但它们在作用域和初始化规则上存在差异,后面会具体介绍。

三、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 中常见的作用域有三种:

  1. 全局作用域
  2. 函数作用域
  3. 块级作用域

五、全局作用域

在函数和代码块之外声明的变量,通常位于最外层作用域中。

例如:

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)

ab 是函数的形参。

调用函数时:

foo(2, 3);

23 是传入函数的实参。

执行函数时,可以近似理解为:

function foo(a, b) {
    // a 接收到 2
    // b 接收到 3
    return a + b;
}

形参 ab 只在 foo 函数内部有效。

下面的代码无法在函数外部直接访问它们:

function foo(a, b) {
    return a + b;
}

foo(1, 2);
console.log(a);

运行后会抛出:

ReferenceError: a is not defined

因为这里的 afoo 函数内部的参数,不属于函数外部。

七、内层函数如何查找变量?

函数内部还可以继续定义函数:

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

使用 letconst 在代码块中声明变量时,会形成块级作用域。

常见的代码块包括:

if (true) {
    // 代码块
}
for (let i = 0; i < 3; i++) {
    // 代码块
}
while (condition) {
    // 代码块
}

也可以单独写一个代码块:

{
    let a = 10;
    console.log(a);
}

console.log(a);

代码块内部可以输出 10,代码块外部则会报错。

九、var 为什么可以离开代码块?

varlet 在代码块中的表现并不相同。

来看下面的例子:

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 的暂时性死区

letconst 不仅具有块级作用域,在声明语句执行之前还会形成暂时性死区,英文为 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,而不是外层值为 100a。但此时内部的 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;
}

这段代码同样会报错。

因此,暂时性死区的核心并不只是“不能在声明前访问变量”,还包括:

letconst 会将变量绑定到当前块级作用域。在声明完成之前,该变量处于暂时性死区;即使外层存在同名变量,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 有什么区别?

constlet 都具有块级作用域,也都存在暂时性死区。

它们的主要区别在于变量能否被重新赋值。

使用 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 的变量。

看似神秘的变量访问问题,实际上都可以归结为几个简单问题:

变量声明在哪里?
当前代码位于哪里?
变量使用什么方式声明?
当前作用域中有没有同名变量?
变量是否已经完成初始化?

只要依次回答这些问题,就能判断一个变量为什么可以访问,或者为什么会突然报错。