笔记 - js闭包
From 前端早读课
闭包是js(包括其他绝大多数语言)中非常强大的特性,在MDN上是这样定义的:闭包是指那些能够访问自由变量(既不是本地定义也不作为参数的那些变量)的函数。换句话说,这些函数可以记住它被创建时的环境。
- Example 1
function numberGenerator() {
//闭包内的局部自由变量
var num = 1;
function checkNumber() {
console.log(num);
}
num++;
return checkNumber;
}
var number = numberGenerator();
number();//2
本例中,函数numberGenerator创建了局部自由变量num,以及输出num的函数checkNumber。checkNumber没有自己的局部变量,却可以访问numberGenerator内的变量,这就是闭包在起作用。因此,即使在numberGenerator执行完毕之后,checkNumber仍然能成功访问num,并输出。
- Example 2:
function sayHello() {
var say = function () { console.log(hello) };
//闭包内的局部自由变量
var hello='hello world !';
return say;
}
var sayHelloClosure=sayHello();
sayHelloClosure();//'hello world !'
我们发现,变量hello是在匿名函数之后定义,但它仍能被匿名函数函数访问到。因为程序在编译的时候,变量hello已经在函数作用域中定义,程序执行的时候自然就能访问到了。
闭包总揽:上述例子从比较高的层次去阐释了闭包的概念,归纳为一句话:“定义在封闭函数中的变量即使在该封闭函数执行完之后,仍然能被访问到”。
- 执行上下文
执行上下文是ECMAScript标准中定义的一个抽象概念,用来记录代码运行的环境。它可以是代码最开始执行的全局上下文,也可以是执行进入某个函数体内的上下文。
需要注意的是,程序至始至终只能进入一个执行上下文。这也是为什么说JavaScript是单线程的原因,即每次只能有一个命令在执行。通常,浏览器用“栈”来维护执行上下文。“栈”遵循“后进先出”的原则,也就是说最后进栈的最先出栈(因为我们只能操作栈顶)。当前起作用的执行上下文位于栈顶,当它内部的代码执行完毕之后出栈,然后将下一个元素作为当前的上下文。
举个比较实际的例子:
var x=10;
function foo(a){
var b=20;
function bar(c){
var d=30;
return boop(x+a+b+c+d);
}
function boop(e){
return e*-1;
}
return bar;
}
var moar=foo(5);
//moar函数实际上是foo函数执行之后返回的bar函数
//bar函数中调用boop函数,此时bar函数暂停执行
//boop函数的执行上下文入栈
//当boop函数执行完毕,boop函数的执行上下文出栈,bar函数继续执行
console.log(moar(15));
当有一堆执行上下文,并且一个接着一个地运行时,有些执行到一半暂停后来又继续,当继续执行的时候我们需要一种方式去记住当前的状态。事实上,ECMAScript中已经做出了规定,每个执行上下文都有用来追踪执行过程各种状态的记录器。它们包括以下几个:
- 代码执行状态:在当前执行上下文中用来记录代码执行,暂停,重新执行的状态
- 函数:当前上下文正在执行的函数体(如果当前上下文是脚本或者模块,函数则为null)
- 范畴:内部对象集合,全局运行环境及其作用域下的所有代码,其他相关的状态、资源
- 词法环境:用来解决当前上下文中的标识符引用问题
- 变量环境:包含环境记录的词法环境,而环境记录是由变量声明所产生的
这里我们只关心词法环境,它明确地解释了标识符引用的问题。你可以认为标识符就是变量。我们最初的目的是为了搞清楚函数在返回之后内部的变量为何还能被访问到,词法环境正是我们需要深挖的东西。
备注:严格上来说,变量环境和词法环境都与闭包的实现有关系。但为简单起见,我们把它们合并称为“环境”。
词法环境
ECMAScript6中明确定义:词法环境是用来定义标识符和具体变量之间的关系以及基于词法嵌套结构的函数,它包括环境记录和指向外部词法环境的引用(有可能指向null)。通常词法环境跟一些具体语法结构有关,比如函数声明,块语句声明,Try/Catch语句,这些代码在执行的时候又会生成一个新的词法环境。下面我们来仔细解读一下。
- 用来定义标识符的值:词法环境的目的就是管理代码中的数据。也就是说,它给标识符赋值,让标识符变得有意义。比如,代码段console.log(x/10),如果变量x没有具体值,它是没有意义的,这段代码也没有意义。词法环境通过环境记录将标识符和具体的值联系在一起。
- 词法环境包含环境记录:环境记录完美地记录了词法环境中所有标识符和具体值之间的联系,并且每个词法环境都有自己的环境记录。
- 词法嵌套结构:内部环境引用包含它的外部环境,外部环境还可以有自己的外部环境。因此,一个环境可以作为多个内部环境的外部环境。全局环境是唯一一个没有外部环境的环境。说起来有点绕,我们用洋葱来做个比喻:词法环境就像洋葱一样一层又一层地嵌套着,全局环境是洋葱最外面的一层。
总而言之,每个执行上下文都对于一个词法环境。这个词法环境中记录着变量和它对应的值,还有指向外部环境的引用。它可以是全局环境,模块环境(包括模块之间的引用关系),或函数环境(因函数调用而创建的环境)。
作用域链
基于上述定义,我们已经知道一个环境可以访问它的外部环境,它的外部环境又可以继续访问自己的外部环境,以此类推。每个环境能访问到的标识符集合,我们称之为“作用域”。我们将作用域一层一层嵌套,形成了“作用域链”。
我们来看嵌套结构的例子。
var x=10;
function foo(){
var y=20;
function bar(){
var z=15;
return x+y+z;
}
return bar;
}
如上所示,bar函数嵌套在foo函数中。这个作用域链,或者说函数的环境链,在函数创建的时候就保存起来了。也就是说,它是由源代码的位置静态定义的,这就是我们熟悉的词法作用域。
接下来我们快速地了解一下,“动态作用域”与“静态作用域”的区别,我们就能知道为什么静态作用域是产生闭包的一个必要条件了。
动态作用域 vs 静态作用域
动态作用域的实现是基于栈结构,局部变量和函数参数都是存在栈里。因此,变量的具体值是由运行时当前的栈顶的执行上下文决定的。而静态作用域是指变量在创建的时候就决定了它的值,也就是说,源代码的位置决定了它的值。你可能对二者的区别还是有点模糊,我们来看两个例子帮助我们理解。
Example 1
var x=10;
function foo(){
var y=x+5;
return y;
}
function bar(){
var x=2;
return foo();
}
function main(){
foo();// 静态作用域:15 动态作用域:15
bar();// 静态作用域:15 动态作用域:7
return 0;
}
上例中,静态作用域和动态作用域的执行结果是不一样的。bar函数本质上就是执行foo函数,如果是静态作用域的话,bar函数的变量x是在foo函数创建的时候就确定了,也就是说变量x一直为10,两次输出应该都是15.而动态作用域则根据运行时的x值而返回不同的结果。当执行bar函数时,x的值变成了2,那么foo函数执行后的结果就是7.上例中两个都输出15,为什么呢?
Example 2
var myVar=100;
function foo(){
console.log(myVar);
}
foo();//静态作用域:100 动态作用域:100
(function(){
var myVar=50;
foo();//静态作用域:100 动态作用域:50
})();
// 高阶函数
(function(arg){
var myVar=1500;
arg();//静态作用域:100 动态作用域:1500
})(foo);
类似地,上例中如果是动态作用域的话,myVar变量取决于foo函数调用的位置。而如果是静态作用域的话,程序在两个立即执行函数创建的时候讲变量myVar的值确定了。上例全部输出100.
动态作用域经常会带来不确定性,它不能确定变量的值到底是来自哪个作用域的。
闭包###
上面讲了那么多,看起来有点离题,但其实是覆盖了理解闭包所需的全部知识点。
每个函数都有一个包含词法环境的执行上下文,它的词法环境确定了函数内的变量赋值以及对外部环境的引用。对外部环境的引用使得所有的内部函数能访问到外部作用域的所有变量,无论这些内部函数是在它创建时的作用域内调用还是作用域外调用。看上去函数“记住”了外部环境,但其实上是这个函数有个指向外部环境的引用。
让我们重新回到嵌套结构的例子:
var x=10;
function foo(){
var y=20;
function bar(){
var z=15;
return x+y+z;
}
return bar;
}
var test=foo();
test();//45
基于我们对环境运行机制的理解,上述例子的环境定义可以抽象为如下(伪代码):
GlobalEnvironment = {
EnvironmentRecord: {
//内置标识符
Array: '<func>',
Object: '<func>',
//等等...
//自定义标识符
x: 10
},
outer: null
};
fooEnvironment = {
EnvironmentRecord: {
y: 20,
bar: '<func>'
},
outer: { GlobalEnvironment }
}
barEnvironment = {
EnvironmentRecord: {
z: 15
},
outer: { fooEnvironment }
}
当我们执行test函数时,我们会得到45.因为test函数执行了foo函数,而foo函数返回了bar函数。而bar函数即使在foo函数返回之后,仍然能访问到自由变量y,因为它有指向外部环境(也就是foo函数所在的环境)的引用。同理,bar函数也能访问到全局变量x,因为foo函数所在的环境能访问全局环境。这就是所谓的“作用域链查找”。
回到我们刚才讨论的动态作用域和静态作用域所讨论的那点,为了实现闭包,我们不能用动态作用域链的动态堆栈来存储变量。如果是这样,当函数返回时,变量就必须出栈,而不再存在,这与最初闭包的定义是矛盾的。事实上,外部环境的闭包数据被存在了“堆”中,这样才使得即使函数返回之后内部的变量仍然一直存在(即使它的执行上下文也已经出栈)。
下面再来看几个例子:
Example 1
一个典型的错误是在for循环中有个函数,并且这个函数又用到计数变量。
var result=[];
for(var i=0;i<5;i++){
result[i]=function(){
console.log(i);
}
}
result[0]();//5
result[1]();//5
result[2]();//5
result[3]();//5
result[4]();//5
根据我们刚才所学的,很容易发现这个例子中的错误吧,对环境进行抽象,当for循环结束之后,环境是这样的:
environment:{
EnvironmentRecord:{
result:[...],
i:5
},
outer:null
}
你以为result属猪的五个函数的作用域是不同的,实际上它们是在同一个作用域下。因此,每次变量i增加时,整个作用域下的i也是跟着增加的,而这个i是被所有的函数共享的。因为for循环之后,i 变成了5,所以result数组的函数执行结果都是5.
解决方法之一是为每个函数创建一个额外的封闭上下文,这样它们就有了各自的执行上下文。
var result=[];
for(var i=0;i<5;i++){
result[i]=(function(x){
return function(){console.log(x)};
})(i);
}
result[0]();//0
result[1]();//1
result[2]();//2
result[3]();//3
result[4]();//4
或者使用let声明变量,因为let能创建一个新的作用域,也就是说for循环的每次迭代中都创建了一个标识符i。
Example 2
本例可以看到每次函数的调用是如何生成一个新的闭包。
function iCanThinkOfAName(num,obj){
//array变量和两个函数参数,被嵌套函数doSomething捕获
var array=[1,2,3];
function doSomething(i){
num+=i;
array.push(num);
console.log('num: '+num);
console.log('array '+array);
console.log('obj.value '+obj.value);
}
return doSomething;
}
var referenceObject={value:10};
var foo=iCanThinkOfAName(2,referenceObject);//闭包1
var bar=iCanThinkOfAName(6,referenceObject);//闭包2
foo(2);
bar(2);
referenceObject.value++;
foo(4);
bar(4);
/*
num: 4
array 1,2,3,4
obj.value 10
num: 8
array 1,2,3,8
obj.value 10
num: 8
array 1,2,3,4,8
obj.value 11
num: 12
array 1,2,3,8,12
obj.value 11
*/
函数iCanThinkOfAName每次调用都生成了一个新的闭包,也就是foo函数和bar函数。随后闭包函数的调用更新了闭包内的变量,这也证明了即使当函数iCanThinkOfAName返回之后,函数iCanThinkOfAName中的函数doSomething还是能访问闭包内的变量Array,obj,和num.
Example 3
function mysteriousCalculator(a,b){
var mysteriousVariable=3;
return {
add:function(){
var result=a+b+mysteriousVariable;
return toFixedTwoPlaces(result);
},
subtract:function(){
var result=a-b-mysteriousVariable;
return toFixedTwoPlaces(result);
}
}
}
function toFixedTwoPlaces(value){
return value.toFixed(2);
}
var myCalculator=mysteriousCalculator(10.01,2.01);
console.log(myCalculator.add())
console.log(myCalculator.subtract())
我们可以看到函数mysteriousCalculator是在全局作用域下,并且返回一个对象。本例中的环境抽象出来是这样的:
GlobalEnvironment={
EnvironmentRecord:{
//内置标识符
Array:'<func>',
Object:'<func>',
//等等...
//自定义标识符
mysteriousCalculator:'<func>',
toFixedTwoPlaces:'<func>'
},
outer:null
};
mysteriousCalculatorEnvironment={
EnvironmentRecord:{
a:10.01,
b:2.01,
mysteriousVariable:3
},
outer:GlobalEnvironment
};
addEnvironment={
EnvironmentRecord:{
result:15.02
},
outer:mysteriousCalculatorEnvironment
};
subtractEnvironment={
EnvironmentRecord:{
result:5.00
},
outer:mysteriousCalculatorEnvironment
}
由于函数add和subtract有一个指向外部函数环境mysteriousCalculator的引用,它们能访问到该环境中的变量mysteriousVariable,并且得出最后的计算结果。
Example 4
最后一个例子,我们用来展示闭包的一个重要作用:在外部作用域下维护一个私有变量。
function secretPassword(){
var password='ht1234';
return {
guessPassword:function(guess){
if(guess===password){
return true;
}else{
return false;
}
}
}
}
var passwordGame=secretPassword();
passwordGame.guessPassword('1234');//false
passwordGame.guessPassword('ht1234');//true
这个作用非常强大。它让变量password成了闭包函数guessPassword的私有变量,只能被函数guessPassword访问,而不能被所有的外部函数访问。
总结
- 执行上下文是ECMAScript标准定义的一个抽象概念,用来记录代码运行的环境。程序至始至终只能进入一个执行上下文。
- 每个执行上下文都有一个词法环境,它包含了标识符的赋值以及指向外部环境的引用。
- 每个环境能访问到的标识符集合,我们称之为“作用域”。我们将作用域一层一层嵌套,形成了“作用域链”。
- 每个函数都有一个包含词法环境的执行上下文,它的词法环境确定了函数内的变量赋值以及对外部环境的引用。看上去函数“记住”了外部环境,但其实上是这个函数有个指向外部环境的引用。这就是“闭包”的概念。
- 每当外部封闭函数执行的时候就产生了闭包,也就是说闭包的创建并不一定需要内部函数返回
- JavaScript中闭包作用域是词法作用域,即它在代码写好之后就被静态决定了它的作用域。
- 闭包有很多实际用处,其中一个重要的用处就是在外部作用域下维护一个私有变量。