我们知道,作用域链查找标识符的顺序是从当前作用域开始一级一级往上查找。因此,通过作用域链,JavaScript 函数内部可以读取函数外部的变量,但反过来,函数的外部通常则无法读取函数内部的变量。在实际应用中,有时需要在函数外部访问函数的局部变量,此时最常用的方法就是使用闭包。
那么什么是闭包呢?所谓闭包,就是同时含有对函数对象以及作用域对象引用的对象。闭包主要是用来获取作用域链或原型链上的变量或值。创建闭包最常用的方式是在一个函数中声明内部函数(也称嵌套函数),并返回内部函数。
此时在函数外部就可以通过调用函数得到内部函数,进而调用内部函数来实现对函数局部变量的访问。此时的内部函数就是一个闭包。虽然按照闭包的概念,所有访问了外部变量的 JavaScript 函数都是闭包,但我们平常绝大部分时候所谓的闭包其实指的就是内部函数闭包。
闭包可以将一些数据封装为私有属性以确保这些变量的安全访问,这个功能给应用带来了极大的好处。需要注意的是,闭包如果使用不当,也会带来一些意想不到的问题。下面就通过几个示例来演示一下闭包的创建、使用和可能存在的问题及其解决方法。
【例 1】闭包的创建。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>创建闭包</title>
</head>
<body>
<script>
function outerFunc(){
var b = 0; //局部变量
function innerFunc(){ //声明内部函数
b++; //访问外部函数的局部变量
console.log("内部函数中b="+b);
}
return innerFunc;//返回内部函数
}
var func = outerFunc();//① 通过外部变量引用函数返回的内部函数
console.log(func);//② 输出内部函数定义代码
func();//③ 通过闭包访问局部变量b,此时b=1
console.log("外部函数中b=" + b);//④ 出错,报引用错误
</script>
</body>
</html>
上述代码在外部函数 outerFunc 中声明内部函数 innerFunc,并返回内部函数,同时在 outerFunc 函数外面,变量 func 引用了 outerFunc 函数返回的内部函数,所以内部函数 innerFunc 是一个闭包,该闭包访问了外部函数的局部变量 b。
① 处代码通过调用外部函数返回内部函数并赋给外部变量 func,使 func 变量引用内部函数,所以 ② 处代码将输出 innerFunc 函数的整个定义代码。③ 处代码通过对外部变量 func 添加一对小括号后调用内部函数 innerFunc,从而达到在函数外部访问局部变量 b 的目的。执行 ④ 处代码时将报 ReferenceError 错误,因为 b 是局部变量,不能在函数外部直接访问局部变量。
上述代码在 Chrome 浏览器的运行结果如图 1 所示。
在介绍运行期上下文对象与活动对象中,说到函数执行完毕时,运行期上下文会被销毁,与之关联的活动对象也会随之销毁,因此离开函数后,属于活动对象的局部变量将不能被访问。但为什么示例 1 中的 outerFunc 函数执行完后,它的局部变量还能被内部函数访问呢?这个问题可以使用作用域链来解释。
当执行 ① 处代码调用 outerFunc 函数时,JavaScript 引擎会创建 outerFunc 函数执行上下文的作用域链,这个作用域链包含了 outerFunc 函数执行时的活动对象,同时 JavaScript 引擎也会创建一个闭包,而闭包因为需要访问 outerFunc 函数的局部变量,因而其作用域链也会引用 outerFunc 的活动对象。这样,当 outerFunc 函数执行完后,它的作用域对象因为有闭包的引用而依然存在,故而可以提供给闭包访问。
示例 1 中的内部函数虽然有名称,但在调用时并没有用到这个名称,所以内部函数的名称可以缺省,即可以将内部函数修改为匿名函数,从而简化代码。示例 1 修改后的代码如下所示。
【例 2】创建匿名函数闭包。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>创建匿名函数闭包</title>
</head>
<body>
<script>
function outerFunc(){
var b = 0;
return function(){ //声明并返回匿名函数
b++;
console.log("内部函数中b="+b);
}
}
var func = outerFunc();
console.log(func);
func();
console.log("外部函数中b=" + b);//报引用错误
</script>
</body>
</html>
下面的示例是一个经典的闭包问题示例。
【例 3】经典闭包问题示例。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>经典闭包问题示例</title>
<script>
window.onload = function(){
var aBtn = document.getElementsByTagName('button');
for(var i = 0; i < aBtn.length; i++){
aBtn[i].onclick = function(){
alert("按钮" + (i + 1));
}
}
}
</script>
</head>
<body>
<button>按钮1</button>
<button>按钮2</button>
<button>按钮3</button>
</body>
</html>
该示例期望实现的功能是,单击每个按钮时,在弹出的警告对话框中显示相应的标签内容,即单击 3 个按钮时将分别显示“按钮1”“按钮2”“按钮3”。
上述示例页面加载完后触发窗口加载事件,从而执行外层匿名函数,外层匿名函数执行完循环语句后使活动对象中的局部变量 i 的值修改为 3。外层匿名函数执行完后将撤销,但由于其活动对象中 aBtn 和 i 变量被内层匿名函数引用,因而外层匿名函数的活动对象仍然存在堆中供内层匿名函数访问。
每执行一次循环都将创建一个闭包,这些闭包都引用了外层匿名函数的活动对象,因而访问变量i时都将得到 3,这样最后的结果是单击每个按钮,在警告对话框中显示的文字都是“按钮4”(i+1=3+1),与期望的功能不一致。
造成这个问题的原因是,每个闭包都引用同一个变量,如果我们使不同的闭包引用不同的变量,就可以实现输出的结果不一样。这个需求可使用多种方法实现,在此将介绍使用立即调用函数表达式(IIFE)和 ES6 中的 let 创建块级变量两种方法。
IIFE 指的是:在定义函数的时候直接执行,即此时函数定义变成了一个函数调用语句。要让一个函数定义语句变为函数调用语句,就需要将函数定义语句变为一个函数表达式,然后在该表达式后面再加一对圆括号()即可。将函数定义语句变为一个函数表达式的最常用的方法就是将整个定义语句放到一对圆括号中,示例代码如下所示。
1) IIFE 中的函数为一个匿名函数:
(function (name){
console.log("Hello," + name);
})("张三");
JS 引擎执行上述代码时,会调用匿名,同时将后面圆括号中的参数“张三”传给 name 虚参,结果得到:Hello,张三。
2) IIFE 中的函数为一个有名函数:
(function sayHello(name){
console.log("Hello," + name);
})("李四");
上述代码运行结果和前面的匿名函数的完全一样。
【例 4】使用立即调用函数表达式解决经典闭包问题。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>使用立即调用函数表达式解决经典闭包问题</title>
<script>
window.onload = function(){
var aBtn = document.getElementsByTagName('button');
for(var i = 0;i < aBtn.length; i++){
(function(num){
aBtn[num].onclick = function(){
alert("按钮" + (num + 1));
}
})(i);
}
}
</script>
</head>
<body>
<button>按钮1</button>
<button>按钮2</button>
<button>按钮3</button>
</body>
</html>
上述代码中第二个匿名函数为 IIFE,每次调用该匿名函数时将生成一个对应该函数的活动对象,该对象中包含了一个函数参数,值为当次循环的循环变量值。上述示例中,IIFE 共执行了 3 次,因而共生成了 3 个活动对象,活动对象中包含的参数值分别为 0、1 和 2,依次对应 IIFE 的 3 次执行。
每次执行 IIFE 时,将会产生一个闭包,该闭包会引用对应按钮索引顺序执行 IIFE 的活动对象,而闭包引用的活动对象中的参数值刚好等于按钮的索引值,因而单击 3 个按钮时将在弹出的警告对话框中分别显示“按钮1”“按钮2”“按钮3”。
【例 5】使用 ES6 中的 let 关键字创建块级变量解决经典闭包问题。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>使用ES6中的let关键字创建块级变量解决经典闭包问题</title>
<script>
window.onload = function(){
var aBtn = document.getElementsByTagName('button');
for(let i = 0; i < aBtn.length; i++){
aBtn[i].onclick = function(){
alert("按钮" + (i + 1));
}
}
}
</script>
</head>
<body>
<button>按钮1</button>
<button>按钮2</button>
<button>按钮3</button>
</body>
</html>
上述代码中循环变量使用 let 声明,因而每次循环时,都会产生一个新的块级变量,所以在页面加载完,执行外层匿名函数时产生的活动对象中包含了 3 个对应循环变量的块级变量,变量值分别为 0、1 和 2。每执行一次循环,将会产生一个闭包,该闭包中的变量 i 会引用外层匿名函数的活动对象对应按钮索引的块级变量,因而单击 3 个按钮时将在弹出的警告对话框中分别显示“按钮1”“按钮2”“按钮3”。