1. 概念
先来看一看官方的定义是如何说的(~ ̄▽ ̄)~
闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
——《JS高级程序设计》
函数对象通过作用域链互相关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中称为“闭包”。(注:这个术语非常古老,是指函数变量可以被隐藏于作用域链之内,因此看起来是函数将变量“包裹”了起来。)——《JS权威指南》
闭包是一个函数以及其捆绑的周边环境状态(词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在JS中闭包会随着函数的创建而被同时创建。——《MDN》
闭包(Closure)是指函数和函数所声明的词法环境的组合。简而言之,闭包允许函数访问其自身定义时所在的词法作用域之外的变量。具体来说,当一个函数被定义在另一个函数的内部时,它就形成了一个闭包。——GPT-3.5
2. 作用域链
2.1 执行上下文
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象,而这个上下文中定义的所有变量和函数都存在这个对象中。
上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,入关闭网页或者退出浏览器)。
当代码执行流进入函数时,函数的上下文就被推倒一个上下文栈中,在函数执行完毕后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。
2.2 作用域链
上下文中的代码在执行的时候,会创建变量对象的一个作用域链(包含变量对象的一个列表),它决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。
作用域链的查找是单向的,从内向外,而不会从外向内。这意味着内部函数可以访问外部函数的变量,但外部函数不能直接访问内部函数的变量(这也是闭包能够实现私有变量的原因)。
function outerFunction() {
var outerVar = "I am from outer function";
function innerFunction() {
var innerVar = "I am from inner function";
console.log(outerVar); // 访问外部函数的变量
console.log(innerVar); // 访问自身的变量
}
innerFunction();
}
outerFunction();
对于上面这个例子而言,函数innerFunction的作用域链包含两个对象,一个时自己的变量对象arguments,另一个是全局上下文中的变量对象,这个函数内部之所以能够访问到outerVar就是因为可以在作用域链中找到它。
3. 代码分析
函数执行时,每个执行上下文中都会有一个包含其中变量的对象。全局上下文中的叫变量对象,它会在代码执行期间始终存在。而函数局部上下文中的叫活动对象,只在函数执行期间存在。
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;
}
};
}
在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中,因此在createComparisonFunction()函数中,匿名函数的作用域链中实际上包含createComparisonFunction()的活动对象。
let compare = createComparisonFunction('name');
let result = compare({ name: 'Nicholas' }, { name: 'Matt' });
执行上述代码后的结果如下图:
在createComparisonFunction()返回匿名函数后,它的作用域链被初始化为包含createComparisonFunction())的活动对象和全局变量对象。这样,匿名函数就可以访问到createComparisonFunction()可以访问的所有变量。同时,createComparisonFunction()的活动对象并不能在它执行完毕后销毁,因为匿名函数的作用域链中仍然存在对它的引用,直到匿名函数被销毁后才会被销毁(由此带来内存泄漏的风险)。
/* 释放内存示例 */
// 创建比较函数
let compareNames = createComparisonFunction('name');
// 调用函数
let result = compareNames({ name: 'Nicholas' }, { name: 'Matt' });
// 解除对函数的引用,这样就可以释放内存了
compareNames = null;
上述代码中,创建的比较函数被保存在变量 compareNames 中。把 compareNames 设置为等于 null 会解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。作用域链也会被销毁,其他作用域(除全局作用域之外)也可以销毁。
4. this指向
在闭包中使用 this 会让代码变复杂。
- 如果内部函数没有使用箭头函数定义,则 this 对象会在运行时绑定到执行函数的上下文。
- 如果在全局函数中调用,则 this 在非严格模式下等于 window,在严格模式下等于 undefined。
- 如果作为某个对象的方法调用,则 this 等于这个对象。匿名函数在这种情况下不会绑定到某个对象,这就意味着 this 会指向 window,除非在严格模式下 this 是 undefined。
4.1 案例一
window.identity = 'The Window';
let object = {
identity: 'My Object',
getIdentityFunc() {
return function() {
return this.identity;
};
}
};
console.log(object.getIdentityFunc()()); // 'The Window'
解析:
- getIdentityFunc()方法返回一个函数,这个返回的函数是通过函数表达式定义的,不是箭头函数,因此它会创建一个新的函数,并且该函数的this指向在调用时动态确定。
- console.log(object.getIdentityFunc()())首先调用得到一个函数,然后立即执行这个函数()。这个内部函数的this取决于它的调用方式。
- 此处它被作为一个普通函数调用,而不是作为对象方法调用或者call/apply进行调用,因此在非严格模式下,它的this将指向全局对象(window),非严格模式下为undefined。
4.2 案例二
window.identity = 'The Window';
let object = {
identity: 'My Object',
getIdentityFunc() {
let that = this;
return function() {
return that.identity;
};
}
};
console.log(object.getIdentityFunc()()); // 'My Object'
解析: 在定义匿名函数之前,先把外部函数的 this 保存到变量 that 中。然后在定义闭包时,就可以让它访问 that,因为这是包含函数中名称没有任何冲突的一个变量。即使在外部函数返回之后,that 仍然指向 object(that.identity 引用了 object 对象的 identity 属性),所以调用 object.getIdentityFunc()()就会返回"My Object"。
4.3 案例三
window.identity = 'The Window';
let object = {
identity: 'My Object',
getIdentity () {
return this.identity;
}
};
object.getIdentity(); // 'My Object'
(object.getIdentity)(); // 'My Object'
(object.getIdentity = object.getIdentity)(); // 'The Window'
解析:
- 在普通的对象方法调用中,this 指向对象本身。
- 使用括号包裹对象方法调用仍然保持 this 指向对象本身( 这是因为按照规范,object.getIdentity 和(object.getIdentity)是相等的)。
- 赋值表达式破坏了属性访问的基础,导致赋值后的函数调用时,this 的指向发生改变,此时 this 指向了全局对象。
5. 内存泄漏
由于 IE 在 IE9 之前对 JScript 对象和 COM 对象使用了不同的垃圾回收机制,所以闭包在这些旧版本 IE 中可能会导致问题。在这些版本的 IE 中,把 HTML 元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。
function assignHandler() {
let element = document.getElementById('someElement');
element.onclick = () => console.log(element.id);
}
以上代码创建了一个闭包,即 element 元素的事件处理程序。而这个处理程序又创建了一个循环引用。匿名函数引用着 assignHandler()的活动对象,阻止了对 element 的引用计数归零。只要这个匿名函数存在,element 的引用计数就至少等于 1。也就是说,内存不会被回收。其实只要这个例子稍加修改,就可以避免这种情况,比如:
function assignHandler() {
let element = document.getElementById('someElement');
let id = element.id;
element.onclick = () => console.log(id);
element = null;
}
在这个修改后的版本中,闭包改为引用一个保存着 element.id 的变量 id,从而消除了循环引用。不过,光有这一步还不足以解决内存问题。因为闭包还是会引用包含函数的活动对象,而其中包含 element。即使闭包没有直接引用 element,包含函数的活动对象上还是保存着对它的引用。因此,必须再把 element 设置为 null。这样就解除了对这个 COM 对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。
6. 优劣分析
6.1 优点
- 封装私有变量
- 避免污染全局变量
- 实现柯里化、节流、延迟执行等
6.2 缺点
- 占用内存: 闭包会保留它们包含函数的作用域,所以比其他函数更占用内存,过度使用闭包可能导致内存过度占用 。
- 内存泄漏: V8 等优化的 JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。
7. 使用场景
7.1 循环赋值
// 因为存在闭包的原因上面能依次输出1~10,闭包形成了10个互不干扰的私有作用域
for(var i = 0; i<10; i++){
(function(j){
setTimeout(function(){
console.log(j)
}, 1000)
})(i)
}
7.2 节流防抖
// 节流(节流的目标是控制函数在一定时间间隔内只执行一次)
function throttle(func, delay) {
let timer = null;
return function(...args) {
if (!timer) {
timer = setTimeout(() => {
func(...args);
timer = null;
}, delay);
}
};
}
// 防抖(防抖的目标是确保函数在一连串调用结束后,只执行一次)
function debounce(fn, timeout){
let timer = null
return function(...arg){
clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, arg)
}, timeout)
}
}
7.3 柯里化
function add(x) {
return function(y) {
return x + y;
};
}
add(3)(8) // 11