一、前言
书接上文,我们介绍了js的执行机制,提到了声明提升这个概念,最早的js是没有办法解决声明提升这个问题,而在ECMAScript 2015 (ES6)中引入用于声明变量的let与const 它们的设计目的是解决 var 带来的问题,并为变量的作用域管理提供更好的控制。
我们先来了解了解 let 与 const
let
作用域:let 是块级作用域,意味着它仅在声明它的 {} 大括号内有效。这避免了使用 var 时可能出现的全局或函数作用域问题
防止重复声明:同一作用域内,使用 let 声明的变量不能重复声明,而var是可以重复声明的。
const
const声明的是常量,即一旦赋值后,不能再进行重新赋值,其作用域与let相同,且同样无法重复声明
对象和数组:如果使用 const 声明的是对象或数组,虽然不能重新赋值,但可以修改其内部属性或元素。
我们来看这一段代码
function varTest() {
var x = 1
if (true) {
var x = 2
console.log(x)
}
console.log(x)
}
varTest()
让我们来画图分析
代码经过v8引擎读取后,进行编译,编译将全局执行上下文放入调用栈中,并找到声明函数varTest,全局执行上下文编译过程结束。
到全局执行上下文执行环节,执行函数varTest,又会产生varTest执行上下文,并将其放入调用栈中。
对varTest执行上下文进行编译时,将 x = undefined 放入变量环境,注意if(){var x = 2}中使用 var 声明的变量是在函数作用域中,因此这时又有一个 x = undefined 会覆盖之前的 x 值,varTest执行上下文编译过程结束。
到varTest执行上下文执行环节时,先将 1 赋值给x ,随后再将 2 赋值给 x 输出 x 此时 x = 2 ,然后再输出x,即这段代码会输出两个 2 。
让我们将上文代码中的var x = 2 改为 let 会怎么样 ?
看到这边第第二个输出居然变成了 1 。
从上文介绍中我们了解到了
let 是块级作用域,它仅在声明它的 { } 大括号内有效,它与if语句所形成的{}形成了块级作用域,在varTest执行上下文编译的词法环境中会产生一个 x = undefined ,到了执行阶段,变量环境中的x会被赋值为 1 ,而词法环境中的 x 会被赋值成 2 ,此时这个if语句中{}内的 x 值就是 2,则第一个输出语句输出的就是 2 ,出了let与{ }形成的块级作用域,在变量环境中 x 的值还是 1 第二个输出语句输出的就是 1
让我们来看看这段代码
function foo() {
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a)
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
一看这一串串的abcd头都要大了是吧,不过熟悉了作用域链我们就能轻松的公式化的解决这个问题,还是用画图来解决
按照老办法先编译再执行,先把用 var 声明的变量 a 变量 c 都放进变量环境,然后用 let 声明的 b 放进词法环境 ,词法环境维护了一个新的栈,此时 b 先进栈,然后进入花括号,我们看到了 let ,let 与{ }会形成块级作用域,这个块级作用域又在词法环境中形成了一个块级上下文,存放用 let 声明的变量
b 变量 d 后编译结束,进入执行阶段依次赋完值后就是上图的样子。
随后我们遇见了第一个console.log他在块级作用域中,,它要输出a,它要找a,它一定是先从词法环境中去找,并且从词法环境中维护的栈顶开始往下找。如果没找到就去变量环境中找,一路找到了变量环境中的a = 1,第二个console.log一下就在块级上下文中找到了即b = 3 ,运行出了{ },即这个块级上下文执行完就销毁了
此时第三个输出找的时候是这个样子,此时b = 2,第四个输出找变量c ,先找词法环境,词法环境中没有,就去找变量环境找到了c = 4 ,第五个输出找的是变量d ,而此时变量 d 只有在块级上下文中才有,但是这个块级作用域已经被销毁了,则这个 d 找不到任何值,程序就会报错。
二、作用域链
在讲作用域链之前我们先来讲一讲词法作用域的概念,词法作用域(lexical scope)是指在编程语言中,函数定义在了哪个域中(不是函数在哪里调用),这个域就叫该函数的词法作用域
让我们用代码来继续讲解
function bar() {
console.log(myname)
}
function foo() {
var myname = 'zhangsan'
bar()
console.log(myname)
}
var myname = 'lisi'
foo()
函数bar是在全局中声明的,所以函数bar的词法作用域在全局,同理函数foo的词法作用域也在全局
我们还要知道一个知识点
在每一个执行上下文中都存在一个outer,这个outer就是指向的这个执行上下文的词法作用域,如果在执行上下文中找不到想要的变量,执行上下文就会去它outer指向的执行上下文中去找想要的变量。v8在查找变量的过程中,顺着执行上下文中的outer指向查清一整根链,这种链状关系就叫作用域链
回到代码中,调用函数 foo 时,函数 foo 执行了一半又调用了函数 bar,而函数 bar的肚子里面空空如也,什么变量也没声明,要是对词法作用域不了解的同学就会认为在调用栈中我在 bar 中找不到我就去 foo 中找呗,那就大错特错了。bar执行上下文的outer指向的时全局执行上下文,因此我们要去全局里的变量环境里去找myname的值,找到的是lisi,随后输出lisi,函数bar执行完毕,bar的执行上下文销毁,继续往下走,走到了代码第七行输出myname此时先在自身的变量环境中去找,找到了是zhangsan,即输出zhangsan,因此本段代码的输出是
lisi
zhangsan
三、闭包
没有学过这个闭包的同学第一次听到闭包,想必一定是在某款FPS游戏里面吧,队友大喊:“b包!b包!” 因此你是不是还想问问有没有a包呢 hhh开个玩笑
我们直接上代码开始讲解
function foo() {
function bar() {
var a = 1
console.log(b)
}
var b = 2
return bar
}
foo()
const baz = foo()
baz()
代码相对有些复杂,我们直接开始画图
编译环节就不多赘述了,我们直接开始执行环节,首先是全局中先调用函数foo,生成foo执行上下文,先将foo执行上下文中的b给赋值2,然后函数foo就执行完毕,销毁函数foo的执行上下文,随后将函数foo的返回值给baz,我们可以从代码看出来foo()的返回值就是函数bar(),因此此时调用函数baz就是相当于调用函数bar ,先是将1赋值给a,
然后输出b,但是bar执行上下文中并没有找到b呀,因此它就去它outer指向的地方也就是foo执行上下文中去找,但是你有没有发现因为函数foo已经执行完了所以foo执行上下文已经被销毁掉了,那bar说:'foo你带我走吧,你走了我可怎么活。",不用担心foo还是留了一手的,虽然它被销毁了,但是它被需要的那部分变量还是会被保存到内存中,这就是闭包(closure)
即我们可以总结出
1.为什么会有闭包这个概念:词法作用域的规则 和 函数调用完毕它的执行上下文一定会被销毁这一规则冲突
2.闭包的概念:在js中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量(outer)。当通过调用一个外部函数返回的一个内部函数时,即使外部函数调用完了(调用完会自动销毁),但是内部函数引用了外部函数中的变量,那么这些变量依然需要保存在内存中(注意是只保存需要的变量),我们把这些变量的集合称为 闭包
四、闭包的优缺点
缺点:内存泄露(调用栈的可用空间变小)
- 闭包会保持对其外部作用域的引用,这可能导致内存无法被及时释放。如果闭包持续存在,并且外部作用域的对象也被闭包引用,那么这些对象无法被垃圾回收,可能导致在调用栈中的占用增加。
- 每次创建闭包时,都会有额外的上下文开销,尤其是在频繁创建闭包的情况下,可能会影响性能。
优点:可以实现变量的私有化,封装模块
- 闭包允许我们创建私有变量,这些变量不会被外部访问。例如,在函数内定义的变量无法从函数外部直接访问。通过返回内部函数,外部代码可以使用它们,同时保持内部状态的封闭性。
- 利用闭包可以实现模块化编程,将相关的功能封装在一起,避免全局命名冲突
利用其优点我们可以实现在避免全局命名冲突情况下的累加器功能实现
function add() {
let num = 0
return function foo() {
console.log(++num)
}
}
const res = add()
res()
res()
五、强化练习
function fn() {
var arr = []
for (var i = 0; i < 5; i++) {
arr.push(function () {
console.log(i)
})
}
return arr
}
var funcs = fn()
for (var j = 0; j < 5; i++) {
funcs[j]()
}
试试分析上面代码的输出,结果是 5 5 5 5 5