你不知道的JavaScript之变量声明提升和闭包

声明提升和闭包

提升

JavaScript 是一门解释型语言,原则上是不需要编译的。但是它在代码执行之前会有一个编译的流程,这个流程发生在代码执行的前一刻。

示例:

console.log(a)    // undefined
var a = 2
console.log(a)    // 2

在这段代码中,按照常规逻辑,引擎通过作用域没有找到,可能会抛出一个错误 ReferenceError: a is not defined,但是这种情况并没有发生。
实际上,在代码运行的前一刻,会执行预编译操作。当遇到 var a = 2 这样的语句时,会被拆分成两部分,var a(变量声明)+ a = 2(变量赋值)。其中,变量声明会发生在预编译阶段,把 a 这个变量放到全局作用域中,没有赋值,则为 undefined。
当执行第一个打印操作的时候,已经完成了预编译相关的处理,所以可以访问a,得到undefined;然后开始执行后面的赋值操作;等到第二个打印操作的时候已经可以正常访问 a 的值了。所以上述代码也可以像下面这样理解:

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

变量声明提升,函数声明整体提升

foo()    // foo
bar()    // TypeError: bar is not a function(此时bar为undefined)
function foo() {
  console.log(foo.name)
}
var bar = function () {
  console.log(bar.name)
}

变量声明提升,var 声明的变量在预编译的时候会被提升到当前执行环境的顶部;
函数声明整体提升,以函数声明声明函数的函数,会被整体提升到当前执行环境的顶部,所以执行语句可以写在函数声明前面。函数表达式不可以,因为函数表达式走的是变量声明提升的规则。

同一个变量既赋值给了变量,又作为函数声明的标识符

console.log(foo)    // function
var foo = 1
console.log(foo)    // 1
function foo() {
  console.log(foo.name)
}
console.log(foo)
/**
 * 这里常规思路是 function,其实还是 1
 * 因为函数声明这段代码已经被整体提升到了当前执行环境的顶部,已经在前面执行过了
 */ 

闭包

一个闭包的基本示例:

function foo() {
  let a = 3
  let tempFunc = function () {
    return a
  }
  return tempFunc
}
// 通过 bar 标识符引用了 foo 内部的函数 tempFunc
let bar = foo()
console.log(bar())

在这里,函数 bar 的词法作用域能够访问 foo 的内部作用域,这让foo的内部函数能够在自己的词法作用域外执行,但是依然能够访问自身词法作用域的变量。

在foo()执行后, 通常会期待foo()的整个内部作用域都被销毁, 因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。 由于看上去foo()的内容不会再被使用, 所以很自然地会考虑对其进行回收。
而闭包的“ 神奇”之处正是可以阻止这件事情的发生。 事实上内部作用域依然存在, 因此没有被回收。谁在使用这个内部作用域?原来是bar()本身在使用。
拜bar()所声明的位置所赐, 它拥有涵盖foo()内部作用域的闭包, 使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。
bar()依然持有对该作用域的引用,而这个引用就叫作闭包。
这个函数在定义时的词法作用域以外的地方被调用。 闭包使得函数可以继续访问定义时的词法作用域。

基本示例的变种:

let bar
function foo() {
  let a = 2
  let tempFunc = function () {
    return a
  }
  bar = tempFunc
}
foo()
console.log(bar())

无论通过何种手段将内部函数传递到所在的词法作用域以外, 它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

循环中的闭包

常见考题:

for (var i = 1; i <= 5; i ++) {
  setTimeout(function () {
    console.log(i)
  }, i*1000)
}
// 输出什么?

正常情况下,我们对这段代码行为的预期是分别输出数字1~5,每秒一次,每次一个。
但实际上,这段代码在运行时会以每秒一次的频率输出五次6。
首先解释6是从哪里来的。 这个循环的终止条件是i不再<=5。条件首次成立时i的值是6。因此,输出显示的是循环结束时i的最终值。
仔细想一下, 这好像又是显而易见的, 延迟函数的回调会在循环结束时才执行。 事实上,当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个6出来。

所以,可以把上面的代码,转换成下面的形式:

// 这里的 i 的作用域是在全局,不是预期的只有循环才能访问的
for (var i = 1; i <= 5; i ++) {}
setTimeout(function () {
  console.log(i)
}, 1*1000)
setTimeout(function () {
  console.log(i)
}, 2*1000)
setTimeout(function () {
  console.log(i)
}, 3*1000)
setTimeout(function () {
  console.log(i)
}, 4*1000)
setTimeout(function () {
  console.log(i)
}, 5*1000)
/**
 * 这里的循环是同步的,而定时器里面的方法是异步调用的
 * 当回调方法执行的时候,i 已经变成 6 了
 */

1、使用IIFE函数改造使其符合预期

for (var i = 1; i <= 5; i ++) {
  (function (j) {
    setTimeout(function () {
      console.log(j)
    }, j*1000)
  }(i))
}
/**
 * 这里通过立即执行函数给每次迭代都生成了一个新的作用域
 * 通过内部声明的变量j,把外部的i通过j传入内部作用域
 */

2、通过块级作用域使其符合预期

for (let i = 1; i <= 5; i ++) {
  setTimeout(function () {
    console.log(i)
  }, i*1000)
}

此时,变量i在循环过程中不止被声明一次,每次迭代都会声明。 随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

一句话总结闭包:当函数可以记住并访问所在的词法作用域, 即使函数是在当前词法作用域之外执行, 这时就产生了闭包。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容