接下来需要对之前我们学的作用域原理来一个清晰的认识。
此例中,外部函数指的是包括了内部函数的函数
1.1 闭包的是什么?
闭包是基于词法作用域书写代码时所产生的自然结果,闭包是一个晦涩难懂的概念。它准确点来说是,函数和创建该函数的词法作用域的组合,这个环境包括了这个闭包创建时所能访问的所有局部变量。
下面我们通过几个例子来理解闭包的概念。
function foo() {
var a=2;
function bar() {
console.log(a);
}
return bar();
}
var baz=foo();
baz; //2
在此函数中,我们定义了一个函数foo(),它有一个局部变量a,以及一个隐藏(局部)函数bar(),此函数的返回值为隐藏函数
我们在全局作用域中创建了一个对象,让它引用foo(),在return的作用下,它会间接的引用bar(),这样在外部我们也能够使用内部函数,打破了之前说的函数作用域调用规则-----------内部函数在自己定义的词法域之外执行
在foo()函数执行完毕后,按道理要被引擎当做垃圾回收,但是事实并没有,因为在调用foo()函数完毕后,还要调用内部函数bar(),bar()引用foo()中的函数a,所以在foo()函数调用结束后,bar()还需要foo(),所以bar()产生的闭包(包括了bar()函数以及所产生创建的局部变量等)阻止了引擎对foo()的回收-----------------bar()保持着对foo()的引用,这个引用就是闭包
这个函数在定义时的词法域以外的地方被调用,闭包使得它可以继续访问定义时的词法作用域
我们可以这样理解:
在大仓库中有若干个小仓库以及货物,公司派人来采购物品(函数调用)需要用到小仓库里面的货物,于是大仓库通知小仓库管理员(内部函数)与采购人员在外进行协商(函数在定义时的词法域以外被调用),这时小仓库管理员明确表示采购用到的商品一部分在大仓库中(内部函数对外部函数中变量的引用),于是大仓库管理员也被迫留在此进行协商(阻止内部函数引用的外部函数销毁),达成协议(闭包)。所以闭包更像是一种协议,内部函数与引用的外部函数以及引擎之间的协议。
从上我们可以看出形成闭包必要的特征
- 内部函数发生调用
- 内部函数引用外部函数的变量
- 内部函数在定义以外的词法作用域执行
无论使用何种方式对函数的值进行传递,当函数在别处被调用时总能观察到闭包
例如
function foo() {
var a=2;
function baz() {
console.log(a);
}
bar(baz);
}
function bar(fn) {
fn();
}
foo();
把内部函数baz传给bar,当调用这个内部函数(fn)时,它涵盖的foo(),内部作用域的闭包就可以观察到了,因为它会输出a。
间接的传递函数也是可以的
var fn;
function foo() {
var a=2;
function bar() {
console.log(a);
}
fn=bar(); //传递值给全局变量fn
}
function baz() {
fn();
}
foo();
baz();
在foo(),中我们将内部函数bar()赋值给了全局变量fn,在另一个函数中引用了fn,在这里面引用的是fn,实际上引用的却是bar(),因而会出现闭包-------------间接调用出现闭包。
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
在我们通常写代码时经常有接触到闭包。只是你以前并没有发现而已。比如我们经常使用的JavaScript内置函数工具。
function wait(message) {
setTimeout(function timer() {
console.log(message);
},1000);
};
wait("Hello Word");
setTimeout函数是JavaScript中的延时处理函数,它的声明位置并不在wait函数中。但是它在wait()中,调用了内部函数timer()。timer()拥有包括整个wait()的闭包。因此能够引用wait()中的message。
我们现在来讨论一下,引擎实现此代码的原理。内置的setTimeout(...)持有对一个参数的引用,这个参数没有固定的名称,在这里它的名称是 timer 。引擎会在wait(...)执行完毕后再开始处理setTimeout(....)函数,而词法作用域只在闭包的作用下保持完整---------这就是闭包。
本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用,。在定时器,监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步或者同步任务中,只要使用了回调函数,实际上就是在使用闭包
总结:闭包在函数调用时发生,无论函数是如何调用,只要它在自己的词法作用域外发生了调用,且它本身引用了外部函数的变量,那么在闭包的作用下,外部函数不会被销毁,它还能访问之前的词法作用域------如此便达到了,在全局作用域下,使用外部函数的变量和数据
1.2 循环中的闭包
在for循环中配合着JavaScript工具函数会生成闭包。这样恰好说明了JavaScript中的工具函数执行是在其声明处。
for(var i=1;i<=5;i++){
setTimeout(function foo() {
console.log(i);
},i*1000);
};
单单按照以前学的知识来看,这里会输出1-5的数字,每次出现的时间是当前数字乘以1000毫秒。
但是,我们现在学了闭包,采用闭包理解的话,你就不这么认为了。结果是,会输出5次6。
为什么会这样呢???
首先得解释6从哪里来,这个循环的终止条件是i不在<=5,条件首次成立时 i 的值是6。因此,输出显示的是循环结束时 i 的最终值。
仔细想想的话,确实是这么一回事,延迟函数的回调会在循环结束的时执行。事实上,当定时器运行时即使每个迭代中执行的是setTimeout(....,0),所有的回调函数依然是在循环结束后才会被执行,因此每次输出一个6出来。
我们还要理解setTimeout(...)函数,它是异步执行的,什么是异步执行的呢?就是当主程序执行完毕以后才会执行,JavaScript是单线程运行的,每一次的循环都得等到for(....)中的i循环完毕了以后,setTimeout(...)才会开始执行。
那么为什么会打印5次呢???
答案是在JavaScript中维护着一个setTimeout(...)队列,在第一次i=1时,便启动了setTimeout(....),但是它必须等待for(...)执行完毕才会执行,所以会加入setTimeout(...)队列中,当i=2时,又执行了新的setTimeout(...)队列,于是又加入了setTimeout(....)队列中,一直到 i=6 ,此时有五个setTimeout(....)等待执行。更因为这些setTimeout(...)共用的是一个 i 所以当它们准备输出结果时,i早已变成6。因此输出五个6
值得一提的是在setTimeout(...,0);中的数字可以改变优先级,数字越小优先级越高执行越早。
那么我们想要让之前的代码运行起来每一次都会setTimeout(....)都会输出当前的 i 值。
接下来进行思考,既然setTimeout(....)是得当for(...)循环结束了以后才执行的,那么我们能否用IIFE立即执行函数表达式让它立即执行呢?
根据之前学习的作用域,尽管五个函数是分别在五个迭代中定义的,但是,他们使用的却是共享的i,所以我们需要五个函数都有自己的闭包作用域这样才能记录下 i 的值。
使用IIFE刚好可以创建一个全新的词法作用域。
for(var i=1;i<=5;i++)
{
(function IIFE() {
setTimeout(function foo() {
console.log(i);
},i*1000);
})();
};
测试一下
依旧输出5次6,那么是什么原因呢?
IIFE创建了新的作用域,但是此作用域中没有任何实质内容,我们在里面创建一个变量记录下 i 的值。
for(var i=1;i<=5;i++)
{
(function IIFE() {
var j=i;
setTimeout(function foo() {
console.log(i);
},i*1000);
})();
};
输出结果:
接下来根据我们之前学习的IIFE的作用之一,括号传递参数,来对上面式子进行改进。
for(var i=1;i<=5;i++)
{
(function IIFE(j) {
setTimeout(function foo() {
console.log(j);
},j*1000);
})(i); //括号传递参数
};
在迭代内使用IIFE会为每一个迭代生成一个全新的作用域,使得延迟函数的回调,可以将新的作用域封闭在每个迭代中,每个迭代中将会有一个正确的变量供我们使用。
总结:在循环中,调用了JavaScript的工具函数,因为JavaScript是单线程语言,工具函数就会进入待执行栈,等到for函数执行完毕之后,再执行。在for中每一次循环迭代都会引用一次工具函数,所以也就输出多个相同的结果。并且它们在每一次迭代中被调用一次,但是它们所用的 i 却是共享的。
所以我们需要每次迭代时都有一个自己的闭包,能都记录下这个i值供我们输出
如何解决呢?将工具函数放入IIFE中,记录下每次变化的i值(j=i),工具函数对记录下的i值进行调用。
1.3 块作用域与闭包
仔细思考我们对前面的解决方案的分析。我们使用IIFE在每次迭代时都创建一个新的作用域,目的就是为了让setTimeout(...)队列不再使用同一个共享变量 i 。换句话说,每一次循环迭代我们都需要一个块作用域。那么我们能否用 "let" 关键字来为每一次迭代创建一个新的块作用域呢?
for(var i=1;i<=5;i++)
{
let j=i;
setTimeout(function foo() {
console.log(j);
},j*1000);
};
输出结果
事实证明这是可行的,但是在JavaScript中还有更酷的行为
for(let i=1;i<=5;i++)
{
setTimeout(function foo() {
console.log(i);
},i*1000);
};
使用 let 关键字在for(...)中创建变量有一个特点,每次迭代都将重新声明一次变量,且此变量的值将是上一次迭代循环的值。那么我们使用 let 便可实现
1.4 模块
模块与闭包息息相关,它是代码编写的一种模式。
接下来我们将学习模块
考虑以下代码:
function foo(){
var something="cool";
var another=[1,2,3];
function dosomething(){
console.log(something);
}
function another(){
console.log(another.join("!"));
};
}
正如这段代码所示的,这里面,只有两个内部变量dosomething、another。两个内部函数dosomething(...)、another(...)。它们的词法作用域(闭包)就是foo函数的内部作用域。
接下来考虑以下代码
function CoolModule() {
var something="cool";
var another=[1,2,3];
function dosomething() {
console.log(something);
};
function doanother() {
console.log(another.join("!"));
};
return{
dosomething: dosomething,
doanother: doanother
}
}
var foo=CoolMudule();
foo.dosomething(); //cool
foo.doanother(); //1!2!3
这个模式在JavaScript叫做模块,最常见的实现模块模式的方法叫做模块暴露。
接下来对这段代码进行分析,CoolModule是一个函数,必须要通过调用它来创建一个模块实例。如果不执行外部函数,那么内部作用域和闭包都无法实现。
细心点你或许会注意到在CoolModule函数中的返回值类型不同,这是字面量语法(key:value.......)来表示对象,当调用dosomething时调用dosomething,当调用doanother时调用doanother。
这个返回对象中含有对内部函数而不是内部变量数据的引用。我们保持这个内部数据变量是隐藏且私有的状态,可以将这个对象的返回值看成是模块共有的API。要用哪种功能,获取这个对象,然后调用函数就对了。
这个对象类型的返回值最终被赋给外部变量foo,然后就可以通过访问foo来访问API中的内容和方法了。比如:foo.dosomething();
注意在模块中返回一个对象{ return object}不是必须的,你也可以返回一个内部函数,我们经常使用的 jquery 就是这样。jquery和$符是 jquery 的API,但是它们都是 jquery 的内部函数,因为函数也是对象,所以它们拥有属性。
dosomething()以及doanother()都具有涵盖模块实例内部作用域的闭包,不过要注意,在此例中是一定要调用CoolModule(....)函数,当返回一个含有属性引用(调用外部函数的属性,此例中dosomething引用something属性,doanother引用another属性)的对象的方式来将函数传递到词法作用域外部时,我们已经创造了闭包。
模块创建的条件
-
必须有外部的封闭函数,且该外部函数至少调用一次(每调用一次都会创建一次模块实例)
-
外部封闭函数的返回值必须是一个内部函数或者内部函数的对象,且内部函数有对外部函数的私有变量的引用(如此才能形成涵盖外部函数的闭包)
一个带有函数属性的对象不一定是模块,它还得形成涵盖自己作用域的闭包才行。
1.4.1 单例模式
在上一个我们编写的CoolModule(...)中,我们每调用一次CoolModule(...)就会创建一个实例,如果我们只需要调用一次CoolModule(...)时,就可以用单例模块模式。
创建单例模块模式非常简单
- 外部封闭函数在IIFE中编写
- IIFE赋值创建模块实例对象
- 调用内部函数时,对象.内部函数
var foo= ( function CoolMudule() {
var something="cool";
var another=[1,2,3];
function dosomething() {
console.log(something);
};
function doanother() {
console.log(another.join("!"));
};
return{
dosomething: dosomething,
doanother: doanother
}
})();
foo.dosomething();
foo.doanother();
我们将外部函数转换成了IIFE,并且创建了外部函数的实例对象foo。当我们需要调用内部函数时,只需要对象.内部函数即可。
1.4.2 传递参数的模块
模块知识形式特殊的函数,所以也具有一般函数的特性,比如传递参数
function CoolModule(id) {
function identify() {
console.log(id);
}
return{
identify: identify()
};
}
var foo=CoolModule("boot1");
var foo1=CoolModule("boot2");
foo.identify();
foo1.identify();
像在此例中,我们需要创建两个实例,因此就不需要用到单例模块模式。
1.4.3 通过实例更改公共API的方法
模块模式一个强大的用法是,通过实例更改公共API内部的函数
var foo=(function CoolModule(id) {
var publicAPI={
change:change,
identify:identify1
}
function change() { //改变publicAPI中的属性 identify的值,使它等于identify2
publicAPI.identify=identify2;
}
function identify1() { //输出ID
console.log(id);
}
function identify2() { //改变ID为大写
console.log(id.toUpperCase());
}
return publicAPI;
})("foo module")
foo.identify(); //foo module
foo.change();
foo.identify(); //FOO MODULE
我们首先 var 了一个变量publicAPI,这个变量中有两个类型属性,一个是change,另一个是identify。CoolModule(...)中有三个内部函数,一个是change,它的作用是改变publicAPI中 identify 的值使它的值变为 identify2;一个是 identify1 输出id的值,还有一个是 identify2 输出id的大写值。
这里面我们用到了传递参数、单例模式。
我们通过实例调用内部函数,更改了公共API的方法使之两个相同的函数,输出不一样的结果。
通过在模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例方法或者属性进行修改,包括添加、删除、命名以及重新赋值等等。
总结: 闭包并不是一个晦涩难懂的概念,只要是内部函数被包含自己的外部封闭函数以外的对象调用并且它还保持着对原来词法域的引用,那么就产生了闭包。
在for循环中创建函数,那么很容易因为闭包而出现问题,原因是:在for中创建函数,这里的函数并不会执行、而是声明,等到调用时或者说是主程序完成时才开始执行,而这时 i 值已经为结束循环的值了,每一次循环,每一次迭代时都会创建一个函数,这些函数没有闭包自己的作用域,共用一个i值,所以输出为相同的 i。
解决的方法主要是为每一个函数创建一个闭包。方法有
-
将创建的函数放入IIFE中(让它立刻执行),并记录下每一次循环的i值
-
利用 let 创建循环变量,let 在循环中每一次迭代都会创建新的变量,这个变量的值是上一个变量的值,所以如此一来,这些函数就不会共用一个循环变量 i 了
模块:不暴露私有的数据和函数下,外部函数的返回值为内部函数或者对象。当想要调用该外部封闭函数的方法时,创建变量并且赋值,再调用方法。
模块特征:
-
为创建内部作用域而调用了一个包装函数(外部封闭函数)
-
包装函数的返回值至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。