作用域链
先看一段代码,下面代码输出的结果是什么?
function bar() {
console.log(myName)
}
function foo() {
var myName = "极客邦"
bar()
}
var myName = "极客时间"
foo()
当执行到 console.log(myName)
这句代码的时候,其调用栈的状态图如下所示:
此时,全局执行上下文和foo
函数的执行上下文都包含变量myName
,那么 bar
函数里面的myName
值到底该选择哪个呢?
也许你的第一反应是按照调用栈的顺序来查找变量,查找方式如下:
- 先查找栈顶的函数上下文中是否存在
myName
变量,这里没有,所以接着往下查找foo
函数上下文中的变量; - 在
foo
函数中找到了myName
变量,这时就使用foo
函数中的myName
。
如果按照这种方式来查找变量,那么最终执行bar
函数打印出来的结果就应该是“极客邦”。但实际情况并非如此,而是打印“极客时间”。要解释这个问题,就涉及到作用域链了。
其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。
当一段代码使用了一个变量时,JS引擎首先会在“当前的执行上下文”中查找该变量,如果当前的变量环境中仍没有查找到,那么JS引擎会继续在outer所指向的执行上下文中查找。
如前面的代码示例,bar
函数和foo
函数的outer都是指向全局上下文的。
如果在bar
函数或者foo
函数中使用了外部变量,那么JS引擎会去全局执行上下文中查找。这个查找的链条就称为作用域链。
那么上面的两个函数的 outer 为什么指向全局上下文呢? 这是因为JS在执行过程中,其作用域链是由词法作用域决定的(而非调用栈)。
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它能够预测代码在执行过程中如何查找标识符。是在代码编译阶段就决定好的,和函数是如何调用的没有关系。
如上图函数的定义位置所示,整个词法作用域链的顺序是:foo
函数作用域—>bar
函数作用域—>main
函数作用域—> 全局作用域。
块级作用域中的变量查找
function bar() {
var myName = "极客世界"
let test1 = 100
if (1) {
let myName = "Chrome浏览器"
console.log(test)
}
}
function foo() {
var myName = "极客邦"
let test = 2
{
let test = 3
bar()
}
}
var myName = "极客时间"
let myAge = 10
let test = 1
foo()
要想得出其执行结果,就要站在作用域链的角度来分析下其执行过程。当执行到代码块时,如果代码块中有 let
或者 const
声明的变量,那么变量就会存放到该函数的词法环境中。对于上面这段代码,当执行到 bar
函数内部的 if
语句块时,其调用栈的情况如下图所示:
test
变量的查找过程如图中标注的1、2、3、4、5序号所示。
首先是在 bar
函数的执行上下文中查找,但因为 bar
函数的执行上下文中没有定义 test
变量,所以根据词法作用域的规则,下一步就在 bar
函数的外部作用域中查找,也就是全局作用域。 在全局作用域中,是按照词法环境和变量环境的顺序查找。
闭包
先看一段代码:
function foo() {
var myName = "极客时间"
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("极客邦")
bar.getName()
console.log(bar.getName())
当执行到 foo
函数内部的return 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
方法背的一个专属背包,无论在哪里调用了 setName
和 getName
方法,它们都会背着这个 foo
函数的专属背包。
之所以是专属背包,是因为除了 setName
和 getName
函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo
函数的闭包。
在 JS 中,根据 词法作用域 的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo
,那么这些变量的集合就称为 foo
函数的闭包。
同时有两个函数的作用域链指针(外函数和内函数)指向了同一个变量环境对象,所以无论你删除其中任何一个指针,该变量环境对象都无法被垃圾回收(无论是标记清除还是计数法)清除,所以保存在了内存中。所以就有了所谓的”闭包“
这些闭包是如何使用的呢?当执行到 bar.setName
方法中的myName = "极客邦"
这句代码时,JS 引擎会沿着“当前执行上下文–>foo 函数闭包–> 全局执行上下文”的顺序来查找 myName
变量。
setName
的执行上下文中没有 myName
变量,foo
函数的闭包中包含了变量 myName
,所以调用 setName
时,会修改 foo
闭包中的 myName
变量的值。同样的流程,当调用 bar.getName
的时候,所访问的变量 myName
也是位于 foo
函数闭包中的。
可以通过“开发者工具”来看看闭包的情况,打开 Chrome 的“开发者工具”,在 bar
函数任意地方打上断点,然后刷新页面,可以看到如下内容:
当调用 bar.getName
的时候,右边 Scope 项就体现出了作用域链的情况:Local 就是当前的 getName
函数的作用域,Closure(foo) 是指 foo
函数的闭包,最下面的 Global 就是指全局作用域
,从“Local–>Closure(foo)–>Global”就是一个完整的作用域链。
闭包是怎么回收的
如果闭包使用不正确,会很容易造成内存泄漏。
通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JS 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JS 引擎的垃圾回收器就会回收这块内存。
所以在使用闭包的时候,要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。
延伸:
以下函数的输出结果是什么? 会产生闭包吗?
var bar = {
myName:"time.geekbang.com",
printName: function () {
console.log(myName)
}
}
function foo() {
let myName = "极客时间"
return bar.printName
}
let myName = "极客邦"
let _printName = foo()
_printName()
bar.printName()
bar
不是一个函数,因此 bar
当中的 printName
其实是一个全局声明的函数,bar
当中的 myName
只是对象的一个属性,也和 printName
没有联系,如果要产生联系,需要使用 this 关键字,表示这里的 myName
是对象的一个属性,不然的话,printName
会通过词法作用域链去到其声明的环境,函数在被创建的时候它的作用域链就已经确定了,所以不论是直接调用bar
对象中的printName
方法还是在foo
函数中返回的printName
方法,他们的作用域链都是 自身->全局作用域,自身没有myName
就到全局中找,找到了全局作用域中的myName = ' 极客邦'
,所以两次打印都是“极客邦”啦~
不会产生闭包。