让我们彻底搞懂闭包和精通函数
还记得开始学习前端,闭包作用域神马的都非常难以理解,遇到面试只能提前去背,其实被一仔细问都很难说出所以然,相信很多新手都有和我曾经一样的问题,所以我们今天来学习一下闭包和函数,一起来了解这一部分吧~
我们其实每天都在写闭包,每天编码都无时无刻的享受着闭包带来的便利,我们的动画处理,事件回调,包括在一些框架中闭包一直都存在,闭包和作用域规则息息相关,所以我们会连带了解一下作用域相关知识。
理解闭包
闭包允许函数访问函数外部的变量,只要变量存在于声明函数的作用域中即可访问。所声明的函数不会因为作用域的消失而消失,而是任何时间都可以调用。
var value = "我是外部变量";
function testFun(){
console.log(value);
}
testFun(); // 执行函数
这样子的代码我们每天都在写,其实它就是一个闭包,我们可以把代码套入到刚刚的概念中; 首先函数声明和变量都在全局作用域中(符合在同样的作用域概念)也满足任何时间去调用testFun函数,但是我们拿这种简单的例子体现不出闭包的作用。
// 一个更为直观的闭包例子
var value = "shenhao";
var later = null;
function outsideFun(){
var innerValue = "函数作用域声明的变量";
function insideFun(){
// 由于作用域链
console.log(value); // shenhao
console.log(innervalue); // 函数作用域声明的变量
}
// 把内部函数传递给later
later = insideFun;
}
outsideFun();
later();
按照道理来说,执行later时候访问变量,value肯定是有值的,因为它存在于全局环境中,那么innerValue真的因为外部作用域的消失而是null么,答案是不可能的,由于闭包原因,我们还能访问innerValue的值。所以我们能得出一个结论: 当在外部函数内部创建一个函数的时候,还额外的创建了一个闭包,这个闭包包含了创建它时此作用域全部的变量,当这个作用域消失的时候,那么闭包中还在,因此我们就可以在作用域消失之后还能访问变量啦。
那我们可以用一个图来描述一下这个关系(此图借鉴了js忍者秘籍第5章闭包相关内容的气泡图)
闭包就可以使用这个图来解释,那么我们必须要记住的一点就是,通过闭包来访问变量的函数都有一个作用域链,作用域链保存着闭包的信息。
使用闭包的时候,所有的信息都会存储在内存中,那么这些信息什么时候被销毁,取决于JS引擎认为这些信息不再使用才会被回收或者当页面卸载时。
用闭包实现私有变量
开发者如果不止会一种编程语言,那么肯定就知道私有变量这个概念,但是在JS中是不存在的,但是我们可以使用闭包来构建一个“类私有变量”,但是并不是完美真正的私有变量,我们看一下demo
function Test(){
var count = 0;
this.addCount = function(){
count ++;
}
this.getCount = function(){
return count;
}
}
const private = new Test();
private.addCount();
private.getCount(); // 1
Test方法中的count就是我们所说的私有属性,只能通过内置的add和get方法才能对其操作,那么我们也可以构造多个函数,那么当然不同函数中的count都是具有独立性的。我们通过上述代码简单实现了一个私有属性的例子,就是通过闭包,外部通过内部的函数去获取/修改变量而外部不能直接去更改变量。
回调函数callBack
当我们做WEB应用时候,做一些动画的需求,常见地要求我们同时操作好几个DOM,操作好几个动画状态,那我们在学习闭包之前,都是在全局作用域去定义变量,然后去调用多个函数,对应的函数再去改变对应的变量,这样非常不好,会造成代码过于繁重且控制多个变量会非常麻烦。那么我们可以用闭包解决这个问题,假设我们需要改变2个动画区域,我们可以这么写。
function animated(elementID){
var elem = document.getElementById(elementID);
var tick = 0;
var timer = setInterval(() => {
...
})
}
如果我们把tick和timer等变量挪到全局作用域中,那么我们控制多个elment的动画,将会产生大量的变量,如果我们通过闭包,计时器中的回调都可以操作对应自己的私有变量,那么我们就可以这样得出结论:
闭包不是一个创建函数时候的快照,而是一个可以在函数执行的时候能够修改变量的状态封装,只要闭包存在,我们就可以修改变量的值
执行上下文跟踪代码
我们都知道JS是单线程的执行模型,我们都知道JS有2种代码类型,一种是全局代码,一种是函数代码,那么执行上下文也分为全局执行上下文和函数执行上下文,全局执行上下文则js程序开始时就创建了,函数执行上下文那么就是函数执行开始创建,那么我们可以看一个例子来了解一下:
<script>
// 全局执行上下文开始
var start = "开始";
function method(){
// 函数执行文开始
}
method();
</script>
当一个函数开始执行,则创建一个新的执行上下文,执行完毕则会关闭上下文回到创建时的执行上下文接着执行代码,所以JS为了跟踪这种调用顺序就要使用执行上下文栈,也是大家熟悉的 “调用栈”;
(PS:有那味了)
就像一个托盘一样,盘子越垒越高,每一个盘子都是函数执行栈,执行完以后最终都会回归到全局执行栈中
执行上下文除了可以定位执行的函数之外,在静态环境下也可以准确地定位到具体的标识符噢。
作用域(词法环境)
词法环境又称之为作用域,是js引擎内部实现的一套跟踪标识符和变量映射关系的机制,很多文章都没有把词法环境的概念讲清楚,那么表达“映射关系”是最贴切的。
作用域是和js代码息息相关的,那么在ES6初版的时候没有块级作用域的解决方案,所以从C,Java开发转过来的开发者们就会觉得JS应该也有这样的概念,但是其实从ES6的let const之后才解决了这个问题。我们都知道这种作用域场景最直观的表达就是代码嵌套,那么我们写一个例子:
var all = "全局的变量"
function a(){
var aValue = "a函数里面的变量"
function b(){
var bValue = "b函数里面的变量"
for(var c = 0; c<=10; c++){
console.log(all + aValue + bvalue + c);
}
}
}
JS引擎是如何跟踪这些代码,分析它们的结构,这就是词法环境的作用。
执行上下文对应的词法环境,词法环境中包含了上下文中定义标识符映射表,所以for循环具有a和b的标识符映射表,无论何时创建了函数,那么会有一个与之对应的词法环境,那么这个词法环境会存储在[[Enviroment]]这个内部属性上,所以这就是JS引擎分析代码之间关联的重要原因。
那么现在我们已经了解了基本的标识符查找规则,那么变量类型也是我们需要简单了解的,尽管这比较简单。
理解JS的变量类型
像我们现在讨论的这个话题,基本是中小公司面试必问的,烂大街的let
conat var 有什么区别?这个问题出镜率实在太多了,网上给的答案也太多了,我们就根据刚刚所提到的知识点来总结一下:
可变性
词法环境
const是无法改变的
let,const 比较var的区别则就是词法环境不一样,let和const解决了JS块级作用域的缺陷。
社区中很多大佬或者某些公司的代码规范中,出现了强制要求使用const替换var和let的事情,因为非常多代码是因为变量的可变性造成的BUG,那么我们在日常开发中,其实是很少遇到只能通过变量的可变来做的需求,所以我认为这种观念还是蛮正确的,值得提倡
那么我们来深入const的工作原理,深层次的了解一下:
我们一般使用const是这样的,第一是不会再次赋值的特殊变量或者是一个固定的值,比如我们在运算的时候,需要定义人数,数量诸如此类,也是为了JS代码的运行稳定为程序优化提供便利。
const a = "一次赋值";
a = 1; // 重新赋一个新值 报错
const b = {};
b.age = 20; // 不会报错
const c = [];
c.pus("1"); // 不会报错
const只允许初始化赋一次值,以后如何有“新值”赋给const变量,则会报错,但是在const变量上对对象,数组的修改是允许的。
变量的可变性已经描述完了,现在我们可以看看变量的词法环境的区别。
当我们使用关键字var的时候,该变量的词法环境是最近的函数内部或者全局定义的,那么我们就可以用一个demo表示:
function fun(){
for(var i = 0;i<10;i++){
var thisMessage = "和变量i一个作用域"
}
}
1. 从这个例子就可以看出,变量i是var定义的,所以它属于最近的函数词法环境内,所以变量i和thisMessage是同一个作用域,for循环中的变量i也因此忽略块级作用域
所以很多文章教程中,包括我们日常开发的时候,在写for循环中类似的临时变量的时候,需要使用let定义变量,避免在多个循环中(都在一个函数词法环境中)会造成冲突。
那么我们使用let改写此demo之后:
for(let i = 0; i<10;i++)
那么我们可以在一个词法环境中写多个类似的代码,从而不造成冲突,外部词法环境也无法访问到i变量。
我们已经了解了标识符的定义和词法环境相关知识,那么我们下来应该进行深入探索在词法环境中如何注册标识符。
注册标识符
我们如果是从Java或者C转JS的童鞋们,当我们第一次接触到下面的代码,你或许感觉到懵逼。
var test = "我是一个测试变量";
testFun(test);
function testFun(str){
return "Hello" + str;
}
为什么我的函数还没定义,就可以执行?我们都知道JS是逐行执行代码的,按道理说函数还没执行到声明这一块,是不可以调用testFun。那么JS是如何注册函数的,如何知道testFun的存在的呢?
JS在创建新的词法环境的时候,会执行2步。
js会访问并注册在当前词法环境注册的变量和函数
执行代码:取决于变量类型和词法环境类型
那我们要细细分析一下这个注册过程。
1. 首先如果是创建了函数词法环境,那么JS先会定义行参和参数默认值
2. 如果是全局词法环境,那么将会把函数声明的代码查询到(函数表达式或者是箭头函数都不会被查到)查询到之后将会把值赋给函数同名的标识符中,如果标识符已存在将会被改写,如果是块级作用域则跳过此步骤。
3. 变量声明会在函数和全局作用域中,找到当前函数和其他函数中使用var声明的变量以及在其他函数或者代码块之外的let和const变量,在块级作用域中查找let和const变量,如果不存在将赋值为undefined,存在即保留。
总结:JS注册标识符将依据当前词法环境进行注册。
所以我们就可以解释我们刚刚写的demo出现的疑惑了,是因为JS在第一阶段中加载了全局词法环境,将会把函数声明语句赋给一个新的标识符,因此我们在第二个阶段代码执行时能顺利地取到函数。
那么我们在开发中不仅仅会遇到上面这种情况,我们可能还会遇到函数标识符重载的问题:
console.log(typeof fun); // function
var fun = 3;
function fun(){}
console.log(typeof fun); // number
这就涉及到一个概念叫做变量提升,例如函数声明将提升到全局作用域顶部,变量声明将提升到函数作用域顶部
那么我们来分析一下上述的代码:
首先在第一个阶段,函数声明语句将把函数赋给同名标识符,所以在代码执行的时候fun标识符是有值的而且类型就是函数
然后当进行变量处理的时候,发现fun标识符已被注册所以不会被赋值undefind,当代码执行时fun已被再次赋值为3,所以第二个console打印出的类型是number
闭包工作原理
我们之前了解过闭包可以让我们访问函数创建的作用域的全部变量,也举了几个例子,比如动画处理和回调。
我们将利用词法环境和执行上下文重新分析我们之前的demo,这将会对我们理解闭包会更有帮助。
1. 计数器私有变量的例子
function Test(){
var count = 0;
this.addCount = function(){
count ++;
}
this.getCount = function(){
return count;
}
}
分析:我们通过new关键字调用Test这个构造函数,我们在外部访问addCount函数实际上就是创建了这个闭包,在我们执行Test.addCount这样的函数之前我们的执行上下文是全局执行上下文,因为我们调用函数会创建新的执行上下文那么也会创建新的词法环境,那么addCount这个词法环境包含着创建这个函数的词法环境,当在addCount中找不到count变量那么就会从外部环境去寻找(因为此时对象是活跃的,count变量就在此对象中),就是这么简单...
我们理解了执行上下文和词法环境的作用,词法环境主要的作用就是跟踪函数中定义的变量,新的执行环境将创建新的词法环境。
请注意,JS没有真正的私有变量,上面的demo我们可以改变它:
var testImport = {};
var Test = new Test();
testImport.getCount = Test.getCount
console.log(testImport.getCount); // 可以访问
2. 动画处理的例子
function animated(elementID){
var elem = document.getElementById(elementID);
var tick = 0;
var timer = setInterval(() => {
...
})
}
animated("box1");
animated("box2");
分析:我们调用了animated的2个方法也就创建了2个词法环境,同样的我们也创建了新的执行上下文,每个词法环境都有对应的elem,tick等变量,我们在这个代码中设置了setInterval这个函数,只要有一个函数在访问变量,那么这个闭包就会一直存在,除非计时器被清除,随后回调函数被执行,然后这个闭包会访问创建闭包时候的变量,极大的简化了代码。
小结
我们彻底理解了闭包的概念,也通过安全气泡的方法来加深我们对于词法环境的理解,保存创建函数时的词法环境我们是存储[[Environment]]属性中的。所以我们当作用域消失还能访问到创建时候的变量
2. JS引擎通过执行上下文栈(调用栈)来跟踪代码的执行,每次调用函数都会有新的执行上下文,并且像我们演示服务生托盘一样推到最顶端
3. JS通过词法环境来跟踪标识符(也就是作用域)
4. 我们可以定义全局,函数,局部作用域级别的变量
5. 使用let const var关键字声明变量时,我们理解了它们的区别:可再次赋值和是否可以局部作用域。