执行环境,作用域,作用域链相关知识整理

725 阅读16分钟

一堆 blahblah...

在复习 Javascript 闭包的过程中,了解到了三个概念:执行环境、作用域、作用域链。查阅了一些资料,对于它们的理解还停留在很抽象的层面,于是决定着手写这篇内容,本文会围绕着这三个概念,记录一下我个人理解的过程,便于回顾,也想尝试一下能否用一个较为清晰的方式将它们呈现出来。

对于浏览器和Javascript之间是如何工作的,可参考下我的这篇内容 (点击查看👆)

根据掘友的指正或者自我理解的提升,会对本文进行持续修改或补充,修改部分则会用[删除线+斜体]去表示,补充内容会单独列出。


执行环境、作用域、作用域链

这三个概念网络上铺天盖地都是解释,在我看完了一堆资料之后,就感觉似懂非懂的🤭

于是我提出了以下几个问题:

  1. 执行环境,作用域,作用域链有什么关联?

  2. 执行环境,作用域,作用域链是什么?

  3. 执行环境,作用域,作用域链的创建过程?

  4. 执行环境,作用域,作用域链有什么作用?

本文风格主要是提问与解答,就问题展开探索,最后也会针对上述关键问题进行解答。


执行环境

1. 执行环境是什么

执行环境(Execution Context)是用来描述「 函数被执行时所在的环境 」的一个抽象概念。其实我们可以粗略的先把它当做一个在函数被执行时用来储存变量的容器,函数内的行为也都在这个容器内发生。当代码被浏览器运行时,最外围的且第一个产生的环境是 全局执行环境,由函数调用产生的环境是 函数执行环境

2. 执行环境有何作用

存储函数内的一切变量或方法。

3. 执行环境的创建时间

进入到函数时(还未执行函数内部代码)创建。

红宝书上写了,执行环境都会有一个与之关联的变量对象,保存了所有有权访问的变量和函数。所以我先粗略的将执行环境当做存储一个存储变量的容器(👇下文会对这句话做详细解释)。


执行栈

为了更好的理解 执行环境 是如何工作的,有一个概念需要铺垫一下 —— 执行栈(Execution Stack)。

执行栈是计算机科学中存储有关正在运行的子程序的消息的栈。

1. 执行栈是什么

浏览器用 JS 引擎运行我们所写的 JavaScript 代码,而 JS 引擎构建出执行栈,用来记录程序的运行状况。执行栈 是一种数据结构,顾名思义和数据结构保持相同的先进后出FirstInLastOut)特性。 在运行 JavaScript 代码时,每当遇到一个 函数调用 ,就会将对应的 函数执行环境 加到 执行栈 里去,并为它准备好足够的内存空间使用,所以我们也可以称它为 调用栈(Call Stack)。我们进入的函数,一定是在栈顶的函数。而当我们从一个函数中返回时,这个函数就会被从栈顶推出。

2. 执行栈有何作用

JS 引擎用 执行栈 来追踪当前程序的运行状况,保证执行流按照执行栈存储的顺序有序执行。

3. 执行栈是什么时候产生的

当代码被运行时,执行栈 就会被相继创建。

因为 JavaScript 是单线程,所以也只有一个 执行栈

关于执行栈 举个🌰:

// 1️⃣全局执行环境,第1个入栈

var g = "Hello, My name is ";

function SayHello1() {
  var a = "Tom";
  SayHello2();    // 3️⃣第2个函数调用,第3个入栈
  var x = g + a;
  console.log(x);
}

function SayHello2() {
  var b = "Jerry";
  SayHello3();   // 4️⃣第3个函数调用,第4个入栈
  var y = g + b;
  console.log(y);
}

function SayHello3() {
  var c = "Mark";
  var z = g + c;
  console.log(z);
}

SayHello1();  // 2️⃣第1个函数调用,第2个入栈

// console 信息
My name is Mark     // SayHello3() 第1个执行完毕,第1个出栈
My name is Jerry    // SayHello2() 第2个执行完毕,第2个出栈
My name is Tom      // SayHello1() 第3个执行完毕,第3个出栈

执行栈如下图所示: Execution Stack & Codes 入栈顺序:1 -> 2 -> 3 -> 4
出栈顺序:4 -> 3 -> 2 -> 1

🖐🏻划重点:执行环境是一个函数执行时所在的环境,每个函数都有它自己的一个执行环境。执行栈存储了一堆被调用的函数的执行环境,执行流会按照执行栈的存储顺序依次执行函数。


作用域

1. 作用域是什么

作用域一个函数能有权访问的所有变量的集合。每个函数都有属于它的作用域,相互独立的函数之间,作用域也是相互独立的,无权互相访问。

一段程序代码中所声明的变量并不总是有效/可用的,而 作用域 就是用来确定我们所声明的变量的可作用范围

一个函数的作用域,仅指在这个函数体内定义的变量和方法,也就是本地作用域(Local Scope)。

2. 作用域是什么时候被创建的

因为 JS 是 词法作用域,也就是根据 词法环境 产生出的 作用域

和动态作用域不同的是,动态作用域在函数调用的时候才会形成,词法作用域则是在函数被创建定义时(还未被执行)就会形成了。

词法环境(Lexical Environments)是一种规范类型,用于根据ECMAScript代码的词法嵌套结构来定义标识符与特定变量和函数的关联。

3. 作用域有何作用

作用域其实就是 JS 定义的一套规则,这套规则用来管理 JS 引擎如何在当前作用域以及嵌套的作用域中进行变量查找。

作用域 它负责收集和维护所有声明的变量的查询,确定当前执行的代码对这些变量的访问权限。作用域 其实就是一套提供根据名字查找变量的规则。


作用域链

1. 作用域链是什么

作用域链(Scope Chain)定义了一个函数能访问的所有变量和函数的有序集合作用域链的首部一定是当前函数作用域,每多嵌套一层外部函数,就会在作用域链末尾多添加一节。

2. 作用域的创建时间

和作用域相似的,因为词法作用域的特点,作用域链也是在函数定义时就会被创建,并且和函数定义的位置有关。在函数创建的时候就会同时创建一个包含所有外部作用域的作用域链,存储在该函数的内部属性[[Scope]]中。

正如我们所知,作用域链是必须产生作用域嵌套才会形成的。也就是说,作用域链和函数是息息相关的。当我们在进入一个函数执行环境时(被调用但还未执行内部代码前),就会创建一个该执行环境相关的作用域链,来确定变量在该执行环境内能够被有序访问。撇开执行环境的作用域链是没有意义的

[补充1]: 所以,作用域链 是在函数调用后被创建的。还有一点需要注意,虽然 作用域链 是在函数被调用后创建,但对于某一变量在该执行环境下是否可见,遵循词法作用域的特性,在函数声明时就已经确定了的。换句话说也就是,每个函数的当前作用域是在函数定义时就已经确定了,是静态的且不会被改变的。而 作用域链 又是一组 作用域 的列表,所以 作用域链 的内容其实也是声明时就已经确定好了的。

SayHello3() 函数为例,它的作用域链是这个样子:
Scope Chain

3. 作用域链的作用

作用域链 定义了一系列外部作用域的集合,当我们在使用一个变量时,会先在当前函数的本地作用域(在函数内定义的变量和函数的集合)中查找,如果没有则会沿着作用域链进行查找,直到最顶层。所以作用域链就是保证了在一个确定的执行环境中能够对变量或函数进行有序的访问

🖐🏻划重点:作用域链的创建会通过复制的方式,保存外部作用域的内存地址。所以作用域链上存的都是作用域指针。如果在当前函数内修改作用域链上的外部可访问对象当然也是不会影响其真正的值的。

以上三段是对执行环境、作用域、作用域链三个概念的简单描述,接下来我们得思考一下它们互相有什么关联呢🤔?

一开始我在理解执行环境和作用域的时候,总是会觉得对它们的描述好像差不多...
一个是函数执行时所在的环境,这个环境内装载着可访问的所有变量,一个是函数函数所有有权访问的变量的集合所以它们到底是不是一个东西呢? 答案是false


执行环境 ≠ 作用域

可以注意到我上文一直用了“粗略的”一词,执行环境并不仅仅只有作用域。

Deep in 执行环境,我们会发现执行环境其实有这三个部分组成:

  1. 变量对象(Variable Object)
  2. 作用域链(Scope Chain)
  3. this 对象

下面我们来了解一下这三个部分是什么,依旧用 Q & A 的方式:


执行环境 - 变量对象

1. 变量对象是什么

变量对象(Variable Object)是一个对象,存储了与其执行环境相关的一些变量或函数。包含有传入函数的参数、函数内部定义的变量或函数,每一个变量都会在变量对象上对应有一个同名属性,变量的话属性值为变量的值,函数的话属性值为函数的内存地址。

上文所说的作用域是一个抽象的概念,而就我理解,执行环境关联的变量对象就是对作用域这一基本概念的具体实现。

2. 变量对象是什么时候产生的

变量对象Variable Object) 在执行环境被创建时同时被创建(进入函数后,但还未执行函数内部代码时被创建)。

变量对象的属性在执行环境被创建时会预先初始化一遍,在真正执行到内部代码时,才会对属性进行赋值操作。

// memo: 创建变量对象时会涉及到变量提升,先函数后变量,同名时以函数为准

3. 变量对象的作用

用来存储和 当前执行环境 相关的所有变量和函数,并提供在该 执行环境 下能够访问的权力。

当我们在一个执行环境中使用某一变量或函数时,都会最先从该执行环境的变量对象上查找是否有这一属性。


执行环境 - 作用域链

执行环境中的作用域链是什么?

上文中所描述的是 作用域链 的概念,而此时执行环境中的 作用域链 就是这一概念的具体应用场景。

在执行环境中变量对象就是其作用域范围,作用域链是当前作用域和一系列外部作用域的集合,那么执行环境作用域链也就是当前变量对象和外部所有变量的集合,同样该作用域链上存储的也都是对外部作用域即外部变量对象的引用。

作用域链的本质是变量对象的一组指针列表。


执行环境 - this对象

1. 为什么需要 this 对象?

我们通过一个 🌰 来看下,为什么需要 this 对象:

var name = 'Peter';

function SayHi() {
    console.log('Hi, my name is ' + name + ', nice to meet you !');
}

var Mike = {
    name: 'Mike',
    greeting: SayHi
};

SayHi();     // 'Hi, my name is Peter, nice to meet you !'

// 欢迎 Mike 来打下招呼吧
Mike.greeting();      // 'Hi, my name is Peter, nice to meet you !'

我们会发现最后两句函数调用,输出了同样的内容,这是为什么呢?

分析一下 SayHi() 函数,SayHi() 函数内部会将 name 属性输出。而我们知道,当我们在函数内部使用一个变量时,会遍历其作用域链进行查找,而词法作用域的特点使得在函数定义时就已经确定了作用域和作用域链。所以,不管我们以何种方式调用 SayHi() ,输出的结果都会是同一个。

问题来了:我们如何才能输出属于 Mikename 呢?

于是,为了获得 当前运行环境 中的变量,this 对象就由此诞生了 ! 让我们改造一下刚刚的 SayHi() 函数,如下:

var name = 'Peter';

function SayHi() {
    // code change: name => this.name
    console.log('Hi, my name is ' + this.name + ', nice to meet you !');
}

var Mike = {
    name: 'Mike',
    greeting: SayHi
};

SayHi();  // 'Hi, my name is Peter, nice to meet you !'

// 欢迎 Mike 来打下招呼吧
Mike.greeting();  // 'Hi, my name is Mike, nice to meet you !'

我们可以看到,name 变成了 this.name 之后,当 SayHi()Mike 执行环境内被调用时,准确输出了属于当前执行环境 —— Mikename 属性。

2. this 对象的作用

因为函数可以在不同的执行环境中执行,所以我们需要一种机制,能够在函数内部获得当前的运行环境。

3. this 对象到底是什么

通过上述对 this 对象由来的介绍,我们可以知道 this 对象就是在函数内部,用来表示「 函数当前真正运行的执行环境 」。

所以 this 的指向是在函数被调用时被确定的。也就是函数执行环境被创建时。 this 指向真正调用它的执行环境。

// memo: this 关键字是前端基础知识点之一,文先不做详细展开,日后视情况补充...

上面文字基本上已经将本文开头的问题都回答了一遍,其中最关键的第一个问题,针对第一个问题我再做一下归纳和叙述:


回到问题1

❗ 执行环境、作用域、作用域链三者的关联是什么?

先上总结:
1. 作用域链上包含了当前作用域和一系列外部作用域。
2. 执行环境所关联的变量对象上的所有属性就是当前作用域的内容。
3. 执行环境被创建时通过复制函数的内部属性[[Scope]],构建起执行环境的作用域链,将执行环境的变量
对象推到作用域链的顶部,形成执行环境完整的作用域链。  

执行环境工作流

这三者的关系就涉及了对执行环境的工作流程的理解

( 📌 接下来我们对执行环境的整个运作场景进行还原 )

执行环境 的生命周期主要有三个阶段:

  1. 创建阶段(进入函数上下文阶段)
  2. 执行阶段
  3. 回收阶段

1️⃣ 创建阶段

1) 创建变量对象、2) 建立作用域链、3) 确定 this 指向

1) 创建变量对象

  1. 创建与当前执行环境关联的变量对象
  2. 检查函数接收到的所有参数,创建同名属性并赋值。
  3. 检查函数内的函数声明(通过 function 关键字声明的函数),每遇到一个函数声明,就在变量对象中添加一个同名属性,属性值为该函数所在的内存地址。
  4. 检查函数内的变量声明(通过 var 关键字声明的变量),每遇到一个变量声明,就在变量对象中添加一个同名属性,属性值为 undefined。(在创建阶段,如果变量与函数同名,则以函数为准)

未进入执行阶段之前,变量对象中的属性都不能访问!但是进入执行阶段之后,变量对象转变为了活动对象Activation Object),里面的属性都能被访问了,然后开始进行执行阶段的操作。

2) 建立作用域链

  1. 通过复制函数的内部属性 [[Scope]] 构建起执行环境的作用域链(所以作用域链的本质是一个指向变量对象的指针列表)。
  2. 变量对象推到作用域链的顶部,形成该执行环境完整的作用域链

3) 确定 this 指向

  1. 因为 this 指向真正调用它的对象。所以,当函数被作为某个对象的属性调用时,this 指向调用它的对象。当函数独立调用时,this 指向 undefined,这种调用方式在非严格模式中,this 会被指向 全局对象,浏览器中则是 window 对象。

2️⃣ 执行阶段

1) 执行代码、2) 变量赋值、3) 函数引用

4)执行代码

  1. 执行函数内部代码(在代码执行阶段,变量对象的属性值可能会发生再次修改)。

5) 变量赋值

  1. 执行函数内部代码,遇到 变量 赋值语句则对 变量对象 上的对应属性进行赋值(如果存在与变量同名的函数,创建阶段以函数为准,但执行阶段会被变量的值覆盖)。

6)函数引用

  1. 执行函数内部代码,遇到 变量 为函数引用时,将复制函数 内存地址变量对象 对应的属性上。

3️⃣ 回收阶段

  1. 函数执行完成,被推出执行栈,等待销毁。



以上就是本文的全部内容,因为主要是文字叙述,有些部分可能还不够直观,之后我会陆续补上一些图例说明,便于更好的理解。全文都为本人手敲,若有理解有误的地方还请指出,感谢阅读,欢迎点赞 💛

本文使用 mdnice 排版