什么是闭包(closure)

闭包是函数和声明该函数的词法环境的组合。
红宝书上这么定义,闭包是指那些引用了另一个函数作用域中变量的函数,通常是在潜逃函数中实现的。

具有以下特性:

  1. 函数嵌套函数
  2. 内部函数可以访问外部作用域(或外部函数)的变量和参数
  3. 参数和变量不会被回收机制回收,一直存在于内存中,除非手动清除

由此我们可以看出我们使用闭包是解决如下问题:

  1. 希望变量长期存在内存中
  2. 避免全局变量污染

执行环境、变量对象、活动对象

首先我们来看一下定义

执行环境

执行环境(execution context,有时也称为环境),是JavaScript中最为重要的一个概念。执行环境定义了变量或函数有权访问的其他数据,决定了他们各自的行为。每个执行环境都有一个与之关联的 变量对象, 环境中定义的所有变量和函数都保存在这个对象中。
全局执行环境是最外围的一个执行环境。根据ECMAScript实现所在的宿主环境不同,表示执行环境的环境对象也不一样。在web浏览器中,全局执行环境被认为是Window对象,因此所有全局变量和函数都是作为window对象的属性和方法创建的。某个执行环境中的所有代码执行完毕后,该环境就会被销毁,保存在其中的所有变量和函数也会随之销毁。
每个函数也会有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行完之后,栈将其环境弹出,把控制权返回给之前的执行环境。
当代码在一个环境中执行的时候,会创建变量对象的一个作用域链(scope chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象(activation object)作为变量对象。活动对象在最开始的时候只包含一个变量,即arguments对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自于包含(外部)环境,而再下一个变量对象则来自于下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

静态作用域

js采用的是词法作用域,函数的作用域在函数定义的时候就决定了

  1. var scope = 'global scope'
  2. function checkScope() {
  3. var scope = 'local scope'
  4. function f() {
  5. return scope
  6. }
  7. return f()
  8. }
  9. checkScope()
  10. var scope = 'global scope'
  11. function checkScope() {
  12. var scope = 'local scpe'
  13. function f() {
  14. return scope
  15. }
  16. return f
  17. }
  18. checkScope()()

因为函数的作用域在函数定义的时候就决定了。所以两个都是local scope

变量对象

正如就开始所讲的那样,变量对象是执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明
不同的执行上下文(全局上下文和函数上下文)的变量对象不同
全局上下文的变量对象就是全局对象
函数上下文
关于函数的执行上下文,变量对象是不能直接访问的。我们用函数的活动对象来代表

  1. VO(functionContext) === AO

当进入到函数的执行环境中,活动对象就会创建,并且会初始化一个属性arguments

  1. AO = {
  2. arguments: <Arg0>
  3. }

Arguments对象是活动对象的一个属性,它包含如下属性

  1. callee -- 当前正在执行的函数
  2. length -- 实际传递给该函数的参数长度
  3. properties-indexes -- 通过数组的方式访问函数的参数
  1. function foo(x, y, z) {
  2. // 定义函数时的参数长度(和实际传递的并不一定想到)
  3. console.log(foo.length) // 3
  4. // 实际传递给该函数的长度
  5. console.log(arguments.length) // 2
  6. // 正在执行的函数
  7. console.log(arguments.callee === foo) // true
  8. // 参数共享
  9. console.log(x === arguments[0]) // true
  10. console.log(x) // 10
  11. arguments[0] = 20
  12. console.log(x) // 20
  13. x = 30
  14. console.log(arguments[0]) // 30
  15. // 如果没有真正传递该参数,那么不会共享
  16. z = 40
  17. console.log(arguments[Z]) // undefined
  18. arguments[Z] = 50
  19. console.log(z) // 40
  20. }
  21. foo(10, 20)

处理函数上下文阶段
处理函数上下文分为如下两个阶段

进入执行环境 执行代码

进入执行环境
一旦进入到函数的执行环境,变量对象就会根据如下的规则和顺序填充属性
1:对于声明函数时定义的参数而言,调用该函数时,如果传递了对应的参数,那么就会将声明的参数赋值
为传递来的值,如果没有传递对应的值,则该参数赋值为undefined
2:对于函数声明而言,就会新建一个变量名为该函数名的属性。如果该属性在第一步已经声明过了。则覆

3:对于通过var这样的声明的变量而言,会新建一个属性,但赋值为undefined。因为是会定义为
undefined,所以不会影响到之前定义的变量

  1. function test(a, b) {
  2. var c = 10
  3. function d() {}
  4. var e = function _e(){}
  5. (function x() {})
  6. }
  7. test(10)
  8. 当我们随着传递参数10进入到执行环境时,该执行环境的活动对象如下
  9. AO(test) = {
  10. a: 10
  11. b: undefined
  12. c: undefined
  13. d: <reference to FunctionDeclaration 'd'>
  14. e: undefined
  15. }
  16. 我们注意到活动对象中没有包含 x,这是因为x是通过函数表达式生成的而不是函数声明,函数表达式不会影响AO
  17. 但是函数 _e 同样是函数表达式,不同的是,我们把他赋值给了变量e,他现在可以通过 e 来访问。
  18. 代码执行阶段
  19. 进入到这个阶段的时候,AO/VO 已经被属性填充(但是并不是所有的属性都有我们的真正传递的值,他们大部分还都是undefined
  20. 参考上面那个例子,在这个阶段,AO/VO 就会被更新成如下
  21. AO['C'] = 10
  22. AO['e'] = <reference to FunctionExpression "_e">

理解JS闭包形成过程

关于作用域/作用域链 请参考你真的了解 作用域/作用域链吗? 理解作用域链创建和使用细节对理解闭包非常重要。

关于变量

在很多文章中,我们可能会看到这样一句话:我们可以通过 var 或者 不通过var,直接声明一个变量来创建一个全局变量。实际并不是这样。 切记:
全局变量只能通过 var 关键字来创建

闭包中的this

任何在函数中定义的变量,都可以认为是私有变量,因为不能在函数外部访问这些变量。私有变量包括函数的参数、局部变量和函数内定义的函数。
把有权访问私有变量的公有方法称为特权方法(privileged method)

  1. window.gId="global this";
  2. let obj={
  3. gId:'myobj',
  4. getidfc(){
  5. return function(){
  6. return this.gId;
  7. }
  8. }
  9. }
  10. console.log(obj.getId()()); //global this
  11. // why?

上面返回的为啥不是myobj而是global this,
因为每个函数在被调用的时候都会自动创建两个特殊的变量 this和arguments。内部函数永远都不可能直接访问外部函数的变量;
如果我想访问怎么办呢?我们看下下面的

  1. window.gId="global this";
  2. let obj={
  3. gId:'myobj',
  4. getidfc(){
  5. let that=this; //我们把this保存为一个闭包可以访问的对象上。
  6. return function(){
  7. return that.gId;
  8. }
  9. }
  10. }
  11. 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 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直 接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个:

  1. 是判断当前函数是不是存在一些语法上的错误。
  2. 是检查函数内部是否引用 了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到 该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。

你真的了解javascript 的闭包吗? - 图1