闭包是什么
函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中称为“闭包”。——《JavaScript权威指南》
匿名函数经常被人误认为是闭包(closure)。闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。 ——《JavaScript高级程序设计第4版》
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。 ——《MDN Web Docs》
总结:
闭包是指有权访问另一个函数作用域中变量的函数。
闭包与作用域
JS 堆栈内存释放
- 堆内存:存储引用类型值,对象类型就是键值对,函数就是代码字符串。
- 堆内存释放:将引用类型的空间地址变量赋值成 null,或没有变量占用堆内存了浏览器就会释放掉这个地址
- 栈内存:提供代码执行的环境和存储基本类型值。
- 栈内存释放:一般当函数执行完后函数的私有作用域就会被释放掉。
但栈内存的释放也有特殊情况:
① 函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。
② 全局下的栈内存只有页面被关闭的时候才会被释放。
作用域链、变量(活动)对象、上下文
作用域链就是一个包含变量对象的指针的列表。而变量对象与上下文相关联。
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。
上下文是一个抽象的概念,可以理解为一个代码集(包含定义、声明的变量和函数),具体是以变量对象的形式所利用,每一个上下文都有一个关联的变量对象,而这个上下文中定义的所有变量和函数(其实只是指针)都存在于这个对象上。这个对象无法被用户访问。
全局上下文是最外层的上下文,在浏览器中是window对象。
上下文在其所有代码都执行完毕之后会被销毁(内存释放),包括定义在它上面的所有变量和函数(全局上下文在应用程序退出之前才会被销毁,如关闭网页或退出浏览器)
函数与作用域链
函数在定义时,会保存一个作用域链,链上最后一个必然是全局变量对象。
函数调用时,则会创建一个活动对象,该对象以arguments和命名参数作初始化。当执行流进入时,再将函数中其他所有的变量和函数加入该活动对象。然后将这个活动对象加入作用域链的最前端,当函数执行完之后,该活动对象随上下文销毁。
变量对象和活动对象其实是一样的,只不过变量对象用来描述在全局作用域下的变量组成的对象,即全局对象,浏览器中是window,node中是global
由于函数会保存他所定义时的一条作用域链,也就是说内部函数,即闭包,会保存一条作用域链,这条作用域链在定义时生成,链中从前往后依次是外部函数的活动对象,外外部函数的活动对象,直到全局变量对象,而内部函数因为尚未执行所以没有产生活动对象,更不会出现在作用域最前端。
在《JavaScript高级程序设计(第4版)》10.14小节中提到,这条作用域链会保存在定义的函数的内部[[Scope]]
属性中。
作用域链的生成过程:
- 全局环境下的函数定义时就保存一条作用域链,当函数执行时,这个函数的上下文生效,从而产生该函数的活动对象,并把该活动对象加到作用域链前端,对于函数作用域内的一切变量和函数,都会与这条新的作用域链产生联系,最常见的就是按照作用域链去追溯去访问变量和函数
- 如果该函数还有一个内部函数(闭包),那么
[外部函数,window]
这条作用域链会在内部函数定义时添加到内部函数的[[Scope]]
属性中 - 闭包执行,闭包自己的活动对象被添加到作用域链前端,在闭包内进行访问可根据这条作用域链进行。闭包活动对象的构建分两个阶段:
- 调用时,对象仅以arguments和命名参数作初始化
- 执行流进入时,将函数中其他所有的变量和函数(包括对其他函数作用域的引用)加入该活动对象,这个阶段就有对变量和函数溯源过程,可能会顺着作用域链进行访问。
通过下面的例子来展示作用域链:
function createComparisonFunction(propertyName) {
return function(object1, object2) {
let value1 = object1[propertyName];
let value2 = object2[propertyName];
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
};
}
let compare = createComparisonFunction('name');
let result = compare({ name: 'Nicholas' }, { name: 'Matt' });
createComparisonFunction()
保存一条作用域链,由于它定义在全局作用域下,于是它的作用域链是这样的——createComparisonFunction作用域--全局作用域
,作用域链上的都是指针,指向各自的变量(活动)对象。- 而匿名函数,由于是在
createComparisonFunction()
中定义的,所以会继承createComparisonFunction()
的作用域链,并在执行时在作用域链前面添加自己的活动对象,因为该匿名函数可以访问其他函数作用域的变量(propertyName是属于createComparisonFunction活动对象的变量),所以该匿名函数是一个闭包。
闭包的作用、用途
模拟私有变量和私有方法
在上面我们已经归纳出闭包的一个重要特点——可以访问其他函数作用域的变量,那么我们可以保存一些私有变量,只通过闭包函数进行访问。
function data(){
let _students=[
{
name:"Jack",
age:23,
id:2021111523,
major:'Information Security'
},
{
name:"Mark",
age:22,
id:2021111322,
major:'Information Security'
}
]
let getInfoAll = function(){
console.log(JSON.stringify(_students,null,'--'));
}
let searchStuById = function(id){
for(let info of _students){
if(info.id == id){
console.log('get '+id+JSON.stringify(info,null,'--'));
return;
}
}
console.log('not found');
}
return {getInfoAll,searchStuById};
}
let stuDataFunc = data();
stuDataFunc.getInfoAll();
stuDataFunc.searchStuById(2021111523);
stuDataFunc.searchStuById(2021111522);
/*
[
--{
----"name": "Jack",
----"age": 23,
----"id": 2021111523,
----"major": "Information Security"
--},
--{
----"name": "Mark",
----"age": 22,
----"id": 2021111322,
----"major": "Information Security"
--}
]
get 2021111523{
--"name": "Jack",
--"age": 23,
--"id": 2021111523,
--"major": "Information Security"
}
not found
*/
在上面的例子中,_students
就成了一个私有的变量了。这是不是跟面向对象编程很像了。
同样的,我们可以设置一个私有方法,只让返回的函数去调用。
函数工厂
用一个抽象的函数来描述某一类行为,然后通过传参了具象化。比如增长行为属于一类,更具体的增长行为表现在增长速度上,是每次加5还是加10,通过传参就可以实现更具体的行为:
function makeAdder(x) {
return function(y) {
return x + y;
};
}
var add5 = makeAdder(5);
var add10 = makeAdder(10);
console.log(add5(2)); // 7
console.log(add10(2)); // 12
上面出现的createComparisonFunction()
也是这样的,将比较这一行为抽象出来,然后生成由传参决定具体对那种属性进行比较这一更具体的行为。
又比如通过传参设置字体大小:
function makeSizer(size) {
return function() {
document.body.style.fontSize = size + 'px';
};
}
var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);
document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;
将原材料处理生成可用的产品,就像工厂生产一样。
闭包的不足
不要在循环时创建闭包
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}
function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];
for (var i = 0; i < helpText.length; i++) {
var item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}
setupHelp();
上面的代码中,在showHelp
函数接收到的始终是'Your age (you must be over 16)'
。原因是赋值给 onfocus
的是闭包(有权访问另一个函数作用域中变量的函数)。这些闭包是由他们的函数定义和在 setupHelp
作用域中捕获的环境所组成的。这三个闭包在循环中被创建,但他们共享了同一个作用域——setupHelp
的函数作用域,在这个作用域中存在一个变量item
。这是因为变量item
使用var
进行声明,由于变量提升,所以可以在setupHelp
函数作用域内可用。当onfocus
的回调执行时,item.help
的值被决定。由于循环在事件触发之前早已执行完毕,变量对象item
(被三个闭包所共享)已经指向了helpText
的最后一项。
如果想继续用闭包的方式解决这个问题,则应该利用函数工厂创建一个新的函数作用域作为隔离,写法如下:
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}
function makeHelpCallback(help) {
return function() {
showHelp(help);
};
}
function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];
for (var i = 0; i < helpText.length; i++) {
var item = helpText[i];
document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
}
}
}
setupHelp();
在这里makeHelpCallback
会分别把每一个传入的参数item.help
存入三个不同的活动对象之中,于是每一个回调都对应一个新的上下文和活动对象,在这些环境中,help
指向 helpText
数组中对应的字符串。
另一种方法就是使用匿名函数:
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}
function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];
for (var i = 0; i < helpText.length; i++) {
(function() {
var item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
})(); // 马上把当前循环项的item与事件回调相关联起来
}
}
setupHelp();
这里同样是通过创建新的作用域来隔离,每一个onfocus
的回调函数都会对应了一个独立的活动对象,且该活动对象都分别加入了不同的item
(因为这个匿名函数立即执行了)。
更好的解决方法就是使用新语法里的let
了:
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}
function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];
for (var i = 0; i < helpText.length; i++) {
let item = helpText[i]; //用let关键字创建块级作用域
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}
setupHelp();
性能问题
如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。
函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。因此被闭包引用的函数作用域对应的栈内存无法被释放。
例如,在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造函数中。原因是这将导致每次构造器被调用时,方法都会被重新赋值一次(也就是说,对于每个对象的创建,方法都会被重新赋值)。
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
this.getName = function() {
return this.name;
};
this.getMessage = function() {
return this.message;
};
}
在上面的代码中,我们并没有利用到闭包的好处,因此可以避免使用闭包。修改成如下:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
}
MyObject.prototype.getName = function() {
return this.name;
};
MyObject.prototype.getMessage = function() {
return this.message;
};
这样的修改后,继承的原型可以为所有对象共享,不必在每一次创建对象时定义方法。
参考文献
闭包——《MDN Web Docs》
面试 | JS 闭包经典使用场景和含闭包必刷题
《JavaScript高级程序设计》第4版