恶补JavaScript基础之闭包
提醒:本文篇幅较大,请确保有足够的时间再行阅读,新手建议2小时以上。
闭包的定义
Closures (闭包)是使用被作用域封闭的变量,函数,闭包等执行的一个函数的作用域。通常我们用和其相应的函数来指代这些作用域。(可以访问独立数据的函数) 闭包是指这样的作用域,它包含有一个函数,这个函数可以调用被这个作用域所封闭的变量、函数或者闭包等内容。通常我们通过闭包所对应的函数来获得对闭包的访问。 —-MDN web docs
要深入了解闭包就必须先了几个知识点,作用域、作用域链、执行环境和活动对象,你也可以阅读本系列的《执行上下文》篇章,更加详细的了解前置知识。
作用域
几行代码初步了解一下作用域
js中有局部作用域和全局作用域,他们的范围内的变量分别为局部变量和全局变量。
var name = 'zhangsan'
function fun() {
console.log(name)
var name = 'lisi'
}
fun() // undefined
console.log(name) // 1
第一个输出undefined是因为在函数内部的作用域链中的活动对象里面有name这个变量(因为有name变量所以不会去顺着作用域链网外层去查找),只是在预编译阶段被复制为了undefined,而在使用name的时候还没有被赋值。第二个name直接是使用全局活动对象里面的值,也就是全局变量。
这段解释如果有疑问的话不妨继续往下看哈,我会在下面详细解释一波。请耐心把下面的文字看完,这么多字我自己也是醉醉的。。。
深入理解作用域
提醒:本节内容由本人自己理解总结,某些词汇不当或者理解错误的请大家指出
作用域其实是一套查找规则,当你定义一个变量的时候,首先会在当前的作用域去查找是否存在这个变量,如果没有则创建一个,有则忽略。这个过程是在js的预处理过程实现的,更准确来说在词法语法分析阶段。如果你在全局作用域环境下运行代码,那么解释器会创建一个全局作用域,这个全局作用域下包含了一个列表,这个列表就是作用域链,作用域链上包含了一个对象,这个对象被称之为活动对象
其实词法分析的预处理就是一行一行的扫描代码,在词法分析阶段如果遇到了声明语句比如var a
、function a( ) {}
等),解释器会首先检测一下当前的活动对象里面是否有属性a,如果有的话就忽略,如果没有的话就创建一个属性a,并赋值为undefined。预处理结束后,就会生成可执行的代码,然后进入代码执行阶段。
在代码执行阶段当你对 a 进行赋值的时候,解释器会根据作用域链先去查找活动对象,看一下活动对象是否存在属性 a ,如果存在的话就对 a 进行赋值,如果不存在就创建属性 a 并且对他赋值。
同样的,当运行一个函数的时候(比如函数b),也是在全局的活动对象去查看是否有属性b,如果有就直接执行,如果没有的话会自己创建一个b,并赋值为undefined,然后去执行b(这个b不是函数,而是一个值为undefined的属性,所以这时候浏览器报错的时候是b不是一个函数)。
理解作用域链和活动对象
详细解释一下定义变量用var 和不用的区别,以此来更深刻理解以上几个概念。
全局环境运行一段代码的时候会有一个作用域链,这个作用域链只包含一个活动对象(全局活动对象);而你的代码在函数里面运行的时候,在定义这个函数的时候,这个函数就会包含一个作用域链,这个作用域链也只有一项,就是全局活动对象,而当你运行这个函数的时候会创建一个新的活动对象,并将它推入到这个作用域链中;这时候这个函数所对应的作用域链就包含了两个活动对象了,一个是函数声明的时候就包含了一个活动对象,指向全局活动对象;另一个是函数执行的时候创建的新的活动对象。
这个新的活动对象被推入到函数作用域链后也会进行词法语法分析,当遇到申明语句的时候,解释器只会查看当前活动对象(这里指函数自己创建的那个活动对象)中是否含有该属性,如果有则忽略,没有的话,就会创建一个。在代码执行阶段,当你对一个变量赋值的时候,解释器会从作用域链里面查看第一个活动对象(这里指函数自己创建的那个活动对象),如果有则赋值,如果没有,会查看作用域链里面查看第二个活动对象(这里指全局活动对象),如果还是没有的话,这时候会在全局活动对象中创建对应属性并赋值。
通过以上过程,在我们声明变量的时候,用了var的话,在词法分析阶段会把对应的变量放到函数对应的活动对象中,而不使用var的话,则会跳过词法分析阶段,直接进入到函数的执行阶段。在执行阶段,解释器会顺着作用域链逐个查找上面的活动对象,直到最后一个活动对象(全局活动对象),如果最后一个还是没找到的话,就会在最后一个活动对象上创建这个属性。所以你不用var创建的变量就变成了全局变量。
另外一点,通过这个过程可以发现,js的解释器在确定作用域的时候,是通过词法分析阶段来遍历活动对象来实现的,所以在js中,作用域更准确来说应该叫做词法作用域。
我们修改一下最开始的例子
var name = 'zhangsan'
function fun() {
console.log(name)
name = 'lisi' // 去掉了var
}
fun() // zhangsan
console.log(name) //lisi
在函数里面没有使用 var 定义,所以是全局变量,在函数内部活动对象里面找不到 name 所以取全局找了,然紧接着 name 被修改成了lisi,所以第二次打印出来的是lisi。
注意:
- 作用域链的创建是在函数定义的时候,每一个函数都有自己的作用域链,只不过在函数执行的时候被推入了一个新的活动对象用于词法分析
- 在函数执行完成并且返回结果之后,垃圾回收机制会将函数对应的活动对象删除,因此在函数外面访问不到函数里面的变量原因有两个:
- 你从函数外面访问变量的时候,是通过查找作用域链上的活动对象来实现的,这个作用域链跟函数作用域链没有交集。
- 函数的作用域链上包含的活动对象在函数执行完成后已经被删除了。所以综合这两点,会导致在函数外面你访问不到函数里面的变量。
你觉得此时我们可以来看看闭包了嘛?还不行!。
我们上节主要阐述作用域链和活动对象,难道你对预编译过程没有兴趣吗?
预编译时的变量提升和函数提升
我们来了解一下变量提升和函数提升,回到最开始那个例子(为了大家看的方便我复制过来)
var name = 'zhangsan'
function fun() {
console.log(name)
var name = 'lisi'
}
fun() // undefined
console.log(name) // zhangsan
我们详细分解一下这段代码的预编译阶段和执行阶段吧。
1.预编译阶段
// 第1行,遇到 var 关键字,解析到全局活动对象
name = undefined
// 第2行,遇到 function 关键字,解析到全局活动对象
fun = function fun(){
console.log(name)
var name = 'lisi';
}
// 第6行,没有遇到关键字,不解析
// 第7行,没有遇到关键字,不解析
2.执行阶段
第1行,遇到表达式 name = 'zhangsan', name 被赋值成 zhangsan
第6行,遇到函数调用 fun() ,开始预编译局部
2.1预编译函数里面的作用域(此时会在作用域链上面推入一个新的活动对象)
// 第3行,没有遇到关键字,不解析
// 第4行,遇到 var 关键字,解析到此时的活动对象(函数内部的活动对象)
name = undefined
2.2开始执行函数作用域里面的代码
第3行,打印出 undefined
第4行,遇到表达式,把局部 name 从undefined 改成 lisi
此时函数作用域代码执行完成,继续执行全局代码
好了,变量提升的过程就差不多说完了,如果还不清楚的话可以去阅读一下下面的参考4.
此时,我们终于可以来看看闭包了。
初识闭包
接下来,在介绍闭包之前我们先看一段代码
var product = function(id) {
var prodID=id //赋值ID,外部不可调用
this.prodName="" //这个属性外部可以访问,有this. 实例化后就是属性
function loadProd(obj) {
// 我们假设这个过程需要复杂的获取
if(prodID===1) obj.prodName="霸王油"
if(prodID===2) obj.prodName="老鼠药"
}
loadProd(this)
}
var p = new product(1)
console.log(p.prodName) //霸王油
以上代码中 var申明的 prodID可以理解为私有变量,而this.prodName
是被构造出来的属性,被实例化后是可以被外部访问的loadProd
理解为私有函数,外部是无法调用的,我们在product里面直接执行它,就将内部变量prodName
的赋值给了父函数product
的this.prodName
属性,此时从外部去实例化product
后就可以访问到内部的变量,可以看出内部函数loadProd
赋值后,this.prodName
的值一直保存在内存中。这可以初步理解为闭包,不过我们还得在内部传入this,这未免太低端了。
看看下面的代码你是不是更熟悉了?
var product = function(id) {
var prodID=id; // 赋值ID,外部不可调用
var prodName=" "; // 这个属性外部不可以访问
return function() {
// 我们假设这个过程需要复杂的获取
if(prodID==1) prodName="霸王油";
if(prodID==2) prodName="老鼠药";
return prodName;
}
}
var p=new product(1);
console.log(p()); //霸王油
在上面代码中,全部变量定义为私有变量,然后将私有变量经过“处理”后从内部匿名函数直接return出来。父级函数被调用之后按照常理此时活动对象应该被销毁,但是因为闭包的存在,父级函数的活动对象会被保存在返回的这个函数的活动对象中,此时父级函数中的变量就被保存下来了,所以闭包是可以保存父函数的内部变量的,这样我们就可以从外部实例访问到函数块里面的变量了。如果要销毁这种保存,只需把实例化的对象设为null(这里为p,p=null),下次垃圾回收机制就会将闭包回收。
从原理理解闭包
耐心把下面的文字看完,这么多字我也是醉醉的。。。
假设你在全局定义了变量 a 和函数 b ,此时全局的 活动对象 就包含了两个属性,a 和 b,同时函数 b 还包含一个作用域链,里面只有一个全局的活动对象。如果在 b 中定义了一个函数 c ,那么在运行 b 的时候,此时 b 的作用域链上就多了一个活动对象,这个活动对象里面包含一个属性就是 c ,而 c 也包含一个作用域链,c 的作用域链里就有两个活动对象了,第一个是 b 的活动对象,第二个是全局活动对象。如果把 c 作为返回值并且赋值给 a,此时 a 与 c 就建立了一个引用关系,这个引用关系是在函数 b 运行的时候创建的,由于建立了这个引用关系,所以在 b 执行完成之后,b 的作用域链上的活动对象不会被删除,而当运行 a 的时候,实际上运行了 c ,此时 c 的作用域链上会被推入一个新的活动对象,此时 c 的作用域链上就有三个活动对象了。
var a = null;
function b () {
function c () {
//do something
}
a = c;
}
第一个是c自己的活动对象,这个活动对象是新创建的,要经历词法分析的,会按照c中的代码初始化一遍。第二个是 b 的活动对象,第三个是全局活动对象。
所以当 c 中的代码要操作某个变量的时候会依次查找这三个活动对象,如果这个变量是在 b 中被定义的,那么就会在 b 的活动对象中找到并且进行相应的操作。而操作的结果也会被保存在 b 的活动对象中。那么,函数 a 运行完成后或者说函数 a 对应的函数 c 运行完成之后, c 自己的活动对象被垃圾回收机制回收(删除)了,然而此时,c 中的作用域链中的其他两个活动对象( b 的活动对象和全局活动对象)就被保存下来了。因为 b 的活动对象没有被删除,那么 b 中的变量就被保存下来了。
如果想要删除 b 的活动对象,那么你就要断开 a 和 c 的联系,并且把 a 赋值为 null,那么在下一次垃圾回收机制运行的时候就把 b 给回收了。
再看一个例子
var xxoo = null;
function fuck() {
var abc = 2;
function innnerFuck() {
console.log(abc);
}
xxoo = innnerFoo; // 将 innnerFuck的引用,赋值给全局变量中的xxoo
}
function shit() {
xxoo(); // 这里保存了innnerFuck的引用
}
fuck(); //执行闭包,让xxoo与innnerFuck建立引用关系
shit(); // 2
在上面的例子中,当fuck( )
执行完毕之后,按照常理来说,fuck
的作用域链会被清理,所占用的内存也会被垃圾收机制释放。但是通过xxoo = innerFuck
,函数innerFuck
的引用被保留了下来,。这个行为,导致了 fuck 的变量对象也被保留了下来。所以,函数xxoo在函数shit内部执行时,依然可以访问这个被保留下来的变量对象。
闭包的注意
由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE浏览器中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的函数私有变量设置为null即可。
有些时候我们需要在、一些事件(比如点击或者按键)上面,触发某种行为。
看看这个例子(来自MDN)。点击三个连接会切换字体大小,将想要触发的这个行为(改变字体大小)保存在一个函数中,返回出去。
模拟私有方法(来自MDN)
var Counter = (function() {
var privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
})();
console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */
我们先用一个立即执行函数来创建这个计数器,以便于外部可以直接访问,不需要再实例化。
这里有很多细节。在以往的示例中,每个闭包都有它自己的环境;而这次我们只创建了一个环境,为三个函数所共享:Counter.increment,Counter.decrement 和 Counter.value。
该共享环境创建于一个匿名函数体内,该函数一经定义立刻执行。环境中包含两个私有项:名为 privateCounter 的变量和名为 changeBy 的函数。 这两项都无法在匿名函数外部直接访问。必须通过匿名包装器返回的三个公共函数访问。
这三个公共函数是共享同一个环境的闭包。多亏 JavaScript 的词法范围的作用域,它们都可以访问 privateCounter 变量和 changeBy 函数。
上面这种方式已经比较接近于模块化开发的方式了。在这里可以了解模块化开发。
经典的面试题
第一种(没有timeout的,但类似于timeout)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script type="text/javascript">
var nodes = document.getElementsByTagName("div");
for(var i = 0; i < nodes.length; i++){
nodes[i].onclick = function(){
alert(i);
}
}
</script>
</head>
<body>
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
</body>
</html>
上栗的原本需求是分别点击1到5弹出0到4,但是很不巧,1到5的div弹出的都是5。下面我们来解决这个问题:先说说为什么会弹出都是5,这里我觉得重点在于“异步”还是“同步”。其实在执行事件监听的那个函数的时候for循环就已经执行完成了,此时i为5,所以每次弹出的都是5.
好了,下面开始说解决办法。
方法一:用立即执行函数(IIFE)传一个参数过去(这是我头脑里最快想到的方法)
var nodes = document.getElementsByTagName("div")
for(var i = 0; i < nodes.length; i++){
nodes[i].onclick =(function(j){ //这里专门用j来区分,此时里面的弹出的遍量已经不是for条件里面的i了
return function () {
alert(j);
}
})(i);这里传入i
}
这种情况下,在每次for循环的时候,“当时”的i就被传到了事件监听函数,并作为局部变量“保存了下来”,显然每次传入的i不同。注意,这里就是“同步”保存下来的,而不是像原来代码中是“异步”去取的。所以呢,最后alert的是那些当时存下来的局部的i,而不是for循环中的循环变量i。两者根本就没有任何关系了。当然,此时的循环变量i已经是5了。
方法二:给nodes[i]定义一个属性i并赋值为for循环里面的i(这是在网上看到的,好像有点皮哦)
var nodes = document.getElementsByTagName("div")
for(var i = 0; i < nodes.length; i++){
nodes[i].i = i
nodes[i].onclick = function(){
alert(this.i);
}
}
我觉得这跟第一种的思想类似,在dom对象上定义一个属性,在循环里面赋值,此时i是属性,this.i不然是不一样的额。
方法三:
var nodes = document.getElementsByTagName("div")
for(var i = 0; i < nodes.length;i++){
(function(j){
nodes[i].onclick = function () {
alert(j);
}
})(i);这里传入i
}
此方法其实跟方法一是差不多的,方法一是吧事件直接绑在IIFE上的(实际上是里面return的函数上),本方法是直接把事件绑定到IIFE里面的函数上面的,其实是一个意思。保存i的方式也是一样的。
方法四:
var nodes = document.getElementsByTagName("div");
for(var i = 0; i < nodes.length;i++){
nodes[i].onclick =(function(){
alert(i);
})();
}
这种方法就是让事件“同步”执行,事件绑定一个自执行函数,这里不像原问题中,绑定的是一个“函数的定义”而不是执行,这里的话就是绑定了一个自己执行了的函数,此时就是循环一次执行一次,变成“同步”了,当然是每次弹出不同的数字。
方法五:使用es6中的let关键字
var nodes = document.getElementsByTagName("div")
for(let i = 0; i < nodes.length; i++){ //仅仅在这里吧var改成let
nodes[i].onclick = function(){
alert(i);
}
}
let关键字会申明一个会计作用域,在这里每次循环都会生成一个块级作用域,并且它们是相互独立的,没有关系的,所以每次弹出的i都是在取不同作用域的i,所以是可以解决的。
除了上面集中方法,方法一,三。四还有很多变种,大体思想都差不多,为事件创建一个块级作用域,将i进行保存;或者是让“异步”变成“同步”执行。
第二种(有timeout的)
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log(i);
}, i*1000 );
}
这种跟上面的类似额,setTimeout将循环体里面变成了一个“异步”的操作,会打印出5个6以下我写几种方法
方法一:直接用IIFE创建一个闭包环境
for (var i=1; i<=5; i++) { //!也可以创建函数表达式哦
!function (a) {
setTimeout( function timer() {
console.log(a);
}, i*1000 );
}(i)
}
方法二:在setTimeout的第一个参数那设置闭包环境
for (var i=1; i<=5; i++) {
setTimeout(
(function (a) {
return function timer() {
console.log(a);
}
})(i), i*1000 );
}
方法三:let关键字
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log(i);
}, i*1000 );
}
——————————————————-2021-04-12更新—————————————————-
我发现了这篇文章,使用 Chrome 的调试工具来理解闭包,我谈谈一个个疑问:
闭包是指外层函数的执行环境,Chrome好像确实是这么显示的,跟很多经典书籍不一样。很多书籍还是认为内层那个去访问外层函数中的变量的函数是闭包。
本文作者观点是以 Chrome 为准,书籍都是错的。
我的理解:
我认为 Chrome 调试面板中的 Closure 指的是当前闭包形成所在的执行环境!书籍上的观点跟 Chrome 是一致的
而作者说的闭包只有在函数被调用的时候才会被创建,这个确实是的,但是为什么呢?我上面已经提到过了,因为函数的执行环境是在被调用的时候才会形成。而执行环境包含三部分:this值、Scope、变量对象VO(ES5之前是这三部分,现在的标准已经加了很多东西了)。而内层函数(执行环境)为什么能访问外层函数(执行环境)的变量呢。因为当前执行环境的 Scope 中保存的它父级以及祖先的执行环境,而在执行环境中变量对象里面保存了当前执行环境的变量列表。
要想更清楚的理解闭包,首先就要明确闭包这个概念,下图是我在网上找到的资料
我很赞同的观点:在 JavaScript 中与闭包这个定义基本一致的就是正在执行的 JavaScript 函数!
是不是觉得 JavaScript 函数,这个概念太「普通」了,跟神秘的闭包不太搭嘎?反正我之前理解闭包是一愣一愣的,但是只要理解了JavaScript 代码执行方式,以及执行环境,发现这些东西不是那么难理解。可以看看我最近写的 事件循环机制,帮助理解一下JavaScript 代码是怎么执行的。
其实只要函数被调用了(执行环境生成的时候)就形成了闭包。闭包其实不是啥高深的概念,可以直接简单的理解为 携带祖先的执行环境的函数在被调用的时候就形成了闭包。至于说垃圾回收机制为什么不回收闭包呢?这就是垃圾回收的机制啊,他只跟执行环境有关系啊,闭包只是函数执行环境中的一种情况,语言在被设计的时候就对这种情况做了对应的处理机制而已。
以上纯属个人观点!
参考文章
[1].http://www.cduyzh.com/js-closure/
[2].http://www.jianshu.com/p/21a16d44f150
[3].极好的文章
[4].闭包
恶补JavaScript基础系列
恶补JavaScript基础系列目录地址:https://www.sixtyden.com/archive。
恶补JavaScript基础系列是我在从学校毕业入坑前端的学习产物,它主要是我看完书以及其他资料后的一个浓缩总结。以下是我参考的主要资料:
JavaScript高级程序设计
你不知道的JavaScript(上卷)
陪你读书(JavaScript web前端)
王福朋的博客
冴羽写博客的地方
本人能力有限,如果有错误或者不严谨的地方,请务必给予指出,十分感谢!愿与君共勉。
(完)