JavaScript作用域与执行上下文
本文最后更新于 2025年7月30日 下午
作用域
参考:https://github.com/mqyqingfeng/Blog/issues/3 。
作用域
作用域是指程序源代码中定义变量的区域。作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。
JavaScript 作用域有三种:全局作用域、函数作用域、块级作用域。
静态作用域与动态作用域
因为 JavaScript 采用的是词法作用域,所以函数作用域在函数定义的时候就决定了,函数的作用域基于函数创建的位置。与词法作用域相对的是动态作用域,如果使用的是动态作用域,那么函数作用域就是在被函数调用的时候才被决定。
执行上下文
参考:https://github.com/mqyqingfeng/Blog/issues/4 。
执行上下文
JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个“准备工作”,但 JavaScript 引擎遇到一段怎样的代码时才会做“准备工作”?
JavaScript 可执行代码(executable code)就三种:全局代码、函数代码、eval 代码。比如,当执行到一个函数的时候,就会进行准备工作,这里的“准备工作”,就叫做执行上下文(execution context)。
执行上下文栈
如何管理创建的那么多执行上下文呢?JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。
当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候,首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,且只有当整个应用程序结束的时候执行上下文栈才会被清空。所以程序结束之前,执行上下文栈最底部永远有 globalContext。
当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。
执行上下文之变量对象
参考:https://github.com/mqyqingfeng/Blog/issues/5 。
当 JavaScript 代码执行一段可执行代码(executable code)时,都会创建对应的执行上下文。对于每个执行上下文,都有三个重要属性:变量对象(Variable object)、作用域链(Scope chain)、this。
变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。不同执行上下文下的变量对象会稍有不同,所以我们来聊聊全局上下文的变量对象和函数上下文的变量对象。
全局上下文的变量对象
全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。
在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。
注意,不要混淆全局上下文和全局对象。全局上下文包括变量对象、作用域链、this,全局上下文的变量对象中包含全局对象。
全局上下文的变量对象初始化只包括全局对象。
函数上下文的变量对象
在函数上下文中,我们用活动对象(activation object)来表示变量对象。
活动对象(AO)和变量对象(VO)其实是一个东西,只是变量对象是规范上的或说是引擎上实现的,不可在 JavaScript 环境中访问。只有到当进入函数执行上下文中,这个执行上下文的变量对象才会被激活,所以叫 activation object。被激活的变量对象就是活动对象,只有活动对象上的各种属性才能被访问。
活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。
函数上下文的变量对象初始化只包括 Arguments 对象。
执行上下文的执行过程
执行上下文的代码会分成两个阶段进行处理:分析(进入执行上下文)和执行(代码执行)。
全局上下文
全局上下文当进入执行上下文时,这时候还没有执行代码,变量对象会包括两方面。
- 函数声明
- 由名称和对应值(函数对象)组成一个变量对象的属性被创建
- 如果变量对象当中已经存在相同名称的属性,则完全替换这个属性
- 变量声明
- 使用
var
声明的变量:由名称和 undefined 值组成一个变量对象的属性被创建
(无论声明时是否已经初始化变量,此时值都为 undefined,等执行时再修改值)
(使用let
和const
声明的变量都属于未声明的变量,等执行时再添加到变量对象) - 如果变量名称跟已经声明的函数相同,则变量声明不会干扰已经存在的属性
- 如果变量名称跟已经声明的变量相同,则变量声明将会覆盖已经存在的属性
- 使用
1 |
|
1 |
|
函数上下文
函数上下文当进入执行上下文时,这时候还没有执行代码,变量对象会包括三方面。
- 函数的所有形参
- 由名称和对应值组成的一个变量对象的属性被创建
(有实参,属性值为对应值;没有实参,属性值为 undefined)
- 由名称和对应值组成的一个变量对象的属性被创建
- 函数声明
- 由名称和对应值(函数对象)组成一个变量对象的属性被创建
- 如果变量对象当中已经存在相同名称的属性,则完全替换这个属性
- 变量声明
- 使用
var
声明的变量:由名称和 undefined 值组成一个变量对象的属性被创建
(无论声明时是否已经初始化变量,此时值都为 undefined,等执行时再修改值)
(使用let
和const
声明的变量都处于暂时性死区,等执行时再添加到变量对象) - 如果变量名称跟已经声明的形参或函数相同,则变量声明不会干扰已经存在的属性
- 如果变量名称跟已经声明的变量相同,则变量声明将会覆盖已经存在的属性
- 使用
1 |
|
1 |
|
代码执行
在代码执行阶段,JavaScript 引擎会按顺序执行代码,根据代码,修改变量对象的值。
要特别注意的是,var
、let
、const
三者声明的效果不同。
总结
- 全局上下文的变量对象初始化只包括全局对象。
- 函数上下文的变量对象初始化只包括 Arguments 对象。
- 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值。
- 在代码执行阶段,会再次修改变量对象的属性值。
需要特别注意的是,ES6 添加了 let 和 const 声明。使用 var 声明的变量会成为全局对象的属性,但是使用 let 和 const 声明的变量不会成为全局对象的属性,而是会成为的变量对象的属性,与全局对象“平级”。
因此,ES6 之前,全局上下文的变量对象就是全局对象;ES6 之后,全局上下文的变量对象包含全局对象和使用 let 和 const 声明的变量和对象。要特别注意的是,var
、let
、const
三者声明的效果不同。
执行上下文之作用域链
参考:https://github.com/mqyqingfeng/Blog/issues/6 。
当 JavaScript 代码执行一段可执行代码(executable code)时,都会创建对应的执行上下文。对于每个执行上下文,都有三个重要属性:变量对象(Variable object)、作用域链(Scope chain)、this。
当查找变量的时候,会先从当前上下文的变量对象中查找,若没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
函数创建
函数作用域在函数定义的时候就决定了。
这是因为函数有一个内部属性 [[scope]],当函数创建的时候,它就会保存所有父变量对象到其中,可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链。
函数激活
当函数激活时,进入函数上下文,创建变量对象/活动对象后,就会将活动对象添加到作用链的前端。此时,执行上下文的作用域链,我们命名为 Scope:Scope = [AO].concat([[Scope]])
。至此作用域链创建完毕。
总结
以下面的例子为例,来总结一下函数执行上下文中作用域链和变量对象的创建过程。
1 |
|
- checkscope 函数被创建,保存作用域链到内部属性 [[scope]]。
1 |
|
- 创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈。
1 |
|
- 不立刻执行 checkscope 函数,开始做准备工作。
- 第一步:复制函数 [[scope]] 属性创建作用域链。
1 |
|
- 第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明。
1 |
|
- 第三步:将活动对象压入 checkscope 作用域链顶端。
1 |
|
- 准备工作做完,开始执行函数,随着函数的执行,修改活动对象的属性值。
1 |
|
- 函数执行完毕后,函数上下文从执行上下文栈中弹出。
1 |
|
执行上下文之 this
参考:https://juejin.cn/post/6844903488304971789 。
当 JavaScript 代码执行一段可执行代码(executable code)时,都会创建对应的执行上下文。对于每个执行上下文,都有三个重要属性:变量对象(Variable object)、作用域链(Scope chain)、this。
this 只在函数调用阶段确定,也就是执行上下文创建的阶段进行赋值,然后保存下来。这个特性导致了 this 的多变性:当函数在不同的调用方式下都可能会导致 this 的值不同。标准函数和箭头函数也有所不同。
标准函数
在标准函数中,this 引用的是把标准函数当成方法调用的对象。可分为 4 种情况。
直接被调用
当独立调用函数的时候,严格模式下 this 会指向 undefined,非严格模式自动转为指向全局对象。
不管是全局函数被直接调用还是父函数里的子函数被父函数直接调用,都指向全局对象。
1 |
|
1 |
|
作为对象的方法被调用
当函数作为对象的方法被调用时,这时 this 指向调用它的对象。
当对象在全局代码中声明的时候,对象内部属性中的 this 指向全局对象;
当对象在一个函数中声明的时候,严格模式下 this 会指向 undefined,非严格模式自动转为指向全局对象。
1 |
|
1 |
|
1 |
|
使用 call 或 apply 被调用
当函数使用 call 或 apply 被调用时,由其传入的参数作为 this 值。
1 |
|
作为构造函数被调用
当函数作为构造函数被调用时,那么其中的 this 就代表它即将 new 出来的对象。
箭头函数
箭头函数会捕获其所在上下文的 this 值,作为自己的 this 值,箭头函数 this 由其所在上下文的 this 所决定。
对箭头函数使用 apply 或 call 方法只是传入参数,改变不了 this。
1 |
|
1 |
|
1 |
|
总结
- 函数的
color
与 其所在的上下文的作用域链 有关 - 箭头函数的
this.color
与 其所在的上下文的 this 有关 - 标准函数的
this.color
与 把其当成方法调用的对象 有关
注意,全局上下文的变量对象包含着全局对象和使用 let 和 const 声明的变量和对象。
练习:
- 1.
1 |
|
- 2.
1 |
|
- 3.
1 |
|
- 4.
1 |
|
- 5.
1 |
|
- 6.
1 |
|
- 7.
1 |
|
- 8.
1 |
|
闭包
参考:https://github.com/mqyqingfeng/Blog/issues/9 。
定义
MDN 对闭包的定义为:闭包是指那些能够访问自由变量的函数。
自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。
所以,可以得出:闭包 = 函数 + 函数能够访问的自由变量。
理论角度
所有的 JavaScript 函数都是闭包。因为在创建函数的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
实践角度
从实践角度,符合以下条件的函数才算是闭包:
- 创建闭包的函数已经被销毁,但闭包仍然存在(比如,内部函数从父函数中返回)
- 闭包的代码中引用了自由变量
用途
优点
- 闭包及其变量长期在内存中,可长时间访问。
- 创建闭包的函数的变量可被外部访问。
- 私有化变量,避免污染全局变量。
缺点
- 内存消耗很大,不能滥用闭包,否则会造成网页性能问题。
- 容易造成内存泄漏。
1 |
|