什么是闭包
通俗地说,当内部函数引用了外部变量时,就形成了 闭包 。
function outer() {
var happy = 'happy';
function inner() {
console.log(happy);
}
}
outer();
要深入地去解释的话,要了解一些其他的知识点:作用域 , 作用域链 , 执行上下文 。
作用域
作用域 决定了函数可以访问哪一些变量。在函数定义时就已经确定的了。如:
// 浏览器中的JavaScript执行机制:10 | 作用域链和闭包:代码中出现相同的变量,JavaScript引擎是如何选择的?
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() // "极客邦"
作用域链
以下代码会输出什么呢?
var x = 100;
function log() {
var a = 1;
return function sub() {
console.log(a);
console.log(x);
}
}
var fn = log(); // 1
fn(); // 100
根据上文介绍的 作用域 知识点可知,sub 中访问了 x ,如果是不能访问的话,将会报错,但看到结果是输出了 100 ,所以 sub 是访问到了外部的全局变量 x 。这个寻找的过程就是 作用域链的行为 。
作用域链,描述的是当前作用域与其他作用域的关系,也就是当前函数寻找对应变量的路径指引。
JavaScript 高级程序设计第4版:
上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定 了各级上下文中的代码在访问变量和函数时的顺序。
作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。(P311)
根据 JavaScript 高级程序设计第4版 的补充,假如当前函数内执行一个函数,那么该函数的寻找过程也跟变量类似。
所以补充一下:
作用域链,描述的是当前作用域与其他作用域的关系,也就是当前函数寻找对应变量/函数的路径指引(作用类似于地址簿)。
此外,作用域链在函数定义时就会创建,保存在内部的[[Scope]] 。
执行上下文
执行上下文,函数没每执行一次都会产生一个新的执行上下文环境。
作用域 描述了函数寻找变量/函数的辐射的范围,作用域链描述了该“函数家族”的家庭关系,可以指引函数一步步去寻找对应的变量/函数。那么,真正找到这个变量时,它的值,是怎样的呢?
这个就是 执行上下文 来决定的了,因为不同的调用,可能会有不同的参数。
这个就好比,这个地方有一个叫 章三 的小伙子,但是你不知道 章三 到底是什么样子的。不同的人去了解 章三 会有不同的感觉。如:
function log(x) {
return function sub() {
console.log(x);
}
}
log(100)(); // 100
log(10)(); // 10
JavaScript 高级程序设计第4版:
上下文在其所有代码都执行完毕后会被销毁,包括定义 在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。 在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript 程序的执行流就是通过这个上下文栈进行控制的。
从JavaScript 高级程序设计第4版角度来看闭包
根据 JavaScript 高级程序设计第4版 中对 闭包 的解释,来解释一下下面的代码:
var x = 100;
function log() {
var b = 1;
return function sub() {
console.log(b);
return b;
}
}
var fn = log();
var result = fn();
// 解除对函数的引用,这样可以释放内存
fn = null;
来逐步解释一下:
log函数执行时,它的作用域链会包含两个对象,一个是log() 活动对象,一个是全局变量对象log函数执行后,返回一个sub的函数,此时,应该销毁log() 活动对象,但由于sub函数引用了外部变量b,所以该活动对象不能被销毁fn函数执行,也就是执行sub函数,此时它的作用域链会包含三个对象,一个是自身的活动对象,一个是log() 活动对象,一个是全局变量对象。因为sub引用了log方法内的变量,所以作用域链中会保留log() 活动对象。
此处,可能会有个疑问,作用域链 是函数定义时,就创建了,而对应的 活动对象 是对应的执行上下文的时候才产生的。
所以 sub 的作用域链,是怎么指向 log()活动对象 的呢?
JavaScript 高级程序设计第4版:
全局上下文中的叫变量对象,它 会在代码执行期间始终存在。而函数局部上下文中的叫活动对象,只在函数执行期间存在。
在定义 compare()函数时,就会为它创建作用域链,预装载全局变量对象,并保存在内部的[[Scope]]中。在 调用这个函数时,会创建相应的执行上下文,然后通过复制函数的[[Scope]]来创建其作用域链。接着 会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。(P310)
根据 JavaScript 高级程序设计第4版 中的描述可知,当 sub 定义时,应当预装载了 log() 活动对象 和 全局变量对象 并保存在 [[Scope]] 中。
待 sub 执行时,会复制函数的 [[Scope]] 来创建其作用域链,并将其 自身活动对象 推入作用域链的前端。
所以,sub 的作用域链中会包含 log() 活动对象 。
因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。
从其他角度来看闭包
浏览器是如何工作的:Chrome V8让你更懂JavaScript :
惰性解析
所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。
- 在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,这主要是基于以下两点:
- 首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。因为有时候一个页面的 JavaScript 代码很大,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间;
- 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存。
- 基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析。
- 闭包给惰性解析带来的问题:上文的 d 不能随着 foo 函数的执行上下文被销毁掉。
预解析器
V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析。
- 判断当前函数是不是存在一些语法上的错误,发现了语法错误,那么就会向 V8 抛出语法错误;
- 检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。
当 V8 遇到函数时,会进行预解析,此时会发现内部函数引用了外部变量,那么,预解析器会将栈中的变量复制到堆中(类似 Closure(foo) 这样的对象)。
这里可能会产生2个疑问:
- 当说到闭包无法释放内存时,指的是不是
Closure(foo)这个对象无法被释放? - 当函数执行完了之后,执行上下文会被销毁,
Closure(foo)是在执行上下文时创建的,为什么没有随着执行上下文意一起销毁?
根据 浏览器是如何工作的:Chrome V8让你更懂JavaScript 对预解析器的介绍,第一个问题的答案应该是肯定的。因为函数执行完了,执行上下文被销毁了,被引用的变量理应被销毁,但引用外部变量的那个函数,将使用保存在堆中的变量,因此存在了引用关系,所以内存无法释放。
第二个问题,是因为 Closure(foo) 还存在引用,所以不会被销毁。是引用了外部变量的那个函数保存了它的引用,所以它不会被销毁。
闭包如何被回收
真相只有一个:只要没有被引用,就会被回收。
这不仅仅只是适用于闭包这种情况,这适用于所有情况。之所以在这里提出,是因为,闭包有可能有以下2种情况:
一种是函数不作为返回值返回:
function foo() {
var a = 1;
function bar() {
console.log(a);
}
bar();
}
var fn = foo();
另一种是函数作为返回值返回:
function foo() {
var a = 1;
return function bar() {
console.log(a);
}
}
var fn = foo();
var result = fn();
可以明显地感知,第一种情况,当 foo 函数执行完毕后,没有什么引用被保持住,所以,当执行完毕后,就会被回收的了。
第二种情况,fn 保持了 bar 函数的引用,当 fn 存在的期间,闭包内存都不会被回收的。所以需要主动地释放内存,让 fn = null 执行即可。