Blog icon indicating copy to clipboard operation
Blog copied to clipboard

JavaScript深入之闭包

Open mqyqingfeng opened this issue 7 years ago • 132 comments

定义

MDN 对闭包的定义为:

闭包是指那些能够访问自由变量的函数。

那什么是自由变量呢?

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

由此,我们可以看出闭包共有两部分组成:

闭包 = 函数 + 函数能够访问的自由变量

举个例子:

var a = 1;

function foo() {
    console.log(a);
}

foo();

foo 函数可以访问变量 a,但是 a 既不是 foo 函数的局部变量,也不是 foo 函数的参数,所以 a 就是自由变量。

那么,函数 foo + foo 函数访问的自由变量 a 不就是构成了一个闭包嘛……

还真是这样的!

所以在《JavaScript权威指南》中就讲到:从技术的角度讲,所有的JavaScript函数都是闭包。

咦,这怎么跟我们平时看到的讲到的闭包不一样呢!?

别着急,这是理论上的闭包,其实还有一个实践角度上的闭包,让我们看看汤姆大叔翻译的关于闭包的文章中的定义:

ECMAScript中,闭包指的是:

  1. 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  2. 从实践角度:以下函数才算是闭包:
    1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    2. 在代码中引用了自由变量

接下来就来讲讲实践上的闭包。

分析

让我们先写个例子,例子依然是来自《JavaScript权威指南》,稍微做点改动:

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}

var foo = checkscope();
foo();

首先我们要分析一下这段代码中执行上下文栈和执行上下文的变化情况。

另一个与这段代码相似的例子,在《JavaScript深入之执行上下文》中有着非常详细的分析。如果看不懂以下的执行过程,建议先阅读这篇文章。

这里直接给出简要的执行过程:

  1. 进入全局代码,创建全局执行上下文,全局执行上下文压入执行上下文栈
  2. 全局执行上下文初始化
  3. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 执行上下文被压入执行上下文栈
  4. checkscope 执行上下文初始化,创建变量对象、作用域链、this等
  5. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
  6. 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈
  7. f 执行上下文初始化,创建变量对象、作用域链、this等
  8. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出

了解到这个过程,我们应该思考一个问题,那就是:

当 f 函数执行的时候,checkscope 函数上下文已经被销毁了啊(即从执行上下文栈中被弹出),怎么还会读取到 checkscope 作用域下的 scope 值呢?

以上的代码,要是转换成 PHP,就会报错,因为在 PHP 中,f 函数只能读取到自己作用域和全局作用域里的值,所以读不到 checkscope 下的 scope 值。(这段我问的PHP同事……)

然而 JavaScript 却是可以的!

当我们了解了具体的执行过程后,我们知道 f 执行上下文维护了一个作用域链:

fContext = {
    Scope: [AO, checkscopeContext.AO, globalContext.VO],
}

对的,就是因为这个作用域链,f 函数依然可以读取到 checkscopeContext.AO 的值,说明当 f 函数引用了 checkscopeContext.AO 中的值的时候,即使 checkscopeContext 被销毁了,但是 JavaScript 依然会让 checkscopeContext.AO 活在内存中,f 函数依然可以通过 f 函数的作用域链找到它,正是因为 JavaScript 做到了这一点,从而实现了闭包这个概念。

所以,让我们再看一遍实践角度上闭包的定义:

  1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  2. 在代码中引用了自由变量

在这里再补充一个《JavaScript权威指南》英文原版对闭包的定义:

This combination of a function object and a scope (a set of variable bindings) in which the function’s variables are resolved is called a closure in the computer science literature.

闭包在计算机科学中也只是一个普通的概念,大家不要去想得太复杂。

必刷题

接下来,看这道刷题必刷,面试必考的闭包题:

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]();

答案是都是 3,让我们分析一下原因:

当执行到 data[0] 函数之前,此时全局上下文的 VO 为:

globalContext = {
    VO: {
        data: [...],
        i: 3
    }
}

当执行 data[0] 函数的时候,data[0] 函数的作用域链为:

data[0]Context = {
    Scope: [AO, globalContext.VO]
}

data[0]Context 的 AO 并没有 i 值,所以会从 globalContext.VO 中查找,i 为 3,所以打印的结果就是 3。

data[1] 和 data[2] 是一样的道理。

所以让我们改成闭包看看:

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = (function (i) {
        return function(){
            console.log(i);
        }
  })(i);
}

data[0]();
data[1]();
data[2]();

当执行到 data[0] 函数之前,此时全局上下文的 VO 为:

globalContext = {
    VO: {
        data: [...],
        i: 3
    }
}

跟没改之前一模一样。

当执行 data[0] 函数的时候,data[0] 函数的作用域链发生了改变:

data[0]Context = {
    Scope: [AO, 匿名函数Context.AO globalContext.VO]
}

匿名函数执行上下文的AO为:

匿名函数Context = {
    AO: {
        arguments: {
            0: 0,
            length: 1
        },
        i: 0
    }
}

data[0]Context 的 AO 并没有 i 值,所以会沿着作用域链从匿名函数 Context.AO 中查找,这时候就会找 i 为 0,找到了就不会往 globalContext.VO 中查找了,即使 globalContext.VO 也有 i 的值(值为3),所以打印的结果就是0。

data[1] 和 data[2] 是一样的道理。

下一篇文章

JavaScript深入之参数按值传递

相关链接

如果想了解执行上下文的具体变化,不妨循序渐进,阅读这六篇:

《JavaScript深入之词法作用域和动态作用域》

《JavaScript深入之执行上下文栈》

《JavaScript深入之变量对象》

《JavaScript深入之作用域链》

《JavaScript深入之从ECMAScript规范解读this》

《JavaScript深入之执行上下文》

深入系列

JavaScript深入系列目录地址:https://github.com/mqyqingfeng/Blog

JavaScript深入系列预计写十五篇左右,旨在帮大家捋顺JavaScript底层知识,重点讲解如原型、作用域、执行上下文、变量对象、this、闭包、按值传递、call、apply、bind、new、继承等难点概念。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎star,对作者也是一种鼓励。

mqyqingfeng avatar Apr 27 '17 02:04 mqyqingfeng

支持下一啊,虽然对闭包已经看了很多了,每次看一遍都会有一番不同的感受,学习就是一个重复的过程。

jawil avatar Apr 27 '17 07:04 jawil

请问下学长为什么

globalContext = {
    VO: {
        data: [...],
        i: 3
    }
}

这里是3??怎么来的 为什么不是0,1,2

rivens-77 avatar May 10 '17 07:05 rivens-77

当执行到data[0]函数的时候,for循环已经执行完了,i是全局变量,此时的值为3,举个例子:

for (var i = 0; i < 3; i++) {}
console.log(i) // 3

mqyqingfeng avatar May 10 '17 07:05 mqyqingfeng

循环结束后

data[0] = function(){console.log(i)}
data[1] = function(){console.log(i)}
data[2] = function(){console.log(i)}

执行data[0]()data[1]()data[2]()时,i=3,所以都打印3 这个例子看完N遍后终于知道原理了

spicychocolate avatar May 12 '17 08:05 spicychocolate

匿名函数Context = {
    AO: {
        arguments: {
            0: 1,
            length: 1
        },
        i: 0
    }
}

里面的arguments0:1为什么是1呢,按我的理解应该和i的值相同,所以不是0: 0

fi3ework avatar May 22 '17 07:05 fi3ework

@fi3ework 嗯,这里是笔误,感谢指出,o( ̄▽ ̄)d

mqyqingfeng avatar May 22 '17 07:05 mqyqingfeng

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() { 
        console.log(c); 
        console.log(a);
    }
    fn = innnerFoo; 
}

function bar() {
    var c = 100;
    fn(); 
}

foo();
bar();

大神,帮我把这个例子分析下?自己解释感觉说服不了自己,c 为什么会报错,我怎么感觉会读取到bar 执行上下文中变量对象c

xdwxls avatar Jun 01 '17 01:06 xdwxls

@xdwxls 词法作用域的问题,具体可以看第二篇《JavaScript深入之词法作用域和动态作用域》,关于这道题,你可以简单理解为函数能够读取到的值跟函数定义的位置有关,跟执行的位置无关

mqyqingfeng avatar Jun 01 '17 02:06 mqyqingfeng

大神,那是不是执行上下文中的作用域scope仅是父级的一个VO记录,不会像跟ECStack那样跟函数执行的次序有关呢?

tangshuimei avatar Jun 02 '17 08:06 tangshuimei

@xdwxls 我觉得可能是虽然fn(),即innerFoo()是在bar里面执行的,但是innerFoo函数的时候他的作用域scope里面分别是[AO,fooContext.AO,globalContext.AO],并没有包括barContext.AO在里面,所以根本就没有声明c这个变量,所以会显示is not define,我就猜猜而已......

tangshuimei avatar Jun 02 '17 09:06 tangshuimei

@tangshuimei 是的,你可以这样理解,如果要更严谨的话,可以说,执行上下文中的作用域 scope 是由函数的 [[scope]]属性初始化,而函数的[[scope]] 属性保存了函数创建时词法层面上的父级们的 VO 引用,跟函数的执行顺序无关。

mqyqingfeng avatar Jun 02 '17 11:06 mqyqingfeng

@tangshuimei 哈哈,关于这道题的分析,我赞同你的观点~

mqyqingfeng avatar Jun 02 '17 11:06 mqyqingfeng

@tangshuimei 你说 innerFoo函数的时候他的作用域scope里面分别是[AO,fooContext.AO,globalContext.AO], 那这个时候AO 具体表示什么呢???有点费解

xdwxls avatar Jun 04 '17 09:06 xdwxls

@xdwxls AO 表示活动对象,储存了函数的参数、函数内声明的变量等,在 innnerFoo 中,查找变量 c,就要在 innerFoo 函数的作用域链,也就是 [AO,fooContext.AO,globalContext.AO] 中找到变量 c 的声明,因为没有,所以最终会报错~

mqyqingfeng avatar Jun 04 '17 09:06 mqyqingfeng

我在想,我们在想这个作用域链的时候是不是把for循环的AO给漏了?比如说下面这个例子:

var data = [ ];
for( var i=0; i<3 ; i++ ){
        data [ i ] = function ( ) {
            console.log ( i );
        };
       data [ i ]( i );
}

这里返回的是1,2,3

frankchou1 avatar Jul 18 '17 03:07 frankchou1

@frankchou1 for 循环不会创建一个执行上下文,所有不会有 AO, i 的值是在全局对象的 AO 中,代码初始的时候为:

globalContext = {
    VO: {
        data: [...],
        i: 0
    }
}

代码执行的时候,不断修改 i 的值

mqyqingfeng avatar Jul 19 '17 03:07 mqyqingfeng

@frankchou1 看你修改了几次格式,Github 的评论支持 markdown 格式,使用代码块可以用 ```js 和 ``` 包裹

mqyqingfeng avatar Jul 19 '17 03:07 mqyqingfeng

var data = [];

for (let i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]();

求教下,要是把var i 改成let 这个原理有事怎么样子的呢?

Muscliy avatar Jul 20 '17 09:07 Muscliy

@Muscliy let 关键字将 for 循环的块隐式地声明为块作用域。而 for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。 这是《你不知道的 JavaScript》中的解释。

bighuang624 avatar Jul 20 '17 09:07 bighuang624

@bighuang624 谢谢,我刚刚用babel 转了下发现其实多了_loop的函数,这个就解释的通了,看来《你不知道的 JavaScript》 这个书很好

"use strict";

var data = [];

var _loop = function _loop(i) {
  data[i] = function () {
    console.log(i);
  };
};

for (var i = 0; i < 3; i++) {
  _loop(i);
}

data[0]();
data[1]();
data[2]();

Muscliy avatar Jul 20 '17 10:07 Muscliy

非常感谢博主!以前对闭包总是雾里看花,终隔一层,提到闭包,有人说函数就是闭包,有人说必须是嵌套,又是引用怎么怎么样,其实现在看来,两者都是,只不过是一种狭义和广义上概念的区别。 另外,通过楼主的分析,渐渐发现,只要理清了变量的查找规则,AO对象词法分析期和执行期的变化,闭包这东西,正是基于这些规则下产生的一种自然而然的现象。

keyiran avatar Aug 20 '17 12:08 keyiran

看了这么多写闭包的,这个是我看完之后唯一恍然大悟的,之前都是一知半解的。感谢,比心💟

alicejxr avatar Sep 19 '17 09:09 alicejxr

学习了啊

dengnan123 avatar Oct 30 '17 02:10 dengnan123

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]();

答案是都是 3,让我们分析一下原因:

当执行到 data[0] 函数之前,此时全局上下文的 VO 为:

globalContext = {
    VO: {
        data: [...],
        i: 3
    }
}

data[0]的时候i不是0吗?为什么是3,整个循环走完了吗?

jasonzhangdong avatar Nov 19 '17 15:11 jasonzhangdong

@jasonzhangdong 正是如此,data[0] 是一个函数名,data0 表示执行这个函数,当执行函数的时候,循环已经走完了,i 的值为 3:

for (var i = 0; i < 3; i++) {

}
console.log(i) // 3

mqyqingfeng avatar Nov 20 '17 03:11 mqyqingfeng

文中的:“ 即使 checkscopeContext 被销毁了,但是 JavaScript 依然会让 checkscopeContext.AO 活在内存中 ” 。"活在内存中" 这句话有点描述不完整的感觉。是所有的闭包函数都销毁之后,都会存活在内存中吗?哪什么时候才会从内存中删除呢?

proc07 avatar Dec 01 '17 14:12 proc07

第一个例子,换成let,就变成012了

imnaifu avatar Dec 04 '17 15:12 imnaifu

@jxZhangLi 非常好的问题,要解决这个问题,还要了解一些垃圾回收机制,不过我还没有怎么研究过……关于这个问题,我个人的看法是,并不会被删除,这些变量放在闭包和放在全局作用域,对内存而言是一样的。

mqyqingfeng avatar Dec 05 '17 01:12 mqyqingfeng

@imnaifu 是的,这是 ES6 的特性,可是为什么换成 let 就会正常打印 012 呢?

mqyqingfeng avatar Dec 05 '17 02:12 mqyqingfeng

@mqyqingfeng @jxZhangLi 第一点是内层函数f引用了自由变量scope;第二点是函数f返回到全局作用域中去了,这点使得f从根出发可到达;所以标记清除法并不会去清除这个scope,个人的一点理解。

roadwild avatar Dec 05 '17 02:12 roadwild