【详解】JS中的作用域、闭包和回收机制

时间:2024-03-25 11:34:50

  在讲解主要内容之前,我们先来看看JS的解析顺序,我们惯性地觉得JS是从上往下执行的,所以我们要用一个变量来首先声明它,来看下面这段代码:

  alert(a);
  var a = 1;

  大家觉得这段代码有什么问题吗?会报错吧,a变量没有声明就调用?或者,可能弹出数字1?

  实际上,你会发现这段代码执行的结果是弹出一个“undefined”,为什么会这样呢?下面我们就来讲讲JS的解析顺序。

1、JS的解析顺序

  其实说JS是自上而下解析是正确的,但是是分为两步:

  1. 先自上而下解析声明,包括用var、function声明的变量和函数,以及函数的参数的声明(隐式声明)。这里要注意解析声明并不会赋值,比如你写了var a = 1;在这一步只会解析var a。这一步被称为预解析,在作用域内声明的变量会被提升到作用域的顶部,且对其赋值undefined,这个过程称之为变量提升;而在作用域内的函数定义会被提升到作用域的顶部,其值为函数本身,这个过程称之为函数提升。
  2. 再自上而下执行,包括赋值、判断、循环、函数调用等等。这里要注意通过function声明的部分是直接跳过的,因为这部分属于声明,而不是执行代码,只有当这个函数被调用的时候,函数内部的代码才会被解析,而事件类函数是要事件触发的时候才会执行。

  我们来看下面这个案例:

alert(a);
var a = 123;
function a(){
alert('a1');
}
function a(){
alert('a2');
}
alert(a);
a();

  解析分析:

step1:解析声明var a、function a(){alert('a1');}、function a(){alert('a2')},这里因为开始声明的变量a和后面声明的两个函数a重名了,而JS中函数的声明优先于变量的声明(即使变量声明在后,函数声明在前,a也依然是函数),所以第3行的函数声明会覆盖第2行的变量声明,而第6行又声明了一个同名函数,此时后面声明会覆盖前面声明,故a会变为第6行声明的函数。

    step2:执行第1行alert(a),这里弹出第6行声明的函数体function a(){alert('a2');};然后执行第2行a=123,这里a从函数又变成了数字123;然后执行第9行的alert(a),这里弹出123;最后执行a(),此时a是数字不是函数,这个调用是有语法错误的,故会报错。

  以上是JS解析的基本规则,为了能熟练运用这个规则,我们还需要了解一下JS中的作用域,本文将用大量的案例来讲解作用域和闭包的知识。

2、JS的作用域

  在ES5中作用域分为两种:

  1. 全局作用域
  2. 局部作用域

  直接定义在script标签下的变量和函数都在同一个作用域——全局作用域,在全局作用域里定义的变量和函数,分别被称为全局变量和全局函数,它们在函数作用域里也是能够被访问的。

  在某个函数或者对象的内部定义的变量和函数所在的作用域为局部作用域,这部分变量和函数只在函数或对象的内部有效,将不能在函数或对象外直接访问(只能间接访问)。在ES6中还会有块级作用域,即任何用{ }包含起来的代码块都为一个块级作用域,在本文中只讲ES5中的全局和局部作用域。

特性:

  除了父级的this和arguments这两个特殊的对象,局部作用域可以访问父级和全局作用域里的变量和函数;父级和全局不能直接访问局部的变量和函数。当局部声明的变量(或函数)与父级或者全局的变量(或函数)名字相同时,局部优先使用自己内部声明的。

  接下来我们一起看几个例子:

案例1:

    fn();
alert(a);
var a = 0;
alert(a);
function fn() {
var a=1;
}

  解析分析:首先看全局作用域

  step 1:解析声明 var a、function fn(){}。

  step 2:执行 fn();----------函数局部作用域:step1:解析声明 var a;

                       step2:执行a=1; 注意这里的a是函数内部的a,不是全局的a。

        alert(a);-----在全局找到var a的声明,a未赋值,弹出undefined。

        a=0;----------给全局变量a赋值0。

        alert(a);------弹出0。

案例2:

    fn();
alert(a);
var a = 0;
alert(a);
function fn() {
a = 1; //跟案例1仅此处不同
}

  解析分析:先看全局,局部直接跳过,调用的时候再看。

  step1:解析声明:var a、function fn(){}。

  step2:执行:fn();----------step1:查找声明,没有任何声明。

              step2:执行a=1,这里函数内部没有声明变量a,会往上看父级有没有,父级(全局)有变量a,这里直接给这个a赋值。

        alert(a);-------弹出1,因为这个全局变量,在执行fn()函数的时候被赋值为1。

        a=0;------------将a的值改为0。

        alert(a);--------弹出0。

案例3:

    var a = 0;

    !function(a){
alert(a)
}()

  解析分析:注意函数参数的隐式声明。

  step1:解析声明var a、function(){}。

  step2:执行a=0。

       function(){}这个匿名函数的自执行-------step1:解析声明var a,这里是形参a的声明。

                          step2:执行alert(a),弹出undefined。

  注意这里弹出undefined,是因为自执行的传参的()里也没有传任何参数,且函数内部在alert(a)之前也未给a赋值,故a为undefined。

案例4:

    function fn(a){
var a =0;
alert(a);
}
fn(3);

  解析分析:函数形参的声明是在函数的第一行代码之前,调用函数时传入的实参赋值给形参也是在执行的第一步。

  step1:解析声明function fn(){}

  step2:执行fn(3)-------step1:声明形参var a,注意第一行代码的var a声明是多余的。

            step2:执行a=3,先将传入的实参数3赋值给形参a。

                  a=0,改变参数a的值为0。

                  alert(a),弹出0。

案例5:

    var a = 1;
function fn() {
var b = 5;
return function () {
b++;
alert(b)
}
}
fn()();

  解析分析:函数的作用域在函数定义时就决定了它的位置,而不是在执行的时候决定的,只不过这个作用域在执行时,才生效。

  step1:解析声明:var a、function fn(){}。

  step2:执行:a=1。

        fn()----------step1:声明:var b、function(){}

             step2:执行:b=5; return function(){}。

        fn()()-------相当于执行的是上一步return返回的匿名函数。

             step1:声明:没有声明。

             step2:执行:b++,b这个变量自己没有,往父级fn里找,可以找到变量b,其值为5,这里++,变为6。

                    alert(b),弹出6;

案例6:

    fn()();
var a = 0;
function fn() {
alert(a);
var a = 3;
function c() {
alert(a);
}
return c;
}

  解析分析:

  step1:解析声明:var a、function fn(){}。

  step2:执行:fn()-------step1:声明var a、function c(){}。

              step2:执行alert(a),先在自己的作用域找有没有a,有a未赋值,弹出undefined。

                  a=3,给自己的a赋值3。

                  return c,返回c这个函数。

·        fn()()-----相当于执行c函数。step1:解析声明,没有任何声明。

                     step2:执行alert(a),自己没有a,往上父级有a,弹出父级a的值3。

        a=0,给全局变量a赋值0。

案例7:

    function fn() {
var a;
alert(a);
if(true){
var a = 1;
}
alert(a);
}
fn();

  解析分析:ES5中if、for、while、switch等的{ }不算单独的作用域。

  step1:解析声明:function fn(){}。

  step2:执行:fn()-------step1:声明var a。

              step2:执行alert(a),弹出undefined。

                  if(true),if判断为真,下一步执行if内部代码

                  a=1,注意if里虽然有var a,但属于重复声明,这个a就是fn函数体一开始声明的那个a。

                  alert(a),弹出1。

案例8:

     var y = 1;
if(function(){}){
y += typeof f;
}
console.log(y);

  解析分析:JS中有六种情况为假:“ ”,0,NaN,undefined,null,false。当一个变量未声明就直接用typeof获得它的类型时,typeof会返回‘undefined’。

  step1:解析声明:var y、function(){}。

  ste2:执行:y=1。

        if判断:先将function(){}隐式转换为字符串,字符串不为空,再转换为true。

        if内部:y += typeof f,等价于y = y + typeof f,先看等号右边f是一个未声明的变量,默认声明,typeof f 为undefined。

                  当+号的两边不都是数字的时候,会实现拼接,故得到1undefined,赋值给y。

        console.log(y),控制台打印出1undefined。

案例9:

    var foo = 1;
function bar(){
if(!foo){
var foo = 10;
}
alert(foo);
}
bar();

  解析分析:

  step1:解析声明:var foo、function bar(){}。

  step2:执行foo=1。

        bar()-------step1:声明var foo。

            step2:执行if判断,foo是函数作用域里声明的foo,此时为undefined,会转换为false,故!foo为true。

                  if内部,foo=10。

                 alert(foo),弹出10。

特殊案例:

    var a = 5;
function fn() {
var a = 10;
alert(a)
function b() {
a++;
alert(a)
}
return b;
}
var c = fn();
c();
fn()();
c();

  解析分析:

  step1:解析声明:var a、function fn(){}、var c。

  step2:执行:①a=5。

        ②c=fn(),先执行fn()-----step1:声明var a、function b(){}。

                  step2:执行a = 10。

                        alert(a),弹出10。

                        return b。

             将fn()执行的结果赋值给c,此时c=b。

        ③c(),相当于执行b()------step1:没有声明。

                    step2:a++,自己没有a,找父级fn要,父级a=10,此时a=11。

                      alert(a),弹出11。

        ④fn()(),先执行fn()-----step1:声明var a、function b(){}。

                  step2:执行a = 10。

                        alert(a),弹出10。

                        return b。

            再执行fn()(),相当于执行b()------step1:没有声明。

                            step2:a++,自己没有a,找父级fn要,父级a=10,此时a=11。

                              alert(a),弹出11。

        ⑤c(),相当于执行b()?------step1:没有声明。

                      step2:a++,自己没有a,找父级fn要,父级a=10,此时a=11?

                        alert(a),弹出11?实际运行的时候我们会发现这里弹出的是12。

  这就是我们接下来要讲的闭包。

3、JS中的闭包

  在上一节的特殊案例中,函数b中用到了父级作用域的一个变量a,然后我们将这个函数b赋给了c,当c被调用的时候,变量a的值会保存c此次对其执行的改变,故当我们第二次调用c的时候,a的值会在11的基础上再加1,如果我们重复调用c,我们就能看到a的值每次都在增加。

  这里需要注意的是,同一个函数定义,每被调用执行一次都是在产生一个新的作用域,比如上例中的fn,第一次调用的时候把b赋值给了c,然后在倒数第二行的时候又被调用了一次,但此次产生的作用域和c=fn()()时产生的作用域是不同的两个,所以倒数第二行的fn()()不会影响到c()中a的值。

  我们来看看闭包形成的条件:

  1. 函数嵌套函数。
  2. 内部函数使用了外部函数的参数或者变量。

  作用:内部使用到的那个父级的参数或变量,能够被永久保存下来。

案例1:

    function fn() {
var a = 1;
return function () {
alert(++a)
}
}
var fn2 = fn();
fn2() //弹出2
fn2() //弹出3
var g = fn();
g(); // 弹出2
fn2(); // 弹出4
g(); //弹出3

  这个案例跟上一个案例类似,就不一步一步解析分析了,需要注意的是fn2和g,虽然都等于fn(),但是因为fn这个函数定义每次调用都会产生不同的作用域,故而fn2和g内部的变量a在是不同的作用域下,互不影响。而像fn2这样通过表达式被赋值的函数,每次调用都是在同一个作用域。

案例2:来讲一个闭包运用的例子

  假设我们页面上有n个li,我们要给每个li注册一个点击事件,点击的时候弹出li的序号,我们一般会这样写:

    var aLi = document.getElementByTagName('li');
for(var i = 0 ; i < aLi.length ; i++){
aLi[i].onclick = function(){
alert(i);
}
}

  初看觉得代码没有问题,但是实际上等我们运行的时候就会发现点击任何一个li弹出的都是n,这是因为当我们点击的时候,for循环早已运行完毕,i的值已经增加到n。

  (对上面的现象不明白的初学者可以看这段解释:for循环是给每一个li注册点击事件,仅注册而不执行,所以for循环不会等我们点击了第i个li之后,再执行第i+1次循环,当页面加载的时候,for循环已经瞬间执行完毕了,故i的值已经等于n了,这时候不论我们点击任何一个li,弹出的值都会是n。)

  闭包能够很好地解决这个问题,我们来回想一下闭包形成的条件,第一条函数嵌套函数,那么我们可以在点击函数的外面再包含一个函数;第二条内部函数使用外部函数的参数或者变量,这个参数或者变量会被永久保存下来,那么我们可以把i作为外面那个函数的参数或者变量,而这里i是在for循环的时候被声明的,那么我们可以作为外面函数的参数来用。

  var aLi = document.getElementsByTagName("li");
for(var i=0;i<aLi.length;i++){
(function (index) {
aLi[i].onclick=function () {
          alert(index)
       }
     })(i)//实参
  }

  这段代码完美地解决了我们刚才的问题,我们把for循环拆分来分析一下,如下面的代码:

    var aLi = document.getElementsByTagName("li");
var n = aLi.length; (function (index) {//此处隐含有一句 var index = 0;
aLi[0].onclick=function () {
         alert(index)
       }
  })(0)//实参 (function (index) {//此处隐含有一句 var index = 1;
aLi[1].onclick=function () {
         alert(index)
       }
   })(1)//实参 ...... (function (index) {
aLi[n-1].onclick=function () {
         alert(index)
       }
   })(n-1)//实参

  闭包虽然有这么好的优点,但在我们的实际工作中,我们会尽量避免使用它,因为使用的那个变量会被保存不会被释放(除了刷新页面或关闭页面),由于闭包的特性,会对内存的消耗较大。下面我们来讲一下JS的回收机制。

4、JS的回收机制

  在之前的例子中,我们提到一个函数的定义(通过function定义的函数)在每次调用都会形成一个新的作用域,这个现象令人费解,感觉JS一定是做了什么手脚。这就是我们接下来要讲的JS的回收机制。

  实际上任何一门编程语言都有自己的回收机制,又称为垃圾回收机制,试想如果一个语言没有自己的回收机制会是什么样?那我们的程序将会因为没有及时回收无用的变量和函数而占据越来越多的内存,会使得我们的程序越来越慢。好比我们的城市,如果没有垃圾处理机制,大家想想会是什么样?所以回收机制对于一门编程语言来说至关重要。

  回收机制要工作,首先得有回收的规则,即要明白哪些是要回收的,哪些是不回收的。那么,JS中是如何规定的呢?

  JS中规定变量所在的作用域的生命周期决定了变量的生命周期。故而全局变量是不会被回收的,除非您关闭网页,结束window;而函数内部的变量,则在函数被调用时生效,函数执行结束时会被回收,这就是为什么我们在父级不能直接访问子级变量的原因,而闭包又会有所不同。注意生命周期的长短由执行的时候决定,我们又回到之前那个特殊案例:

    var a = 5;
function fn() {
var a = 10;
alert(a)
function b() {
a++;
alert(a)
}
return b;
}
var c = fn();
c();
fn()();
c();

  我们来看看全局都有哪些变量和函数:变量a、c,函数fn,它们是不会被回收的,所以我们可以在全局去调用它们。

  重点来看看这句代码var c = fn(),首先执行fn(),首先按照我们的理解,当fn执行完后,它里面的变量和函数都会被回收,所占用的内存都会释放,但是这里,fn执行完毕后返回了一个b函数,这个函数赋值给了c,同时函数中还用到了父级fn的一个变量a,此时因为c是全局变量,其生命周期还未结束,所以JS会为c开辟一个闭包空间用来存储变量a和函数体b,同时回收掉fn里的变量和函数。

  这样在执行下一行代码fn()()的时候,再次调用fn(),因为之前调用fn后,里面的函数和变量等已经回收,所以这次又会重新为fn里面的函数和变量分配空间,产生一个新的作用域,fn()执行完后依然返回一个函数b,此时再执行fn()(),相当于执行b(),执行完毕后,因为全局没有变量引用到b,而b的父级fn函数的声明周期已经结束,所以会回收掉变量a和函数b,所以这行代码看似运用了闭包,实际上是一个假的闭包。

  最后一行代码c(),会直接执行之前为c开辟的闭包空间里的b函数体,函数体内部用到的变量a保存了上一次的值,所以这次会在上一次的基础上+1。

  通俗地讲,闭包实际上就是保护变量的一个封闭空间,保护一个即将被释放的变量不被释放,以便下次再用到它。所以它和全局变量一样都比较耗内存,一般我们会尽量避免使用它,比如我们之前在闭包的案例2,我们其实可以通过给每一个li对象一个自定义属性来实现所要的功能:

  

    var aLi = document.getElementsByTagName('li');

        for(var i = 0;i < aLi.length;i++){
aLi[i].index = i;//自定义属性来存储i
aLi[i].onclick = function () {
alert(this.index);
}
}

  而有时候,我们想要一直使用到的变量,也可以用定义为全局变量的方式来达到不被回收的目的,但是相比起闭包而言,全局变量还有一个更大的缺点,就是全局污染,当你随意地定义全局变量来容纳你应用的所有资源时,你的程序和其他应用程序、组件或类库之间发生冲突的可能性就会显著升高,这种时候使用闭包来隐藏信息,是一个有效的方法。