一篇文章带你理解JavaScript中的作用域问题

152 阅读6分钟

在讨论作用域问题之前,我们先来了解一下JavaScript代码的执行过程,主要包括三个步骤:

  1. 词法分析: 将源代码转换成一系列的 tokens。
  2. 语法分析: 将 tokens 组织成抽象语法树(AST),同时进行语法错误检测。
  3. 生成代码: 从 AST 生成字节码或机器码,并执行代码。

接下来让我们看一个例子:

const x = 10;

1.词法分析:生成的 Tokens

之前我们已经生成了一些 tokens,这里我们再列一遍:

  1. 关键词(Keyword)const - 用于声明常量。
  2. 标识符(Identifier)x - 变量名。
  3. 运算符(Operator)= - 赋值运算符。
  4. 字面量(Literal)10 - 一个数字字面量。
  5. 分隔符(Punctuator); - 语句结束符。

2.语法分析:生成抽象语法树(AST)

基于这些 tokens,我们会构建一个简单的抽象语法树(AST)。这个 AST 反映了代码的结构和意图。

VariableDeclaration
├── Keyword: 'const'
└── VariableDeclarator
    ├── Identifier: 'x'
    └── Literal: 10

在这个 AST 中,我们可以看到它是一个变量声明(VariableDeclaration),包含了使用 const 关键字声明的一个常量 x,并将其值设置为 10

3.生成代码(Code Generation)

在这一阶段,编译器或解释器会根据 AST 生成目标代码。在 JavaScript 的环境中,目标代码通常是字节码或可执行代码,取决于具体的 JavaScript 引擎(如 V8 引擎、SpiderMonkey 等)。

生成伪代码示例

基于简单的逻辑,我们可以用一种简化的方式表述这段代码的执行过程:

// 1. 创建一个新变量 x
// 2. 将10赋值给 x
x = 10; // Store 10 in the variable x

这就是js代码的一个执行过程。

作用域

1.全局作用域

  • 全局作用域的变量可以在任何地方访问,而不仅仅局限于定义它们的地方。
  • 在浏览器环境中,所有的全局变量都是 window 对象的属性。

2.函数作用域

  • 函数作用域是指在函数内部定义的作用域。函数作用域使得变量在函数内部是可访问的,但在函数外部不可访问。

作用域规则:内层作用域可以访问外层作用域,外层不能访问内层。

让我们直接看例子:

var a = 1

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

fn()
console.log(b);

image.png

在函数fn中可以访问全局作用域中定义的a,但全局作用域并不可以访问函数作用域中定义的b。

3.块级作用域

  • 级作用域由 {} 大括号定义,例如在 if 语句、for 循环或任何花括号包围的代码段内。
  • 在块内使用 let 或 const 声明的变量仅在该块中可用,块外无法访问这些变量。
if(1){
    let b = 2
    const c = 3
    console.log(b, c);
}
console.log(b, c);

image.png 在这个例子中,{}里定用let,const定义的b与c,在{}外是无法访问到的,这个作用域就是块级作用域。

let a = 1

if(true){
    console.log(a);   // 暂时性死区
    let a = 2
}

image.png 很多人对这段代码报错的原因可能不太清晰,不是说内层作用域可以访问外层作用域吗,那全局里定义了一个a,在执行console.log(a);时,在{}块级作用域找不到a,那不应该往外找,最后找到全局作用域定义的a,然后输出吗?这是不对的,为什么呢?

这与我们前面提到的JS代码的执行过程有关系,在代码执行前会先进行词法分析,会先将将源代码转换成一系列的 tokens,那在这个{}的块级作用域里,在词法分析阶段就分析出了有一个a,那既然这个块级作用域中已经定义了一个a了,console.log(a);在执行时就只会在块级作用域中找这个a,但由于代码是从上往下执行的let a = 2console.log(a);之后,因此会报错。此时的a处于“暂时性死区”,在访问它时就会报错。

欺骗语法

1. eval()

eval() 是一个全局函数,可以将传入的字符串作为 JavaScript 代码执行。

我们直接看例子:

function foo(str, a) {
    eval(str) // var b = 3
    console.log(a, b);
}
var b = 2

foo('var b = 3', 1)
console.log(b);

image.png

这里的 var b = 3 总是会在 foo 的作用域内,相当于在foo函数中执行了var b = 3这段代码。于 var 声明是函数作用域的,因此它不会修改全局范围内的 b 而是会创建一个新的局部变量 b。所以在最后的console.log(b);输出的结果是全局作用域中的b,结果是2。

2. with(){}

with()是用来修改对象里的属性的一个函数,让我们先用一个例子来了解一下with()的用法:

var obj = {
    a: 1,
    b: 2,
    c: 3
}

// obj.a = 2
// obj.b = 3
// obj.c = 4
with(obj){
    a = 2
    b = 3
    c = 4
}
console.log(obj);

image.png

但是with()的使用有一个问题,让我们再来看一个例子:

var o2 = {
    b: 2
}
function foo(obj) {
    with(obj){
        a = 2
    }
}
foo(o2)
console.log(o2);
console.log(a);

image.png

大家看到这段代码可能就有疑问,这个a是哪里定义的,为什么最后输出它没有报错。而这,就是使用with()会带来的问题——当对象中没有属性 x 时,with修改x属性会导致 x 泄露到全局。

因此,在这个例子我们可以输出a的值。

var let const的区别

我们先来看两个对比的例子

console.log(a);
var a
a = 1

image.png

console.log(a);
let a
a = 1

image.png

对比这两个例子我们可以得到varlet的一个区别—— var 声明的变量会存在声明提升(将变量的声明提升到当前作用域的顶部),而let 不会


我们再来看这两个例子:

var a = 1
console.log(a);
var a = 2
console.log(a);

image.png

let a = 1
console.log(a);
let a = 2
console.log(a);

image.png

从这两个例子的对比,我们可以得出varlet的第二个区别——var 可以重复声明变量,let 不行


// 使用 var 声明全局变量
var globalVar = "I am a global variable with var";

// 使用 let 声明全局变量
let globalLet = "I am a global variable with let";

// 验证这些变量是否在 window 对象上
console.log(window.globalVar); // 输出: "I am a global variable with var"
console.log(window.globalLet); // 输出: undefined

这个例子说明的就是varlet的第三个区别——var 在全局声明的变量会默认添加在 window 对象上,let 不会


const唯一不一样的地方就是它声明的变量值无法修改

const a = 1

console.log(a);
a = 2

console.log(a);

image.png

总结

  1. var 声明的变量会存在声明提升(将变量的声明提升到当前作用域的顶部)
  2. let 不会
  3. var 可以重复声明变量,let 不行
  4. var 在全局声明的变量会默认添加在 window 对象上,let 不会
  5. const 声明的变量值无法修改

这就是我们今天要探讨的JS中的作用域的问题啦

20200229174423_bzukt.jpg