JS 函数作用域 与 闭包本质

226 阅读7分钟

前言

回顾一下,上节我们讨论了:

  1. 什么是上下文,上下文的作用是什么?
  2. 如何管理上下文?
  3. 什么情况下会创建上下文?
  4. 全局上下文的创建过程?

你能回答几个,如果没有答案可以前往查看:

【深入理解JS】执行上下文详解

上节我们没有讨论 函数执行上下文 的创建,这节我们来细说。

函数上下文与 全局上下文类似,但又有所不同。

还记得吗,我们在上节中说过,当函数声明 在被 赋值 时,会 创建一个函数对象 ,该对象内部会 保存 函数 声明 时的上下文环境。

此节我们会详细的讨论函数的作用域,并在此基础上,自然而然的推出所谓的 闭包 机制

作用域

看看MDN怎说:

作用域是当前的执行上下文

这句话更加准确来说,作用域是 执行上下文中的文本环境 (或者叫词法环境)

其实非常简单的道理,上节我们说:

执行上下文的作用是: 正在执行的代码在当前执行上下文中查找需要的变量

所谓的作用域就是指执行时查找变量的区域,因此自然而然就是执行上下文

作用域分类:

  • 全局作用域 (全局执行上下文,上节讲解)
  • 函数作用域 (函数执行上下文,本节讲解)
  • 块级作用域 (下节讲解)

函数作用域

函数作用域就是函数的 执行上下文

更准确来说是 文本环境 Lexical Environment

下面的讲解对三者不作区分

函数对象

  • 函数的作用域,取决于 函数执行上下文的创建。
  • 同时,函数的作用域也去与函数对象生成时 [[scope]] 属性保存的上下文有关。

下面对这两点进行解释:

函数对象在函数 赋值时创建

函数的赋值有两种情况:

  1. 生成上下文时,函数声明会在此法环境中进行整体提升,也就是赋值
  2. 在函数执行时,才对指向函数的变量进行赋值

非顶级函数声明 的 函数对象 在函数语句 执行的时候创建。

函数对象中的 ‘体内’([[scope]]属性) 保存了 创建对象时的当前执行上下文

因此函数的作用域 取决于 函数对象创建 时的位置,而与调用时无关

函数的作用域与调用位置和方式无关

函数执行上下文中的 this 指向与调用方式有关(姑且不谈)

有关函数对象如何决定函数的作用域,我们接着往下说

函数执行上下文

函数的执行上下文,只有 函数调用 时,才会被创建并压入 执行上下文调用栈的栈顶

我们结合上节的知识,分析如下代码的执行流程:

function foo() {
    console.log(a);
  }
  function bar() {
    var a = 3;
    foo();
  }
  var a = 2;
  bar();

该段代码被js引擎解析

1. 第一步 在执行上下文栈中 压入 全局执行上下文

经过一系列步骤,全局上下文被创建在栈中

image.png

  • 全局scope 中没有保存任何信息
  • 全局对象 中保存了 内置默认对象foo bar a , 其中 aundefined
  • foobar 在赋值过程中创建了函数对象,该对象内部保存了 当前执行上下文 即此刻的全局执行上下文

1. 开始执行代码

全局执行上下文生成好后,开始执行代码,第一行 `a = 2``全局对象` 中的 `a``undefined` 修改为 `2`

然后 `执行bar`

2. 创建 bar函数执行上下文

在执行bar前一刻,生成 函数执行上下文 并压入执行上下文栈的 栈顶

函数执行上下文的生成步骤与全局上下文有些许不同:

  1. 函数上下文的 Lexical Environment 中只有 函数scope ,没有 全局对象
  2. 函数上下文的 outer 会 指向 函数对象中保存的上下文 ,从而形成 作用域链

在bar函数执行上下文中

  • this Binding 指向 全局对象window (关于this指向问题以后讲解)
  • scope 中保存了 a 值为 undefined
  • 执行上下文指向 全局执行上下文 (从函数对象中得知)

3. 开始执行 bar

bar的函数执行上下文生成后,就可以开始执行代码,第一行代码将 bar 执行上下文中的 aundefined 修改为 3

第二行代码开始 执行foo

4. 创建 foo 函数执行上下文

foo函数执行前,先创建其执行上下文,压入 ECS (执行上下文栈)的栈顶

该函数的执行上下文很简单

  • This Binding 指向
  • scope 中空空如也
  • 执行上下文指向 全局执行上下文 (从函数对象中得知)

5. 开始执行 foo

输出a, 则沿着 foo 函数的上下文 开始寻找a ,

fooscope 中 没有 a ,于是找到 其上下文指向的 全局上下文,在全局上下文的 scope 中没有找到,接着在 全局上下文的 全局对象 中找到 a = 2 , 所以输出 2

image.png

6. 弹出栈顶

foo 执行结束其上下文首先弹出栈顶

紧接着bar执行结束其上下文弹出栈顶

最后回到全局执行上下文来到栈顶的状态

闭包

所谓的闭包,用一句话解释:

函数执行结束后,本该被释放掉的执行上下文被保存了下来

下面就来解释这句话

本该释放的函数执行上下文中,保存了函数执行时所有的状态(即变量)

我们如何保存这个上下文?

上文中有这样一段描述:

函数对象生成时 , 会保存其所处的执行上下文

这里需要提一嘴,这里所谓的保存,指的不是将那块内存里的内容拷贝,而是说指向那块内存

因此,一个简单的思路是,在一个函数的体内,创建另一个函数,该函数的对象在创建时,会保存父函数的执行上下文

function foo() {
    let a = 2;
    function bar() {
      console.log(++a);
    }
    return bar;
  }

  let bar = foo();
  bar(); //<p align=left>3</p>
  bar(); //4

整个执行上下文的流程不再做分析,我们直接来到foo函数的执行上下文:

  1. 创建foo函数的执行上下文时,会将 bar 函数声明到其 scope 中, 而它指向一个函数对象,该对象的体内( [[scope]]属性 保存了 当前执行上下文 ,也就是 foo函数的执行上下文 , 我们最后又将 bar 函数作为 返回值 ,保存在了全局上下文
  2. 函数 foo 的上下文被弹出栈,但其在内存中生成存储数据的对象并没有按照常规失去引用,而是继续被全局保存的 bar 引用着,因此它并没消失
  3. 我们调用 bar 函数,生成 bar 的执行上下文,但其上下文中没有 a ,则沿着 [[scope]] 找到了 foo 当时生成的执行上下文,让其加一

总结上文,闭包的原理总结一句话:

本该被释放的空间由于没有失去引用,继续保存并可以被访问

我们只有利用声明函数一种方式去引用函数的上下文吗?答案是否定的

比如我们可以用一个对象,保存我们想要保存 的内容,然后将其返回给全局:

function foo() {
    let a = 2;
    return { a } //es6对象语法糖 等于 {a : a}
  }

  let bar = foo();
  console.log(++bar.a); //3
  console.log(++bar.a); //4

这就是闭包的原理,闭包应该说是底层的原理导致的一个自然而言的现象,而闭包又有很多有用的应用,但其实它并不难。

至于js引擎的垃圾回收机制,我们以后再讲。