掌握JavaScript中让人迷惑的闭包
目录
JavaScript中闭包的定义
维基百科中关于闭包的定义
闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures)。
是在支持 头等函数 的编程语言中,实现 词法绑定 的一种技术。
闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。
环境里是若干对符号和值的对应关系,它既要包括 约束变量(该函数内部绑定的符号),也要包括 自由变量(在函数外部定义但在函数内被引用)。
闭包跟函数最大的不同在于,当捕捉闭包的时候,它的 自由变量 会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。
MDN中关于闭包的定义
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是 闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
看完上边维基百科和 MDN 对闭包的解释。是不是一脸懵逼?什么是词法绑定?头等函数?自由变量又指的是什么?
但我们从以上两个官方的解释来看,能统一得出一点结论:
闭包是一个结构体,其实现是函数。
想要理解闭包,首先需要知道两个前提知识点:1. 函数的执行过程,2. 内存管理
JS中的一等公民——函数
何为一等公民?c语言中的抽象数据模型 类(class)、Java8之前的对象(Object)、这些都是在各自语言中的一等公民。它们都有一个特性:使用灵活,能够在程序中无限使用的对象,作为函数时的参数传递,以及作为函数的返回值。
在 JavaScript 中,函数是非常重要的,并且是一等公民:
- 函数可以作为另外一个函数的参数。
- 函数页可以作为另外一个函数的返回值。
JS中函数的使用是非常灵活的,具体我们用几个案列来解释:
- 将函数作为另外一个函数的参数。
function foo(baz){
baz()
}
function bar(
console.log('bar')
)
foo(bar)
- 函数作为函数的返回值,js 允许函数内部再定义函数。
function foo() {
function bar() {
console.log('bar')
}
return bar
}
var fn = foo()
fn()
- 数组中的高阶函数
/*
高阶函数:一个函数如果接收另一个函数作为参数,或者该函数会返回另外一个函数作为返回值的函数,那么这个函数就称之为是一个高阶函数。
*/
var nums = [10, 7, 9, 100, 11]
var newNums = nums.filter((item) => {
return item % 2 === 0 // 偶数
})
console.log(newNums) // [10, 100]
console.log(nums) // [10, 5, 11, 100, 55]
有了以上对函数的理解。我们再来看看 js中的内存管理:垃圾回收机制。
JS的内存管理
内存结构
JavaScript会在 定义变量时 为我们分配内存。
但是内存的分配方式是一样的吗?
JS 对于基本数据类型内存的分配会在执行时,直接在栈空间进行分配;
JS 对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并将这块空间的指针返回值变量引用;
例如以下代码在内存中的结构:
var name = "mjy"
var age = 18
var obj = {
name: 'mjy',
job: 'coder'
}
function foo() {
console.log('hello')
}
内存管理:垃圾回收机制
因为内存大小的有限,所以当内存不再被需要的时候,我们就要对其进行内存释放。以便腾出更多的空间。
在手动管理内存的语言中,如 c:通过 malloc 来进行内存的申请,通过 free 进行内存的释放。
但是这种方式十分的低效麻烦,而且一不小心使用完后就会忘记释放。
所以大部分现代编程语言都有自己的垃圾回收机制:
垃圾回收的英文是:Garbage Collection,简称 GC。
对于不再使用的对象,我们都称之为垃圾。它需要被回收掉。
在 JavaScript 引擎中,有着自己的垃圾回收算法:
常见GC算法
1. 引用计数法:
当一个对象有一个引用指向它时,那么这个对象的引用就 +1,当一个对象的引用为 0 时,这个对象就可以被销毁掉,也就是可以被GC回收掉。
这个算法有一个很大的弊端:循环引用,导致无法被正常回收。
2. 标记清除法(运用最广):
这个算法是设置一个根对象(root object),垃圾回收器会定期从这个根对象开始,找所有从根对象开始有引用到的对象,对于那些没有引用到的对象,就认为是不可达(不可用)的对象。GC就会对其进行回收。
这个算法可以很好的解决循环引用产生的问题:
在有了以上两个知识点(函数执行过程、内存管理)的了解后,我们就可以正式得来了解闭包了
JavaScript中函数的执行过程
要理解闭包,首先就要理解JavaScript中的函数,以及JavaScript函数的执行过程。可以看我之前写过的这篇文章 理解JavaScript函数执行过程,作用域链。
我们先来看这样的一段代码,在 js 引擎中的执行过程。
function foo() {
var name = "mjy"
var age = 18
}
function test() {
console.log('test')
}
foo()
test()
函数解析时
在 js 引擎解析代码时,GO 对象被创建。代码依次解析,当解析到 foo、test 函数时,会在堆内存中为其开辟一块内存空间,内存空间中存放该函数的父级作用域(parentScope)和函数执行体。并将开辟的内存空间地址以址返回的形式存储在 GO 对象的 foo 与 test 中。
函数执行时
开始执行代码,函数执行上下文入栈,并生成函数AO对象,依次执行函数中的代码。
函数对象销毁
当foo函数执行完毕,函数执行上下文出栈,foo函数的对象AO销毁。
闭包的产生
在理解了上面函数的执行过程后,我们来看下面这样一个函数。
function foo() {
var name = "mjy"
var age = 18
function bar() {
console.log(name)
console.log(age)
}
return bar
}
var fn = foo()
fn()
当foo函数执行时,foo函数创建 AO 对象。foo中代码进行解析(当函数确定要被执行时,foo中的代码才会进行解析),foo函数体代码解析到bar函数,并在内存中为bar开辟内存空间。
解析完毕,执行 return bar
代码,将bar函数的内存空间地址,以址返回的形式赋值到变量fn中,fn = foo()
。
当foo函数执行完毕时,本该销毁的fooAO对象并不会被消耗(也就是不会被GC回收掉)。因为此时 foo 函数的 AO 对象,在根对象GO中是可达的,有变量在指向它(不清楚这一步可以回头看:GC算法--标记清除法)。
这也是闭包会产生内存泄漏的原因。
foo函数执行完毕出栈后,test函数入栈。开始执行生成test函数AO对象,并解析后依次执行bar函数体中的代码:console.log(name)
。
bar沿着函数作用域链查找要打印的值 name
首先会在自身AO对象中查找。如果未查找到,将会沿着父级作用域继续查找,在父级作用域foo函数的AO中对象查询到 name
属性,将其打印。
理解总结
以上就是一整个闭包的形成过程了。我们现在再回过头来看 维基百科、MDN中对闭包的定义:
闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包>>。(function closures)。
是在支持 头等函数 的编程语言中,实现 词法绑定 的一种技术。
闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。
环境里是若干对符号和值的对应关系,它既要包括 约束变量(该函数内部绑定的符号),也要包括 自由变量(在函数外部定义但在函数内被引用)。
闭包跟函数最大的不同在于,当捕捉闭包的时候,它的 自由变量 会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是 闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
现在看来是不是就很清晰了。
在理解了闭包后,你会发现 维基百科 和 MDN 中对闭包的解释定义是十分准确的(官方喜欢用名词来解释名词,因为这样才能更准确得表达)。
这里摘抄coderwhy老师对闭包的理解:
- 一个普通的函数function,如果它可以访问外层作用域的自由变量。那么这个函数就是一个闭包。
- 从广义的角度来说:JavaScript 中的函数都是闭包。
- 从狭义的角度来说:JavaScript 中的一个函数,如果访问了外层作用域的变量,那么它就是一个闭包。