声明提升和闭包
提升
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在循环过程中不止被声明一次,每次迭代都会声明。 随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
一句话总结闭包:当函数可以记住并访问所在的词法作用域, 即使函数是在当前词法作用域之外执行, 这时就产生了闭包。