本文已参与「新人创作礼」活动,一起开启掘金创作之路。
Tips:如果你看完了整篇文章,务必看一看本文的拓展内容,知识点很多,不要错过,码字不易,点个赞鼓励一下
作用域、作用域链、预编译和闭包
拿上你专属的保温杯,一包瓜子,今天带你拿捏作用域、作用域链、预编译和闭包,让你以后遇见它不会像嗑瓜子一样上火。
作用域
作用域简单来说就是指,一段代码中所用到的的变量、函数和对象等的生效范围。
1. 全局作用域
全局作用域,是可以被整个程序访问到的作用域
var c = 'ccc'
function F() {
console.log(c)
}
F()
复制代码
上面这段代码我们拿去执行,打印结果如下:
会发现,在函数F里面,并没有定义变量c,可是,在调用函数F的时候,他仍然打印出了c的值,这是因为内部作用域是可以访问到外部作用域的(原因会在后文作用域链中提到),而全局作用域就是整个程序最外层的作用域,那么函数F就是在全局作用域里面的内部作用域,也正因为全局作用域是最外层的作用域,所以,定义在全局作用域下的变量可以被整个程序访问到。
如果把第一行代码var关键字去掉呢
会发现结果还是一样,这是因为c='ccc'写在了全局作用域,就算没有var,let,const的声明,也会被挂在window对象上,等价于window.c='ccc'。
2. 函数作用域
函数作用域,就是定义在函数里的变量的作用范围在这个函数里。
function F() {
var c = 'ccc'
}
console.log(c)
复制代码
将c定义在函数里,然后在全局下打印这个c,结果如下:
结果是报错了,报错内容是c没有被定义,这是因为,c是定义在函数里的,全局作用域是函数F的外部作用域,外部作用域是无法访问到内部作用域的(原因会在后文作用域链中提到),所以这里报了错。
3. 块级作用域
块级作用域,简单来说,{}(花括号)包裹的就是一个块级,花括号里面可以访问外面,外面无法访问里面,常见的if,for循环,while循环,let作用域等
function foo(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a);//1
console.log(b);//3
}
console.log(b)//2
console.log(c)//4
console.log(d)//error 因为代码块执行后就销毁,故d被销毁了,不存在,所以是error,如果存在却未定义叫做undefined
}
foo()
复制代码
上述例子有五个打印,结果分别是1,3,2,4,报错,这里涉及到一个花括号包裹的代码块,是个块级作用域,我们来分析一下:
-
第一个打印a,结果是1,是因为花括号是写在foo函数里面的,内部作用域可以访问外部作用域,因此,a能够顺利打印,结果为1,
-
紧接着花括号里面打印了b,b在花括号的外面和里面都有定义,那么从里面找到外面(具体会在后文作用域链中详细解释),先找花括号里面是否有b,如果没有就往foo里面找,一层层向外,那么这里的结果是有b,因此打印的是3
-
然后在花括号外面打印了b,因为,花括号包裹的是块级作用域,且花括号里面的b是关键词let声明的,所以根据外部作用域不能访问到内部作用域,是访问不到花括号里的b的,因此只能访问花括号外的b,故打印的是2
-
然后是打印c,c虽然是花括号里的,但是他是关键词var声明的,var不会有块级作用域的效果,所以c可以被访问到,打印是4
-
最后是d,d也是let声明的,且因为代码块执行后就销毁,故d被销毁了,不存在,所以访问不到,可是画括号的外部也没有定义d,故报未定义d的错。
变量提升和预编译
1. 变量提升
变量提升javaScript 代码在执行过程中,JavaScript引擎会把
变量声明部分
和函数声明部分
提升到代码的最前面的“行为”,根据提升的顺序,如果变量名相同,那么后者覆盖前者,且函数声明提升会在变量声明提升之后,当使用let,const等关键字时,是不会进行变量提升的。
首先我们了解一下代码是怎么运行的
- 在执行过程中,若使用未声明的变量,js执行会报错
- 在一个变量定义之前使用它,不会报错,但是该变量的值为undefined,而不是定义的值
- 在一个函数定义之前使用它,是不会报错,且函数能正确执行
其次是要分清函数声明和函数表达式(因为函数声明是会进行变量提升的,但是函数表达式不会):
-
函数声明:function 函数名(){}
-
函数表达式: var 函数名=function(){}
最后要分清变量的声明和赋值,一般我们写代码是这样写的,var a = 'aaa',但这其实是两部分,var a是变量声明,a = 'aaa'是赋值。
下面举一个变量提升的简单例子,看一下这段代码的打印结果:
console.log(a)
var a = 123
foo()
function foo (){
console.log(a)
}
复制代码
var a;
function foo (){
console.log(a)
}
console.log(a)
var a = 123
foo()
复制代码
这样一来,执行结果就相符了
2. 预编译
在了解预编译之前首先了解一下AO对象和GO对象
- AO对象(Activation Object),在函数预编译时创建,函数执行上下文对象
- GO对象(Global Object),在全局预编译时创建,全局执行上下文对象
接下来,我们来了解预编译到底会干些什么
预编译发生在函数执行的前一刻(四部曲)
- 创建AO对象(Activation Object)
- 找形参和变量声明,将变量声明和形参作为AO的属性名,值为undefined
- 将实参和形参值统一
- 在函数体里找函数声明,将函数名作为AO对象的属性名,值赋予函数体
以下面代码举个例子:
function test(a,b){
console.log(a)
c = 0
var c;
a = 3
b = 2
console.log(b)
function b(){}
function d(){}
console.log(b)
}
test(1)
复制代码
让我们来分析一下这段代码:
//首先创建一个AO对象
AO:{
//然后找形参和变量声明,将变量声明和形参作为AO的属性名,值为undefined
a:undefined,
b:undefined,
c:undefined
//接着将实参和形参值统一
a:1,
b:undefined,
c:undefined,
//最后在函数体里找函数声明,将函数名作为AO对象的属性名,值赋予函数体
a:1,
b:function b() {},
c:undefined,
d:function d() {}
}
复制代码
至此,预编译的四部曲结束,预编译完成,接下来是开始调用,函数执行 第一行执行console.log(a),此时经过预编译后,a的值是1,所以打印出来应该是1 接着c被赋值成0,var c会被变量提升,提到前面去声明,a被赋值成3,b被赋值成2,所以此时的AO对象变成了
AO:{
a: 3,
b: 2,
c: 0,
d: function d() {}
}
复制代码
所以接下来执行console.log(b),打印的结果就是2, 紧接着,b和d的函数声明在预编译的时候就已经编译过了(先执行),b的值被改成了2(后执行),所以值为2(前者被后者覆盖),而d没有变, 所以最后一行console.log(b)打印结果还是2,上截图
预编译也发生在全局(三部曲)
- 创建GO对象
- 找形参和变量声明,将变量声明和形参作为GO的属性名,值为undefined
- 在全局里找函数声明,将函数名作为GO对象的属性名,值赋予函数体
与函数的预编译相比,它少了将实参形参统一的步骤,除此之外基本与函数预编译一致,可以参考函数预编译的例子,这里就不重复解释了
作用域链
在了解作用域之前,要先了解一下这三个定义:
执行期上下文
:当函数执行时,会创建一个称为执行期上下文的内部对象。一个执行期上下文定义了一个函数执行时的环境,函数每次执行时对应的执行上下文都是独一无二的,所以多次调用一个函数会导致创建多个之I型那个上下文,当函数执行完毕,它所产生的执行上下文会被销毁[[scope]]
:作用域属性,也称为隐式属性,仅支持引擎自己访问。函数作用域,是不可访问的,其中存储了执行期上下文的集合。查找方式
:当根据作用域链查找变量的时候,是从作用域链的顶端一次向下查找的。
在 **[[scoped]]** 作用域属性中存储的执行器的上下文对象的集合,这个集合呈链式链接,我们把它称为作用域链
我们结合实例去理解作用域链:
function a(){
function b(){
}
b()
}
a()
复制代码
这是一个很简单的函数嵌套,每个函数都有他们自己的 [[scoped]] 属性,他们里面的作用域链都是不同的,首先看a的,
a的里面只有a的AO对象和全局的GO对象,接着是b的
b的里面有b的AO对象,a的AO对象和全局的GO对象, 综上可以知道,每个函数的作用域链其实是不一样的,他是从自身开始一层一层向外,越外层,排在越后面。
在知道了作用域链了之后,我们对其查找方式也可以进一步详细说明,前文说过查找变量的方式,当根据作用域链查找变量的时候,是从作用域链的顶端一次向下查找变量的。根据上面的图,我们知道,我们从哪个函数里面找变量,该函数是排在最顶端的,然后顺着作用域链去找,比如从b函数里面找变量,先从b的AO开始,如果找不到就进入a的AO,如果再找不到,就进入GO里面找。
这就是作用域链,它是一个链式集合。
闭包
什么情况下会形成闭包
函数内部定义的函数,被返回了出去并在外部调用时会产生闭包
function a(){
function b(){
var bbb = 234
console.log(aaa);//123 闭包
}
var aaa = 123
return b//b定义在a里面,但是被保存出去了
}
var demo = a()
demo()
复制代码
函数b是定义在函数a内部的,函数a执行时将函数b返回了出来,赋值给变量demo并调用。这种情况就产生了闭包
什么是闭包
闭包定义:在js中根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回的一个内部函数后,即使该外部函数已经执行结束,但是内部函数引用外部函数的变量依然保存在内存中,我们把这些变量的集合称为闭包
首先我们要知道,在一个执行上下文里面包括变量环境,词法环境和一个outer,简单的理解,就是var和function的声明会存储在变量环境中,而let,const,try-catch等声明会存储在词法环境当中,词法环境仍然保持一个栈的存储结构,而outer是指向当前执行上下文的上一级(父级)执行上下文。
接着我们用一个实例,让我们对闭包的认识不再那么抽象:
function foo() {
var myName = 'aaa'
let test1 = 1
let test2 = 2
var innerBar = {
getName: function () {
console.log(test1);
return myName
},
setName: function(newName){
myName = newName
}
}
return innerBar
}
var bar = foo()
bar.setName('bbb')
bar.getName()
console.log(bar.getName());
复制代码
全局下定义了foo函数和bar变量,将全局上下文压入调用栈,
接着,是foo函数的执行,所以将foo的执行上下文压入调用栈,并返回其内部的innerBar,将innerBar赋值给bar变量,
在foo执行结束后,foo的执行上下文应该被销毁,但是由于后面调用了getName函数和setName函数,且这两个函数里有使用到foo中定义的myName和test1,所以,foo的执行上下文(AO对象)并没有被销毁,而是变成了这样
没错,foo(closure)这个变量的集合就是闭包,它里面只有被用到的myName和test1,这个闭包就像一个小背包一样,函数getName和setName无论在哪里被调用,都会带着这个小背包。
相信,通过上述的例子,大家对闭包的认识更具象化了,那么闭包到底有什么用呢,他肯定是有优缺点的。
闭包的优点(作用)
- 实现公有化变量(企业的模块开发)
- 模块化开发,防止污染全局变量
假设现在有一个变量count = 0,要实现一个函数,使得每调用一次函数count的值都加一,如下
var count = 0
function test(){
count++
console.log(count);
}
test()//1
test()//2
test()//3
test()//4
复制代码
那么如果是这样写,就会造成全局变量污染,因为count是定义在全局的,但是闭包能够解决这个问题,
function add(){
var num = 0
function a(){
console.log(++num);
}
return a
}
var res = add()
res()
res()
res()
res()
复制代码
使用闭包的方法,这样既能保证模块化开发,又能放止污染全局变量
- 做缓存
function fruit(){
var food = 'apple'
var obj = {
eatFood:function(){
if(food!==''){
console.log('I am eating ' + food);
food = ''
}else{
console.log('There is nothing');
}
},
pushFood:function(myFood){
food = myFood
}
}
return obj
}
var person = fruit()
person.eatFood()
person.eatFood()
person.pushFood('banana')
person.eatFood()
复制代码
这段代码运行的结果如下
我们会发现,像这样,我们可以使得两个或多个函数,去连续的修改一个变量(此处的food),这就叫做缓存。
- 实现属性的私有化
闭包的缺点
- 闭包会导致原有的作用域链不释放,造成内存泄漏,导致调用栈的空间原来越少,而调用栈其实是有固定大小的,所以会导致栈溢出。闭包虽然有这个缺点,但是它利大于弊,我们要注意的是不要滥用闭包就好。
拓展
变量的查找路径
前面我们知道了,执行上下文中,有变量环境,词法环境,和outer,当我们查找变量的时候,我们是从词法环境开始找,如果没有,则进到变量环境找。如图:
那么如果还没有,就会去到outer指向的执行上下文里继续找,就这样,直到找到为止,如果找到底了还是没有,就会报错。前文说过outer是指向当前执行上下文的上一级(父级)执行上下文,那么这个上一级的执行上下文到底是哪个呢?我们看下面这个例子:
function bar(){
console.log(myName);
}
function foo () {
var myName = 'aaa'
bar()
}
var myName = 'bbb'
foo()
复制代码
bar里没有myName,他要去父级执行上下文中去找,这个打印结果一般来说,大家都会觉得会进入foo的执行上下文去找(也就是说认为outer指向的是foo,foo是bar的父级),所以最终打印的应该是aaa,实则不然:
打印的结果是bbb,因为,bar的父级执行上下文其实是Window全局,所以他找到了全局下的myName,打印bbb,这是因为,父级执行上下文不是看这个函数在哪里被调用的,而是看它在哪里被定义的。 bar是在全局下定义的,所以全局的执行上下文才是它的父级执行上下文,bar执行上下文中的outer指向全局执行上下文(GO对象)。
一道难题(面试题)
for(var i = 0;i<6;i++){
setTimeout(()=>{
console.log(i);
})
}
复制代码
可以看到,上述代码的执行结果是打印了六次6,这是因为setTimeout是异步执行的,简而言之就是会放到最后一起执行,我们如何让他照常打印出0,1,2,3,4,5呢?
最根本的思路:找个变量将i存起来。
第一种方法就是用let
将代码中for循环的var i 改成let i
for(let i = 0;i<6;i++){
setTimeout(()=>{
console.log(i);
})
}
复制代码
其原理如下
let i;
for(i=0;i<10;i++){
let j = i
setTimeout(()=>{
console.log(j)
})
}
复制代码
这其实就是相当于在for循环里面定义了一个j,去保存i的值,而let 声明不会变量提升,var的变量声明会提升,所以将var改成let即可,这是最简单的方法,那么其实还有另外两种。
第二种就是今天聊到的闭包
将代码改写成闭包的样子:
for(var i = 0;i<6;i++){
(function(j){
setTimeout(()=>{
console.log(j);
})
})(i)
}
复制代码
这是一个自执行函数形成闭包,因为let声明不会变量提升,var会,但是在函数中var声明提升到函数体内的最前面,不会提升到函数外面,换句话说,var的变量提升会穿过到{}(花括号)块级作用域外面,但是不会穿过到函数作用域外面,这就相当于,在自执行函数定义了一个形参j,然后将i传进去,用j去保存i的值,而这里定义j变量提升不会提到外面去,所以生效,这就是闭包的好处。打印结果如下:
第三种方法就是用setTimeout的第三个参数
将代码中setTimeout后面传入第三个参数
for(var i = 0;i<6;i++){
setTimeout((j)=>{
console.log(j);
},1000,i)
}
复制代码
这第三个参数,就是将i传进去,然后setTimeout中的箭头函数定义一个形参j去保存i,最后的效果如下:
这三种方法在最基本的思路都是用另一个变量去保存i的值,其中有一种是闭包的方法解决问题,就拉出来讲一下。
以上就是本次给大家带来的内容,码字不易,点个赞支持一下吧!!