作用域链和闭包
我们已经知道什么是作用域,以及 ES6 是如何通过变量环境和词法环境同时支持变量提升和块级作用域,在最后我们也提到了如何通过词法环境和变量环境来查找变量,这其中就涉及作用域链的概念。
今天我们就来聊一聊什么是作用域链,并通过作用域链再来讲讲什么是闭包。
首先我们来看下面这段代码。
function bar() {
console.log(myName)
}
function foo() {
var myName = 'heora'
bar()
}
var myName = 'yueluo'
foo()
js
你觉得这段代码中的 bar
函数和 foo
函数打印出来的内容是什么?让我们分析一下这两段代码的执行流程。
当代码执行到 bar 函数内部是,其调用栈状态如下所示。
从图中可以看到,全局执行上下文和 foo
函数的执行上下文都包含变量 myName
,那么 bar 函数里面 myName
的值到底该选哪个呢?
也许你的第一反应是按照调用栈的顺序来查找变量,查找方式如下:
- 先查找栈顶是否存在
myName
变量,如果没有,接着往下查找foo
函数中的变量; - 在
foo
函数中可以找到myName
变量,这时候就要使用foo
函数中的myName
。
如果按照这种思路来查找变量, 那么最终打印结果应该是 heora
。但实际并非如此,上述代码实际会打印 yueluo
。要想解析清楚这个问题,那么你就需要搞清楚作用域链。
作用域链
关于作用域链,很多人会感到费解,如如果你理解了调用栈、执行上下文、词法环境、变量环境等概念,那么你理解起来作用域链会更加容易。
其实在每个执行上下文的变量环境中,都包含一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。
当一段代码使用一个变量时,JavaScript 引擎首先会在 “当前的执行上下文” 中查找该变量。比如上面这段代码在查找 myName
变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer
所指向的执行上下文中查找。
从图中可以看出,bar
函数和 foo
函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar
函数或者 foo
函数中使用外部变量,那么 JavaScript 引擎回去全局执行上下文中查找。我们把这个查找的链条就叫做作用域链。
现在你知道变量是通过作用域链来查找的,不过还有一个疑问没有解开,foo
函数调用的 bar
函数,那为什么 bar
函数的外部引用是全局执行上下文,而不是 foo
函数的执行上下文?
要回答这个问题,你还需要知道什么是 词法作用域。在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。
词法作用域
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能预测代码在执行过程中如何查找标识符。
从图中可以看出,词法作用域是根据代码的位置来决定的,其中 main
函数包含了 bar
函数,bar
函数包括了 foo
函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个语法的词法作用域链顺序是:foo
函数作用域 - bar
函数作用域 - main
函数作用域 - 全局作用域。
了解词法作用域以及 JavaScript 中的作用域链,我们再回头看这个问题。
function bar() {
console.log(myName)
}
function foo() {
var myName = 'heora'
bar()
}
var myName = 'yueluo'
foo()
js
在这段代码中。foo
和 bar
的上级作用域都是全局作用域,所以如果 foo
或者 bar
函数使用了一个它们没有定义的变量,它们就会到全局作用域中去查找。也也就是说,词法作用域是代码编译阶段就决定好的,与函数调用无关。
块级作用域中的变量查找
前面我们通过全局作用域和函数作用域分析了作用域链,接下来我们再来看看块级作用域中变量是如何查找的。
在编写代码的时候,如果你使用了一个在当前作用域中不存在的变量,这时 JavaScript 引擎就需要按照作用域链在其他作用域中查找该变量,如果你不了解该过程,很大概率会写出不稳定的代码。
我们先来看下面这段代码。
function bar() {
var myName = 'heora'
let test1 = 100
if (1) {
let myName = 'chrome browser'
console.log(test)
}
}
function foo() {
var myName = 'yueluo'
let test = 2
{
let test = 3
bar()
}
}
var myName = '月落'
let myAge = 10
let test = 1
foo()
js
你可以自己分析下这段代码的执行流程,看看是否分析出来执行结果。
要想得出其执行结果,我们还是需要站在作用域链和词法环境的角度来分析其执行过程。
ES6 是支持块级作用域的,当执行到代码块时,如果代码块中有 let 或者 const 声明的变量,那么变量就会存放到函数的词法环境中。对于上面这段代码,当执行到 bar 函数内部的 if 语句块时,其调用栈的情况如图所示:
执行到 bar 函数的 if 语句块内,需要打印出变量 test,那么就需要查找到 test 变量的值,其查找过程已经在图中使用序号标出。
下面就来解释一下这个过程。首先是在 bar 函数的执行上下文中查找,但因为 bar 函数的执行上下文中没有定义 test 变量,所以根据词法作用域的规则,下一次就在 bar 函数的外部作用域中查找,也就是全局作用域。
闭包
了解了作用域链,接着我们就可以来聊聊闭包了。理解了变量环境、词法环境和作用域链等概念,接下来再理解什么是闭包就容易多了。
这里你可以结合下面这段代码来理解什么闭包。
function foo() {
let myName = 'heora'
let test1 = 1
let test2 = 2
var innerBar = {
getName: function() {
console.log(test1)
return myName
},
setName: function(newName) {
myName = newName
}
}
return innerBar
}
var bar = foo()
bar.setName('yueluo')
bar.getName()
console.log(bar.getName())
js
首先我们看看执行到 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
函数的闭包。
现在我们可以给闭包一个正式的定义了。**在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。**比如外部函数是 foo
,那么这些变量的集合就称为 foo
函数的闭包。
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
那这些闭包是如何使用呢?当执行到 bar.setName
方法中的 myName = 'yueluo'
这句代码时,JavaScript 会沿着 “当前执行上下文 - foo 函数闭包 - 全局执行上下文” 的顺序来查找 myName
变量。
从图中可以看出,setName
的执行上下文没有 myName
变量,foo
函数的闭包中包含变量 myName
,所以调用 setName
时,会修改 foo
闭包中的 myName
变量的值。
同样的流程,当调用 bar.getName
函数的时候,所访问的变量 myName
也是位于 foo
函数闭包中的。
你也可以通过 “开发者工具” 来看看闭包的情况,打开 chrome 的 “开发者工具”,在 innerBar
函数任意地方打上断点,然后页面,就可以看到如下内容:
从图中可以看到,当调用 bar.getName
函数时,右边 Scope
项就体现出作用域链的情况:Local 就是当前的 getName
函数的作用域,Closure(foo) 是指 foo 函数的闭包,最下面的 Global 就是指全局作用域,从”Local - Closure(foo) - Global“ 就是一个完整的作用域链。
闭包是如何回收的
理解什么是闭包之后,接下来我们再来简单聊一聊闭包是什么时候销毁的。如果闭包使用不正确,很容易造成内存泄露,明白闭包如何回收能让你正确的使用闭包。
通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭。但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。
所以在使用闭包的时候,应该尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在。但如果使用频率不高,而且占用内存又比较大,那就尽量让它成为一个局部变量。
这里对闭包回收的问题做了一个简单的介绍,其实闭包如何回收还牵涉到 JavaScript 的垃圾回收机制,关于垃圾回收,我们后面再来详细介绍。
总结
- 首先,介绍了什么是作用域链,我们把通过作用域查找变量的链条称为作用域链;作用域链是通过词法作用域来确定的,而词法作用域反映代码结构。词法作用域是代码编译阶段就决定好的,与函数调用无关。
- 其次,介绍了在块级作用域中是如何通过作用域链来查找变量的。
- 最后,又基于作用域链和词法环境介绍了什么是闭包。