深入Js作用域:闭包

概念

其实很多专业文献所定义的闭包都非常抽象,很难看懂。我们分别来看看这些概念:

《MDN》:闭包是函数和声明该函数的词法环境的组合。

《你不知道的 Js》:闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在它的词法作用域之外执行时。

《Js 高级程序设计》:有权访问另一个函数作用域中的变量的函数。

《Js 权威指南第6版》:函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中成为闭包。从技术角度,所有的 Js 函数都是闭包。

大家看下来是不是感觉有点乱,其实它们都提到了:函数作用域作用域链。我们来进行简单分析。

概念解析

例一:

var a = 456;
function foo1() {
    var a = 123;
    return funciton foo2() {
        console.log(a);
    }
}

var fun = foo1();
fun(); // 123

这是我们最熟悉的一种闭包:foo2 定义在 foo1 作用域中,但是 foo2 是在全局作用域中调用的。foo2 仍可以访问 foo1 作用域中的变量。

它符合《Js 高级程序设计》、《你不知道的 Js》的定义。

相信大家比较疑惑的应该就是《MDN》和《Js 权威指南第6版》的定义了,我们来看看第二个例子。
例二:

var a = 1;

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

foo();

这个例子有两个作用域:全局作用域、函数 foo 作用域。这是符合《MDN》和《Js 权威指南第6版》定义的。

这么说来,Js 所有函数都是闭包?

其实这是理论上的闭包。我们来看看汤姆大叔翻译的完整概念。

完整概念

引自《深入理解JavaScript系列(16):闭包(Closures)-汤姆大叔》

ECMAScript 中,闭包指的是:

理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。

实践角度:以下函数才算是闭包:
即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
在代码中引用了自由变量

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

看到这,相信大家都掌握了闭包的概念,那么我们就从一个实际例子来分析一下吧。

分析

var scope = "global scope";

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

var foo = checkscope();
foo();

这段代码的只做简短过程分析(大家对过程有疑问可以参考上一章博客)

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

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 值呢?

大家还记得我在《深入Js作用域:变量对象》提到,当执行期上下文被销毁时,函数的 AO 也就被销毁了。但是当有闭包引用时,激活对象就不会被销毁,因为他仍然被引用。

我们来看看到底怎么回事:

第 3 步到第 4 步(执行 checkscope):

第 6 步到第 7 步(执行 f):

大家可以从图中看到:

执行到 f 函数时,虽然 checkscope 的执行上下文已经销毁,但是 f 函数 [[scope]] 引用了 checkscope 函数的 AO(因此并未被销毁),然后为本身创建一个新的 AO。

同时,f 要返回 scope 变量,那么就需要进行标识符解析,它的 AO 上并不存在 scope,因此它会沿着作用域链向上找。这也解释了为什么 foo() 返回的值是 “local scope”。

相关例子

例一:

let nAdd;
let t = () => {
    let n = 99;
    nAdd = () => {
        n++;
    };
    let t2 = () => {
        console.log(n);
    };
    return t2;
};

let a1 = t();
let a2 = t();

nAdd();
a1();    //99
a2();    //100

分析:
1、执行 let a1 = t(); 此时 a1 获得一个闭包 t2 引用。全局变量 nAdd = 匿名函数。

2、执行 let a2 = t(); 此时 a2 形成一个闭包 t2 引用。全局变量 nAdd 被重新赋值,nAdd = 匿名函数(新的)。

3、大家还记得每次执行函数,都会创建一个新的执行上下文吧,所以 a1 和 a2 引用的是两个不同的闭包。

4、执行 nAdd(); 此时 a2 引用的闭包的 [[scope]] 上的 AO(t) 中的标识 n 会变成 100。

5、执行 a1(); 由于 a1 引用的闭包作用域链上的 n 为 99,因此输出 99。

6、执行 a2(); 输出 100。

总结

引自《Js 权威指南》

相信坚持看到这里的同学,大概对 Js 作用域有了一个深刻的认识。看一遍没看明白没关系,多看几遍、多翻一些资料其实就慢慢理解了。其实我也是花了挺长时间,看了各种版本的说法,然后慢慢总结的。

拓展

Chrome 里的 Closure

大家可以看看这篇文章:https://www.cnblogs.com/lsgxeva/p/7976111.html
大概是说 chrome 开发者工具中对闭包的定义好像和书里定义的有差异,我查了一些资料,并未对 source -> scope -> closure 这个 closure 做详细说明,因此我也不好妄下结论。权当拓展知识了解。

深入 Js 作用域系列的参考文章

你不懂JS:作用域与闭包
深入理解JavaScript系列 11 - 16,汤姆大叔
深入理解JavaScript系列 1 - 8,冴羽
一道js面试题引发的思考