🚀 个人主页 极客小俊
✍🏻 作者简介:web开发者、设计师、技术分享博主
🐋 希望大家多多支持一下, 我们一起进步!😄
🏅 如果文章对你有帮助的话,欢迎评论 💬点赞👍🏻 收藏 📂加关注
前言
这么多年了,你是否还在讨论javascript闭包
呢? 闭包
这个概念几乎也是任何前端面试官都会必考的问题!
并且理解javascript闭包
也是迈向高级前端开发工程师
的必经之路!
也只有理解了闭包
的原理和运行
机制才能写出更为安全和优雅的javascript
代码
那么你是否学习javascript
很久了但闭包
还没有搞懂呢? 😂 闭包
很晦涩难懂吗? 或许你把闭包
这个概念想象得太过神奇! 今天就来揭秘javascript闭包
一个前端开发经久不衰的话题!
学习条件
这里我也特别说明一下闭包
其实牵扯的东西还是有点多,涉及到以下JS知识点
:
函数的执行上下文环境(Execution context of function)
变量对象(Variable object)
活动对象(Active object)
作用域(scope)
作用域链(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
内部的变量或者数据,再然后重点来了:
如果实在你还无法理解这里的【作用域链】,那么你可以理解为是一种描述路径的术语, 沿着该路径可以找到需要的变量值!
再次回到闭包
的概念上来, 也就是当一个子函数
引用了父级函数的某个变量或数据
,那么 闭包
其实就产生了
并且这个变量或数据
的生命周期始终能保持使用,就能间接保持原构父级函数 在内存中的变量对象
不会消失
所以尽管outerTest()函数
已经调用结束, 但是子函数
却始终能引用到该父级函数
中的变量的值,并且该变量值只能通这种方法来访问!
即使再次调用相同的outerTest()函数
,但只会生成相对应的变量对象
,新的变量对象
只是对应新的值, 和上次那次调用的是各自独立的!
如图
简而言之 在嵌套在父级函数
内部的子函数被定义时,并且也引用了父级函数的数据时
就产生了闭包
需要重点注意的是: 一个闭包内对变量的修改,不会影响到另外一个闭包中的变量
以上案例就是在outerTest函数
执行完并返回后,闭包
使得JS
中的的垃圾回收机制GC(Garbage collection)
不会收回outerTest函数
所占用的资源,这里指的资源是它的变量对象
, 因为outerTest函数
的内部函数innerTest
的执行一直需要依赖outerTest函数
中的变量或者其他数据。这就是对闭包
产生和特性最直白通俗的描述!
那么现在回过头来再次理解为什么每次调用fn1()函数
变量num
会累加? 看下面这张图!
如图
因为由于闭包
的存在使得函数outerTest
返回后,函数outerTest
中的num变量
其实始终存在于内存中,这样每次执行fn1()
,都会找到内存中与之对应outerTest函数
的变量对象
的num变量
进行累加1后,输出num
的值
闭包具体步骤总结
- 当执行
函数outerTest
的时候,outerTest函数
会进入相应的执行上下文环境
! - 在创建
函数outerTest
执行环境的过程中,首先会为函数outerTest
添加一个scope属性
,即函数outerTest
的作用域,其值就为函数outerTest
中的作用域链scope chain
- 然后
执行环境
会创建一个活动对象(activation object)
。活动对象也是当前被调用这个函数所拥有的一个对象,它是用来保存数据的, 它不能通过JS
代码直接访问, (如果你实在理解不了可以想象成一个抽象的对象) - 创建完
活动对象
后,把该活动对象
添加到outerTest函数
的作用域链
中的最顶端,也就是图中的第0位
,此时outerTest函数
的作用域链
包含了两个对象:outerTest函数
的活动对象
和全局window变量对象
也就是图中蓝色和绿色
两个对象 - 然后在
outerTest函数
的活动对象
上添加一个arguments属性
,它保存着调用outerTest函数
时所传递的实际参数,当然我们这里并没有传递任何参数进来! - 再然后把所有
outerTest函数
的形参
和内部的innerTest函数
、以及num变量
这些数据的引用也添加到outerTest函数
的活动对象
上。 - 此时完成了
函数innerTest
的定义,因此如同第3步,函数innerTest
的作用域链
以及innerTest函数
的变量对象跟之前outerTest函数
一样被初始化了, 那么到这里整个outerTest函数
从定义到执行的步骤就完成了! - 然后在外部
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 在内存中保持变量数据一直不丢失!
还是以最开始的例子, 由于闭包
的影响,函数outerTest
中num变量
会一直存在于内存中,因此每次执行外部的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、通过调用闭包的内部函数获取到闭包的成员变量:
在闭包中返回该函数,在外部接收该函数并执行就能获取闭包的成员变量。
原因是因为词法作用域
,也就是函数的作用域是其声明的作用域而不是执行调用时的作用域
。