什么是闭包(closure)
闭包是函数和声明该函数的词法环境的组合。
红宝书上这么定义,闭包是指那些引用了另一个函数作用域中变量的函数,通常是在潜逃函数中实现的。
具有以下特性:
- 函数嵌套函数
- 内部函数可以访问外部作用域(或外部函数)的变量和参数
- 参数和变量不会被回收机制回收,一直存在于内存中,除非手动清除
由此我们可以看出我们使用闭包是解决如下问题:
- 希望变量长期存在内存中
- 避免全局变量污染
执行环境、变量对象、活动对象
执行环境
执行环境(execution context,有时也称为环境),是JavaScript中最为重要的一个概念。执行环境定义了变量或函数有权访问的其他数据,决定了他们各自的行为。每个执行环境都有一个与之关联的 变量对象, 环境中定义的所有变量和函数都保存在这个对象中。
全局执行环境是最外围的一个执行环境。根据ECMAScript实现所在的宿主环境不同,表示执行环境的环境对象也不一样。在web浏览器中,全局执行环境被认为是Window对象,因此所有全局变量和函数都是作为window对象的属性和方法创建的。某个执行环境中的所有代码执行完毕后,该环境就会被销毁,保存在其中的所有变量和函数也会随之销毁。
每个函数也会有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行完之后,栈将其环境弹出,把控制权返回给之前的执行环境。
当代码在一个环境中执行的时候,会创建变量对象的一个作用域链(scope chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象(activation object)作为变量对象。活动对象在最开始的时候只包含一个变量,即arguments对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自于包含(外部)环境,而再下一个变量对象则来自于下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。
静态作用域
js采用的是词法作用域,函数的作用域在函数定义的时候就决定了
var scope = 'global scope'
function checkScope() {
var scope = 'local scope'
function f() {
return scope
}
return f()
}
checkScope()
var scope = 'global scope'
function checkScope() {
var scope = 'local scpe'
function f() {
return scope
}
return f
}
checkScope()()
因为函数的作用域在函数定义的时候就决定了。所以两个都是local scope
变量对象
正如就开始所讲的那样,变量对象是执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明
不同的执行上下文(全局上下文和函数上下文)的变量对象不同
全局上下文的变量对象就是全局对象
函数上下文
关于函数的执行上下文,变量对象是不能直接访问的。我们用函数的活动对象来代表
VO(functionContext) === AO
当进入到函数的执行环境中,活动对象就会创建,并且会初始化一个属性arguments
AO = {
arguments: <Arg0>
}
Arguments对象是活动对象的一个属性,它包含如下属性
callee -- 当前正在执行的函数
length -- 实际传递给该函数的参数长度
properties-indexes -- 通过数组的方式访问函数的参数
function foo(x, y, z) {
// 定义函数时的参数长度(和实际传递的并不一定想到)
console.log(foo.length) // 3
// 实际传递给该函数的长度
console.log(arguments.length) // 2
// 正在执行的函数
console.log(arguments.callee === foo) // true
// 参数共享
console.log(x === arguments[0]) // true
console.log(x) // 10
arguments[0] = 20
console.log(x) // 20
x = 30
console.log(arguments[0]) // 30
// 如果没有真正传递该参数,那么不会共享
z = 40
console.log(arguments[Z]) // undefined
arguments[Z] = 50
console.log(z) // 40
}
foo(10, 20)
处理函数上下文阶段
处理函数上下文分为如下两个阶段
进入执行环境 执行代码
进入执行环境
一旦进入到函数的执行环境,变量对象就会根据如下的规则和顺序填充属性
1:对于声明函数时定义的参数而言,调用该函数时,如果传递了对应的参数,那么就会将声明的参数赋值
为传递来的值,如果没有传递对应的值,则该参数赋值为undefined
2:对于函数声明而言,就会新建一个变量名为该函数名的属性。如果该属性在第一步已经声明过了。则覆
盖
3:对于通过var这样的声明的变量而言,会新建一个属性,但赋值为undefined。因为是会定义为
undefined,所以不会影响到之前定义的变量
function test(a, b) {
var c = 10
function d() {}
var e = function _e(){}
(function x() {})
}
test(10)
当我们随着传递参数10进入到执行环境时,该执行环境的活动对象如下
AO(test) = {
a: 10
b: undefined
c: undefined
d: <reference to FunctionDeclaration 'd'>
e: undefined
}
我们注意到活动对象中没有包含 x,这是因为x是通过函数表达式生成的而不是函数声明,函数表达式不会影响AO
但是函数 _e 同样是函数表达式,不同的是,我们把他赋值给了变量e,他现在可以通过 e 来访问。
代码执行阶段
进入到这个阶段的时候,AO/VO 已经被属性填充(但是并不是所有的属性都有我们的真正传递的值,他们大部分还都是undefined)
参考上面那个例子,在这个阶段,AO/VO 就会被更新成如下
AO['C'] = 10
AO['e'] = <reference to FunctionExpression "_e">
理解JS闭包形成过程
关于作用域/作用域链 请参考你真的了解 作用域/作用域链吗? 理解作用域链创建和使用细节对理解闭包非常重要。
关于变量
在很多文章中,我们可能会看到这样一句话:我们可以通过 var 或者 不通过var,直接声明一个变量来创建一个全局变量。实际并不是这样。 切记:
全局变量只能通过 var 关键字来创建
闭包中的this
任何在函数中定义的变量,都可以认为是私有变量,因为不能在函数外部访问这些变量。私有变量包括函数的参数、局部变量和函数内定义的函数。
把有权访问私有变量的公有方法称为特权方法(privileged method)
window.gId="global this";
let obj={
gId:'myobj',
getidfc(){
return function(){
return this.gId;
}
}
}
console.log(obj.getId()()); //global this
// why?
上面返回的为啥不是myobj而是global this,
因为每个函数在被调用的时候都会自动创建两个特殊的变量 this和arguments。内部函数永远都不可能直接访问外部函数的变量;
如果我想访问怎么办呢?我们看下下面的
window.gId="global this";
let obj={
gId:'myobj',
getidfc(){
let that=this; //我们把this保存为一个闭包可以访问的对象上。
return function(){
return that.gId;
}
}
}
console.log(obj.getId()()); //myobj
this和arguments都是不能直接在内部函数中访问的,如果想访问包含作用域中的arguments对象,则同样需要将其引用先保存早闭包可以访问的另一个变量中。
闭包的缺点
缺点:因为闭包会保留他们包含函数的作用域,所以比其他函数更占内存,在你没有手动清除前是常驻内存的,会增大内存使用量,并且使用不当很容易造成内存泄露(OOM)。
V8等优化的javascript引擎会努力回收被闭包困住的内存,如果不是因为一些特殊场景需要闭包,在没有必要的情况下。
v8中的闭包
V8 执行JavaScript 代码,需要经过编译和执行两个阶段,其中编译过程是指 V8 将 JavaScript 代码转换为字节码或者二进制机器代码的阶段,而执行阶段则是指解释器解释执行字码,或者是 CPU 直接执行二进制机器代码的阶段。
在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,这主要是基于以下两点:
- 首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会 严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。
- 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和 编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存。
基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。
v8的预解析器
V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直 接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个:
- 是判断当前函数是不是存在一些语法上的错误。
- 是检查函数内部是否引用 了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到 该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。