一. 前置知识
- 作用域:全局作用域:全局作用域为程序的最外层作用域,一直存在。函数作用域:函数作用域只有函数被定义时才会创建,包含在父级函数作用域或全局作用域内。
- 作用域规则:内层作用域可以访问外层作用域,外层不可访问内层
- js执行机制:当js代码开始执行时,首先创建全局执行上下文并推入调用栈,先编译再执行,执行中当一个函数被调用时,该函数函数执行上下文被创建并推入调用栈的顶部。 当函数执行完成后,它的执行上下文会从调用栈中弹出(销毁),接着返回到之前的执行上下文。 如果调用栈为空,即执行完毕。
二. let 解决声明提升
最早的js的执行上下文是没有词法环境的,为了解决变量提升的问题,后面开辟了词法环境(用于存放let和const),形成块级作用域,有块级作用域和没有块级作用域编译是不一样的,如以下俩个例子;
编译varTest函数时,先用var声明一个变量x存在于变量环境中,然后if中用let声明一个变量x则会形成一个块级作用域,会生成一个自己的栈,(也就是说如果有三个if里有let,则会在词法环境中形成三个栈,每个栈之间各自执行,互不影响)。执行时,首先将1赋给变量环境中的,然后进入if,因为在if中用let声明了一个x=2,所以词法环境中的x应该先在自己的作用域里面找,找到x=2,然后再执行console.log(x)
,因为是在自己的块级作用域中执行,所以输出2,而外面的console.log(x)
也在自己的作用域中找输出1,所以先打印2再打印1。即块级作用域里面的变量与外部作用域的变量互不影响。
三. 执行上下文的执行顺序
当执行上下文的时候,先从词法环境中开始找,然后再找变量环境。执行时词法环境会维护一个栈,每一个块级作用域会形成一个块级执行上下文对象入栈,执行完后出栈销毁。比如以下例子;
如图,先分析foo函数代码,用var声明变量a,let声明变量b,再一个{let:}
块级作用域中let声明b和d,var声明c,打印a和b,然后再在外层打印b c d。
- 编译过程:变量环境中声明a,然后
let b = 2
形成一个块级作用域(块级上下文),词法环境中会维护一个新的栈,let b = 2
则 b 被压入栈中,然后又{let:}
生成一个块级上下文let声明的b,d压入栈中,因为var声明是不会进入词法环境的,let和const才会,所以var c = 4
声明的 c 则在变量环境中。 - 执行上下文时会先从词法环境中执行,先执行黄色方框上下文(最后生成的上下文)时,执行
console.log(a)
时,会先从当前块级执行上下文中找,找不到再往它的上一级(作用域链)找,也就是绿色方框,找不到再到变量环境中找(也就是沿着如图白色路线),所以 a 输出 1,b输出 3,执行完该块级上下文(黄色方框),弹出销毁,接着执行上一个执行上下文(foo执行上下文)也就是输出 b c d,分别为 2,4, undefined。
四. 作用域链
在阐述作用域链之前我们首先要知道每个执行上下文都会创建一个叫outer的东西相当于指针,指向它的词法作用域,也就是它的上一级。当在该执行上下文找不到变量,会去到上一级去找,也就是它的外层作用域。
词法作用域:函数定义在了哪个域中,这个域就叫该函数的词法作用域,也就是变量所处的环境。
示例:foo函数是在全局中声明的,所以foo函数的词法作用域就是全局作用域,bar函数是在foo函数中声明,即bar函数的词法作用域就是foo函数作用域。
function bar() {
console.log(myname);
}
function foo() {
var myname = '张三'
bar()
console.log(myname);
}
var myname = '李四'
foo()
如图,由于bar函数是在全局中声明的所以 bar函数中的执行上下文中的 outer 指向foo的词法作用域(全局执行上下文),执行bar函数中的console.log(myname);
时,先在该执行上下文中找,然后再沿outer指向的区域中找(也就是它的上一级),打印输出 李四,然后执行foo函数中的console.log(myname);
时先访问该区域中的变量,找到后输出 张三。这也就是为什么内层作用域可以访问外层作用域,而外层不能访问内层。
再看如下代码;分析其调用栈的结构以及outer的指向(每个函数的词法作用域)。
function main() {
let count = 2
function bar() {
let count = 3
function foo() {
let count = 4
}
foo()
}
bar()
}
main()
由于foo函数是声明在bar函数里面的,所以foo函数的词法作用域是bar函数,以此类推,bar函数的词法作用域是main函数,而main函数的词法作用域是全局作用域。所以foo函数outer指向bar,bar函数outer指向main,main函数outer指向全局,全局outer为null。访问变量时从栈顶开始,如果访问不到则沿着outer指针访问,也就是上图的白色箭头。foo=》bar=》main=》全局 这个就是作用域链。所以作用域链就是v8在查找变量的过程中,顺着执行上下文的 outer 指向查清一整根链,这种链状关系就叫作用域链。
五. 闭包
function foo() {
function bar() {
var a = 1
console.log(b);
}
var b = 2
var c = 3
return bar
}
const baz = foo()
baz()
如示代码,在foo函数中声明了bar,但是return bar
没有触发bar函数执行,(bar()
才是执行bar函数)而是将它return返回出来了,在外层用baz接收返回出来的函数体。
先全局入栈,然后在全局中调用foo函数,foo执行上下文入栈,然后先编译再运行,全程没有调用bar函数,只是声明了bar函数,然后return出来bar函数,然后foo执行上下文执行完了销毁出栈,但是执行上下文时用baz接收返回出来的函数体bar(baz就是bar),然后再用baz()调用bar函数,然后bar执行上下文入栈,执行时console.log(b)
在自己的作用域中找不到,然后去outer指针指向的foo函数作用域找,但是foo执行完已经销毁出栈了,这样的话就找不到了。
但是根据作用域链以及作用域规则,该作用域找不到的话,会顺着作用域链往它的上一级找,沿着outer指针指向的作用域(foo函数作用域),但是这样不就冲突了吗。
如上图所示:为了解决上面的冲突,虽然foo函数执行完毕会被销毁,但是它会留下一个小区间(如图中closure区域也就是闭包)。所以闭包就是一个函数被销毁之后留下的产物。这样就同时满足了执行上下文执行完毕会被销毁 以及 作用域链的访问原则(当在当前作用域找不到就会沿着outer找到foo函数,虽然foo会被销毁,但会留下一个存放变量的产物闭包)。
- 闭包的概念:在 js 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量。当通过调用一个外部函数返回的一个内部函数时,即使外部函数调用完了,但是内部函数引用了外部函数中的变量,那么这些变量依然需要保存在内存中,我们把这些变量的集合称为闭包。(被内部函数用到了的变量才会保存到闭包中)
- 为什么会有闭包:为了解决词法作用域的规则 和 函数调用完毕它的执行上下文一定会被销毁冲突这一规则冲突。
- 闭包的缺点:如果每个函数执行上下文存在闭包,那么每个函数上下文执行完后都会留下闭包,占用一定空间,导致调用栈的可用空间减少,也就是内存泄露。
- 闭包的优点:变量私有化,实现数据封装(如下)。
function add() {
let num = 0
console.log(++num)
}
add()
add()
add()
要使得调用一次函数使得num+1,也就是输出1,2,3。当然可以从全局变量中定义num,但是使用闭包就可以减少全局变量的声明,有助于实现数据隐藏和封装,模块化编程。就可以变成以下代码;
function add() {
let num = 0
return function foo() {
console.log(++num);
}
}
const res = add()
res()
res()
res()
观察下面代码,推出其输出值
如图也是一个闭包,fn函数中声明了foo函数(并未调用执行),而是将它加入数组的每一项中,然后将数组返回出来,此时数组arr
为 [Function: foo],[Function: foo],[Function: foo],[Function: foo],[Function: foo]]
也就是每一项是一个函数,执行完fn函数后,fn函数销毁,但是由于销毁后又调用了该函数中的foo函数,所以保留了foo中需要访问的 i 变量,接着执行全局将返回的数组arr赋值funcs,此时funcs也就是每一项都是函数foo的一个数组,然后使用j便历整个数组打印输出i,由于形成比闭包,闭包中存在变量i=5,所以依次打印5,最后输出结果为五个5。
那么如何使得它输出为0,1,2,3,4呢? 也就是说闭包里面的值就应该是0到4,那我们再写个函数将它触发即可,如下图;
每一次当foo函数执行完销毁时留下的闭包里的值分别是此时 n 的值(由此时的i传入)也就是0到4。因为函数foo就相当于arr中函数的词法作用域,所以输出时找的话是向它的词法作用域找,结果输出0到4。