今天看啥  ›  专栏  ›  吴灿同学

从变量提升、执行上下文到闭包、this

吴灿同学  · 掘金  ·  · 2019-10-26 15:52
阅读 12

从变量提升、执行上下文到闭包、this

一、变量提升

  1. 什么是变量提升?

    变量提升是指在 JS 代码的执行过程中,JavaScript 引擎把变量和函数的声明部分提升到代码开头的行为。

  2. 为什么会有变量提升?

    ES6 之前的 JS 没有块级作用域,所以把作用域内部的变量统一提升是最快速、最简单的设计。

  3. 变量提升带来的问题

    (1) 变量容易在不被察觉的情况下被覆盖掉;

    (2) 本应销毁的变量没有被销毁。

  4. 同名变量和函数的两点处理原则

    (1) 如果是同名的函数,JavaScript 编译阶段会选择最后声明的那个;

    (2) 如果变量和函数同名,那么在编译阶段,变量的声明会被忽略。执行阶段变量正常赋值。

    console.log(foo)     // 执行结果为:f foo() {},说明变量foo的声明被忽略了
    function foo() {}
    var foo = 2;
    console.log(foo)     // 执行结果为:2,说明变量foo的赋值被执行了
    复制代码
  5. 如何解决变量提升带来的问题

    ES6 引入let,让 JavaScript 也有块级作用域。

  6. 在同一段代码中,ES6 是如何做到既支持变量提升又支持块级作用域的?

    JS 代码在执行过程中会经历编译和执行两个过程。

    • 编译阶段,创建执行上下文时,通过var声明的变量,被放到执行上下文的变量环境中,通过let声明的变量,被放到执行上下文的词法环境中。块级作用域中通过let声明的变量没有被放到词法环境。
    • 执行阶段,进入块级作用域时,通过let声明的变量,被存放到当前执行上下文的词法环境中一个单独的区域,这个区域并不影响块级作用域外面的变量,即这个区域和区域外的同名变量是独立的存在。
  7. 什么是变量环境和词法环境?

    变量环境和词法环境都是执行上下文中定义的对象。变量环境对象保存的是变量提升的内容,词法环境对象保存的是通过letconst声明的变量。

二、执行上下文

  1. 什么是执行上下文?

    JS 代码在编译的时候会生成执行上下文和可执行代码。执行上下文是 JavaScript 执行一段代码时的环境,其中存在一个变量环境的对象,该对象保存了变量和函数的声明部分。

  2. 什么情况下会创建执行上下文?

    • JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
    • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束后,创建的执行上下文会被销毁。
    • 当使用eval函数的时候,eval的代码也会被编译,并创建执行上下文。
  3. 如何管理执行上下文?

    JavaScript 用调用栈管理上下文。

三、调用栈

  1. 什么是调用栈?

    调用栈是管理执行上下文的栈,是 JavaScript 引擎追踪函数执行的一个机制,通过调用栈可以追踪到哪个函数正在被执行以及各函数之间的调用关系。

  2. 代码执行过程中,调用栈是如何变化的?

    (1) 首先创建全局上下文,并将其压入栈底,然后执行全局上下文。

    (2) 执行到函数调用代码时,编译被调用的函数,为其创建一个执行上下文,并将执行上下文压入栈中。然后执行函数代码。

    (3) 再次执行到函数调用代码时,重复第 (2) 步。

    (4) 栈顶函数执行完后,其执行上下文从栈顶弹出,并将函数的返回值赋给调用函数的相应变量。

    (5) 继续执行栈顶函数,并重复第 (4) 步,直至全局代码执行完毕。

  3. 如何在浏览器中查看调用栈的信息?

    以 Chrome 浏览器为例,在开发者工具的"Sources"工具栏中,给指定代码加上断点,代码执行到断点处时暂停执行,在右侧的"Call Stack"中查看当前的调用栈。

    另一种方法是,在函数代码中加上console.trace()语句,然后在控制台查看输出结果。

  4. 什么是栈溢出?什么情况下会出现栈溢出?

    调用栈是有大小的,当入栈的执行上下文超过一定数目时,JavaScript 引擎就会报栈溢出的错误:

    Uncaught RangeError: Maximum call stack size exceeded
    复制代码

    没有终止条件的递归函数,会一直创建新的执行上下文,并反复将执行上下文压入调用栈中,最终超出栈的容量限制,导致栈溢出。

四、作用域

  1. 什么是作用域?

    作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

  2. 有哪几种作用域?

    ES6 之前,只有两种作用域:全局作用域和函数作用域。ES6 增加了块级作用域。

    (1) 全局作用域中的对象在代码中的任何地方都能被访问,其生命周期伴随着页面的生命周期。

    (2) 函数作用域就是在函数内部定义的变量或函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

    (3) 块级作用域是在大括号内代码块中使用letconst声明的变量,变量只能在代码块内被访问。代码块执行结束后,代码块内定义的变量会被销毁。

  3. 什么是作用域链?

    每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文。当一段代码使用了一个变量时,JavaScript 引擎首先会在当前的执行上下文中查找该变量,如果没有找到,就继续在外部引用所指向的执行上下文中查找。这个查找的链条就是作用域。

  4. 变量的具体查找方式?

    在当前执行上下文中,沿着词法环境的栈顶向下查询,如果找到了变量,就直接返回给 JavaScript 引擎,如果没有找到,就继续在变量环境中查找,找到了就返回,没找到就在当前执行上下文的外部引用所指向的执行上下文中查找。

  5. 为什么函数的执行上下文的外部引用不是调用它的函数的执行上下文?

    因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。词法作用域是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,在代码阶段就决定好了,和函数是怎么调用的没有关系。通过词法作用域能够预测代码在执行过程中如何查找标识符。

五、闭包

  1. 什么是闭包?

    在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使外部函数已经执行结束了,内部函数引用外部函数的变量也依然保存在内存中,这些变量的集合称为闭包

  2. 如何在浏览器中查看闭包?

    打开 Chrome 的开发者工具,在使用闭包的函数的任意地方打上断点,然后刷新页面,代码执行到断点处时,可以在右侧"Scope"中的"Closure"查看闭包。

  3. 闭包中变量的查找方式?

    先在当前执行上下文中查找,如果没有找到,就查找外部函数的闭包,仍没有找到就去外部函数的外部引用所指向的执行上下文中查找。体现在"Scope"中,这个查找过程就是:Local -> Closure -> Global

  4. 闭包是怎么回收的?

    通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,垃圾回收器就会回收这块内存。

  5. 闭包的应用场景

    通常使用只有一个方法的对象的地方,都可以使用闭包。

    (1) 为响应事件而执行的函数。

    function makeSizer(size) {
        return function() {
            document.body.style.fontSize = size + 'px';
        };
    }
    document.getElementById('size-12').onclick = makeSizer(12);
    document.getElementById('size-14').onclick = makeSizer(14);
    复制代码

    (2) 用闭包模拟私有方法:用闭包定义公共函数,并令其可以访问私有函数和变量。这个方式称为“模块模式”。

    var makeCounter = function() {
        var privateCounter = 0;
        function changeBy(val) {
            privateCounter += val;
        }
        return {
            increment: function() {
                changeBy(1);
            },
            document: function() {
                changeBy(-1);
            },
            value: function() {
                return privateCounter;
            }
        }
    }
    
    var Counter1 = makeCounter();
    // Counter1和Counter2指向两个不同的对象,在一个闭包内对变量的修改,不会影响到另外一个闭包中的变量
    var Counter2 = makeCounter();
    var Counter3 = Counter1;       // Counter3和Counter1指向同一个对象,会相互影响
    复制代码

    (3) 解决循环中使用var定义变量造成的常见错误。

    var a = [];
    
    for (var i = 0; i < 10; i++) {
        a[i] = {};
        a[i].log = function() {
            console.log(i);
        }
    }
    
    a[2].log();    // 10
    复制代码

    问题:调用数组a任何一个元素的log方法,都打印10。

    原因:数组a中每个元素中的log属性的值都为function() { console.log(i) },for循环执行结束后,i没有被销毁,值为10,所以打印的结果都是10。

    解决方案:

    1. 增加一个闭包

      var a = [];
      
      function set(x) {
          return function() {
              console.log(x);
          }
      }
      
      for (var i = 0; i < 10; i++) {
          a[i] = {};
          a[i].log = set(i);
      }
      
      a[2].log()   // 2
      复制代码

      原理:可以理解为闭包帮忙保存了每次执行循环时当前的i值。

      • 数组a中第i个元素的log属性值为set(i)函数的返回值;
      • set函数编译时,变量x进入set函数的执行上下文,执行时,x被赋值为传入的i
      • set函数执行完后,其执行上下文从调用栈顶部弹出,但闭包中的变量x依然保存在内存中,a[i]被赋值为set函数返回的函数function() { console.log(x) }
      • 数组中每个元素的log属性被赋值时,都会重新编译并执行set函数,生成新的函数执行上下文,函数执行完后,其执行上下文被销毁,但被赋值为当前i值的x都继续保存在内存中,所以每个元素的log方法所”拥有“的x值都不一样。
    2. 使用立即执行的匿名函数

      var a = [];
      
      for (var i = 0; i < 10; i++) {
          a[i] = {};
          a[i].log = function(num) {
              return function() {
                  console.log(num);
              }
          }(i)
      }
      
      a[2].log()  // 2
      复制代码

      原理:和第一种方案相同。

    3. 使用let关键字

      var a = [];
      
      for (let i = 0; i < 10; i++) {
          a[i] = {};
          a[i].log = function() {
              console.log(i);
          }
      }
      
      a[2].log()  // 2
      复制代码

      原理:花括号加let关键字形成了块级作用域。数组a的每个元素的log方法的[[Scopes]]下都有一个Block属性保存块级作用域的值,如下图所示。

      avatar

      使用var关键字的结果如下图所示。

      avatar

  6. 合理使用闭包

    如果不是某些特定任务需要使用闭包,不建议在其他函数中创建函数,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。

    例如,在创建新的对象或者类型时,方法通常应该关联于对象的原型,而不是定义到对象的构造函数中。原因是后者会导致每次构造函数被调用时,方法都会被重新赋值一次。

六、this指向

  1. JavaScript 中为什么会出现this

    JavaScript 的作用域机制不支持在对象内部的方法中使用对象内部的属性,所以使用this来解决这个问题。

  2. this是什么?

    this是和执行上下文绑定的,每个执行上下文都有一个this

    结合上文所述,执行上下文中包含:变量环境、词法环境、outer(当前执行上下文指向外部执行上下文的引用)、this

  3. 不同执行上下文中的this

    (1) 全局上下文中的this:指向window对象。这也是this和作用域链的唯一交点,作用域链的最低端包含了window对象,全局执行上下文中的this也是指向window对象。

    (2) 函数执行上下文中的this

    • 通过函数的call方法设置的this:调用call方法的函数中的this指向了call方法传入的第一个参数。也可以通过applybind方法设置函数执行上下文的this

      let bar = {
          myName: 'Helena'
      }
      function foo() {
          this.myName = 'Wucan'
      }
      foo.call(bar)
      console.log(bar.myName)  // Wucan
      复制代码
    • 通过对象调用方法设置this:使用对象来调用其内部的一个方法,该方法的this是指向对象本身的。

      var myObj = {
          name: 'Helena',
          showThis: function() {
              console.log(this.name);
          }
      }
      myObj.showThis()  // Helena
      myObj.showThis.call(myObj)   // Helena
      
      var foo = myObj.showThis;
      foo()   // undefined,this指向了全局对象
      复制代码

      结论:

      • 在全局环境中调用一个函数,函数内部的this指向的是全局变量window
      • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的this指向对象本身
    • 在构造函数中设置this

      function Person() {
          this.name = 'name'
      }
      var p = new Person()
      复制代码

      通过new和构造函数创建的对象中的this指向对象本身。

  4. this的设计缺陷及应对方案?

    (1) 嵌套函数中的this不会从外层函数中继承

    var name = 'Wucan';
    
    var myObj = {
        name: 'Helena',
        showThis: function() {
            console.log(this.name);     // Helena,this指向myObj
            function bar() {
                console.log(this.name);    // Wucan,this指向window
            }
            bar();
        }
    }
    
    myObj.showThis()
    复制代码

    解决方案:

    • 第一种方案:把this保存为一个变量,再利用变量的作用域机制传递给嵌套函数。
    • 第二种方案:把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的this

    (2) 普通函数中的this默认指向全局对象window

    • 第一种方案:通过调用call方法改变this的指向。
    • 第二种方案:设置 JavaScript 为严格模式。严格模式下,默认执行一个函数,其函数执行上下文中的this值是undefined

问题:

  1. 下面这段代码会产生栈溢出的问题,如何优化它,以解决栈溢出的问题?

    function runStack (n) {
      if (n === 0) return 100;
      return runStack( n- 2);
    }
    runStack(50000)
    复制代码

    试着改为以下代码即可不溢出:

    function rs(n) {
        while (n !== 0)
            n = n - 2;
        n = 100;
        return n;
    }
    rs(58579862)
    复制代码

    想试一下传入多大的参数会出现异常,于是试了下:这个值的临界点有时是58579862,有时是58999998,反正不是个确定的值。想知道这个值的大小和什么有关系?为什么超过了临界值就不会输出结果,也不报错?

  2. 下面这段代码,想结合上述概念手动模拟一下编译和执行的整个过程:

    function foo() {
        var myName = "极客时间"
        let test1 = 1
        const test2 = 2
        var innerBar = {
            getName:function(){
                console.log(test1)
                return myName
            },
            setName:function(newName){
                myName = newName
            }
        }
        return innerBar
    }
    var bar = foo()
    bar.setName("极客邦")
    bar.getName()
    console.log(bar.getName())
    复制代码

    打断点查看的时候,发现有一个地方与想象中不一样。代码执行到断点处时,按照先编译再执行的思路,Local中应该有四个值为undefined的变量:myNametest1test2innerBar,实际却如下图所示。test1是在执行完let test1 = 1后出现在Local中且同时被赋值为2的。

    avatar

    以为是letconst声明在编译时还是有区别的,于是尝试做以下两种修改:

    (1) let test1 = 1 改为const test1 = 1,结果一样;

    (2) 将getName()方法中的console.log(test1)改为console.log(test2),断点执行如下图,test2变成了test1

    avatar

    所以猜想这里是否与闭包中引用了外部函数的变量有关?


参考:

  1. 【极客时间】浏览器工作原理,李兵。
  2. 【MDN】闭包:developer.mozilla.org/zh-CN/docs/…



原文地址:访问原文地址
快照地址: 访问文章快照