JS作用域链和闭包
- 引题
- 作用域链
- 词法作用域
- 闭包
- 思考题
- 闭包如何回收
引题
有没有人跟我一样,面试中要是问基础,最怕遇到的就是闭包问题,闭包在 JavaScript 中几乎无处不在,理解作用域链是理解闭包的基础,同时作用域链和作用域还是所有编程语言的基础。
首先来看一段示例代码:
function bar() {
console.log(myname)
}
function foo() {
var myname = 'yy'
bar()
}
var myname = 'qq'
foo()
如果用调用栈的方式来描述这个执行过程,可以参考下图:
如果你看过调用栈你的第一反应可能是按照调用栈的顺序来查找变量:
先查找栈顶是否存在 myname
变量,如果没有就往下查找 foo
函数中的变量,找到了所有返回 yy
。
但如果你运行这段代码就会知道,实际并非如此,最终输出结果其实是 qq
,为何会是这种情况呢?要解释清楚这个问题,就需要先搞清楚作用域链。
作用域链
在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用成为 outer
。
当一段代码使用一个变量时,JavaScript 引擎首先会在当前执行上下文查找该变量,如果找不到就会继续在 outer
所指向的执行上下文中查找。图中可以看出,函数 foo
和 bar
的 outer
都指向全局执行上下文,所以 bar
函数中找不到变量 myname
时,下一步就是去全局执行上下文中找,结果为 qq
。我们将这个查找的链条称为作用域链。
不过还有一个疑问,bar
函数是在 foo
函数中被调用的,为何它的外部引用 outer
不是 foo
函数执行上下文却是全局执行作用域呢?要回答这个问题,还需要知道词法作用域,因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。
词法作用域
词法作用域是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态作用域。结合图就能更好的理解这句话:
从图中可以看出,main
函数包含了 bar
函数,bar
函数中包含了 foo
函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:
foo
函数作用域——>bar
函数作用域——>main
函数作用域——>全局作用域
了解了词法作用域以及 JavaScript 的作用域链,再回过头来看上面的那个问题的答案就是:
因为根据词法作用域,foo
和 bar
函数在被声明时的位置决定了它们的上级作用域都是全局作用域,所以当 bar
函数使用了一个它自己没有定义的变量时,顺着它的作用域链往上找,就是全局作用域。
因此,词法作用域是代码编译阶段就决定好的,和函数是怎么调用的是没有关系的。
闭包
了解了变量环境、词法环境和作用域链,接下来聊聊闭包可能你会更好的理解。先来看下面这段示例代码:
function foo() {
var myname = 'yy'
let test1 = 1
const test2 = 2
var innerbar = {
getName: function() {
console.log(test1)
return myname
},
setName: function(newName) {
myname = newName
}
}
return innerbar
}
var bar = foo()
bar.setName('qq')
bar.getName()
console.log(bar.getName())
首先我们看当执行到 foo
函数内部 innerbar
这段代码时调用栈的情况,参考下图:
innerbar
是一个对象,包含了 getName
和 setName
两个方法,这两个方法都是在 foo
函数内部定义的,并且这两个方法内部都使用了 myname
和 test1
两个变量。根据词法作用域的规则,内部函数 getName
和 setName
总是可以访问它们的外部函数 foo
中的变量,所以当 innerbar
对象返回给全局变量 bar
时,虽然 foo
函数已经执行结束,但是 getName
和 setName
函数依然可以使用 foo
函数中的变量 myname
和 test1
,所以当 foo
函数执行完成之后,其整个调用栈的状态如下图所示:
从上图看出,foo
函数执行完后其执行上下文从栈顶弹出了,但是由于返回的 setName
和 getName
方法中使用了 foo
函数内部的变量 myname
和 test1
,所以这两个变量依旧保存在内存中。这就像是 setName
和 getName
方法背的一个专属背包,无论在哪调用它们都会背着这个专属包,而且除了它们其他任何地方都无法访问这个专属背包,这个背包就被称为 foo
函数的闭包。
现在我们对闭包一个正式的定义:
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo
,那么这些变量的集合就称为 foo
函数的闭包。
那这些闭包是如何使用的呢?当执行 bar.setName('qq')
这个方法时,这段代码 JavaScript 引擎会沿着 当前执行上下文——>foo
函数闭包——>全局执行上下文 的顺序来查找 myname
变量,可以参考下图的调用栈状态图:
图中可以看出,setName
的执行上下文中没有 myname
变量,foo
函数闭包中包含了该变量,所以会修改闭包中的 myname
变量的值;同理,当调用 bar.getName
时,所访问的变量也是位于 foo
函数闭包中的。
Chrome的“开发者工具”中也可以看到闭包的情况
思考题
var bar = {
myname: 'yy',
printname: function() {
console.log(myname)
}
}
function foo() {
let myname = 'qq'
return bar.printname
}
let myname = 'out'
let _printname = foo()
_printname()
bar.printname()
执行 let _printname=foo()
这段代码的调用栈状态图可参考下图
_printname()
其实调用的就是 bar
对象的 printname
方法,而这个方法用到了 myname
变量,但是由于该函数作用域内并没有这个变量,根据词法作用域规则,它声明的位置决定了它的作用域链,它的上一个作用域就是全局作用域,它返回的 myname
变量就是全局作用域中的 myname
,即输出结果为 out
。
通过开发者工具查看
如果你觉得
bar.printname()
应该返回的是yy
,在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,但是 JavaScript 的 this 机制 可以帮你理解并了解如何获取对象内部属性。
闭包如何回收
通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是一个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么就会回收这块内存。
所以,在使用闭包时,尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大,就尽量让它成为一个局部变量。