JavaScript的闭包原来是这样一回事
想要明白闭包,可不是一件很简单的事情,接下来,我将会从上下文和作用域开始,一步一步讲清楚闭包的概念,希望对各位读者能够有所启发。
执行上下文和作用域
执行上下文(execution context),简称上下文(context),这是JavaScript中最重要的概念。上下文是什么,假如有以下两个场景:
场景一:
:smile: 纪晓岚:和大人走的那么着急干嘛?
:confounded: 和珅:肚子痛,我要去方便~
场景二:
:smile: 纪晓岚:和大人走的那么着急干嘛?
:relieved: 和珅:有个官员想贿赂我两万两,让它给他个方便,我现在要去处理以下。
上述两个场景中,都有相同名字的变量“方便”,但显然两者的内容含义是不一样的,同一个变量,根据这个变量所处的上下文的不同,值也可能不同
全局上下文和函数上下文
JavaScript环境中,最外层的上下文叫做“全局上下文(Global Execution Context)”,不同环境对全局上下文的实现不同,在浏览器中,全局上下文是window
。
每一个函数也都有一个函数上下文(Function Execution Context),当函数被调用时,就会创建一个函数上下文,并压入上下文栈(Context Stack),函数执行结束就把它弹出,我们来看一个具体例子:
function compare(value1, value2){
if(value1 < value2){
return -1
} else if(value1 > value2){
return 1
} else{
return 0
}
}
let result = compare(5, 10)
console.log(result) //> -1
假设我们在浏览器中执行以上代码,可以看到,函数compare
和变量result
处于全局上下文中,当调用函数compare()
时,会把它的函数上下文给压入栈中:
当执行完毕之后,就把它弹出栈:
我们稍微改造一下代码,调用console.log()
函数:
function compare(value1, value2) {
console.log('Starting compare...') // 增加一行输出
if (value1 < value2) {
return -1
} else if (value1 > value2) {
return 1
} else {
return 0
}
}
let result = compare(5, 10)
console.log(result)
//> Starting compare...
//> -1
在compare()
函数中调用console.log()
函数,那么上下文栈就会:
当console.log()
函数执行结束,就会把它的上下文从栈中弹出,然后继续执行compare()
函数
作用域和作用域链
上下文用来确定一个变量的值或者函数的行为(可以访问哪些数据),而作用域是用来确定一个变量/函数的可见性(visibility),也就是,确定这个变量/函数可以被访问的范围。看个例子:
var myName = 'window'
function printMyName() {
console.log(myName)
}
printMyName() //> window
console.log(myName) //> window
我们在浏览器中执行上述代码,首先我们用var
定义了一个变量myName
,这个变量可以在全局上下文中被访问到,也可以在printMyName()
函数上下文中访问到。
我们增加一行代码:
var myName = 'window'
function printMyName() {
var myName = 'function' // 在函数中定义相同名字变量
console.log(myName)
}
printMyName() //> function
console.log(myName) //> window
这一次我们看到printMyName()
函数输出的结果不一样了,它访问的是函数里面的myName
,而不会去访问全局上下文中的myName
,这得益于作用域链:
在printMyName
的函数上下文中,它创建了一个作用域链,在printMyName()
函数中解析myName
时,首先会在当前作用域里去寻找myName
是在哪里定义的。如果当前作用域找到存在myName
变量定义的话,那么就会使用当前作用域的myName
变量。如果找不到的话,则会向上查找(Look-up),即沿着作用域链向上查找。
VO和AO
首先我们回顾这张图
在上一节中,我们介绍到了作用域链,为了行文方便,简单略过了两件东西,见下图:
这两样东西是作用域链上的,它们其实是两个对象,正式名称叫做变量对象(VO, Variable Object),它存放着当前的所有变量/函数,以及可执行代码,在作用域链最前面的变量对象VO的代码总是会被执行。
在函数中,则把**激活变量(AO, Activation Object)**作为变量对象VO,激活变量不太一样,当进入一个函数执行时,激活变量AO会被创建,并且它首先定义的第一个变量是arguments
,接着就是实际参数,再然后就是函数中的其他定义的变量/函数。
我们补充了关于VO和AO的概念:
刚才我们提到在作用域链最前面的变量对象VO的代码总是会被执行,也就是图中作用域第0对应的VO,其包含的代码会被执行。
这里的arguments是为空的,表示函数没有参数,假如有传参的话:
function printMyName(value1, value2) {
//...
}
printMyName(5, 10)
则其对应的AO为:
小结
总结以下刚才函数执行的整个过程:先创建函数上下文,压入上下文栈中,再创建函数上下文对应的作用域链,作用域链包含一整个调用过程中的VO(或AO),一个VO(或AO)包含对应作用域的所有变量/函数(以及可执行代码)。函数执行结束后,函数上下文会被弹出栈,AO、作用域链会被销毁。
全局VO不一定被销毁,可能会被其他函数作用域链引用
闭包的不同
闭包(Closure)的定义很简单,闭包是函数,它引用了其他作用域中的变量。闭包一词还是挺形象的,它将其他作用域的变量“封闭包围”起来。我们来看一个经典的例子:
function createComparisionFunction(propertyName) {
return function (o1, o2) {
let v1 = o1[propertyName]
let v2 = o2[propertyName]
switch (true) {
case v1 < v2:
return -1
case v1 > v2:
return 1
default:
return 0
}
}
}
在这个例子中,createComparisionFunction
函数返回了一个闭包,这个闭包引用了createComparisionFunction
函数的propertyName
变量。当我们执行以下代码时:
let compare = createComparisionFunction('age')
let result = compare({ age: 1 }, { age: 2 })
函数上下文、作用域链表示如下:
可以看到,在闭包中执行上下文的作用域链中,引用了全局VO和createComparisionFunction
函数的AO,这样闭包可以访问全局和createComparisionFunction
函数的所有变量了。
但是,这样带来了一个副作用(side effect)——createComparisionFunction
函数执行结束后,上下文会被销毁,但是它的AO却会留在内存中,因为它被闭包函数所引用,必须得等闭包函数销毁后,AO才会被收回释放,比如:
let compare = createComparisionFunction('age')
let result = compare({ age: 1 }, { age: 2 })
compare = null // 解除引用,闭包函数和引用的AO都会被垃圾回收器回收释放
引用其他作用域的闭包
刚才我们提到,闭包是“引用其他作用域变量”的函数,我们比较常看到的是函数作用域,但其实闭包也可以引用块级作用域的变量:
let foo
{
let name = 'goods'
foo = function(){
console.log(name)
}
}
浏览器优化
接上面的例子,在运行闭包之后,我们发现“包围”起来的变量中有些其实是不需要,比如argument
:
现代浏览器会做一个优化,把明显不需要的变量给踢掉。我们来看一个简单的例子:
function breadProduct(){
let name = 'deliciousBread'
let productDate = new Date()
function bread(){
console.log(name)
}
return bread
}
const mybread = breadProduct()
我们来看一下,当执行结束breadProduct
结束,mybread
中使用[[Scopes]]
去保存作用域链:
我们主要关注到,一个是breadProduct
函数的AO,一个是全局作用域:
当时,我们看一下breadProduct
中,name
被显式地引用到,所以它留下来了,但是age
没有,就清理掉了。
这样做的目的很简单,就是节省内存空间,当然这是现代浏览器的一种可选的优化手段,而不是JS规范的必要要求。在以前的浏览器中,尤其是老式终端设备的浏览器中,会保留所有的变量。
例外:当使用eval
用于eval
会动态执行语句,因此没办法确定哪个变量会不会被应用,因此浏览器会保留所有变量,我们稍微改一下代码:
function breadProduct() {
let name = 'deliciousBread'
let productDate = new Date()
function bread() {
eval()
}
return bread
}
const mybread = breadProduct()
只要有eval()
,所有变量都会被保存下来:
注意:同一个AO
当我们有多个闭包时,我们来看看会发生什么事情:
function breadProduct() {
let name = 'deliciousBread'
let productDate = new Date()
function bread() {
console.log(name)
}
function otherBread(){
console.log(productDate)
}
return bread
}
const mybread = breadProduct()
虽然otherBread()
函数没有被返回,并且随着breadProduct()
函数执行结束而被清除,但是它显式引用了productDate
,所有productDate
被保留了下来:
this
引用
在闭包中,this
引用情况有点复杂,闭包一般是匿名函数,匿名函数中不会自动将对象绑定到this
中,看一个例子:
window.identify = 'The window'
let object = {
identify: 'My Object',
getIdentify(){
return function(){
return this.identify
}
}
}
console.log(object.getIdentify()()) //> 'The window'
我们可以通过定义一个引用this
的变量来解决:
window.identify = 'The window'
let object = {
identify: 'My Object',
getIdentify() {
let that = this
return function () {
return that.identify
}
}
}
object.getIdentify()()
内存泄漏
闭包的最大好处就是它能够保存其他作用域的变量,当然,使用不当也会造成内存泄漏,试看一例:
function assignHandler(){
let element = document.getElementById('someElement')
element.onclick = () => console.log(element.id)
}
element
引用了某一个DOM节点,并且这个节点的onclick
方法引用了element
,这样一来element
就没办法被GC清除掉,假设element
是一个很大的对象的话,那么很可能造成内存泄漏。我们可以修改一下引用关系:
function assignHandler(){
let element = document.getElementById('someElement')
let id = element.id
element.onclick = () => console.log(id)
element = null
}
思考题
试问以下题目会不会造成内存泄漏:
let big = null
let count = 0
setInterval(function () {
let bigReference = big
function unused() {
if (bigReference) {
}
}
big = {
count: count++,
place: new Array(2e9),
introduce: function () {
console.log('I am big')
}
}
}, 1000)
总结
All in all, 闭包就是引用其他作用域变量的一个函数,要明白它,需要从执行上下文到作用域链到AO。闭包的好处有很多,最大的好处就是它能够保存其他作用域的变量,以便后续使用,典型的引用场景有Module、柯里化等等。当然,闭包也有内存泄漏的风险,因此,谨慎使用。
参考
[1] What is the Difference Between Scope and Context in JavaScript?
[2] Professional JavaScript For Web Developers : Chapter4/Chapter10
[3] JavaScript 的静态作用域链与“动态”闭包链
[4] You don't know JavaScript Yet: Scope and Closure Chapter 7