要想深入了解闭包,必须要有对下面几个方面的了解:预编译、作用域链、自动垃圾收集机制。
预编译
在 Javascript 解释器进入到一个执行上下文时,会有三个步骤:
- 语法分析
- 预编译
- 执行
我们这里主要关注在代码执行前的预编译阶段发生了什么:
- 开辟内存,创建变量对象 VO(在全局执行上下文中,VO是全局对象自身;在函数执行上下文中,VO 是不能直接访问的,此时由活动对象 AO 扮演 VO 的角色);
- 变量声明、函数声明、实参值赋予形参(仅在函数执行上下文中),将声明的数据保存在 VO / AO 中。
用伪代码表示这一过程:
// 全局执行上下文
var foo = 1
function bar() {}
var baz = function() {}
qux = 2 // 非声明
// 预编译
VO(window): {
Math: <...>,
String: <...>
...
...
foo: undefined,
baz: undefined,
bar: function() {}
}
// 函数执行上下文
add(1, 2)
function add (x, y) {
return x + y
}
// 预编译
AO(add): {
arguments: [1, 2],
x: 1,
y: 2
}
作用域链
[[scope]] 是函数创建时所有父变量对象的层级链,它是函数的属性,储存在函数对象中,在函数创建时即已确定。如何理解这句话呢?观察下面实例:
var x = 10
function fn() {
console.log(x)
}
function show(f) {
var x = 20
f()
}
show(fn) //10
fn 函数被调用时,其作用域链是 AO(fn) + VO(window),并不包括 AO(show)。当函数被调用时,解释器创建并进入一个新的执行上下文,这个执行上下文类似于这样:
activeExecutionContext = {
AO: vars + function declarations + arguments,
this: context object,
scope: AO + [[scope]] // 原型链,执行上下文的属性
}
作用域链本质上是一个指向变量对象的指针列表,标识符解析沿着作用域链一级一级进行搜索,保证了对执行上下文中有权访问的所有变量和函数的有序访问。
自动垃圾收集机制
现在各大浏览器通常用采用的垃圾回收有两种方法:标记清除、引用计数。
标记清除
这是 JavaScript 中最常见的垃圾回收方式。为什么说这是种最常见的方法,因为从2012年起,所有现代浏览器都使用了标记清除的垃圾回收方法,除了低版本 IE,它们采用的是引用计数法。
那什么叫标记清除呢? JavaScript 中有个全局对象,浏览器中是 window。定期的,垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象,对这些活着的对象进行标记,这是标记阶段。清除阶段就是清除那些没有被标记的对象。
标记清除法的一个问题就是不那么有效率,因为在标记-清除阶段,整个程序将会等待,所以如果程序出现卡顿的情况,那有可能是收集垃圾的过程。
2012年起,所有现代浏览器都使用了这个方法,所有的改进也都是基于这个方法,比如标记整理法。
标记清除有一个问题,就是在清除之后,内存空间是不连续的,即出现了内存碎片。如果后面需要一个比较大的连续的内存空间时,那将不能满足要求。而标记整理法可以有效地解决这个问题。标记阶段没有什么不同,只是标记结束后,标记整理法会将活着的对象向内存的一边移动,最后清理掉边界的内存。不过可以想象,这种做法的效率没有标记清除法高。
引用计数
在内存管理环境中,对象 A 如果有访问对象 B 的权限,叫做对象 A 引用对象 B。引用计数的策略是将“对象是否不再需要”简化成“对象有没有其他对象引用到它”,如果没有对象引用这个对象,那么这个对象将会被回收。
闭包
var foo = function() {
var x = 1
var bar = function() {
console.log(x)
}
return bar
}
var bar = foo()
函数 foo 运行结束后,执行上下文被弹出环境栈,随即上下文环境连同其属性(AO(foo), this, scope)被销毁,内存被释放,控制权交还之前的执行上下文。虽然作用域链被销毁了,但在闭包中,函数 bar 被返回,其属性 [[scope]] 也仍然存在,对变量 x 依然存在引用,内存无法被释放。
通过销毁函数,断开对变量 x 的引用,即可释放内存。
bar = null