深入解析 JavaScript 中的闭包、作用域和执行上下文

简介: ​本文将会讲解闭包的含义及其带来的问题,并通过调试代码的形式帮助读者理解作用域、执行上下文和闭包的概念以及它们在JS中的实现。文章中还会涉及暂时性死区、this 指向以及垃圾回收算法的相关知识。

闭包的含义

在讲述闭包的概念时,一般有两种说法:

①一个函数可以访问并操作位于其外部的变量。

function closureExample() {
   
   
  let num = 1
  function printNum() {
   
   
    num++  // 改变位于函数外部的变量num
    console.log(num)  // 访问位于函数外部的变量num
  }
  printNum()
}
closureExample()
// output: 2

②一个函数可以访问其外层作用域中的变量,即便其外层作用域已经不存在于执行上下文中。

function addX(x) {
   
   
  return function (num) {
   
   
    return num + x // 访问了函数外层的变量x
  };
}

const plusOne = addX(1)  // addX执行完毕,执行上下文已不存在
console.log(plusOne(3))  // 内层函数仍然正常运行,说明其仍可以访问外层变量x

第二种说法其实是第一种说法的一个特殊情况。在这两种说法中,我认为更重要的是第二种,因为这种情况更能体现出闭包的特别之处。

为什么会有闭包

闭包产生的原因主要有两点,第一是在JS中函数是一等公民,第二是JS的静态词法作用域机制,两者的结合决定了闭包是JS必备的语言特性。

JS中函数是一等公民

一等公民的意思是想干啥就干啥,能够做到所有普通变量能做的事情(如作为函数参数、返回值)。具体来讲,JS中的函数可以做到下面这些:

  1. 以字面量的形式声明

这种形式是最常见的函数声明形式。函数声明会被提升,也就是会在所有代码执行之前被首先执行,不管声明被写在代码中的哪个位置,所以一个函数可以在其声明语句之前被调用

foo()  // 因为存在函数提升现象,这行代码可以正常运行
function foo() {
   
   
  console.log('以字面量的形式创建函数)
}
  1. 被赋值给变量、作为数组元素、作为对象属性
const func = function() {
   
   }
array.push(func)
obj.fn = func
  1. 作为其他函数的参数
const btn = document.getElementById("btn")
btn.addEventListener('click', () => {
   
   
  console.log('button clicked')
})
  1. 作为函数返回值
function addX(X){
   
   
  return function(num){
   
   
    return num + X
  }
}
  1. 创建自定义属性

函数和普通的对象一样,都是可以有自己的自定义属性的,可以通过点操作符对属性进行添加、修改和获取。

const foo = () => {
   
   }
foo.nickName = "fool function"
console.log(foo.nickName)  // fool function

作用域、执行上下文和闭包

词法作用域的概念

JS的变量作用域在编译阶段就确定了,是静态的。JS 有 REPL 这个事实会让我们倾向于认为它是一门解释型语言,但其实不然,JS 引擎在解析 JS 时是有一个编译阶段的。

作用域分为全局作用域、函数作用域和块级作用域。作用域层层嵌套,每个内层作用域都可以访问和操作其外层作用域内的变量。

上面这张图中共有三个作用域:

绿色的是全局作用域,其中的内容包括变量foo

橙色的是函数foo的作用域,其中的内容包括参数a变量b函数bar

蓝色的是函数bar的作用域,其中的内容包括参数c

当我们在函数 bar 中尝试访问变量a时,JS首先会在其本地作用域中寻找,如果找不到,就顺着作用域链层层往外寻找,如果到了全局作用域还是找不到,就会报错,在这个例子中函数会使用外层作用域 foo 中的变量a。变量b和变量c的访问过程同理。

执行上下文的概念

执行上下文是在代码运行过程中动态产生的,分为全局上下文函数上下文两种类型。

执行上下文是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中任何的代码都是在执行上下文中运行的。上下文中的内容主要包括当前上下文中的本地变量scopethis,这几个概念的具体内容会在后文中提到。

JS以执行栈的形式管理执行上下文。当代码开始运行时,全局上下文被推入执行栈,上下文中保存全局上下文中的变量。每执行一个新的函数,就会往执行栈中推入一个新的上下文,中断上一个上下文的执行。函数执行完毕后,该函数的上下文会从执行栈中被推出,然后继续执行被中断的上一个上下文。

同样以这段代码为例,其执行过程中执行栈的情况如图所示:

执行栈.drawio.png

当一个上下文执行完毕时,由于其中声明的变量对象已经不会再被用到,所以会被JS引擎自动垃圾回收,从而释放内存空间。

通过调试代码理解

直接说概念很抽象,我们结合具体的代码,通过开发者工具来看看作用域、执行上下文以及闭包到底是怎么一回事。

const calcSum = function (a, b) {
   
   
  return a + b;
};

function addOne(num) {
   
   
  const one = 1;
  return function inner() {
   
   
    return calcSum(num, one);
  };
}

const resultOfOnePlusTwo = addOne(2);

console.log(resultOfOnePlusTwo());

html文件中引入这段代码,在开发者工具的Sources窗口点击右侧的1,给第一行代码打上断点,刷新后就可以逐行运行这段代码了。

此时代码还未开始运行,我们查看窗口右侧,可以看到不少信息。这里我们主要关注ScopeCall StackCall Stack是函数调用栈,实际上也就是执行上下文的执行栈,其中有一个(anonymous),这个其实就是刚刚所说的全局上下文。

我们发现此时Scope中已经出现了我们将要声明的两个常量,这也证实了刚刚所说的JS中存在编译阶段这个事实。但虽然从一开始就知道有这两个变量了,但如果我们尝试在常量声明之前就访问它的话还是会产生报错,这是因为对于letconst声明的变量存在 “暂时性死区” 的限制。

这里我们点击上方被我框出来的那个按钮,就可以逐行运行代码了。

我们点击一下,可以看到Scope中的calcSum已经被赋值了,也就是说第1~3行的常量声明执行完毕。但此时代码直接跳到了第 12 行,也就是说第 5 行的addOne函数声明直接被跳过了,这其实就是因为刚刚提到的函数提升现象。那么这个函数为什么没有出现在Scope中呢?这是因为函数声明会被放在Window对象中,也就是Scope里面的那个Global。我们刷新一下网页,回到代码尚未开始运行的时刻,打开Global标签,可以看到addOne函数已经被添加在其中了。

ScriptGlobal的区别在于Script是单个JS文件的全局作用域,而Global整个html中所有JS文件共享的全局对象(在浏览器中即Window 。这也就意味着同个 html 中其他的 Script 文件也可以访问我们声明的addOne函数,只要它们是在这个文件之后运行的。

言归正传,我们继续运行代码,此时为了给resultOfOnePlusTwo赋值,我们需要运行addOne函数得到其结果。所以点击下一步时,我们会进入addOne函数内部,此时观察执行栈,会看到addOne函数被推入了执行栈,这也就意味着我们进入了一个新的执行上下文。刚刚我们讲到执行上下文中会有本地变量、Scopethis三个要素,这些都可以在黄色框框出来的Scope部分中查看:

Local就是上下文的本地变量,其中包括函数的参数num和函数中声明的常量one

this的值为Window,因为这个函数是在非严格模式下直接调用(严格模式下为 undefined) 的。而不是作为某个对象的方法,通过obj.method()的形式调用,这种情况下this将会指向调用函数的那个对象。

Scope标签下的内容就是这个执行上下文的Scope(作用域),在这个例子中addOne函数的Scope包括本地作用域Local, 全局作用域ScriptGlobal

我们继续运行代码,点击下一步之后可以看到one被赋值1

再点一下就到了函数的 return 语句,这个函数返回了一个内层函数innerinner引用了外层函数的numone变量,也就是说出现了闭包

我们再点一下退出addOne函数的运行,此时观察Call Stack会发现,addOne的上下文已经被推出了。

同时由于我们已经求出了addOne的运行结果,所以resultOfOnePlusTwo也被赋值了,其值为我们刚刚返回的inner函数。

下一步我们想要打印resultOfOnePlusTwo的结果,那么就需要运行这个函数,所以点击下一步会进入inner函数的内部,往执行栈中推入inner函数的上下文。此时观察 Scope,会发现除了 Local, Script 和 Global 之外,出现了一个名为addOne的 Closure,也就是闭包。

点击展开可以看到Closure中的内容是inner引用的外层函数addOne中的变量numone

我们回想刚刚展示的那张关于JS的作用域的图片,再观察这个 Scope 的结构,会发现它和刚才那张图展示的作用域是类似的结构,即本地作用域 - 外层函数作用域 - 全局作用域。但是刚刚讲执行上下文时,笔者提到了执行上下文运行结束后其中的变量会被回收,那这里numone这两个变量为什么又回来了呢?这是因为在inner函数被返回之后,JS引擎为了确保它后续能够被正常地调用,会让它把自己引用的外层函数变量也携带在身上,具体来讲,inner身上有一个私有变量[[Scopes]],它所引用的外层变量会作为一个闭包被储存其中,就好像在自己身上携带了一个背包。当inner函数被运行时,JS引擎会解开它的[[Scopes]]背包作为执行上下文的 Scope。我们可以展开inner函数查看它的[[Scopes]]。这其实就是闭包背后真正的原理。

这里还有一个值得注意的点,就是有时候我们会认为作用域的结构和执行栈应该是相同的,但其实两者没有任何关系,作用域是编译时就确定的,而执行栈和执行上下文是运行时的概念。从这个例子中我们也可以看到,虽然执行栈是inner - 全局这样的结构,但作用域却是inner - addOne - 全局的结构。这说明作用域和函数运行的位置以及执行栈的情况是无关的。

闭包带来的问题

闭包会带来内存泄漏的问题,要理解这一点,首先得了解JS的垃圾回收机制。

JS的垃圾回收机制

① 标记清除法

使用标记清除法的垃圾回收程序在运行时会标记内存中的所有变量,然后将所有存在于执行上下文中,以及被执行上下文中变量所引用的变量的标记去掉。这一步可以通过从window对象开始,递归地遍历其属性,只要某个变量能够被遍历到就说明其存在于上下文中。随后将所有仍带标记的变量销毁并回收它们的内存。

② 引用计数法 (不常用)

引用计数法的核心在于跟踪记录每个对象被引用的次数。每当存在一个变量引用了某个对象,该对象的引用 +1。相反,如果每存在一个引用该对象的变量发生更改,从而不再引用该对象时,该对象的引用 -1。当对象的引用值为 0 时,说明该对象不被任何变量所指向,即没有变量可以访问它。因此,就可以将该对象所在内存空间释放回收。

function Example(){
   
   

    let ObjectA = new Object();
    let ObjectB = new Object();

    ObjectA.p = ObjectB;
    ObjectB.p = ObjectA;   

}

Example();

该方法无法解决循环引用的问题。上述代码中的两个对象在函数结束执行后引用数都不为 0,因此不会被清理,但实际上这两个变量都无法被上下文中的其他变量访问,属于无用变量。如果函数该多次运行,产生了多个无用但不会被回收的ObjectAObjectB,则会造成内存浪费。

内存泄漏

内存泄漏意味着我们声明了大量不会被垃圾回收的变量。

闭包会导致外层函数即使运行完毕,但其上下文中的变量仍作为闭包被内层函数的[[Scopes]]引用,这些被引用的变量无法被垃圾回收。不合理地滥用闭包会导致大量的变量无法被垃圾回收,从而导致内存泄漏,影响性能。这就是闭包可能带来的问题。

参考链接:

相关文章
|
11天前
|
JSON 前端开发 JavaScript
【JavaScript技术专栏】JavaScript异步编程:Promise、async/await解析
【4月更文挑战第30天】JavaScript中的异步编程通过Promise和async/await来解决回调地狱问题。Promise代表可能完成或拒绝的异步操作,有pending、fulfilled和rejected三种状态。它支持链式调用和Promise.all()、Promise.race()等方法。async/await是ES8引入的语法糖,允许异步代码以同步风格编写,提高可读性和可维护性。两者结合使用能更高效地处理非阻塞操作。
|
4天前
|
前端开发 JavaScript
闭包在JavaScript中有许多应用场景
闭包在JavaScript中发挥关键作用,如封装私有变量和函数提升安全性,维护变量生命周期,实现高阶函数,模拟块级作用域,支持回调函数以处理异步操作,以及促进模块化编程,增强代码组织和管理。闭包是理解和掌握JavaScript高级特性的重要一环。
21 7
|
5天前
|
SQL 缓存 JavaScript
深入解析JavaScript中的模板字符串
深入解析JavaScript中的模板字符串
13 1
|
11天前
|
自然语言处理 JavaScript 前端开发
【JavaScript技术专栏】深入理解JavaScript作用域与闭包
【4月更文挑战第30天】了解JavaScript的关键在于掌握作用域和闭包。作用域决定变量和函数的可访问范围,分为全局(在`window`或`global`对象中)和局部(函数内部)。闭包则允许函数访问其创建时的作用域,即使在其他地方调用。它通过作用域链保存对外部变量的引用,常用于实现私有变量、模块化和柯里化。然而,不当使用闭包可能导致内存泄漏和性能下降。理解这些概念能提升代码质量,但也需谨慎处理潜在问题。
|
11天前
|
JavaScript 网络协议 数据处理
Node.js中的Buffer与Stream:深入解析与使用
【4月更文挑战第30天】本文深入解析了Node.js中的Buffer和Stream。Buffer是处理原始数据的全局对象,适用于TCP流和文件I/O,其大小在V8堆外分配。创建Buffer可通过`alloc`和`from`方法,它提供了读写、切片和转换等操作。Stream是处理流式数据的抽象接口,分为可读、可写、双工和转换四种类型,常用于处理大量数据而无需一次性加载到内存。通过监听事件和调用方法,如读取文件的可读流示例,可以实现高效的数据处理。理解和掌握Buffer及Stream能提升Node.js应用的性能。
|
11天前
|
JavaScript 前端开发 开发者
Node.js的包管理和npm工具深度解析
【4月更文挑战第30天】本文深入解析Node.js的包管理和npm工具。包管理促进代码复用和社区协作,包包含元数据描述文件`package.json`和入口文件。npm提供搜索、安装、发布等功能,通过命令行进行操作,如`install`、`search`、`uninstall`。npm支持版本控制、全局安装、脚本定义及私有仓库。理解和熟练运用npm能提升Node.js开发效率。
|
12天前
|
前端开发 JavaScript 数据安全/隐私保护
前端javascript的DOM对象操作技巧,全场景解析(二)
前端javascript的DOM对象操作技巧,全场景解析(二)
|
12天前
|
移动开发 缓存 JavaScript
前端javascript的DOM对象操作技巧,全场景解析(一)
前端javascript的DOM对象操作技巧,全场景解析(一)
|
4天前
|
Java Android开发
Android12 双击power键启动相机源码解析
Android12 双击power键启动相机源码解析
14 0
|
1天前
PandasTA 源码解析(一)(2)
PandasTA 源码解析(一)
7 0

推荐镜像

更多