哦?原来这就是闭包啊!从执行机制层面彻底理解闭包

2,402 阅读15分钟

前言

还记得以前刚接触JavaScript这门语言的时候,有一些语法特性我总觉得有点难以彻底理解。尤其是闭包,小笼包和菠萝包我是知道的,这个是什么包?

以前我经过一段时间的学习,似乎明白了它的涵义,但实际上也只是停留在似乎上,并没有彻底从JavaScript执行机制层面理解这个含义。如果用一句话来表达当时我对闭包的理解,那一定是:“这就是闭包?”。近期学习浏览器原理相关知识,收获颇丰,其中对闭包的涵义更是有了更深的理解,可以说是醍醐灌顶。用一句话来说就是:“原来这就是闭包啊!”。这篇文章算是总结、分享学习过程吧,文中有个人理解不到位的地方,请掘金小朋友们指教。
脱离JavaScript执行机制谈闭包只会让人似懂非懂,越看越迷糊。因此,在聊闭包之前,我们聊聊几个重要的概念:执行上下文、变量环境、词法环境、词法作用域、作用域链。(说好的闭包的详解呢,怎么是JavaScript执行机制的浅谈啊emm..)

再多嘴一句

建议大家从头看到尾,如果大家觉得文章太长,只对闭包感兴趣,可以直接跳到闭包处,看看闭包的定义。如果不太能理解再回头看。当然我最希望大家耐心看完啦。

执行上下文

JavaScript是一种运行时编译的语言,没错JavaScript代码也需要进行编译才能运行。JavaScript引擎会在执行代码段前进行编译,生成执行上下文和可执行代码。这里的代码段有三种情况:

  • 全局代码
  • 函数体内代码
  • eval函数内的代码

为了更好地解释执行上下文,我们先来看看JavaScript中的声明提升现象。

声明提升

ES6之前,大家都用var关键字来声明变量:

var name = 'vian'

实际上这一行代码包含了两个操作:声明赋值

var name  // 声明
name = 'vian'  // 赋值
function greet() {} // 函数声明

大多数人也知道var声明的变量和function函数声明会导致声明提升,如下代码所示:

greet()
console.log(myName)
function greet() {
  console.log('hello')
}
var myName = 'vian'

上方代码输出结果为hello undefined,但是为什么呢?

变量环境

下面我们来分析下这段代码的执行过程。JavaScript引擎在看到这段全局代码的时候,在执行代码前会进行编译。在编译阶段,JavaScript引擎会创建一个执行上下文,执行上下文包含变量环境。引擎遇到函数声明greet,将其添加到变量环境中并将其值设为函数greet的引用,遇到var变量声明name,将其添加到变量环境中并将其值设为undefined。变量环境的结构可参考下图所示:

除声明代码以外的其他代码段将被编译成可执行字节码并保存在内存中,这里不是我们的重点,我们可将可执行代码段认为是下面这段代码。

greet()
console.log(myName)
myName = 'vian'

到这全局执行上下文和可执行代码就创建完成了,全局执行上下文会被压入一个后进先出的栈结构中——调用栈(Call Stack)

调用栈是管理执行上下文调用关系的栈结构。上方说到全局代码在编译阶段创建了全局执行上下文,该上下文压入了调用栈中。调用栈此时的结构如图所示(图中词法环境后面有讲)。

接下来JavaScript引擎进入执行阶段,执行可执行代码。我们一行一行分析执行代码。

  1. greet():遇到函数调用,JS引擎会在当前执行上下文中寻找声明。在全局执行上下文变量环境中找到了函数声明greet,找到其引用的函数代码,准备执行该函数。我们在上文中提到,JS引擎会在执行代码段前进行编译,这个代码段包括了函数体内的代码。因此在执行函数greet前,会对greet函数体内的代码进行编译。根据上文编译阶段处理后,此时的调用栈结构如图所示。
    编译阶段结束,进入执行阶段。执行greet函数体内代码console.log('hello'),输出hello。函数greet执行结束,greet执行上下文出栈待回收。此时调用栈中又只剩下全局执行上下文。重新回到全局上下文执行代码。

  1. console.log(myName): 遇到变量引用,在当前执行上下文中寻找声明。在全局执行上下文中找到声明myName,此时的值为undefined,打印输出undefined。
  2. myName = 'vian':遇到变量赋值,在当前执行上下文中寻找声明。在全局执行上下文中找到声明myName,将值变成'vian'。

至此这段代码就执行完毕了。大家应该对执行上下文和变量环境有了一定的认识。需要注意的是,编译阶段JS引擎对同名声明的处理

greet()
console.log(myName)
function greet() {
  console.log('hello')
}
function greet() {
  console.log('hi')
}
var myName = 'vian'
var greet = '你好'

在创建变量环境的阶段,当遇到同名声明是分两种情况处理:

  • 遇到同名函数声明,将变量环境中函数引用指向新的函数代码
  • 遇到同名变量声明,跳过该声明。

因此后声明的greet函数将覆盖前面声明greet函数,后声明的greet变量将被忽略(注意在执行阶段的最后,greet将被赋值成字符串'你好')。所以该代码输出结果为hi undefined

爆栈是怎么回事

这图非常亲切有没有,你知道这个报错的原因吗?

现在我们知道了调用函数时会先创建一个执行上下文并压入调用栈call stack中,执行结束才会出栈。这个调用栈是有容量的,一旦压入栈中的执行上下文数量超过这个容量,就会爆栈。递归是最容易造成这种错误的,无限递归或者递归过深。

我们可以计算浏览器的调用栈容量:

function getMaxCallStack() {
  try {
    return 1 + getMaxCallStack()  
  } catch (err) {
    return 1
  }
}
getMaxCallStack()

不同浏览器结果不同,我的chrome78为12577。也就是递归层数最多为12577层,否则就会爆栈。

说好的闭包呢,怎么到现在还没讲?

别急别急,好好补刀发育才能carry全场嘛。

到了现在,因为var带来的不确定,已经有不少人在编程中不再使用var声明变量。在ES6发布后,我们迎来了块级作用域和现在更常用的变量声明关键字let、const,同时新的标准并没有抛弃var。你有没想过块级作用域是怎么在兼容旧版本的基础上实现的吗?(const一般用来声明不再重新赋值的变量,其余方面的表现与let相同)

词法环境与块级作用域

什么是作用域?什么是块级作用域?

作用域就是变量可发挥作用的区域,管理着变量的可访性和生命周期块级作用域,字面意思就是在块级内发挥作用的变量区域表现为 {}包含的let、const代码。我们看下面两段代码,看完就能理解什么是块级作用域了。

// 代码段一
var siteName = 'juejin'
var myName = 'em'
{
  var myName = 'vian'
}
console.log(siteName, myName) // juejin vian
// 代码段二
var siteName = 'juejin'
let myName = 'em'
{
  let myName = 'vian'
}
console.log(siteName) // juejin
console.log(myName) // em

结合前文讲的内容,大家可以先试着分析代码段一执行上下文的变化再继续往下看。 代码段一执行上下文分析图:

接下来我们再看代码段二的分析图:

在执行一段代码之前,会查找函数声明和var声明的变量放入变量环境中。但是let、const声明的变量会进行其他处理。编译阶段遇到最外层代码中let关键字声明行。

let myName = 'em'

JS引擎会将其放入一个块级数据结构(如上图绿色图块)中且值赋为undefined,然后压入词法环境中。词法环境是一种后进先出的栈结构。代码进入执行阶段

运行到第一行时:

var siteName = 'juejin'

JS引擎会先在词法环境栈中从上到下找siteName,没有找到则取变量环境中找,找到siteName,对其赋值为'juejin'。

运行到第二行时:

let myName = 'em'

JS引擎先在词法环境栈中从上到下找myName,找到并对其赋值为'em'。 运行到块级代码时:

{
  let myName = 'vian'
}

看到let声明的代码,JS引擎新建一个块级词法环境,将myName放入其中并赋值为'vian',然后将该块级词法环境压入词法环境栈中。离开块级代码时,该块级词法环境出栈。 运行到最后的代码时:

console.log(siteName)
console.log(myName)

在当前执行上下文的词法环境中寻找该声明,找到则返回其值,找不到则在变量环境中继续找。如果还找不到怎么办呢?我们待会再说。

看到这你是不是对ES6中使用let和const实现块级作用域的机制有了一些了解了呢?这里再补充两个注意点

  • 词法环境中每一个块级数据中不允许出现相同的变量,否则会报错:Identifier 'xxx' has already been declared
  • let、const声明的声明虽然也会在编译阶段被处理,但是它们却不能达到变量提升的效果。如下代码会报错Cannot access 'myName' before initialization。这是因为暂时性死区的存在:语法规定let声明前不可达
console.log(myName)
let myName = 'vian'

???闭包呢,都多长了还没开始?

emmm快了快了,还差一点点。

词法作用域与作用域链

上文有说到,查找变量的时候,其实是分两步的。

  1. 在当前执行上下文的词法环境栈中从上到下查找。
  2. 在当前执行上下文的变量环境中查找。

那如果这两步都没找到呢?就不找了吗?这显然不符合事实。我们知道执行上下文是存放在调用栈中的,还记得什么是调用栈吗?——调用栈是用来管理执行上下文调用关系的结构。执行上下文的调用关系是从下到上的,那在当前执行上下文找不到某变量时,是否就去下一个执行上下文中找呢?我们先看一段代码:

var myName = 'vian'
function greet() {
  console.log(myName)
}
function bow() {
  var myName = 'em'
  greet()
}
bow()

大家可以想想一这段代码会输出什么。

有一些人可能会觉得代码会输出'em',但实际上输出的是'vian'。你可能会知道,但是为什么呢?

词法作用域

在分析前,我们先来讲一下词法作用域。

简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,就是代码的位置。 上方代码的词法作用域如图所示:

作用域链

实际上,执行上下文的变量环境会有一个引用“outer”该引用指向外部的执行上下文,这个外部的执行上下文是由词法作用域确定的。我们分析一下该代码的执行过程:

每个执行上下文在创建的时候,其变量环境都会有一个根据词法作用域判定的引用,指向另外一个执行上下文(全局执行上下文outer为null)。根据词法作用域图可知,函数greet和函数bow的外部作用域为全局作用域(词法作用域1)。因此greet和bow执行上下文中变量环境的outer均指向全局执行上下文。 当执行greet函数体内的console.log(myName)时,需要查找变量myName:

  1. 在greet执行上下文词法环境栈中从上到下查找greet变量。
  2. 在greet执行上下文变量环境中查找greet变量。
  3. 沿着greet执行上下文变量环境中outer查找下一个执行查找操作的执行上下文,这里找到了全局执行上下文。
  4. 在全局执行上下文词法环境栈中从上到下查找greet变量。
  5. 在全局执行上下文变量环境中查找变量greet,找到了返回其值。

下图为查找过程:

现在我们可以给作用域链一个定义了:链接执行上下文,确定变量查找过程的链就叫作用域链

总结一下变量的查找过程

  1. 在当前执行上下文词法环境栈中从上到下查找变量,找到返回,否则继续
  2. 在当前执行上下文变量环境中查找变量,找到返回,否则继续
  3. 若当前执行上下文无外部作用域,返回undefined,否则继续
  4. 在外部作用域中重复上述步骤

到了这里,我们发育得已经非常健壮了,可以马上可以挑战我们的boss——闭包了。在开始前我们简单讲一下JavaScript的内存空间。

内存空间

JavaScript执行的过程中,主要有三种内存空间:代码空间、堆空间、栈空间

代码空间

代码空间用来存放编译过程中产生的可执行代码。

堆空间

堆空间用来存放非原始数据类型的数据,即对象及其子类型(数组、集合等)。

栈空间

栈空间就是文中的调用栈,用来存放执行上下文。栈中只会存放原始数据类型的数据。如Null、Undefined、Number、String、Boolean、Symbol、BigInt(ES10新增)。若执行上下文中某变量x为引用类型Object对象,在变量环境(或词法环境)中该变量x的值只是该对象的一个引用地址。

参考下方代码:

var greet = 'hello'
var info = { name: 'vian', gender: 'man' }

这段代码执行后内存空间如图所示。变量环境中的info的值并不是对象本身的数据,而是对象数据在堆空间中的地址。

闭包

在开始讲闭包前,我想让大家思考一下,如果让你给闭包一个定义,你给出的定义是什么?或者说,闭包具体指的是什么?

也许以前大家会觉得难以描述闭包,但是经过前面的学习,了解了一些JavaScript执行机制后,理解闭包是非常简单的一件事情。

话不多说先用一句话表示闭包:调用外部函数返回内部函数,内部函数引用了外部函数的变量,这些变量的集合就是外部函数的闭包(就像一个背包)

调用外部函数返回内部函数,内部函数引用了外部函数的变量,这些变量的集合就是外部函数的闭包

调用外部函数返回内部函数,内部函数引用了外部函数的变量,这些变量的集合就是外部函数的闭包

重要的话说三遍,不然容易忘..

var greetText = 'hello'
function getGreet() {
  const name = 'vian'
  var innerFunc = function() {
    console.log(name)
  }
  return innerFunc
}
let greet = getGreet()
console.log(greetText) // hello
greet() // vian

上方的代码就产生了闭包。

我们分析下代码执行到return innerFunc时(getGreet函数还未执行完毕),调用栈的情况:

当代码执行完return innerFunc后(getGreet函数执行完毕),调用栈情况:

getGreet函数执行完后,getGreet执行上下文出栈。由于getGreet函数返回值中,包含了引用getGreet函数词法作用域变量的函数(返回的innerFunc函数体内包含了该函数体外,getGreet函数体内的变量name)。因此即使getGreet执行上下文会出栈销毁,变量name会作为闭包对象的一个属性保存在堆空间中,不会随着执行上下文的销毁而销毁

当代码执行let greet = getGreet(),全局执行上下文词法环境中的greet被赋值为innerFunc函数引用地址(地址101)。

当代码执行console.log(greetText),在全局执行上下文变量环境中查找到greetText变量,打印输出'hello'。

当代码执行greet(),在全局执行上下文词法环境中查找到greet变量,从内存取出其函数引用,调用函数。此时调用栈情况如下图:

在greet函数编译阶段,JS引擎通过词法作用域得知其使用了外层函数(getGreet)闭包中的数据,将该闭包在堆空间中的地址存放在变量环境中。

代码执行到console.log(name),需要查找变量name。在greet词法环境和变量环境中没有直接找到,查找变量环境引用的闭包数据,在闭包中找到变量name,返回值'vian',最后打印输出'vian'。

最后,greet函数执行完毕,整段代码执行完毕。greet执行上下文出栈。由于getGreet的闭包的内容还被全局执行上下文的greet引用,因此该闭包无法被销毁,跟全局执行上下文一起一直内存中,直至应用关闭

结语

这次的总结分享,让自己的理解和记忆又深刻了一点。看到这大家应该明白,本文是打着闭包的旗号,实际是讲JavaScript的执行机制,尤其是调用栈的机制。但是理解了JavaScript的一些执行机制后,对闭包的理解会更加深刻和透彻。希望本文能帮助到大家。

本文属个人理解,旨在互相学习,有说的不对的地方,师请纠正。转载请注明原帖