大话javascript 3期:闭包

4次阅读

共计 4812 个字符,预计需要花费 13 分钟才能阅读完成。

一、什么是闭包
1. 闭包的定义
闭包是一种特殊的对象。它由两部分构成:函数,以及创建该函数的环境 (包含自由变量)。环境由闭包创建时在作用域中的任何局部变量组成。
闭包是指有权访问另外一个函数作用域中的变量的函数
闭包是函数以及函数声明所在的词法环境的组合。
由此,我们可以看出闭包共有两部分组成:闭包 = 函数 + 函数能够访问的自由变量
举个例子:
var a = 1;
function foo() {
console.log(a);
}
foo();

foo 函数可以访问变量 a,但是 a 既不是 foo 函数的局部变量,也不是 foo 函数的参数,所以 a 就是自由变量。那么,函数 foo + foo 函数访问的自由变量 a 不就是构成了一个闭包嘛
2.闭包的概念
function fa(){
var va = “this is fa”;
function fb(){
console.log(va);
}
return fb;
}

var fc = fa();
fc();
//”this is fa”

其实,简单点说,就是在 A 函数内部,存在 B 函数,B 函数 在 A 函数 执行完毕后再执行。B 执行时,访问了已经执行完毕的 A 函数内部的变量和函数。
由此可知:闭包是函数 A 的执行环境以及执行环境中的函数 B 组合而构成的。
变量都储存在其所在执行环境的活动对象中,所以说是函数 A 的执行环境。
当函数 A 执行完毕后,函数 B 再执行,B 的作用域中就保留着函数 A 的活动对象,因此 B 中可以访问 A 中的变量,函数,arguments 对象。此时产生了闭包。大部分书中,都把函数 B 称为闭包,而在谷歌浏览器中,把 A 函数称为闭包。
3. 闭包的本质
之前说过,当函数执行完毕后,局部活动对象就会被销毁。其中保存的变量,函数都会被销毁。内存中仅保存全局作用域(全局执行环境的变量对象)。但是,闭包的情况就不同了。
以上面的例子来说,函数 fb 和其所在的环境函数 fa,就组成了闭包。函数 fa 执行完毕后,按道理说,函数 fa 执行环境中的 活动对象就应该被销毁了。但是,因为函数 fa 执行时,其中的函数 fb 被返回,被变量 fc 引用着。导致,函数 fa 的活动对象没有被销毁。而在其后 fc() 执行,就是函数 fb 执行时,构建的作用域中保存着函数 fa 的活动对象,因此,函数 fb 中可以通过作用域链访问函数 fa 中的变量。
其实,简单的说:就是 fa 函数执行完毕了,其内部的 fb 函数没有执行,并返回 fb 的引用,当 fb 再次执行时,fb 的作用域中保留着 fa 函数的活动对象。
二、闭包的作用
闭包的特点是读取函数内部局部变量,并将局部变量保存在内存,延长其生命周期。
作用

通过闭包,在外部环境访问内部环境的变量。
使得这些变量一直保存在内存中,不会被垃圾回收。

以使用闭包实现以下功能:
1. 解决类似循环绑定事件的问题
在实际开发中,经常会遇到需要循环绑定事件的需求,比如在 id 为 container 的元素中添加 5 个按钮,每个按钮的文案是相应序号,点击打印输出对应序号。其中第一个方法很容易错误写成:
var container = document.getElementById(‘container’);
for(var i = 1; i <= 5; i++) {
var btn = document.createElement(‘button’),
text = document.createTextNode(i);
btn.appendChild(text);
btn.addEventListener(‘click’, function(){
console.log(i);
})
container.appendChild(btn);
}

虽然给不同的按钮分别绑定了事件函数,但是 5 个函数其实共享了一个变量 i。由于点击事件在 js 代码执行完成之后发生,此时的变量 i 值为 6,所以每个按钮点击打印输出都是 6。为了解决这个问题,我们可以修改代码,给各个点击事件函数建立独立的闭包,保持不同状态的 i。
var container = document.getElementById(‘container’);
for(var i = 1; i <= 5; i++) {
(function(_i) {
var btn = document.createElement(‘button’),
text = document.createTextNode(_i);
btn.appendChild(text);
btn.addEventListener(‘click’, function(){
console.log(_i);
})
container.appendChild(btn);
})(i);
}

注:解决这个问题更好的方法是使用 ES6 的 let,声明块级的局部变量。
2. 封装私有变量
(1) 经典的计数器例子:
function makeCounter() {
var value = 0;
return {
getValue: function() {
return value;
},
increment: function() {
value++;
},
decrement: function() {
value–;
}
}
}

var a = makeCounter();
var b = makeCounter();
b.increment();
b.increment();
b.decrement();
b.getValue(); // 1
a.getValue(); // 0
a.value; // undefined

每次调用 makeCounter 函数,环境是不相同的,所以对 b 进行的 increment/decrement 操作不会影响 a 的 value 属性。同时,对 value 属性,只能通过 getValue 方法进行访问,而不能直接通过 value 属性进行访问。
(2) 经典的循环闭包面试题
for (var i=1;i<=5;i++){
setTimeout(function timer(){
console.log(i);
},i*1000);
}

正常预想下,上面这段代码我们以为是分别输出数字 1 -5,每秒一个。但实际上,运行时输出的却是每秒输出一个 6,一共五次。

Why?
for 循环有一个特点,就是“i 判断失败一次才停止”。所以,i 在不断的自加 1 的时候,直到 i 等于 5,i 才失败,这时候循环体不再执行,会跳出,所以 i 等于 5 没错。那么为什么 5 次循环的 i 都等于 5?原因就是 setTimeout() 的回调,也就是 console.log(i); 被压到任务队列的最后,for 循环是同步任务,所以先执行,等于是空跑了 5 次循环。于是,i 都等于 5 之后,console.log(i); 刚开始第一次执行,当然输出全是 5。
根据 setTimeout 定义的操作在函数调用栈清空之后才会执行的特点,for 循环里定义了 5 个 setTimeout 操作。而当这些操作开始执行时,for 循环的 i 值,已经先一步变成了 6。因此输出结果总为 6。而我们想要让输出结果依次执行,我们就必须借助闭包的特性,每次循环时,将 i 值保存在一个闭包中,当 setTimeout 中定义的操作执行时,则访问对应闭包保存的 i 值即可。
简单来说,原因是,延迟函数的回调会在循环结束时才执行。根据作用域的工作原理,循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,实际上只有一个 i。

解决办法
方法 1:用立即执行函数模拟块级作用域
利用立即执行函数和函数作用域来解决,用自执行函数传参,这样自执行函数内部形成了局部作用域,不受外部变量变化的影响。
我们可以通过立即执行函数创建作用域。(立即执行函数会通过声明并立即执行一个函数来创建作用域)。
for (var i=1; i<=5; i++) {
(function(i) {
setTimeout(function timer() {
console.log(i);
}, i*1000 );
})(i)
}
// 1
// 2
// 3
// 4
// 5

方法 2:利用闭包
function makeClosures(i){// 这里就和 内部的匿名函数构成闭包了
var i = i; // 这步是不需要的,为了让看客们看的轻松点
return function(){
console.log(i); // 匿名没有执行,它可以访问 i 的值,保存着这个 i 的值。
}
}

for (var i=1; i<=5; i++) {
setTimeout(makeClosures(i),i*1000);

// 这里简单说下,这里 makeClosures(i),是函数执行,并不是传参,不是一个概念
// 每次循环时,都执行了 makeClosures 函数,都返回了一个没有被执行的匿名函数
//(这里就是返回了 5 个匿名函数),每个匿名函数都是一个局部作用域,保存着每次传进来的 i 值
// 因此,每个匿名函数执行时,读取 `i` 值,都是自己作用域内保存的值,是不一样的。所以,就得到了想要的结果
}

//1
//2
//3
//4
//5

方法 3:利用块级作用域
ES6 引入的 let 在循环中不止会被声明一次,在每次迭代都会声明:
for (let i=1;i<=5;i++){
setTimeout(function timer(){
console.log(i);
},i*1000);
}

因为使用 let,导致每次循环都会创建一个新的块级作用域,这样,虽然 setTimeout 中的匿名函数内没有 i 值,但它向上作用域读取 i 值,就读到了块级作用域内 i 的值。
三、闭包的问题
使用闭包会将局部变量保持在内存中,所以会占用大量内存,影响性能。所以在不再需要使用这些局部变量的时候,应该手动将这些变量设置为 null, 使变量能被回收。
当闭包的作用域中保存一些 DOM 节点时,较容易出现循环引用,可能会造成内存泄漏。原因是在 IE9 以下的浏览器中,由于 BOM 和 DOM 中的对象是使用 C ++ 以 COM 对象的方式实现的,而 COM 对象的垃圾收集机制采用的是引用计数策略,当出现循环引用时,会导致对象无法被回收。当然,同样可以通过设置变量为 null 解决。
举例如下:
function func() {
var element = document.getElementById(‘test’);
element.onClick = function() {
console.log(element.id);
};
}

func 函数为 element 添加了闭包点击事件,匿名函数中又对 element 进行了引用,使得 element 的引用始终不为 0。解决办法是使用变量保存所需内容,并在退出函数时将 element 置为 null。
function func() {
var element = document.getElementById(‘test’),
id = element.id;
element.onClick = function() {
console.log(id);
};
element = null;
}

四、应用场景:模块与柯里化
模块也是利用了闭包的一个强大的代码模式。
function CoolModule(){
var something=”cool”;
var anothor=[1,2,3];

function doSomething(){
console.log(something);
}

function doAnthor(){
console.log(anothor.join(“!”));
}

return{
doSomethig:doSomething,
doAnothor:doAnother
};
}

var foo=CoolMOdule();
foo.doSomething();//cool
foo.doAnother();//1!2!3

模块有 2 个主要特征:

为创建内部作用域而调用了一个包装函数;
包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

关于模块的引入

import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上;
module 会将整个模块的 API 导入并绑定到一个变量上;
export 会将当前模块的一个标识符导出为公共 API。

正文完
 0