由浅入深理解js的闭包、作用域、执行上下文

222 阅读9分钟

在JavaScript中作用域、执行上下文与闭包一直都是让人比较难以理解的存在,在本文将由浅入深介绍作用域、作用域链、执行上下文以及闭包在代码中的应用与其堆栈内的运行方式。

作用域 (Scope)

作用域分为词法作用域和动态作用域,在JavaScript中只有词法作用域的概念,没有动态作用域的概念,词法作用域是指函数的作用域由函数定义时的位置决定,与函数被调用时的位置无关

  • 每个函数都有一个作用域,称为函数作用域。函数作用域是指函数内部定义的变量和函数只能在该函数内部访问。当函数执行完毕时,这些变量和函数的作用域将被销毁。
  • JavaScript还有一个全局作用域,表示在整个代码中定义的变量和函数,它们可以在整个代码中访问。 作用域在词法解析时确定,依照函数定义的位置确定其上层作用域和自身作用域,而不是调用的位置

作用域链 (Scope Chain)

作用域链是由当前函数自身的作用域Scope加上其父级作用域parentScope形成的,而父级作用域内也是由自身的作用域加上自己的父级作用域组成,当访问一个变量或者属性时,会通过作用域一层一层往父级查找,这一系列作用域串联就称为作用域链。

  • 假定在一个函数内访问一个变量,如果在当前函数内没有找到该变量会去父级作用域查找,父级作用域没有再去父级作用域的父级作用域,一直找到全局作用域,如果依旧没有找到才会抛出x is undefind 错误

我们可以认为作用域链是一套变量和函数的访问规则

了解完作用域,在这之前先来了解下js解析运行时产生的执行上下文以及堆内存中的对象都代表什么:

名词解释
ECS执行上下文栈(Execution Context Stack),也可称为调用栈,以栈的形式调用创建的执行上下文
GEC全局执行上下文(Global Execution Context),在执行全局代码前创建
FEC函数执行上下文(Functional Execution Context),在执行函数前创建
ECS执行上下文栈(Execution Context Stack),也可称为调用栈,以栈的形式调用创建的执行上下文
VOVariable Object,早期ECMA规范中的变量环境,对应Object
VEVariable Environment,最新ECMA规范中的变量环境,对应环境记录
GO全局对象(Global Object),解析全局代码时创建,GEC中关联的VO就是GO
AO函数对象(Activation Object),解析函数体代码时创建,FEC中关联的VO就是AO

执行上下文

执行上下文描述了 JavaScript 代码执行的环境,是代码具体执行的地方。在 JavaScript 中,每个执行上下文都有自己的变量对象、作用域链和 this 指向。绑定 this, 创建词法环境 创建变量环境 JavaScript 中有三种不同类型的执行上下文:全局执行上下文函数执行上下文eval 执行上下文

| 名词 |解释 ECS 执行上下文栈(Execution Context Stack),也可称为调用栈,以栈的形式调用创建的执行上下文 GEC 全局执行上下文(Global Execution Context),在执行全局代码前创建 FEC 函数执行上下文(Functional Execution Context),在执行函数前创建 VO Variable Object,早期ECMA规范中的变量环境,对应Object VE Variable Environment,最新ECMA规范中的变量环境,对应环境记录 GO 全局对象(Global Object),解析全局代码时创建,GEC中关联的VO就是GO AO 函数对象(Activation Object),解析函数体代码时创建,FEC中关联的VO就是AO

全局对象 Global Object (GO)

在 JavaScript 中,GO 指的是全局对象。全局对象在 JavaScript 中是一个特殊的对象,包含了全局作用域中定义的所有变量和函数。 在浏览器环境中,全局对象是 window 对象。在 Node.js 环境中,全局对象是 global 对象。

活动对象 Active Object (AO)

在 JavaScript 中,当函数执行时,会创建一个名为 AO 的对象,也称为活动对象。AO 对象用于存储函数的参数和局部变量。当函数执行完毕时,AO 对象会被销毁。

VO,VE与GO,AO的关系

执行上下文创建时,无论全局执行上下文还是函数执行上下文都会存在一个VO对象,其中全局执行上下文中的VO对应着全局变量GO,而函数执行上下文中的VO对应函数执行时创建的AO对象 。 最新ECMA的版本规范中:去除了VO这个概念,由VE替代,每一个执行上下文会关联到一个变量环境(VE),在执行代码中变量和函数的声明会作为环境记录(Environment Record)添加到变量环境中。对于函数来说,参数也会被作为环境记录添加到变量环境中。新版本规范将变量环境改成了VE,执行过程还是不变,只是关联的变量环境不同,将VE当作VO即可

闭包

闭包是指一个函数可以访问其外部作用域中的变量和函数。

闭包的作用

  • 使用外部变量的值始终保持在内存中
  • 创建私有变量和函数,以确保它们不会被外部作用域中的其他代码访问或修改。

简单了解了以上概念,一起用闭包的形成以及来看它们在堆栈的工作形式

闭包的形成与消除

接下来看看这段代码在堆栈中是怎么运行的

function foo(){
  var name = 'foo'
  var age = 18
  function bar (){
    console.log(name)
    console.log(age)
  }
  return bar
}
var fn = foo()
fn()
fn = null

js代码从解析到执行可以简单分为以下步骤:

  1. 解析全局代码,全局执行上下文进入执行上下文栈(调用栈),在堆内存中开辟一块空间存放GO对象,在GO对象内初始化自定义的全局变量,赋值为undefind,将全局上下文中的VO对象指向堆内存中的GO对象

  2. 执行全局代码,依次给全局变量赋值,运行到第1行代码时,堆内存分配一块空间给foo函数,堆内存的foo函数的父级作用域指向当前的GO对象,并将foo函数地址值给到GO对象中的foo变量进行赋值

  3. 定义foo函数执行完后,执行到第10行代码,foo函数开始被调用,foo函数上下文入栈,内存中分配一块空间作为foo函数的AO对象,初始化foo函数中的变量赋值为undefind,foo函数执行上下文的VO对象指向foo函数的AO对象

  4. 开始执行foo函数代码,依次给foo执行上下文的的OA对象中name,age,bar赋值,运行到bar函数定义时,分配一块空间给bar函数,其中bar函数的父级作用域指向foo函数的AO对象

  5. 执行到foo函数的最后一行,将bar函数的值返回,给GO对象中的fn赋值

  6. 到此foo函数执行上下文的任务完成了,推出调用栈销毁,虽然该执行上下文被销毁,但GO对象的fn保存着bar函数的引用,而bar函数的父级作用域又指向foo函数的AO对象,所以foo函数OA对象与bar函数都不会被销毁,此时依旧可以通过执行fn来获取AO对象内的变量值,闭包也就此形成

  • 内存回收机制,从根对象(GO对象)出发,逐层访问,若某一块内存失去了来自根对象的引用,该内存会被标记一段时间后销毁回收
  1. 接下来执行fn函数,全局对象中的fn函数指向bar函数,所以执行的是bar函数,bar函数执行上下文入栈,bar函数的OA对象被创建,bar函数执行上下文的VO对象指向bar函数的AO对象

  2. bar函数开始访问name,age执行打印,虽然bar函数执行上下文作用域内没name,age,但是其作用域链的父级作用域指向foo的AO对象,由于闭包的存在foo的OA对象没有被销毁,所以依旧可以拿到name,age的值

  3. bar函数执行完成,bar函数执行上下文推出调用栈,bar函数的OA对象由于没有在被引用,将会被内存回收标记销毁(foo函数的OA对象依旧存在内存中,若其中保存的数据过大,长久占用内存可能造成内存泄露)

  4. 为了不造成内存泄露导致程序出现性能问题,开始执行最后一行代码,将fn函数的指向变为null,此时bar函数和foo函数的OA对象都失去了来自根对象的引用,将会标记等待内存回收机制回收

总结以上流程

  1. 解析代码(函数),提取变量,在堆内存中开辟一块AO对象空间,将提取的变量加入到AO对象中进行初始化(初始赋值为undefind,变量提升)
  2. 创建执行上下文FEC,其中包含:VO对象:其指向刚才创建的AO对象(地址值),作用域链AO对象 + 父级AO对象,this:(函数执行时确定)。将执行上下文推入执行上下文栈(ESC 调用栈)
  3. 开始在执行上下文EFC中从上到下逐行执行函数体内容(包含给AO中初始化过的变量赋值)
  4. 执行完执行上下文的函数体后,执行上下文被推出销毁,此时堆内存中的对应AO对象若没有地址值指向它,将会等待垃圾回收机制回收这块内存(销毁),从而释放内存。若依旧有地址值指向该OA对象,则不会被回收(闭包可能导致内存溢出)