闭包以及它的亲戚们

    技术2024-08-22  63

    闭包

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

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

    即闭包共有两部分组成:

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

    如下例子:

    var a = 1; function foo() { console.log(a); } foo();

    foo 函数可以访问变量 a,但是 a 既不是 foo 函数的局部变量,也不是 foo 函数的参数,所以 a 就是自由变量。这样看来数 foo + foo 函数访问的自由变量 a 就是构成了一个闭包 所以在《JavaScript权威指南》中就讲到:从技术的角度讲,所有的JavaScript函数都是闭包。

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

    首先我们要分析一下这段代码中执行上下文栈和执行上下文的变化情况。 这里直接给出简要的执行过程:

    进入全局代码,创建全局执行上下文,全局执行上下文压入执行上下文栈 全局执行上下文初始化 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 执行上下文被压入执行上下文栈 checkscope 执行上下文初始化,创建变量对象、作用域链、this等 checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈 f 执行上下文初始化,创建变量对象、作用域链、this等 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 做到了这一点,从而实现了闭包这个概念。

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

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

    必刷题

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

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

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

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

    globalContext = { GO: { 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] 是一样的道理。

    2.作用域与作用域链

    作用域

    作用域是指程序源代码中定义变量的区域。

    作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

    JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。

    静态作用域与动态作用域

    因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。

    而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。

    让我们认真看个例子就能明白之间的区别:

    var value = 1; function foo() { console.log(value); } function bar() { var value = 2; foo(); } bar();

    // 结果是 ??? 假设JavaScript采用静态作用域,让我们分析下执行过程:

    执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。

    假设JavaScript采用动态作用域,让我们分析下执行过程:

    执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。

    前面我们已经说了,JavaScript采用的是静态作用域,所以这个例子的结果是 1。

    动态作用域

    也许你会好奇什么语言是动态作用域?

    bash 就是动态作用域,不信的话,把下面的脚本存成例如 scope.bash,然后进入相应的目录,用命令行执行 bash ./scope.bash,看看打印的值是多少。

    value=1 function foo () { echo $value; } function bar () { local value=2; foo; } bar

    这个文件也可以在 Github 博客仓库中找到。

    思考题 最后,让我们看一个《JavaScript权威指南》中的例子:

    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 scope"; function f(){ return scope; } return f; } checkscope()();

    猜猜两段代码各自的执行结果是多少?

    这里直接告诉大家结果,两段代码都会打印:local scope。

    原因也很简单,因为JavaScript采用的是词法作用域,函数的作用域基于函数创建的位置。

    而引用《JavaScript权威指南》的回答就是:

    JavaScript 函数的执行用到了作用域链,这个作用域链是在函数定义的时候创建的。嵌套的函数 f() 定义在这个作用域链里,其中的变量 scope 一定是局部变量,不管何时何地执行函数 f(),这种绑定在执行 f() 时依然有效。

    作用域链

    在《JavaScript深入之变量对象》中讲到,当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

    函数创建

    在《JavaScript深入之词法作用域和动态作用域》中讲到,函数的作用域在函数定义的时候就决定了。

    这是因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链!

    举个例子:

    function foo() { function bar() { ... } } //函数创建时,各自的[[scope]]为: foo.[[scope]] = [ globalContext.VO ]; bar.[[scope]] = [ fooContext.AO, globalContext.VO ];
    函数激活

    当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。

    这时候执行上下文的作用域链,我们命名为 Scope:

    Scope = [AO].concat([[Scope]]);

    至此,作用域链创建完毕。

    总结

    以下面的例子为例,结合着之前讲的变量对象和执行上下文栈,我们来总结一下函数执行上下文中作用域链和变量对象的创建过程:

    var scope = “global scope”; function checkscope(){ var scope2 = ‘local scope’; return scope2; } checkscope(); 执行过程如下:

    1.checkscope 函数被创建,保存作用域链到 内部属性[[scope]]

    checkscope.[[scope]] = [ globalContext.VO ];

    2.执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

    ECStack = [ checkscopeContext, globalContext ];

    3.checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链

    checkscopeContext = { Scope: checkscope.[[scope]], }

    4.第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明

    checkscopeContext = { AO: { arguments: { length: 0 }, scope2: undefined }, Scope: checkscope.[[scope]], }

    5.第三步:将活动对象压入 checkscope 作用域链顶端

    checkscopeContext = { AO: { arguments: { length: 0 }, scope2: undefined }, Scope: [AO, [[Scope]]] }

    6.准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值

    checkscopeContext = { AO: { arguments: { length: 0 }, scope2: ‘local scope’ }, Scope: [AO, [[Scope]]] }

    7.查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出

    ECStack = [ globalContext ];

    垃圾回收机制

    // 注意:在js高级语言中,只有使用分配所需要内存

    // 垃圾回收算法主要依赖于引用的概念 // 引用分类:显示引用 隐式引用 // 1.显示引用:一个对象访问另一个对象下的属性 // 2.隐式引用:一个对象通过原型对象访问的属性 // 对象:GO AO 函数对象 普通对象 // 引用计数垃圾收集 // 关键看一个对象是否被其他对象引用 // 没有被其他对象引用的对象称为零引用,零引用的对象被垃圾回收机制所回收 var o = { a:{ b:2 }, }; // 两个对象,一个key为a,一个key为b // 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o // 很显然,没有一个可以被垃圾收集 var o2 = o; // o2变量是第二个对“这个对象”的引用 o = 1; // 现在,“这个对象”只有一个o2变量的引用了,“这个对象”的原始引用o已经没有 var oa = o2.a; // 引用“这个对象”的a属性 // 现在,“这个对象”有两个引用了,一个是o2,一个是oa o2 = "yo"; // 虽然最初的对象现在已经是零引用了,可以被垃圾回收了 // 但是它的属性a的对象还在被oa引用,所以还不能回收 oa = null; // a属性的那个对象现在也是零引用了 // 它可以被垃圾回收了 // 总结:代码从头执行到尾,如果说这个对象以及对象中属性没有被任何全局变量所引用(直接或间接引用),那么对象就会被回收。 // 循环引用:(学术观点不同)浏览器底层处理原理方式不同,ie67内存泄漏(符合引用计数垃圾算法),其他浏览器用的是循环引用算法和引用计数垃圾算法
    Processed: 0.013, SQL: 9