深入理解执行上下文和作用域链

49 阅读3分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第6天,点击查看活动详情

前言

前文记录了执行上下文、作用域及作用域链的相关知识,这篇文章通过一道经典面试题深入理解执行上下文和作用域链

了解以下内容有助于理解后续代码及图解

执行上下文创建变量对象(VO)的过程:

  • 确定所有形参值以及特殊变量arguments
  • 确定函数中通过var声明的变量,将它们的值设置为undefined,如果VO中已有该名称,则直接忽略
  • 确定函数中通过字面量声明的函数,将它们的值设置为指向函数对象,如果VO中已存在该名称,则覆盖

作用域链:

  • VO 中包含一个额外的属性,该属性指向创建该 VO 的函数本身
  • 每个函数在创建时,会有一个隐藏属性[[scope]],它指向创建该函数时的 VO
  • 当访问一个变量时,会先查找自身 VO 中是否存在,如果不存在,则依次查找[[scope]]属性

----- JavaScript之执行上下文、作用域和作用域链

请看题

for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 1000)
}
console.log(i);

问: console.log 输出的 i 的值分别是什么? 为什么? 如何使输出的结果为:3,0,1,2?

回答是什么

掘金上有非常多的文章介绍过这道面试题,要回答这个问题是比较简单的:

  • 全局console先输出 i,值为 3
  • setTimeout 中的 console等待1000ms后输出 i,值也为 3

回答为什么

为什么代码执行结果是这样的?这个问题可能少有人从 执行上下文和作用域链 的角度去分析:

  1. var i 变量提升,属于全局上下文(GO)
  2. 进入for循环,加入三个setTimeout任务到宏任务队列等待
  3. 结束循环,此时 i 被赋值为3
  4. 全局console.log(i),输出3
  5. 全局上下文执行完毕,执行栈中无任务需要执行,开始执行宏任务队列中的三个setTimeout回调任务
  6. 队列:先进先出,先进入先执行
  7. 执行setTimeout回调时,创建回调函数VO
  8. 回调函数使用到变量i,当前VO不存在变量i,则从作用域链中查找
  9. 每个回调函数VO都有一个额外的属性指向创建该VO的函数本身;每个函数又有一个属性[[scope]]指向创建该函数的VO,即回调函数VO中无变量i,继续查找回调函数的[[scope]]属性,此次[[scope]]指向全局上下文全局上下文中存在 变量i,则使用全局变量i

图解:

图解1.png

:图解中省略事件循环相关过程,并将三个匿名回调函数假设为A、B、C三个函数;执行栈中的VO不会同时存在,真实情况是:执行完一个函数就出栈,然后再将下一个回调函数VO入栈。

最终执行的三个回调函数查找到的变量i都是GO中的i,所以输出的 i 的值都是3

如何解决

其他相关文章也都有给出解决方案:

  1. var 改为 let,使变量i 不被定义在 GO 而是 块级作用域
  2. for循环中使用立即执行函数,每次都将i的值作为形参传入,这样在函数上下文中就会存在形参i,取值时就不会取到全局的变量i

主要解释第二种方案:

for (var i = 0; i < 3; i++) {
  (function (i) {
  // 将i作为立即执行函数的形参传入,这样会在该函数VO中添加变量i,保证每个函数访问的变量i都是各自VO中的i
      setTimeout(function () {
        console.log(i);
      }, 1000)
  })(i)
}
console.log(i);

图解:

图解2.png