[路飞]每日一答:谈谈词法作用域,块级作用域,作用域链,静态作用域和动态作用域?

230 阅读6分钟

谈谈词法作用域,块级作用域,作用域链,静态作用域和动态作用域?

词法作用域

词法作用域,顾名思义,他是在编译器的词法分析阶段即确定的作用域。通过查阅资料,我们可以知道,编译器在编译的过程中会经历三个阶段。

  • 分词/词法分析:将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元token);

  • 解析/语法分析:将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树——“抽象语法树”(AST);

  • 代码生成:将AST 转换为可执行代码的过程称被称为代码生成。

词法作用域是由你在写代码时将变量和快作用域写在哪里来决定的,也就是说函数的作用域在函数定义的时候就决定了。

块级作用域

块级作用域在 ES6 中新增的作用域概念,块级作用域可以简单地理解为某一段封闭的代码中的作用域,这一段所谓封闭的代码指的不是某个函数内,而是类似于 if 或者 for的大括号内的作用域。

const list = [1,2,3,4,5]
for (let index = 0 ; index < list.length; index++) {
    const number = list[index]
    console.log(number) // 1 2 3 4 5
}
console.log(number) // ReferenceError: number is not defined
const list = [1,2,3,4,5]
if (list.length > 0) {
    const number = list[0]
    console.log(number) // 1
}
console.log(number) // ReferenceError: number is not defined

上面两段代码中, number都是在块级作用域下定义的变量,可以发现仅仅在当前代码块中可以访问,在大括号外部就不可访问。实际上第一段代码中 for 循环中的 index 变量也是块级作用域,稍后介绍完全局作用域后我们可以来讨论一下面试中经常遇到的八大陷阱之一:循环陷阱。

作用域链

作用域是范围,我们可以理解为可以访问到的变量的所在范围。

作用域链则是决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。

它制定了变量在作用域内的访问规则:作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象。

总结来说就是: 当前作用域找变量,找不到就去上一个外部作用域找,再找不到就再往外找,直到找到或到达全局作用域为止。

const a = 0
for(let index = 0 ; index < 1 ; index++) {
    const a = 1
    console.log(a) // 1
}
if (a === 0) {
  console.log(a) // 0
}
console.log(a) // 0

上述代码中有两个地方都定义了变量a:

for循环是块级作用域,他内部的 a会在内部最先被访问

代码最上面的 a是全局作用域,他会在全局作用域下优先被访问

if里面的 a,没有在 if当前的块级作用域内声明,所以根据作用域链的规则,他会向上一个上下文作用域中去寻找,那么找到的就是全局作用域 a, 假设全局作用域内也没有定义 a,那么将会打印 undefined

静态作用域

静态作用域,顾名思义也就是被确立后不会改变的作用域,实际上它就是在变异阶段就被确定好了的作用域。其实上面第一个提到的词法作用域就是静态作用域!

动态作用域

静态作用域和动态作用域实际上是两个相对应的概念。在 javascript 中采用的是静态作用域。我们可以举例说明动态作用域。

这是一个在《JavaScript权威指南》中的经典例子:

var value = 0;
function fun1() {

  console.log(value);

}
function fun2() {
  var value = 1;
  fun1();

}
fun2();

打印结果是 0

原因是 js 是词法作用域,那么在各个函数中的作用域在编译的时候就已经确定好了, fun1 中的 value 没有被定义,根据作用域链原则,他会寻找到全局作用域的 value打印,结果就是 0,也就是说它不会因为我先调用了 fun2 去改变 fun1 的作用域,尽管 fun1 是在 fun2 中被调用的。

而动态作用域则往往不是这样,动态作用域会在你调用函数的时候才会生成他的作用域,实际上它就等于静态作用域中的这样的形式了:

var value = 0;

function fun2() {
  var value = 1;
  var  fun1 = function () {
      console.log(value); // 1
  }
  fun1();

}
fun2();

在函数调用的时候生成了 fun2 的作用域,在 fun2 中调用 fun1,那么 fun1找不到变量的时候会去调用者的作用域中寻找,即 fun2 中的 value,所以会打印 1

那么有一个值得思考的问题: 我们在javascript中,经常用到的 this的指向 是不是动态作用域呢?

我们调用每个函数,每个函数的 this 指向都可能是不同的,箭头函数的 this 指向父作用域,我们甚至可以手动绑定 this,也就是说 this 在函数调用的时候才会确定他的指向不是吗?所以个人认为,在 javascript中 this的指向是动态作用域的设计。

作用域的坑实例: 循环陷阱

先看代码:

var res = []
function loop () {
  for (var index = 0 ; index <= 10 ; index++) {
    res.push(
      function () {
        console.log(index)
      }
    )
  }
}
loop()
res[0]()
res[1]()

分析代码

  • 定义了一个数组 res 用来储存 函数

  • 定义了一个 loop 函数,用来遍历 0 - 10,并且每次遍历都会向 res中存入一个打印当前 index的方法

  • 执行 loop函数

  • 执行 loop 过后 res中存放的第一个和第二个函数

按照我们正常逻辑来看, res内部存储的应当是 打印0-10的函数,可事实真的是这样吗?

执行结果 :

res[0]() // 11
res[1]() // 11

其实就是作用域在捣鬼。我们可以看到 for循环中的 index使用 var去声明的,所以 index作用域得到了提升,我们可以直接理解为他是 loop下的函数作用域,而非 for循环中的块级作用域。所以循环中的 index指向的都是同一个变量,在遍历完成后index变成了 11,所以你res每个元素执行打印的结果都会是 11

如何解决呢?也很简单,只要将 var改成 let即可。

image.png

这样一来index就会保持为for循环中的块级作用域,在每次 块级执行完后会被销毁,那么每一次得到的 index都会是一个新的变量,所以最终每个 index指向的都是不同的变量,也就不会随着循环而改变了。

当然我们也可以通过立即执行函数的方法去解决:

var res = []
function loop () {
  for (var index = 0 ; index <= 10 ; index++) {
    (function (index) {
      res.push(
        function () {
          console.log(index)
        }
      )
    })(index)
  }
}
loop()
res[0]() // 0
res[1]() // 1

通过立即执行函数,将 for 循环内的打印方法封装,再通过传递 for 循环的 index 来实现变量的私有化,实际上立即执行函数就是为了为函数内部创建独立的作用域的。