JavaScript中的闭包

内存泄漏与调试方法

作者 Joan 发布时间 September 26, 2017

函数的作用域链

1. 作用域

ES6 之前 JS 里面是没有块级作用域(scope)的,JS 的主要作用域是函数作用域。那么作用域到底是什么呢,下面是Wiki中的一段描述:

静态作用域又叫做词法作用域,采用词法作用域的变量叫词法变量。词法变量有一个在编译时静态确定的作用域。词法变量的作用域可以是一个函数或一段代码,该变量在这段代码区域内可见;在这段区域以外该变量不可见(或无法访问)。词法作用域里,取变量的值时,会检查函数定义时的文本环境,捕捉函数定义时对该变量的绑定。大多数现在程序设计语言都是采用静态作用域规则,如C/C++、C#、Python、Java、JavaScript…… https://zh.wikipedia.org/wiki/%E4%BD%9C%E7%94%A8%E5%9F%9F

总之作用域就是规定了一个变量或者函数,它的访问范围,而这个范围是在函数定义的时候就决定了的。

2. 变量对象

环境中定义的所有变量和函数都保存在这个对象中,我们把变量对象variable object)简写为VO,看下面这个例子:

var a = 10;
function test(x) {
  var b = 20;
};
test(30);

此时的变量对象为:

// 全局上下文的变量对象
VO(globalContext) = {
  a: 10,
  test: <reference to function>
};
// test函数上下文的变量对象
VO(test functionContext) = {
  x: 30,
  b: 20
};

3. 作用域链

作用域链本质上是一个指向变量对象的指针列表,它只引用但是不实际包含变量对象。在函数创建的时候,会创建一个预先包含全局变量对象的作用域链,这个作用域链被保存在内部的[[Scope]]中。通过作用域链就可以访问到在一个函数访问范围内的所有变量了。

scope chain

4. 活动对象

那么活动对象又是什么呢?

在函数调用的时候,会创建一个执行环境,通过复制[[Scope]]中的对象构建起执行环境的作用域链。然后就会有一个活动对象被创建,并被推入到执行环境作用域链的前端。

让我们看一个例子:

function test(a, b) {
  var c = 10;
  function d() {}
  var e = function _e() {};
  (function x() {});
}
test(10); // call

当进入带有参数 10 的 test 函数上下文时,AO 表现为如下:

AO(test) = {
  a: 10,
  b: undefined,
  c: undefined,
  d: <reference to FunctionDeclaration "d">
  e: undefined
};

注意AO 里并不包含函数“x”。这是因为“x” 是一个函数表达式(FunctionExpression, 缩写为 FE) 而不是函数声明,函数表达式不会影响 VO。 不管怎样,函数“_e” 同样也是函数表达式,但是就像我们下面将看到的那样,因为它分配给了变量 “e”,所以它可以通过名称“e”来访问。

这个时候,AO中的变量还都是默认的初始值,随着代码一步步执行,AO中变量的值会随之变化。

什么是闭包

在《JavaScript高级程序设计》中,对闭包的定义是这样的:

闭包是指有权访问另一个函数作用域中的变量的函数。创建闭包的常见方式,就是在一个函数内部创建另一个函数。

closure

站在作用域链的角度来看,在一个函数内部定义另一个函数,内部函数会把外部函数的活动对象添加到它的作用域链中。注意,这里闭包所添加的是外部函数的活动对象,而这个活动对象是每次函数被调用的时候都会重新创建的(上面提到过)。外部函数执行完毕后,活动对象应该会被销毁,但是由于闭包比如匿名函数作用域链的引用,它就不会被销毁,直到闭包被销毁。

在Chrome中观察闭包

这段代码涉及到了4个函数,第一个fnGlobal 是一个全局的函数,而fnClosure1 fnGlobal 中的一个闭包、fnClosure2 fnClosure1 中的一个闭包,fnClosure3也是fnGlobal 中的一个闭包,并且它通过函数返回到全局,并被全局变量引用着。下面,我们分别在不同的位置打断点来观察:

function fnGlobal () {
  var m = 100;

  function fnClosure1 () {
    console.log(m);
    
    var a = 88;

    function fnClosure2 () {
      console.log(a+m);
      debugger;
    };

    fnClosure2();
    debugger;
  };

  fnClosure1();
  
  return function fnClosure3(){
    console.log(123);
    debugger;
  }
}

var f = fnGlobal( );

fnClosure2 中,用到了 fnGlobal( ) 的变量m,以及fnClosure1 中的a,因此它的作用域链保留了两个其他函数活动变量:

jcspp 8t 7x s ht41mhai

fnClosure1中,只用到了 fnGlobal( ) 的变量m,因此它的作用域链只保留了一个其他函数活动变量:

bniacfz2z 0 c poo6 81 s

最后我们通过f,执行fnClosure3。我们可以看到fnClosure3中并没有用到任何外部函数的变量,但是它依然保留了 fnGlobal( )活动变量。这个是由于fnClosure1fnClosure3都是fnGlobal( )的闭包,在函数定义的时候,它们的作用域链是共享的,其中一个闭包保留了外部的变量,那么也会影响到其他的闭包。这一点非常重要,也很容易被忽略。这个时候,由于fnClosure3在全局变量中,那么它作用域链上的对象就不会释放,即m会一直在内存中,直到关闭页面。

822zdd qzahv2v x 6x e_e

闭包有什么用

究竟闭包会不会导致内存泄漏

闭包确实会使得某些变量无法释放,但是它不会直接导致内存泄漏。但是当我们不注意的时候,确实会出现内存泄漏的情况。在项目过程中,我就遇到了非常奇怪的内存泄漏的问题,当时使用了WebSocket。不过可以通过下面的代码简单模拟一下:

  var theThing = null;
  var replaceThing = function () {
    var originalThing = theThing;
    var unused = function () {
      if (originalThing)
        console.log("hi");
      };
      theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
          console.log(someMessage);
        }
      };
    };
  // 尝试执行这个函数多次,会发现出现了内存泄漏的问题。
  replaceThing( );
  replaceThing( );
  replaceThing( );
  replaceThing( );

执行三次:

leak

执行四次:

leak2

参考

http://dmitrysoshnikov.com/ecmascript/chapter-2-variable-object/