深入理解JS(三) - 执行上下文、作用域链与闭包

196 阅读17分钟

前言:于无声处听惊雷

你是否发现了,在 JS 中只有内部函数能访问外部函数的变量,外部函数却不能访问内部函数的变量?还有,为什么所有的 JS 代码都是按照从上至下的顺序执行的?它们究竟受到什么机制的控制?善战者无赫赫之功。JavaScript 的精华所在,恰恰就是这些我们经常忽视的地方。你想知道的,我都会为你解答,起航!

一. 作用域(前置知识准备)

作用域相当于 JS 中的地基,是变量的“居住区域”,执行上下文、作用域链与闭包都要基于作用域,所以我们必须重提作用域。
作用域分为三种形态:全局作用域、函数作用域、块级作用域(ES6新增),三者的具体特性我在《深入理解JS(一) - 提升与TDZ》中已有详细说明,这里不多赘述,需要了解的可以点击前往学习一下。

作用域的嵌套关系

这里用一张图说明一下它们三个的嵌套关系。

2. 嵌套作用域示意图.png

  • 嵌套层级:
    • 全局作用域包含所有其他作用域(函数作用域和块级作用域);
    • 函数作用域可嵌套在其他函数作用域或块级作用域内;
    • 块级作用域可嵌套在函数作用域或其他块级作用域内。

词法作用域(很重要)

这里还需要补充说明一下 JS 采用的词法作用域(也叫静态作用域)。注意了!词法作用域和上面所提到的全局作用域、函数作用域、块级作用域是不同维度的概念,你其实是找不出任何一个具体的属于词法作用域的变量或是函数!
词法作用域虽然被称为作用域,但是它本质上只是一种作用域的查找规则,是服务于全局作用域、函数作用域和块级作用域这三个具体存在的作用域类型的。

  • 规则: 在词法作用域中,变量和函数的作用域是在它被定义的时候确定的,并非在调用的时候确定(动态作用域符合),也就是说其作用域与调用位置无关。

    • 注意:调用外部函数,然后接收返回的内部函数这一情况是不算定义的,返回的内部函数的定义其实还是在该外部函数中。
    • 示例:
    // 变量或者函数的作用域只和定义位置有关
    function outer() {
        let x = 1;
        // 定义函数 inner
        function inner() {
            console.log(x); // 访问定义时的父作用域中的 x
        }
        
        
        return inner;
    }
    let x = 0; // 位于全局作用域
    const fn = outer(); // 接收返回的函数,此时不算定义
    fn(); // 输出 1,虽然此时全局作用域中的 x 距离调用位置更近
    
  • 词法作用域是执行上下文、作用域链和闭包的基础: 执行上下文、作用域链和闭包都基于词法作用域,因为词法作用域在代码编译阶段确定变量的可见性规则,而执行上下文在代码执行阶段基于该规则创建作用域链,闭包则是作用域链引用的持久化。
    其实上面的代码除了展示词法作用域的规则之外,还展现了我们要讲解的执行上下文、作用域链和闭包。你看出来了吗?别急,我会在下面的章节中为你讲解。

二. 执行上下文与作用域链

如果你事先没有了解过相关内容,你肯定会被上面的示例代码输出结果为“1”而感到惊讶,因为看起来位于全局作用域中的 x 更贴近于函数 fn(),按照祖传的“就近原则”,似乎应该选择它才对,可事实并非如此。fn()函数执行的时候是如何抉择的呢?这里就不得不请出我们的执行上下文和作用域了。

执行上下文

定义

执行上下文就是代码执行时所处的当前环境,其中包含了这段代码执行所需的所有信息。

分类

与作用域相似,执行上下文也分为了三类,但最后一类有所不同。

  • 全局执行上下文: 这是最基础的执行上下文,当 JavaScript 代码开始执行时,就会自动创建全局执行上下文,而且在整个代码的生命周期中都存在。全局执行上下文受到运行环境的影响,在浏览器环境中为window对象,在Node.js环境中,则是global对象。
  • 函数执行上下文: 每当调用一个函数时,就会为该函数创建一个新的执行上下文。这个执行上下文和全局执行上下文相互独立,它拥有自己的变量环境,在这个环境中可以对变量进行定义和存储。当函数执行完毕后,其对应的执行上下文通常会被销毁。
  • eval 执行上下文(不重要): 当使用 eval() 函数执行代码时,会创建 eval 执行上下文。不过在实际的开发工作中,出于安全和性能方面的考虑,一般不建议使用 eval() 函数,所以 eval 执行上下文相对来说比较少见,我们可以把它丢掉了。

构成

执行上下文跟随着 JS 一同进化,它的构成有好几个版本,我们挑最经典的 ES3 版本进行理解就好了,用起来都大差不差的,主要是理解到位就行。如果想要了解一下 ES6 版本的执行上下文,可以看一下我的另一篇博客《再谈 JS 执行上下文(ES6版)》
ES3: 包含三个部分,变量对象(Variable Object)、作用域链(Scope Chain)、this 指针

  • 变量对象: 存储变量、函数声明和函数的形参。
    • 在全局上下文中,变量对象就是全局对象本身(浏览器中为window,Node.js中为global)。
    • 在函数上下文中,变量对象被激活为活动对象(Activation Object),为函数执行上下文所特有。
  • 作用域链: 由多个变量对象组成的链表,用于查找变量和函数。
    • 作用域链包含当前执行上下文的变量对象和所有父级执行上下文的变量对象(为什么没有子级?因为作用域链是从当前出发,向上查找,和子级没关系)。
  • this指针: 指向当前执行上下文的对象。(了解即可,它不是今天的主角)
    • 在全局上下文中,this 指向全局对象。
    • 在函数上下文中,this 的值取决于函数的调用方式,也就是动态绑定,这点和词法作用域相反。

执行过程(很重要)

执行上下文,顾名思义,当然是执行阶段开始发挥作用,所以我们着重讲解一下执行上下文执行时的栈内情况。执行时,这些执行上下文都将被压入执行栈中,遵守后进先出原则
我们用之前的示例代码修改一下,结合图形进行说明,看懂了之后,执行上下文基本上就入门了。

  • 示例:
    // 变量或者函数的作用域只和定义位置有关
    function outer() {
        let x = 1;
        function inner() {
            console.log('inner:',x); // 访问定义时的父作用域中的 x
        }
    
        function printX() {
            console.log('printX:', x);
        }
        // 不用纠结这个printX()函数的this指向,它不是今天的主角
        // 添加这个例子只是为了说明函数内直接调用其内部函数的作用域链情况
        printX(); // 普通函数调用,此时this仍然指向全局变量对象
        return inner;
    }
    let x = 0; // 位于全局作用域
    const fn = outer(); // 接收返回的函数
    fn(); // 输出 1,虽然此时全局作用域中的 x 距离调用位置更近
    
  • 执行上下文栈内示意图:
    • 图一: 3. 执行上下文栈内示意图1.png
    • 图二: 4. 执行上下文栈内示意图2.png
    • 图三: 5. 执行上下文栈内示意图3.png
    • 执行顺序: 1. 程序开始运行(生命周期开始),全局执行上下文创建(入栈) -> 2. outer 函数执行上下文创建(入栈) -> 3. printX 函数执行上下文创建(入栈) -> 4. printX 函数执行完毕,执行上下文销毁(出栈) -> 5. outer 函数执行完毕,执行上下文销毁 -> 6. fn 函数执行上下文创建(入栈) -> 7. fn 函数执行完毕,执行上下文销毁 -> 8.整个程序执行完毕(生命周期结束),全局执行上下文销毁(出栈)。

作用域链

现在我们通过执行上下文知道了 JS 的代码执行顺序,但它的作用远不止这些,其组成部分之一的作用域链将为我们解答为什么外部函数无法访问内部函数的变量,而内部函数可以访问外部函数的变量,以及在搜寻变量时 JS 遵守了什么规则。
作用域链的定义,我们在上文中执行上下文的“构成”中已经讲述了,这里不多赘述,我们直接上图开始讲解作用域链的变量访问查找机制。

从自身出发,逐级向上(单向)

还是执行上下文中的那段示例代码,我们单独拿出 printX 函数来讲解。

  • printX 函数执行:
    • 作用域:从上图中截取它的作用域链,如下图所示: 6. 作用域链查找示意图1.png 现在开始执行 printX 函数,它要打印这个变量 x 的值,首先就要找到 x 的值。
      1. 先查找自身变量对象,此时自身中只有一个arguments对象,还因为没有参数,所以为空,并未找到 x 变量。 7. 作用域链查找示意图2.png
      1. 沿着作用域链向上一级查找,到父级变量对象 outer 中查找,包含{x: 1,inner: <Function>,printX: <Function>,arguments(空的)},这时在当中找到了变量 x,其值为 1,停止查找 8. 作用域链查找示意图3.png
      1. 此时将查找到的值输出,得到 printX:1
        上述的过程体现出了作用域链查找的特点:从自身出发,逐级向上查找(单向),终止于第一个匹配项。即使全局作用域中还有一个变量 x,其值为 0,但是我们已经在 outer 的变量对象中找到了这个变量 x,所以只会选择 outer 中的 x 进行输出。 9. 作用域链查找示意图4.png
  • 注意: 如果一直向上逐级查找到全局变量对象中,都没有找到这个变量,那么这时候 JS 引擎就会终止查找并报错:ReferenceError: y is not defined。这里表示你正在尝试访问一个不存在的变量(未声明)。
  • 小结: 现在我们已经明白了搜寻变量时,JS 遵守的规则:从自身出发,逐级向上查找(单向),终止于第一个匹配项。并且我们还明白了为什么只有内部函数能访问外部函数的变量,而反过来却不行--作用域链的查找是单向的,并且只能向上,位于作用域链下游的子作用域无法被查找。
  • 举例: 当前作用域就像是一栋大楼的第一层,你要寻找变量就要一层一层向上,找到之前无法回头,当你走到最高层(全局作用域)时还没找到,就只能宣告失败了(我们总不能从顶楼跳下去)。借用《你不知道的JS》这本书中的例图帮助理解:

1. 嵌套作用域链示意图.png

三. 闭包(超级重要)

仅仅知道上面这些,你是不是感觉少了点什么?相信细心的同学已经发现了一个逻辑不能自洽的地方,那就是在调用fn()函数(inner函数)时,你有注意到它的作用域链有点不对劲吗?

10. 闭包作用示意图1.png
天啦噜,outer 这小子这时候不是死了(其执行上下文已被销毁)吗?怎么我们还能见到它,甚至还能访问它的内部变量!这里就要引出“闭包”这个概念了,我相信你看完之后会有所收获的。

概念

  • MDN 中闭包的定义 闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在 JavaScript 中,闭包会随着函数的创建而同时创建。
    MDN 果然没让我们失望,依旧很专业,但是也很难懂。下面我将用自己的话为你讲解。
  • 解析: 将上面的话进行拆解,你就能得到闭包的形成条件和生命周期。
    • 形成条件(缺一不可):
      • 函数嵌套: “捆绑起来(封闭的)的函数”,意味着必须有个内部函数,定义在另一个函数(外部函数)内部。
      • 变量捕获: “函数周围状态(词法环境)的引用”,意味着内部函数必须引用外部函数作用域中的变量。
      • 外部执行: “闭包让函数能访问它的外部作用域”,意味着意味着内部函数需要在定义它的作用域之外被引用(例如被返回给全局变量、作为参数传递给其他函数),即使外部函数已执行完毕,内部函数仍能通过闭包访问外部函数中被引用的变量。
    • 生命周期:
      • 创建: 闭包的生命周期同时开始于该函数的创建。
      • 存活: 外部函数执行完毕之后,闭包并不会跟着销毁,也就是说 闭包生命周期 >= 外部函数执行期
      • 销毁: 闭包的生命结束于其不再被引用且无法访问,这时候 JS 的垃圾回收机制会释放相关的内存。
    • 构成:
      • 这个内部函数整体
      • 被这个内部函数引用的外部作用域的变量,我们把这个行为称之为“捕获”。被捕获到的变量,其生命周期与闭包同步。
  • 总结一下。
  • 定义: 闭包是指我们有权访问另一个函数作用域中变量的函数,即使该函数在其定义的作用域之外执行,它仍然能够访问和修改这些变量。
  • 注意点:
    • 捕获变量引用: 闭包捕获的是变量的引用(引用类型),而非值的副本。补充:对于原始类型(如数字、字符串等),闭包捕获的是变量的值的存储位置,因此能访问和修改该变量的最新值。

      • 学习过引用数据类型的应该都知道引用地址怎么工作,不了解的可以去我上次发的博客《深入理解JS(二) - 堆栈与数据类型》中专项了解一下(文章比较长,专项了解“引用”这个概念即可)。
      • 示例:
      // 这个函数的作用就是返回一个内部函数(功能为计数)
      function createCounter() {
          // 这是要被捕获的外部作用域的变量
          let count = 0;
          // 返回内部函数
          return function () {
              return count++; // 每次调用都修改同一个count
          };
      }
      // 接收内部函数,此时已经是在其定义的作用域之外了
      const counter = createCounter();
      // 分别调用两次内部函数,你会发现每次都修改同一个count
      console.log(counter()); // 0
      console.log(counter()); // 1
      
    • 独立实例: 多次创建闭包时,每一次都会生成独立的变量副本(互不干扰)。

      • 示例:
      // 这个函数的作用就是返回一个内部函数(功能为计数)
      function createCounter() {
          // 这是要被捕获的外部作用域的变量
          let count = 0;
          // 返回内部函数
          return function () {
              return count++; // 每次调用都修改同一个count
          };
      }
      // 接收内部函数,此时已经是在其定义的作用域之外了
      const counter1 = createCounter();
      // 两个函数此时为两个独立的闭包实例,互不干扰
      const counter2 = createCounter();
      // 分别调用,你会发现两个实例中的count互不干扰
      console.log(counter1()); // 0
      console.log(counter2()); // 0
      

案例讲解

说了这么多知识点,你应该也看累了,该结合案例进行消化一下了。回到我们开始时的问题了--为什么outer虽死犹存?

  • 还是这段祖传的代码:

    // 变量或者函数的作用域只和定义位置有关
    function outer() {
        let x = 1;
        function inner() {
            console.log('inner:', x); // 访问定义时的父作用域中的 x
        }
    
        function printX() {
            console.log('printX:', x);
        }
        // 不用纠结这个printX()函数的this指向,它不是今天的主角
        // 添加这个例子只是为了说明函数内直接调用其内部函数的作用域链情况
        printX(); // 普通函数调用,此时this仍然指向全局变量对象
        return inner;
    }
    let x = 0; // 位于全局作用域
    const fn = outer(); // 接收返回的函数
    fn(); // 输出 1,虽然此时全局作用域中的 x 距离调用位置更近
    

    其中的fn()函数(也就是inner()函数)完美符合了我们上面闭包形成的三大条件。由执行过程图二可得这时outer函数的执行上下文已经执行完毕,出栈并被销毁了。

    11. 闭包作用示意图2.png 按理来说,被销毁了之后,outer 就应该淡出我们的视线,但是 fn 函数居然还能通过作用域链找到outer 中的变量x,这是为什么?
    其实就是闭包发力了。虽然出栈操作之后,outer 的函数执行上下文以及其中的作用域链一同被销毁了,但是别忘了,outer 作用域中的属性 x 被返回的内部函数 inner(也就是外部接收后的fn)所捕获了,此时形成了闭包!执行上下文在函数执行结束后会被销毁,但是闭包保留的作用域引用不会被销毁

    • 注意: 闭包是按需保留,它只会保留被自己捕获的作用域引用,外部作用域中未被引用的部分将随所属执行上下文出栈一同被销毁。就像除了变量 x 外,outer 的其他变量,如arguments对象、函数printX()等都会被销毁。
    • 图解:
    • outer 未销毁时

    12. 闭包作用示意图3.png

    • outer 销毁之后

    13. 闭包作用示意图4.png

    • 捕获规则总结:
      变量状态是否被闭包捕获执行上下文出栈后的结果
      被闭包引用保留在闭包中
      未被闭包引用被垃圾回收
      在闭包创建后声明不会被捕获

四. 结语

作用域(静态规则)、执行上下文(动态环境)与闭包(状态持久化)共同构成了 JS 变量访问的核心机制。希望大家在看完这篇博客之后,能对 JS 的执行上下文、作用域链、闭包有一个新的理解。你可以不用去记住这些定义,我也记不住,只要心中有个概念就好了。
用的时候能想起来这个闭包中应该有什么东西,作用域链该向上去往哪个作用域就行了。学到的知识终究要用于实战,这样才有价值。“纸上得来终觉浅,绝知此事要躬行”,你可以把博客中的示例代码复制到本地跑一下,看看运行结果,或者自己修改某个地方,验证你心中的猜测,相信你会很快掌握这个知识点。
如果我有什么错误,或者是哪个地方有缺漏,希望大家能在评论区或者私信我指正,我们一起进步,感谢你们的支持❤️。