JavaScript闭包的底层运行机制

JS闭包的底层运行机制

翻译参考

我已经使用闭包很长时间了,我学会了如何使用它,但是对于闭包怎样工作,幕后发生了什么,我并没有透彻的理解。闭包到底是个什么?维基没有帮到什么忙。当闭包创建与删除时,应该是怎样实现的?

"use strict"
var myClosure = (function outerFunction() {

    var hidden = 1;

    return {
        inc: function innerFunction() {
            return hidden++;
        }
    }

}())

myClosure.inc();    // return 1
myClosure.inc();    // return 2
myClosure.inc();    // return 3
// OK, very nice.但是这是怎么实现的,背后发生了什么

当我最终搞清楚后,我非常兴奋决定向大家解释一下:至少我现在绝对不会忘了。

Tell me and I forget. Teach me and I remember. Invoke me and I learn.
Benjamin Franklin

当我在阅读现有的对闭包的解释时,我竭力将闭包与其他东西形象地关联起来的:哪一个对象引用了其他对象,哪一个对象继承了另一个对象,等等。我没找到想要的插图效果,所以我还是自己画吧。

我假设儒者都已经熟悉JavaScript,知道什么是全局对象(Global Object),知道在JS中,函数是“一等公民”,等等。

作用域链(Scope chain)

当JS代码在执行时,需要一些空间来存储局部变量(local variables).我们就把这些空间称为作用域对象(scope object, 也有人称之为词法环境(Lexical Environment))。比如,当你调用一些函数时,被调函数定义了局部变量,这些变量保存在作用域对象中。你可以把作用域对象看作一个普通对象,但是不能对其进行直接引用;你只能修改它的属性,但不能引用作用域对象本身。

这里,作用域对象的概念,与C或者C++将局部变量存储在栈中不同,JS中作用域对象被分配在堆内存中(至少表现出来是这样),所以即使函数已经返回,它们也可能保持分配状态。稍后再谈。

如你所望,作用域对象可能有父作用域对象(parent scope chain)。当代码要访问一些变量时,解释器查找当前作用域对象的属性,若属性不存在,解释器会到父作用域对象中查找,如果还没有,到父作用域对象的父作用域对象中查找,直到找到或没有父作用域对象为止。我们将这个线性查找过程中的作用域对象序列称之为作用域链。

在作用域链上查找变量的过程和原型链上查找属性的过程很相似,但有一点不同:当你要访问普通对象中并不存在的属性时,且这个属性在作用域链中也没有,它并不会报错,只是返回一个undefined。但你要是在作用域链中访问一个不存在的属性(即不存在的变量),那就会抛出ReferenceError.

作用域链中的最后一个查找的作用域对象就是全局对象(Global Object)。在JS的top-level code(最外层代码)中,作用域链中只有一个对象:全局对象。所以,当在最外层代码中定义变量,他们就定义在了全局对象上。当有函数调用时,作用域链就加入了多个作用域对象。你可能以为当一个函数在最外层代码被调用时,作用域链一定只有两个作用域对象,但这不是真的。因为可能有2个或多个,取决于具体情况。下面详解。

Top-level code

理论扯得够多了,我们来看点儿具体案例。下面是一个很简单的my_scripts.js例子:

"use strict"

var foo = 1
var bar = 2

在最外层代码中我们创建了两个变量。我之前说过,对于最外层代码,作用域对象就是全局对象:

在上图中,有一个执行上下文(也就是最外层的my_scripts.js代码),它引用了作用域对象。当然,全局对象中还有许多其他的东西没有画在图中。

非嵌套函数

考虑如下代码:

"use strict"
var foo = 1
var bar = 2

function myFunc() {
    //-- define local-to-function variables
    var a = 1
    var b = 2
    var foo = 3

    console.log("inside myFunc")
}

console.log("outside")

//-- and then, call it;
myFunc()

当定义了myFunc函数后,myFunc标识符就添加到了当前作用域对象中(本例中就是全局对象),这个标识符引用的是函数对象(function object)。这个函数对象包含了函数的代码字符串和一些其他属性。这些属性中我们要关注的是内部属性[[scope]],它指向了当前作用域对象(current scope object),也就是,当函数被定义时,处于活动状态的作用域对象(本例中,就是全局对象)。

活动状态,就是作用域链查找时,第一个访问到的作用域对象,不用再向下查找。(《JS高级程序设计》里面的活动对象active object,作用域对象就是变量对象(variable object),到了执行阶段,处于活动状态的变量对象就是活动对象(active object))

所以,到console.log("outside");被执行时,我们就有了下面的情况:


我们来看一下:myFunc变量引用的函数对象不仅包含了函数代码,被引用的函数对象还指向函数定义时的作用域对象(图中[[scope]]表示,也就是Global object)。这非常重要!

当函数被调用时,创建新的作用域对象。新创建的作用域对象,继承自被调函数引用的作用域对象,并且包含了myFunc的局部变量(以及它的参数值)。

所以,当实际调用myFunc时,如下:

现在我们就有了一个作用域链:如果想要访问myFunc中的变量,JS将会在第一个作用域对象:myFunc() scope中查找。如果没有找到,就到作用域链的下一个作用域对象中(这里就是Global object)查找。如果要找的属性在作用域链中并没有,就抛出ReferenceError.

例如,我们访问myFunc中的a,就会从第一个作用域对象myFunc() scope中得到1。如果要访问foo,会在myFunc() scope中得到3,不用再到Global object中找了。如果访问bar,会从Global object中得到2。查找的机制和原型继承非常像。

需要注意的是,只要有对这些作用域对象的引用,这些作用域对象就会保存在内存中。当解除对某个作用域对象的最后一个引用时,可以对该作用域对象进行垃圾回收(但不一定立刻被回收)。

所以,当myFunc()返回,且没有对myFunc() scope的引用时,它会被垃圾回收。所以我们又回到之前的状态:

从现在起,我将不再在图中画出函数对象。但记得:在JavaScript中对函数的任何引用都指向函数对象,而函数对象又引用作用域对象。

嵌套函数

正如我们在前面的讨论中看到的,当函数返回后,没有引用作用域对象,因此,它会被垃圾回收。但是,如果我们定义嵌套函数并返回它(或存储在外部某处),会怎么样呢?您已经知道:函数对象总是指向它创建时的作用域对象。因此,当我们定义嵌套函数时,它会获得对外部函数的当前作用域对象的引用。如果我们将这个嵌套函数存储在外部某个地方,那么即使在外部函数返回后,作用域对象也不会被垃圾回收:仍然保持对它的引用!请考虑以下代码:

"use strict"

function createCounter(initial) {
    //-- define local-to-function variables
    var counter = initial

    //-- define nested functions. Each of them will have
    //   a reference to the current scope object

    /**
     * Increments internal counters by given value
     * If given value is not a finite number or is less  than 1, then 1 is used
     */ 
    function increment(value) {
        if (!isFinite(value) || value < 1) {
            value = 1
        }
        counter += value
    }

    /**
     * Return current counter value
     */
    function get() {
        return counter
    }

    //-- return object containing references
    //   to nested function
    return {
        increment: increment,
        get: get
    }
}

//-- create counter object
var myCounter = createCounter(100)

console.log(myCounter.get())    //-- prints "100"

myCounter.increment(5)
console.log(myCounter.get())    //-- prints "105"

当我们调用createCounter(100)时,有如下操作:

注意,createCounter(100) scope被嵌套函数incrementget引用。如果createCounter()没有返回任何内容,当然,这些内部自引用也就不会被包含在内,那么无论如何createCounter(100) scope都会被垃圾回收。但是由于createCounter()返回的对象包含对这些函数的引用,因此有以下内容:

花点时间思考一下:createCounter(100)函数已经返回,但是它的作用域仍然存在,可以由内部函数访问,而且只能由这些函数访问。我们不能直接访问createCounter(100) scope对象,只能调用myCounter.increment()myCounter.get()。这些函数对createCounter的作用域具有唯一的私有访问权。

让我们尝试调用,例如myCounter.get()。回想一下,当调用任何函数时,都会创建新的作用域对象,并且由该函数引用的作用域链会用这个新的作用域对象进行扩充。所以,当调用myCounter.get()时,情况如下:

函数get()的作用域链的第一个作用域对象是空对象get() scope。因此,当get()访问counter变量时,JavaScript无法在作用域链的第一个对象上找到它,进而到下一个作用域对象,并在createCounter(100) scope上使用counter变量。然后函数get()返回它的值。

你可能已经注意到myCounter对象还作为this给到了函数myCounter.get()(在图中用红色箭头表示)。这是因为this从来都不是作用链的一部分,应该意识到这一点。后面会谈到this

调用increment(5)比较有趣,因为这个函数有一个参数:

如上图,参数value存储在调用increment(5)创建的作用域对象中。当此函数访问value值时,JavaScript立即在作用域链中的第一个对象上找到它。但是,当函数访问counter时,JavaScript无法在作用域链中的第一个对象上找到它,进而到下一个作用域对象,并在那里找到它。因此,increment()修改了createCounter(100) scope上的counter变量。实际上没有人能修改这个变量。 This is why closures are so powerful: the myCounter object is impossible to compromise. Closures are very appropriate place to store private things.

注意,即使未使用到参数initial,它也被存储在作用域对象createCounter()中。因此,如果去掉显式的var counter = initial,将initial重命名为counter,并直接使用它,我们可以节省一些内存。但是,为了可读性,我们使用参数initialvar counter

重要的是绑定作用域是“活动的”。函数调用时,不会为此函数拷贝当前的作用域链,而是用新的作用域对象扩展当前作用域链,当链中的任何作用域对象被任何函数修改时,此更改将立即被其作用域链中包含此作用域对象的所有函数观察到。当increment()修改counter值时,对get()的下一次调用将返回更新的值。

这就是为什么这个众所周知的例子不起作用(所有this.innerHTML的i值都为elems.length):

"use strict"

var i, elems = document.getElementsByClassName("myClass");

for (i = 0; i < elems.length; i++) {
    elems[i].addEventListener("click", function() {
        this.innerHTML = i;
    })
}

在循环中创建了多个函数,所有这些函数都引用了其作用域链中的同一作用域对象。所以,它们使用完全相同的变量i,而不是它的私有副本。有关此例的进一步解释,请参见此链接:Don't make functions within a loop.

相似的函数对象,不同的作用域对象

现在,我们试着扩展一点我们的counter例子,来找点乐子。如果我们创建多个counter对象呢:

"use strict"

function createCounter(initial) {
    /* ... see the code from previous example */
}

//-- create counter objects
var myCounter1 = createCounter(100)
var myCounter2 = createCounter(200)

创建myCounter1myCounter2时,我们有以下内容:

牢记,每个函数对象都有对作用域对象的引用。因此,在上面的示例中,myCounter1.incrementmyCounter2.increment引用的函数对象具有完全相同的代码和相同的属性值(namelength其他),但它们的[[scope]]引用的是不同的作用域对象

为了简单起见,图中没有包含单独的函数对象,但它们仍然存在。

var a, b;
a = myCounter1.get()    // a equals 100
b = myCounter2.get()    // b equals 200

myCounter1.increment(1)
myCounter1.increment(2)

myCounter1.increment(5)

a = myCounter1.get()    // a equals 103
b = myCounter2.get()    // b equals 205

所以,这就是它的工作原理。闭包的概念非常强大。

作用域链和"this"

不管你喜不喜欢,this不是作为作用域链的一部分保存的。相反,this的值取决于函数调用模式:也就是说,您可以用不同的this值调用相同的函数。

调用模式

这个主题非常值得再写一篇文章,所以我不会深入讨论,但是作为一个快速的概述,有四种调用模式。我们开始吧:

  1. 方法调用模式
"use strict"

var myObj = {
    myProp: 100,
    myFunc: function myFunc() {
        return this.myProp
    }
}

myObj.myFunc()  //-- returned 100

如果调用表达式包含refinement(点或[下标]),则函数将作为方法调用。所以,在上面的例子中,thismyFunc()的是对myObj的引用。

  1. 函数调用模式
"use strict"

function myFunc() {
    return this
}
myFunc()    //-- returns undefined

如果没有refinement,则取决于代码是否在严格模式下运行:

  • 在严格模式下,thisundefined
  • 非严格模式下,this指向Global Object
  1. 构造函数调用模式
"use strict"

function Myobj() {
    this.a = 'a'
    this.b = 'b'
}
var myObj = new MyObj()

当使用new前缀调用函数时,JavaScripts会分配继承自函数prototype属性的新对象,而这个新分配的对象就作为this分配给函数。

  1. Apply调用模式
"use strict"

function myFunc(myArg) {
    return this.myProp + " " + myArg
}

var result = myFunc.apply(
    { myPtop: "prop" },
    [ "arg" ]
)
//-- result is "prop arg"

我们可以用任意值作为this传递。在上面的例子中,我们使用了Function.prototype.apply()函数。除此之外,也可以用:
- Function.prototype.call()
- Function.prototype.call()
下面的例子中,我们主要用方法调用模式。

嵌套函数中"this"的用法

考虑如下代码:

"use strict"
var myObj = {

    myProp: "outer-value",
    createInnerObj: function createInnerObj() {

        var hidden = "value-in-closure"

        return {
            myProp: "inner-value",
            innerFunc: function innerFunc() {
                return "hidden: " + hidden + "', myProp: '" + this.myProp + "'"
            }
        }

    }
}

var myInnerObj = myObj.createInnerObj()
console.log( myInnerObj.innerFunc() )

输出:hidden: value-in-closure', myProp: 'inner-value'

在调用myObj.createInnerObj()时:

当我们调用myInnerObj.innerFunc()时,它如下所示:

从上面可以清楚地看出,给到myObj.createInnerObj()this指向了myObj,而给到myInnerObj.innerFunc()this指向myInnerObj:这两个函数都是用方法调用模式调用的,如上所述。这就是为什么innerFunc()中的this.myProp 的计算结果是"inner-value",而不是"outer-value"

所以,我们可以使用不同的myProp简单地欺骗innerFun()

/* ... see the definition of myObj above ... */

var myInnerObj = myObj.createInnerObj();
var fakeObject = {
  myProp: "fake-inner-value",
  innerFunc: myInnerObj.innerFunc
};
console.log( fakeObject.innerFunc() );

输出:hidden: 'value-in-closure', myProp: 'fake-inner-value'

或者使用apply()call()

/* ... see the definition of myObj above ... */
 
var myInnerObj = myObj.createInnerObj();
console.log(
  myInnerObj.innerFunc.call(
    {
      myProp: "fake-inner-value-2",
    }
  )
);

输出:hidden: 'value-in-closure', myProp: 'fake-inner-value-2'

然而,有时内部函数实际上需要访问外部函数的this,而与调用内部函数的方式无关。有一个常见的习惯用法:我们需要在闭包中显式保存所需的值(即,在当前作用域对象中),如:var self = this;,并在内部函数中使用self,而不是this。考虑:

"use strict";
 
var myObj = {
 
  myProp: "outer-value",
  createInnerObj: function createInnerObj() {
 
    var self = this;
    var hidden = "value-in-closure";
 
    return {
      myProp: "inner-value",
      innerFunc: function innerFunc() {
        return "hidden: '" + hidden + "', myProp: '" + self.myProp + "'";
      }
    };
 
  }
};
 
var myInnerObj = myObj.createInnerObj();
console.log( myInnerObj.innerFunc() );

输出:hidden: 'value-in-closure', myProp: 'outer-value'

这样,我们有如下:

如上图,这次,innerFunc()通过闭包中存储的self,可以访问到外部函数的this值。

结论

让我们回答在文章开头的几个问题:

  • 什么是闭包?-它是同时引用函数对象和作用域对象的对象。实际上,所有JavaScript函数都是闭包:没有作用域对象就不可能有对函数对象的引用。
  • 它是什么时候创建的?-因为所有的JavaScript函数都是闭包,所以很明显:当定义一个函数时,实际上定义了一个闭包。因此,它是在定义函数时创建的。但要确保区分闭包创建和新作用域对象创建:定义函数时创建闭包(函数+对当前作用域链的引用),调用函数时创建新作用域对象(并用于扩展闭包的作用域链)。
  • 什么时候删除?-就像JavaScript中的任何普通对象一样,当没有对它的引用时,它会被垃圾回收。

进一步阅读:

  • JavaScript: The Good Parts 作者Douglas Crockford。理解闭包是如何工作的当然很好,但理解如何正确使用它们可能更重要。这本书非常简洁,里面有许多伟大且有用的模式。
  • JavaScript: The Definitive Guide 作者David Flanagan。顾名思义,这本书以非常详细的方式解释了语言。
原文地址:https://www.cnblogs.com/wydumn/p/12319750.html