使用闭包,可以通过减少代码的数量和复杂度来添加高级特性,例如实现私有变量。从传统意义上说,闭包是纯函数式编程语言的特性之一。闭包也是主流的开发语言。闭包能够大大简化复杂操作,在javascript库中经常看到闭包的使用。
理解闭包
闭包允许函数访问并操作函数外部的变量。只要变量或函数存在于声明函数时的作用域内,闭包即可使函数能够访问这些变量或函数。
⚠️所声明的函数可以在声明之后的任何时间被调用,甚至当该函数声明的作用域消失之后仍然可以调用。
举个🌰
var outerValue = "ninja";
function outerFunction() {
assert(outerValue === "ninja","I can see the ninja.");
}
outerFunction();
function assert(value, text){
value&&console.log(text)
}
// I can see the ninja.
以上代码中outerFunction函数可以访问outerValue变量。可能我们写过很多这样的代码,却没有意识到其实我们正在创建一个闭包。
外部变量outerValue和函数outerFunction都是在全局作用域中声明的,该作用域(闭包)从未消失(只要程序处于运行状态)。该函数可以访问到外部变量,因为它任然在作用域内并且是可见的。
虽然闭包存在,但是没有体现出闭包的优势,修改一下代码
🌰
var outerValue = "samurai";
var later;
function outerFunction() {
var innerValue = "ninja";
function innerFunction() {
assert(outerValue === "samurai", "I can see the samurai.");
assert(innerValue === "ninja", "I can see the ninja.");
}
later = innerFunction;
}
outerFunction();
later();
function assert(value, text){
value&&console.log(text)
}
// I can see the samurai.
// I can see the ninja.
第一个断言很好理解,因为outerValue在全局作用域内。但是第二个断言,outerFunction函数执行后,通过innerFunction函数的引用赋值给全局变量later,再通过later调用内部函数。
当内部函数innerFunction执行时,外部函数的作用域已经不存在了,就有一种错觉认为内部变量innerValue是undefined。但是当函数执行时,发现第二个断言却执行通过了
当在外部函数中声明内部函数时,不仅定义了函数的声明,而且还创建了闭包。该闭包不仅包含了函数的声明,还包含了在函数声明时该作用域中的所有变量。当最终执行内部函数时,尽管声明时的作用域已经消失了,但是通过闭包,任然能够访问到原始作用域。
这就是闭包。闭包创建了被定义时的作用域内的变量和函数的气泡,因此函数获得了执行时所需的内容。
虽然这些结构不容易看见(没有包含这么多信息的闭包对象可以进行观察),存储和引用这些信息会直接影响性能。谨记每一个通过闭包访问变量的函数都具有一个作用域链,作用域链包含闭包的全部信息,这一点非常重要。因此,虽然闭包是非常有用的,但不能过度使用。使用闭包时,所有的信息都会存储在内存中,直到JavaScript引擎确保这些信息不再使用(可以安全地进行垃圾回收)或页面写在时,才会清理这些信息
使用闭包
封装私有变量
JavaScript不支持私有变量,但是,通过使用闭包,我们可以实现很接近的、可接受的私有变量
举个🌰
function Ninja() {
var feints = 0;
this.getFeints = function(){
return feints;
};
this.feint = function(){
feints++;
};
}
var ninja1 = new Ninja();
ninja1.feint();
assert(ninja1.feints === undefined, "And the private data is inaccessible to us.");
assert(ninja1.getFeints() === 1, "We're able to access the internal feint count.");
var ninja2 = new Ninja();
assert(ninja2.getFeints() === 0, "The second ninja object gets it’s own feints variable.");
function assert(value, text){
value&&console.log(text)
}
// And the private data is inaccessible to us.
// We're able to access the internal feint count.
// The second ninja object gets it’s own feints variable.
通过在函数上使用关键字new 时,就会创建一个新的对象实例,此时调用构造函数,将新的对象作为它的上下文。所有,函数内的this将指向新的实例化对象。
在构造器内部,变量feints用于保存状态,由于JavaScript的作用域规则的限制,因此只能在构造器内部访问该变量。为了让作用域外的代码能够访问该变量,定义了访问该变量的方法getFeints。还有增量方法feint。
当代码执行时通过变量ninja,对象实例是可见的。因此feint方法在闭包内部,因此是可以访问变量feints。在闭包外部,无法访问
通过闭包,通过实例对象可以对内部变量feints进行维护,而不允许直接访问。这是因为作用域规则,闭包内部的变量可以通过闭包内的方法访问,构造器外部的代码则不能访问闭包内部的变量
回调函数
处理回调函数是另一种常见的闭包使用场景,在回调函数中,我们经常需要频繁地访问外部数据
举个🌰
// <div id="box1">First Box</div>
// <div id="box2">First Box</div>
// var elem,tick, timer
function animateIt(elementId) {
var elem = document.getElementById(elementId);
var tick = 0;
var timer = setInterval(function(){
if (tick < 100) {
elem.style.left = elem.style.top = tick + "px";
tick++;
}
else {
clearInterval(timer);
assert(tick === 100, "Tick accessed via a closure.");
assert(elem, "Element also accessed via a closure.");
assert(timer, "Timer reference also obtained via a closure." );
}
}, 10);
}
animateIt("box1");
animateIt("box2");
// Tick accessed via a closure.
// Element also accessed via a closure.
// Timer reference also obtained via a closure.
// Tick accessed via a closure.
// Element also accessed via a closure.
// Timer reference also obtained via a closure.
以上代码中使用了一个独立的匿名函数来完成目标元素的动画效果。
通过闭包,该匿名函数通过3个变量控制动画效果: elem, tick, timer。这三个变量用于控制整个动画的过程,且必须在全局作用域内访问到。
如果我们将这些变量从animateIt函数中移出到全局作用域,就会出现一些问题,多个不同动画的状态同时用三个变量就会冲突。要让动画仍然能够正常工作,就需要为每个动画分别设置三个变量。
通过在函数内部定义变量,并基于闭包,每个动画都能够获得属于自己的私有变量。
上述实例通过闭包写出了简洁直观的代码,通过将变量放置在animateIt函数内部,不需要非常复杂的语法,我们就可以创建一个默认的闭包。
闭包内的函数不仅可以在创建的时刻访问这些变量,而且当闭包内部的函数执行时,还可以更新这些变量的值。闭包不是在创建的那一时刻的状态的快照,而是一个真实的状态封装,只要闭包存在,就可以对变量进行修改。
每个动画都在处理程序的闭包内获得私有变量,且不能访问其他闭包内的变量。
闭包与作用域是强相关的
通过执行上下文来跟踪代码
在JavaScript中,代码执行的基础单元是函数,我们时刻使用函数。
之前提到,JavaScript代码有两种类型:一种是全局代码,在所有函数外部定义;一种是函数代码,位于函数内部。JavaScript引擎执行代码时,每一条语句都处于特定的执行上下文中。
既然具有两种类型的代码,就有两种执行上下文:全局执行上下文和函数执行上下文。二者最重要的差别是:全局执行上下文只有一个,函数执行上下文是在每次调用代码时,就会创建一个新的。
举个🌰
function skulk(ninja) {
report(ninja + " skulking");
}
function report(message) {
console.log(message);
}
skulk("Kuma");
skulk("Yoshi");
// Kuma skulking
// Yoshi skulking
通过这段简单的代码探索执行上下文是如何创建的
- 每个JavaScript程序至创建一个全局上下文,并从全局执行上下文开始执行
- 在全局代码中定义两个函数skulk,report,然后调用skulk("Kuma");。由于在同一个特定时刻只能执行特定代码,所以JavaScript引擎停止执行全局代码,开始执行带有"Kuma"参数的skulk函数。创建新的函数执行上下文
- skulk函数进而调用了report函数。又一次因为在同一特定时刻只能执行特定代码,所以暂停skulk函数执行上下文,创建新的report函数执行上下文
- report函数执行,执行完成后,代码又回到skulk函数,report函数上下文从执行栈退出,skulk函数执行上下文重新激活,skulk函数继续执行
- skulk函数执行完后也会从执行栈退出,重新激活在等待中的全局执行上下文。
- 当skulk("Yoshi")执行时,和上述过程类似,只是参数有了变化
使用词法环境跟踪变量的作用域
词法环境是JavaScript引擎内部用来跟踪表示符与特定变量之间的映射关系。
通常来说,词法环境与特定的JavaScript代码结构关联,既可以是一个函数、一段代码片段,也可以是try-catch语句
词法环境主要基于代码嵌套,每次执行代码时,代码结构都获得与之关联的词法环境。例如,每次调用函数,都将创建新的函数词法环境
除了跟踪局部变量、函数声明、函数的参数和词法环境外,还有必要跟踪外部(父级)词法环境。因为我们需要访问外部代码结构中的变量,如果在当前环境中无法找到某一标识符,就会对外部环境进行查找。一旦查找到匹配的变量,或是在全局环境中仍然无法查找到对应的标识符而返回错误,就会停止查找。
无论何时创建函数,都会创建一个与之相关联的词法环境。无论何时调用函数,都会创建一个新的执行环境。
理解JavaScript的变量类型
变量的可变性
在JavaScript中,可以通过三个关键字定义变量:var、let、const。这三个关键字有两点不同:可变性,与词法环境的关系
const定义的变量不可变,只能声明变量的时候设置一次
let、var声明的值是可以变更任意次数
定义变量的关键字与词法环境
通过与词法环境的关系将其进行分类(按照作用域分类)var是一类,let与const为一类
关键字var
当使用关键字var时,该变量是在距离最近的函数内部或是在全局词法环境中定义的
举个🌰
var globalNinja = "Yoshi";
function reportActivity(){
var functionActivity = "jumping";
for(var i = 1; i < 3; i++) {
var forMessage = globalNinja + " " + functionActivity;
assert(forMessage === "Yoshi jumping","Yoshi is jumping within the for block");
assert(i, "Current loop counter:" + i);
}
assert(i === 3 && forMessage === "Yoshi jumping", "Loop variables accessible outside of the loop");
}
reportActivity();
assert(typeof functionActivity === "undefined" && typeof i === "undefined" && typeof forMessage === "undefined",
"We cannot see function variables outside of a function");
function assert(value, text){
value&&console.log(text)
}
// Yoshi is jumping within the for block
// Current loop counter:1
// Yoshi is jumping within the for block
// Current loop counter:2
// Loop variables accessible outside of the loop
// We cannot see function variables outside of a function
上述代码中,首先定义全局变量globalNinja,接着定义函数reportActivity,在函数中循环使用globalNinja。可以看出,再循环体内可以正常访问到块级作用域中的变量forMessage和i、函数体内的变量functionActivity、全局变量globalNinja
注意⚠️:在块级作用域内定义的变量,在块级作用域外任然能够被访问到。
这源于var声明的变量实际上总是在距离最近的函数内或全局词法环境中注册的,并不会关注块级作用域,
所以在上述实例中forMessage变量虽然包含在for循环内部的块级作用域中,但实际上是在reportActivity函数中注册的(距离最近的函数环境)
在上面的代码中有三种词法环境:(距离最近的函数内部或是在全局词法环境)
- 变量globalNinja是在全局环境中定义的
- reportActivity函数创建的函数环境,包含变量functionActivity、i、forMessage
- for循环的块级作用域,在通过var关键字定义变量时会忽略块级作用域
使用let与const定义具有块级作用域的变量
var是在距离最近的函数或全局词法环境中定义变量,与var不同的是,let和const更加直接。let和const直接在最近的词法环境中定义变量(可以是在块级作用域、循环内、函数内、全局环境内)
修改一下上一个🌰
const globalNinja = "Yoshi";
function reportActivity(){
const functionActivity = "jumping";
for(let i = 1; i < 3; i++) {
let forMessage = globalNinja + " " + functionActivity;
assert(forMessage === "Yoshi jumping",
"Yoshi is jumping within the for block");
assert(i, "Current loop counter:" + i);
}
console.log(typeof i,typeof forMessage);
assert(typeof i === "undefined" && typeof forMessage === "undefined",
"Loop variables not accessible outside the loop");
}
reportActivity();
assert(typeof functionActivity === "undefined" && typeof i === "undefined" && typeof forMessage === "undefined",
"We cannot see function variables outside of a function");
function assert(value, text){
value&&console.log(text)
}
// Yoshi is jumping within the for block
// Current loop counter:1
// Yoshi is jumping within the for block
// Current loop counter:2
// undefined undefined
// Loop variables not accessible outside the loop
// We cannot see function variables outside of a function
当使用let与const声明变量时,变量是在距离最近的环境中定义的。上述代码中,变量forMessage与i是在块级作用域中定义的,变量functionActivity是在函数reportActivity中定义的,变量globalNinja是在全局环境中定义的。
闭包的工作原理
闭包可以访问创建函数时所在作用域内的全部变量,闭包与作用域密切相关。
回顾闭包模拟私有变量的🌰
function Ninja() {
var feints = 0;
this.getFeints = function(){
return feints;
};
this.feint = function(){
feints++;
};
}
var ninja1 = new Ninja();
assert(ninja1.feints === undefined, "And the private data is inaccessible to us.");
ninja1.feint();
assert(ninja1.getFeints() === 1, "We're able to access the internal feint count.");
var ninja2 = new Ninja();
assert(ninja2.getFeints() === 0, "The second ninja object gets it’s own feints variable.");
function assert(value, text){
value&&console.log(text)
}
// And the private data is inaccessible to us.
// We're able to access the internal feint count.
// The second ninja object gets it’s own feints variable.
分析上述代码,利用标识符原理理解这种情况之下闭包的工作原理。通过关键字new调用构造函数时每次都会创建一个新的词法环境,该词法环境保持构造函数内部的局部变量。
- 使用new关键字后,一个新的对象被实例化
- 进入构造函数后,一个新的词法环境被创建,它会跟踪在该作用域内创建的所有局部变量,上面代码中,它始终保持对feints变量的引用
- 在构造函数执行阶段,其创建了两个函数,并将这两个函数值给了新创建的对象(getFeints、feint)与任意其他环境类似,这两个函数都保持着对创建了他们环境的引用
无论何时创建对象,都会保持词法环境的引用。每一个通过构造函数创建的对象实例均获得了各自的方法,当调用构造函数时,各自的实例方法包含各自的变量
私有变量的警告
JavaScript从未阻止我们将一个对象中创建的属性复制给另一个对象
举个🌰
function Ninja() {
var feints = 0;
this.getFeints = function(){
return feints;
};
this.feint = function(){
feints++;
};
}
var ninja1 = new Ninja();
ninja1.feint();
var imposter = {};
imposter.getFeints = ninja1.getFeints;
assert(imposter.getFeints () === 1, "The imposter has access to the feints variable!");
function assert(value, text){
value&&console.log(text)
}
// The imposter has access to the feints variable!
上述代码中,将ninja1对象的方法getFeints赋值给了一个新的对象imposter,然后通过imposter对象的getFeints方法去访问Ninja构造函数中的变量feints,发现可以正常访问。
所以在JavaScript中没有真正的私有对象属性,但是可以通过闭包实现一种可接受的私有变量方案
总结
通过闭包可以访问创建闭包时所处环境中的全部变量。闭包为创建函数时所处的作用域中的函数和变量创建了一个 “安全气泡” 通过这种方式,即使创建函数时所处的作用域已经消失,但是函数仍然能够获得执行时所需的全部内容
我们可以使用闭包的一些高级功能
- 通过构造函数内的变量以及构造方法来模拟对象的私有属性
- 处理回调函数,简化代码
JavaScript引擎通过执行上下文栈跟踪函数的执行。每次调用函数时,都会创建新的函数执行上下文,并推入调用栈顶端,当函数执行完成后,对于的执行上下文将从调用栈推出
JavaScript引擎通过词法环境跟踪标识符(作用域)
关键字var定义距离最近的函数级变量或全局变量
关键字let与const定义距离最近级别的变量,包括块级变量
闭包时JavaScript租用域规则的副作用。当函数创建时所在的租用域消失后,仍然能够调用函数