您的位置:  首页 > 技术 > java语言 > 正文

从来都没有理解JavaScript闭包? 今天非把你教会不可! 看这一篇就够了,全程大白话!

2022-02-22 13:00 https://my.oschina.net/GeekerJun/blog/5457263 极客小俊 次阅读 条评论

从来都没有理解JavaScript闭包? 今天非把你教会不可! 看这一篇就够了,全程大白话!

前言

这么多年了,你是否还在讨论javascript闭包呢? 闭包这个概念几乎也是任何前端面试官都会必考的问题!

并且理解javascript闭包也是迈向高级前端开发工程师的必经之路!

也只有理解了闭包原理和运行机制才能写出更为安全和优雅的javascript代码

那么你是否学习javascript很久了但闭包还没有搞懂呢? 😂 闭包很晦涩难懂吗? 或许你把闭包这个概念想象得太过神奇! 今天就来揭秘javascript闭包 一个前端开发经久不衰的话题!

学习条件

这里我也特别说明一下闭包其实牵扯的东西还是有点多,涉及到以下JS知识点:

  1. 函数的执行上下文环境(Execution context of function)
  2. 变量对象(Variable object)
  3. 活动对象(Active object)
  4. 作用域(scope)
  5. 作用域链(scope chain)

那么如果你对以上所涉及到的知识点还没有清楚,那么建议补一下,我以后也会慢慢提及, 否则理解闭包就会出现歧义!

到底什么是闭包?

概述

闭包比较书面化的解释是: 一个拥有许多变量和绑定了这些变量的环境的表达式,并且通常是一个函数, 而这些变量也是该表达式的一部分。我想如果你是一个零基础的小白, 那么估计不出意外的话应该完全不能理解这句话!😃 没关系想搞懂我们接着往下看...

那么我们首先来看一段JS代码

//函数定义
function outerTest() {
    var num = 0;
    function innerTest() {
        ++num
        console.log(num);
    }
    return innerTest;
}

//调用
var fn1 = outerTest();
fn1();
fn1();
fn1();

运行结果

在这里插入图片描述

以上就是一个闭包的经典案例, 我们慢慢来分析!

其实你会发现以上这段JS代码有两个特点:

1、innerTest函数嵌套在outerTest函数的内部

2、outerTest函数的返回值就是innerTest函数

那么有人就会说函数嵌套函数就是闭包 其实这样子说是不严谨的!

原理分析

接着之前的那一段JS代码 我们来看一张图

在这里插入图片描述

代码分析

当在执行完var fn1 = outerTest();之后,变量fn1实际上是指向了函数innerTest

那么接下来如果再执行fn1()就会改变num变量的值, 当然这个过程通常懂一点程序执行流程也可以分析出来!

关键不同的是之后继续执行fn1()输出的却是num变量累加之后的结果! 你肯定想知道为什么会累加!对吧!😁

首先因为函数innerTest引用了函数outerTest内部的变量或者数据,再然后重点来了:

当一个局部函数或匿名函数被定义的时候,那么它的作用域链也会被初始化,并且虽然有的时候局部函数即便是没有被调用,但是它会执行一个动作: 就是复制一份父函数的作用域链, 并且再将此作用域链的第0位插入该未调用函数的变量对象,等到该函数被调用了就激活为活动对象

如果实在你还无法理解这里的【作用域链】,那么你可以理解为是一种描述路径的术语, 沿着该路径可以找到需要的变量值!

再次回到闭包的概念上来, 也就是当一个子函数引用了父级函数的某个变量或数据,那么 闭包其实就产生了

并且这个变量或数据的生命周期始终能保持使用,就能间接保持原构父级函数 在内存中的变量对象不会消失

所以尽管outerTest()函数已经调用结束, 但是子函数却始终能引用到该父级函数中的变量的值,并且该变量值只能通这种方法来访问!

即使再次调用相同的outerTest()函数,但只会生成相对应的变量对象,新的变量对象只是对应新的值, 和上次那次调用的是各自独立的!

如图

在这里插入图片描述

简而言之 在嵌套在父级函数内部的子函数被定义时,并且也引用了父级函数的数据时就产生了闭包

需要重点注意的是: 一个闭包内对变量的修改,不会影响到另外一个闭包中的变量

以上案例就是在outerTest函数执行完并返回后,闭包使得JS中的的垃圾回收机制GC(Garbage collection)不会收回outerTest函数所占用的资源,这里指的资源是它的变量对象, 因为outerTest函数的内部函数innerTest的执行一直需要依赖outerTest函数中的变量或者其他数据。这就是对闭包产生和特性最直白通俗的描述!

那么现在回过头来再次理解为什么每次调用fn1()函数 变量num会累加? 看下面这张图!

如图

在这里插入图片描述

因为由于闭包的存在使得函数outerTest返回后,函数outerTest中的num变量其实始终存在与内存中,这样每次执行fn1(),都会找到内存中与之对应outerTest函数变量对象num变量进行累加1后,输出num的值

闭包具体步骤总结
  1. 当执行函数outerTest的时候,outerTest函数会进入相应的执行上下文环境!
  2. 在创建函数outerTest执行环境的过程中,首先会为函数outerTest添加一个scope属性,即函数outerTest的作用域,其值就为函数outerTest中的作用域链scope chain
  3. 然后执行环境会创建一个活动对象(activation object)。活动对象也是当前被调用这个函数所拥有的一个对象,它是用来保存数据的, 它不能通过JS代码直接访问, (如果你实在理解不了可以想象成一个抽象的对象)
  4. 创建完活动对象后,把该活动对象添加到outerTest函数作用域链中的最顶端,也就是图中的第0位,此时outerTest函数作用域链包含了两个对象:outerTest函数活动对象全局window变量对象也就是图中蓝色和绿色两个对象
  5. 然后在outerTest函数活动对象上添加一个arguments属性,它保存着调用outerTest函数时所传递的实际参数,当然我们这里并没有传递任何参数进来!
  6. 再然后把所有outerTest函数形参和内部的innerTest函数、以及num变量这些数据的引用也添加到outerTest函数活动对象上。
  7. 此时完成了函数innerTest的定义,因此如同第3步,函数innerTest作用域链以及innerTest函数的变量对象跟之前outerTest函数一样被初始化了, 那么到这里整个outerTest函数从定义到执行的步骤就完成了!
  8. 然后在外部 outerTest函数返回innerTest函数命名为fn1引用变量,又因为innerTest函数作用域链包含了对outerTest函数变量对象的引用,注意:此时outerTest函数已经调用结束,活动对象也变成了内存中滞留的变量对象,那么innerTest函数可以访问到outerTest函数中定义的所有变量和函数, 并且innerTest函数被外部的fn1所引用,函数innerTest又依赖函数outerTest,因此函数outerTest变量对象在返回后不会被JS垃圾回收机制GC(Garbage collection)销毁。

所以当fn1执行也相当于在执行函数innerTest时候也会像以上步骤一样。因此执行时innerTest函数作用域链包中含了3个对象:innerTest函数活动对象outerTest函数变量对象全局window变量对象, 也就是图中蓝色+绿色+紫色三个对象, 如果你觉得上图看不清楚那么就看下面这张图!

如图

在这里插入图片描述

当在innerTest函数中访问一个变量时,搜索顺序是先搜索自身的活动对象如果存在则返回

注意: 如果函数innerTest存在prototype原型对象,则在查找完自身的活动对象后, 会先查找自身的原型对象

如果不存在将继续搜索滞留在内存中outerTest函数变量对象,依次查找直到找到为止, 这就是JS中的数据查找机制 ,当然如果整个作用域链上都无法找到,则返回undefined

我们在理解闭包的时候 重点也是在作用域链这个环节容易出错, 要知道函数的定义与执行的区别。

函数作用域是在函数定义时就已经确定,而不是在执行的时候确定, 这里引出了一个概念词法作用域

举个栗子🌰

function outer(num) { 
  function inner() {
      return num; 
   }
  return inner;
}
var fn1 = outer(1);
console.log(fn1());

我们假设函数fn1作用域是在执行时,也就是console.log(fn1())确定的,那么此时fn1的作用域链是如下:

函数fn1的活动对象->console.log的活动对象->window对象,如果假设成立,那么输出值就必然是undefined

另一种假设也就是函数fn1的作用域是在定义时确定的,就是说fn1指向的inner函数在定义的时候就已经确定了作用域。那么在执行的时候,函数fn1的作用域链为如下:

函数fn1的活动对象->函数outer的变量对象->window对象,如果假设成立,那么输出值也就是1。

所以运行结果最终为1,说明了第2种假设是正确的,也就证明了函数的作用域确实是在定义这个函数的时候就已经确定了这个说法!

有人又会问如果我们不返回outerTest函数行不行呢? 答案肯定是不行的

因为outerTest函数执行完后,innerTest函数没有被返回给外界,只是被outerTest函数所使用

因此函数outerTest函数innerTest互相使用, 但又不被外界使用,那么函数outerTest执行完毕之后就会被GC(Garbage collection)垃圾回收机制回收, 那么outerTest函数执行上下文环境也会被弹出call Stack, 内存中也不会在有outerTest函数所对应的变量对象了, 自然也无法继续保存值了!

在这里插入图片描述

闭包的应用场景

应用场景1 代码模块化

闭包的应用场景主要是用于模块化

闭包可以一定程度上保护函数内的变量安全。

还是刚才的案例举例!

outerTest函数中的num变量只有innerTest函数才能访问,而无法通过其他途径访问到,因此保护了num变量的安全性, 所以闭包模块化基本可以解决函数污染变量随意被修改问题!

比如说Java、php等语言中有支持将方法声明为私有,它们只能被同一个类中的其它方法所调用。

js是没有这种原生支持的,但我们可以使用闭包模拟私有方法

私有方法不仅仅有利于限制对代码的访问权限, 还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口部分。

举个栗子🌰

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()); /* 输出 0 */
Counter.increment();  //执行递增
Counter.increment();  //执行递增
console.log(Counter.value()); /* 输出 2 */
Counter.decrement();   //执行递减	
console.log(Counter.value()); /* 输出 1 */

如图

在这里插入图片描述

以上案例表现了如何使用闭包来定义公共函数,并让它可以访问私有函数变量

IIFE匿名函数包含两个私有数据:名为 privateCounter 变量changeBy函数, 而这两项都无法在这个匿名函数外部直接访问。必须通过匿名函数返回的三个公共函数接口来进行访问!

increment()、decrement()、value()这三个公共函数是共享同一个作用域执行上下文环境的变量对象, 也就是闭包也多亏 js作用域,它们都可以访问 privateCounter变量changeBy函数

应用场景2 在内存中保持变量数据一直不丢失!

还是以最开始的例子, 由于闭包的影响,函数outerTestnum变量会一直存在于内存中,因此每次执行外部的fn1()时,都会给num变量进行累加!

所以每累加一次也就是每调用一次fn1() 就会去内存中一层层寻找outerTest函数变量对象里面的num进行累加!

现在完全明白了闭包了吧!😜

如果你真的理解了闭包,那么下面这个案例就很容易去推理了,也非常经典 就是在事件循环中如何保留每一次循环的索引值!

代码栗子

html代码

<button>Button0</button>
<button>Button1</button>
<button>Button2</button>
<button>Button3</button>
<button>Button4</button>

js代码

window.onload=function(){
   var btns = document.getElementsByTagName('button');
   for(var i = 0,len = btns.length; i < len; i++) {
       btns[i].onclick = function() {
           console.log(i);
       }
   }
}

分析

通过执行该段代码,其实你会发现不论点击哪个button按钮 ,均输出5,

如图

在这里插入图片描述

这是很多初学者 或者还没有完全理解闭包的朋友心中的困惑! 😇 那今天就要跟你解开这个困惑了!

首先你要明白一点, onclick事件是被异步触发的,也就是等着用户事件被触发时,for循环其实早已结束!

此时变量 i 的值已经是5 所以当onlick事件函数顺着作用域链从内向外查找变量 i 时,找到的值总是 5

也就是这个变量i已经在外层的变量对象中一直保存的都是最终值!

如果你想要每次都打印出所 对应的索引号 这里就要使用到闭包了!

修改js代码如下形式

window.onload=function(){
    var btns = document.getElementsByTagName('button');
    for(var i = 0, len = btns.length; i < len; i++) {
        (function(i) {
            btns[i].onclick = function() {
                console.log(i);
            }
        }(i))
    }
}

或者

window.onload=function(){
    var btns = document.getElementsByTagName('button');
    for(var i = 0, len = btns.length; i < len; i++) {
        function test(index){
            btns[index].onclick = function() {
                console.log(index);
            }
        }
        test(i)
    }
}

这样一来每次循环的变量i值都被封闭起来,这样在事件函数执行时,会查找定义时的作用域链,这个作用域链里的变量i值是在每次循环中都被保留在对应的变量对象中,因此点击不同的button按钮会输出不同的变量i

如图

在这里插入图片描述

闭包的缺陷

如果不是某些特定业务需求下, 尽量避免使用闭包,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响, 其会根据闭包数量的多少而在内存中创建更多的变量对象, 最终可能会导致内存溢出 等情况!

当然通常最简单的解决办法就是: 解除对引用变量函数的使用

引用变量函数 = null; 

我们可以将引用变量的值将其设置为null即可,js垃圾回收将会将其清除, 释放内存资源!

总结闭包

1、当内部函数 在定义它的作用域的外部被引用(使用)时,就创建了该内部函数的闭包 ,如果内部函数引用了位于父级函数的变量或者其他数据时,当父级函数调用完毕后,这些变量数据在内存不会被GC(Garbage collection)释放,因为闭包它们被一直引用着!否则两者没有交互就不会长久存在于内存中,所以在Chrome中的debug找不到闭包

2、通过调用闭包的内部函数获取到闭包的成员变量: 在闭包中返回该函数,在外部接收该函数并执行就能获取闭包的成员变量。 原因是因为词法作用域,也就是函数的作用域是其声明的作用域而不是执行调用时的作用域

展开阅读全文
  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接