在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),也可称为调用栈,以栈的形式调用创建的执行上下文 |
VO | Variable Object,早期ECMA规范中的变量环境,对应Object |
VE | Variable 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代码从解析到执行可以简单分为以下步骤:
-
解析全局代码,全局执行上下文进入执行上下文栈(调用栈),在堆内存中开辟一块空间存放GO对象,在GO对象内初始化自定义的全局变量,赋值为undefind,将全局上下文中的VO对象指向堆内存中的GO对象
-
执行全局代码,依次给全局变量赋值,运行到第1行代码时,堆内存分配一块空间给foo函数,堆内存的foo函数的父级作用域指向当前的GO对象,并将foo函数地址值给到GO对象中的foo变量进行赋值
-
定义foo函数执行完后,执行到第10行代码,foo函数开始被调用,foo函数上下文入栈,内存中分配一块空间作为foo函数的AO对象,初始化foo函数中的变量赋值为undefind,foo函数执行上下文的VO对象指向foo函数的AO对象
-
开始执行foo函数代码,依次给foo执行上下文的的OA对象中name,age,bar赋值,运行到bar函数定义时,分配一块空间给bar函数,其中bar函数的父级作用域指向foo函数的AO对象
-
执行到foo函数的最后一行,将bar函数的值返回,给GO对象中的fn赋值
-
到此foo函数执行上下文的任务完成了,推出调用栈销毁,虽然该执行上下文被销毁,但GO对象的fn保存着bar函数的引用,而bar函数的父级作用域又指向foo函数的AO对象,所以foo函数OA对象与bar函数都不会被销毁,此时依旧可以通过执行fn来获取AO对象内的变量值,闭包也就此形成
- 内存回收机制,从根对象(GO对象)出发,逐层访问,若某一块内存失去了来自根对象的引用,该内存会被标记一段时间后销毁回收
-
接下来执行fn函数,全局对象中的fn函数指向bar函数,所以执行的是bar函数,bar函数执行上下文入栈,bar函数的OA对象被创建,bar函数执行上下文的VO对象指向bar函数的AO对象
-
bar函数开始访问name,age执行打印,虽然bar函数执行上下文作用域内没name,age,但是其作用域链的父级作用域指向foo的AO对象,由于闭包的存在foo的OA对象没有被销毁,所以依旧可以拿到name,age的值
-
bar函数执行完成,bar函数执行上下文推出调用栈,bar函数的OA对象由于没有在被引用,将会被内存回收标记销毁(foo函数的OA对象依旧存在内存中,若其中保存的数据过大,长久占用内存可能造成内存泄露)
-
为了不造成内存泄露导致程序出现性能问题,开始执行最后一行代码,将fn函数的指向变为null,此时bar函数和foo函数的OA对象都失去了来自根对象的引用,将会标记等待内存回收机制回收
总结以上流程
- 解析代码(函数),提取变量,在堆内存中开辟一块
AO
对象空间,将提取的变量加入到AO
对象中进行初始化(初始赋值为undefind
,变量提升) - 创建执行上下文
FEC
,其中包含:VO
对象:其指向刚才创建的AO
对象(地址值),作用域链:AO
对象 + 父级AO
对象,this
:(函数执行时确定)。将执行上下文推入执行上下文栈(ESC
调用栈) - 开始在执行上下文
EFC
中从上到下逐行执行函数体内容(包含给AO中初始化过的变量赋值) - 执行完执行上下文的函数体后,执行上下文被推出销毁,此时堆内存中的对应
AO
对象若没有地址值指向它,将会等待垃圾回收机制回收这块内存(销毁),从而释放内存。若依旧有地址值指向该OA对象,则不会被回收(闭包可能导致内存溢出)