嗑嗑瓜子,聊聊作用域、作用域链、变量提升、预编译和闭包,别看太久,瓜子吃多了上火

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

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等关键字时,是不会进行变量提升的。

首先我们了解一下代码是怎么运行的

  1. 在执行过程中,若使用未声明的变量,js执行会报错
  2. 在一个变量定义之前使用它,不会报错,但是该变量的值为undefined,而不是定义的值
  3. 在一个函数定义之前使用它,是不会报错,且函数能正确执行

其次是要分清函数声明和函数表达式(因为函数声明是会进行变量提升的,但是函数表达式不会):

  • 函数声明:function 函数名(){}

  • 函数表达式: var 函数名=function(){}

最后要分清变量的声明和赋值,一般我们写代码是这样写的,var a = 'aaa',但这其实是两部分,var a是变量声明,a = 'aaa'是赋值。

下面举一个变量提升的简单例子,看一下这段代码的打印结果:

console.log(a)
var a = 123
foo()
function foo (){
    console.log(a)
}
复制代码
根据代码的运行规则,第一个打印结果是undefined,这里本应该报错才对,因为a的声明和赋值在console.log之后,可是他没有报错,但值也不是123,这是因为变量提升的关系,var a = 123,拆分成了var a(变量声明)和a = 123(变量赋值),变量声明会被提升到整段代码的最前面,但是赋值不会提升;第二个foo函数的执行也没有报错,打印出了123,这是因为函数声明部分被提升了,因为函数声明提升会在变量声明提升之后,所以这段代码经过变量提升之后是应该是等价于这样的:
var a;
function foo (){
    console.log(a)
}
console.log(a)
var a = 123
foo()
复制代码

这样一来,执行结果就相符了

这里给大家检测一下,函数声明会被提升而函数表达式是不会提升的,下面的这两张图,第一张是函数声明,打印的结果是123,而第二张是函数表达式,他的打印结果是报错了,这就说明了函数声明会被提升而函数表达式是不会提升的,所以,分清楚函数声明和函数表达式很重要。

2. 预编译

在了解预编译之前首先了解一下AO对象和GO对象

  • AO对象(Activation Object),在函数预编译时创建,函数执行上下文对象
  • GO对象(Global Object),在全局预编译时创建,全局执行上下文对象

接下来,我们来了解预编译到底会干些什么

预编译发生在函数执行的前一刻(四部曲)

  1. 创建AO对象(Activation Object)
  2. 找形参和变量声明,将变量声明和形参作为AO的属性名,值为undefined
  3. 将实参和形参值统一
  4. 在函数体里找函数声明,将函数名作为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,上截图

image.png

预编译也发生在全局(三部曲)

  1. 创建GO对象
  2. 找形参和变量声明,将变量声明和形参作为GO的属性名,值为undefined
  3. 在全局里找函数声明,将函数名作为GO对象的属性名,值赋予函数体

与函数的预编译相比,它少了将实参形参统一的步骤,除此之外基本与函数预编译一致,可以参考函数预编译的例子,这里就不重复解释了

作用域链

在了解作用域之前,要先了解一下这三个定义:

  • 执行期上下文:当函数执行时,会创建一个称为执行期上下文的内部对象。一个执行期上下文定义了一个函数执行时的环境,函数每次执行时对应的执行上下文都是独一无二的,所以多次调用一个函数会导致创建多个之I型那个上下文,当函数执行完毕,它所产生的执行上下文会被销毁
  • [[scope]]:作用域属性,也称为隐式属性,仅支持引擎自己访问。函数作用域,是不可访问的,其中存储了执行期上下文的集合。
  • 查找方式:当根据作用域链查找变量的时候,是从作用域链的顶端一次向下查找的。

在 **[[scoped]]** 作用域属性中存储的执行器的上下文对象的集合,这个集合呈链式链接,我们把它称为作用域链

我们结合实例去理解作用域链:

function a(){

    function b(){

    }
    b()
}
a()
复制代码

这是一个很简单的函数嵌套,每个函数都有他们自己的 [[scoped]] 属性,他们里面的作用域链都是不同的,首先看a的,

image.png

a的里面只有a的AO对象和全局的GO对象,接着是b的

image.png

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是指向当前执行上下文的上一级(父级)执行上下文

image.png

接着我们用一个实例,让我们对闭包的认识不再那么抽象:

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变量,将全局上下文压入调用栈,

image.png

接着,是foo函数的执行,所以将foo的执行上下文压入调用栈,并返回其内部的innerBar,将innerBar赋值给bar变量,

image.png

在foo执行结束后,foo的执行上下文应该被销毁,但是由于后面调用了getName函数和setName函数,且这两个函数里有使用到foo中定义的myName和test1,所以,foo的执行上下文(AO对象)并没有被销毁,而是变成了这样

image.png

没错,foo(closure)这个变量的集合就是闭包,它里面只有被用到的myName和test1,这个闭包就像一个小背包一样,函数getName和setName无论在哪里被调用,都会带着这个小背包。

相信,通过上述的例子,大家对闭包的认识更具象化了,那么闭包到底有什么用呢,他肯定是有优缺点的。

闭包的优点(作用)

  1. 实现公有化变量(企业的模块开发)
  2. 模块化开发,防止污染全局变量

假设现在有一个变量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()
复制代码

使用闭包的方法,这样既能保证模块化开发,又能放止污染全局变量

  1. 做缓存
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()
复制代码

这段代码运行的结果如下

image.png

我们会发现,像这样,我们可以使得两个或多个函数,去连续的修改一个变量(此处的food),这就叫做缓存。

  1. 实现属性的私有化

闭包的缺点

  • 闭包会导致原有的作用域链不释放,造成内存泄漏,导致调用栈的空间原来越少,而调用栈其实是有固定大小的,所以会导致栈溢出。闭包虽然有这个缺点,但是它利大于弊,我们要注意的是不要滥用闭包就好。

拓展

变量的查找路径

前面我们知道了,执行上下文中,有变量环境,词法环境,和outer,当我们查找变量的时候,我们是从词法环境开始找,如果没有,则进到变量环境找。如图:

image.png

那么如果还没有,就会去到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,实则不然:

image.png

打印的结果是bbb,因为,bar的父级执行上下文其实是Window全局,所以他找到了全局下的myName,打印bbb,这是因为,父级执行上下文不是看这个函数在哪里被调用的,而是看它在哪里被定义的。 bar是在全局下定义的,所以全局的执行上下文才是它的父级执行上下文,bar执行上下文中的outer指向全局执行上下文(GO对象)。

一道难题(面试题)

for(var i = 0;i<6;i++){
    setTimeout(()=>{
      console.log(i);
    })
}
复制代码

image.png

可以看到,上述代码的执行结果是打印了六次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);
  })
}
复制代码

image.png 其原理如下

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变量提升不会提到外面去,所以生效,这就是闭包的好处。打印结果如下:

image.png

第三种方法就是用setTimeout的第三个参数 将代码中setTimeout后面传入第三个参数

for(var i = 0;i<6;i++){
  setTimeout((j)=>{
    console.log(j);
  },1000,i)
}
复制代码

这第三个参数,就是将i传进去,然后setTimeout中的箭头函数定义一个形参j去保存i,最后的效果如下:

image.png

这三种方法在最基本的思路都是用另一个变量去保存i的值,其中有一种是闭包的方法解决问题,就拉出来讲一下。

以上就是本次给大家带来的内容,码字不易,点个赞支持一下吧!!

猜你喜欢

转载自juejin.im/post/7125369628884205576