执行上下文、作用域和环境记录

570 阅读10分钟

代码已经关联到github: 链接地址 文章有更新也会优先在这,觉得不错可以顺手点个star,这里会持续分享自己的开发经验(:

在说明执行上下文和作用域之前,我们需要了解一下LHS和RHS查询,这样才能更好的理解为什么JavaScript存在执行上下文和作用域。

LHS 和 RHS

我们都知道JavaScript是一门解释型语言/动态/脚本语言,他会在执行之前一刻进行编译,然后交给JS引擎执行。

编译

编译器负责把代码解析成机器指令,通常会有三个步骤:

  1. 分词/词法解析:将JavaScript字符串分解为词法单元(token),如var a = 2=> vara=2
  2. 解析/语法分析:将一个个token的流(数组)转为抽象语法树(AST)
  3. 代码生成:将AST转为机器指令,等待执行。

执行

JS引擎拿到一堆机器指令开始执行,这个时候需要查询变量,LHSRHS就登场了。

  1. LHS (Left-hand Side) :查询目的是变量赋值,如a=1,是为了将值1赋给变量a
  2. RHS (Right-hand Side) :查询的目的就是查询实际值,如foo(),查找foo是函数,才能执行;如果不是函数就会抛出TypeError异常;找不到则会抛出ReferenceError异常。

而两种查询方法获取变量的方式,叫做 作用域(Scope)链, 变量存储的地方叫 环境记录(Environment Record) ,而执行上下文(Execution Context) 包含作用域链、环境记录。

通俗来说:作用域告诉我们在某个执行上下文中可以访问哪些变量,而这些变量正是存储在相应的词法环境的环境记录中。 下面我们分别介绍他们:

执行上下文

什么是执行上下文

执行上下文是代码在运行时创建的抽象环境,它封装了代码执行所需的所有信息。每当全局代码运行、函数被调用或 eval 执行时,都会创建一个对应的执行上下文。

每个执行上下文通常包含:

  • 环境记录(Environment Records),包含一系列环境:变量环境(Variable Environment)、词法环境(Lexical Environment).... :存储当前上下文中定义的变量和函数声明。

  • 作用域(Scope) :一系列词法环境的引用,保证在当前上下文中可以访问到所有外层环境的变量。

  • this 值:当前上下文中 this 的绑定值。

所有的 JavaScript 代码在运行时都是在执行上下文中进行的,每创建一个执行上下文,就会将当前执行上下文放到一个栈顶,这就就是我们常说的执行栈

JavaScript 中有三种情形会创建新的执行上下文:

  • 全局执行上下文,进入全局代码的时候,也就是执行全局代码之前。
  • 函数执行上下文,函数被调用之前。
  • Eval 执行上下文,eval 函数调用之前。

执行上下文的组成

1. 环境记录(Environment Records)

是一个抽象的数据结构,用于存储标识符与其对应值的映射,它决定了变量和函数在代码中的查找规则。每个执行上下文都有一个关联的词法环境,词法环境由两部分组成:

  • 变量环境(Variable Environment)
    • 存储var声明的变量和函数声明(函数提升)。
      • 在创建阶段初始化(变量值为undefined)。
  • 词法环境(Lexical Environment)
    • 存储let/const声明的变量(存在暂时性死区)。
    • 与块级作用域({})绑定。
  • 外部环境记录的引用([outer env]) :指向外层的环境,形成了作用域的关键

在 ECMAScript 规范中,“环境记录”就是对变量绑定的一种抽象表示,用于实现词法作用域规则。

2. 作用域

作用域是一个抽象概念,它描述了程序中哪些部分可以访问某个变量或函数。作用域的形成基于词法环境和它们之间的链式连接(作用域链)。

    • 词法作用域(Lexical Scope) :在代码书写阶段确定,即变量在源代码中的位置决定了它的可见性。ECMAScript 采用词法作用域,意味着在函数定义时就确定了其作用域,而非在运行时动态确定。
    • 作用域链:在执行上下文中,作用域链是由当前词法环境及其外部引用构成的链表。每当 JavaScript 引擎尝试访问变量或函数时,会首先在当前环境中查找,如果找不到就沿着作用域链向外查找,直到找到标识符或到达全局作用域。如果在任何上下文中都找不到标识符,则会引发 ReferenceError。

3. this

当前的代码在哪个对象下被调用,如果没有则默认是window(严格模式、箭头函数除外...)

执行上下文的生命周期

运行 JavaScript 代码时,当代码执行进入一个环境时,就会为该环境创建一个执行上下文,它会在你运行代码前做一些准备工作。

创建阶段

执行上下文的创建大体步骤如下:

  1. 创建环境记录 (Environment Record),其中包含变量环境和词法环境,建立内存空间和变量的初始化,其中:
    • 字面量形式声明的函数:会被分配内存空间并赋值
    • var定义的变量、函数:会被分配内容空间并被变量提升(初始值为undefined)
    • letconst定义的变量:会被分配内容空间并被变量提升(未被赋予值!)
    • 函数环境会初始化创建 arguments 对象并赋值**
  1. 确定作用域(链)

词法环境决定,哪里声明定义,就在哪里确定

  1. 确定 this 指向

this 通常由调用者确定,但有两个特例(排除我们自定绑定this的方式):

    • 箭头函数是词法决定
    • eval如果是间接调用,指向全局
var name = 'window ni'
var msg = 'hao'
function test(){
    this.name = 'hello'
    var msg = '好'
    var _myEval = eval
    eval(`console.log(name, msg)`)//hello 好
    _myEval(`console.log(name, msg)`)//window ni hao
}

伪代码:

executionContextObj = {
    variableObject : {}, // 变量对象,里面包含 Arguments 对象,形式参数,函数和局部变量
    scopeChain : {},// 作用域链,包含内部上下文所有变量对象的列表
    this : {}// 上下文中 this 的指向对象
}

执行阶段

  • 逐行执行代码,变量赋值、函数调用

  • 遇到函数调用时创建新的函数执行上下文并压入调用栈

举个例子:

const foo = function(num){
    var a = "Hello";
    var b = function varB(){};
    function c(){}
    let d = "World"
}
foo(10);
  • 创建阶段
executionContextObj = {
    variableObject : {
      num: 1, //确定形参并且赋值
      arguments: {0:10, length:1}, //确定argumens 
      c: function c(){}, //确定字面变量定义的函数
      a: undefined,// var 定义的局部变量,初始值为undefined
      b: undefined,// var 定义的局部变量,初始值为undefined
      // let 定义的变量只会记录,到执行赋值的阶段这里为暂死区
    }, 
    scopeChain : {},//词法解释阶段已经确定了作用域,该阶段相当于引用
    this : {}
}
  • 执行阶段
executionContextObj = {
    variableObject : {
      num: 1, //确定形参并且赋值
      arguments: {0:10, length:1}, //确定argumens 
      c: function c(){}, //确定字面变量定义的函数
      a: "hello",// var 定义的局部变量,赋值
      b: function varB(){},// var 定义的局部变量,赋值
      d: 'world' //let 定义的局部变量,声明并赋值
    }, 
    scopeChain : {},//词法解释阶段已经确定了作用域,该阶段相当于引用
    this : window //假定是全局定义
}

作用域

MDN中,我们可以找到定义:

The scope is the current context of execution in which value and expressions are "visible" or can be referenced.

翻译一下:作用域(Scope)指的是在执行上下文中可见(或者说是可用)变量的范围

也就是指在JS执行过程中变量、函数、对象等标识符(名字)能够被访问的区域。

  • 词法作用域(Lexical Scope) :在代码编写时就确定了作用域关系,即变量在源代码中出现的位置决定了它的可见范围。
  • 块级作用域:用大括号 {} 包含的区域(如 ifforfunction 内部),ES6 之后使用 letconst 定义的变量拥有块级作用域。

作用域链取值说明

当访问一个变量时,解释器会首先在当前作用域查找标示符,如果找到了,则使用当前作用域下的变量,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是作用域链。

那这个父作用域又是那个呢?实际上是要到创建这个函数的那个域。 作用域中取值,这里强调的是“创建”,而不是“调用” ,切记切记——这种类型的作用域又称为静态作用域,也被称为词法作用域,因为在词法分析时就确定了查找关系(这也是和 this最大的不同!)。

静态作用域取值

function foo(){
    console.log(a)
}

function bar(){
    var a = 3;
    foo();
}
var a = 1;
bar();

上面代码会打印 1,为什么呢?因为此处foo的函数是在全局作用域window上定义的,所以查找时现在foo函数中查找a,找不到会去window上查找,所以此处a=1

顺便在看下this的,感受下其中的不同,虽然这个与作用域无关...

function foo(){
    console.log(this.a)
}

function bar(){
    var a = 2
    foo();
}
var a = 1;

bar() // 1
bar.call({a:3}); //1

此处的两个输出会打印 1,第一个大家可能容易理解,为什么第二个也是1呢?此处foo被调用时,其执行上下文指向的依然是全局执行上下文,所以这里的this也指向window,所以此处a=1

闭包的作用域

闭包其实就是函数握住了外部的词法环境:

const arr = []
function addPlus(a){
  var i = 0 //这里定义成let也一样,因为这个词法
  for(;i<3;i++){
    arr[i] = ()=>{
      console.log(a * i)
    }
  }
  console.log('end i', i)
}

addPlus(1)
arr[0]()//3
arr[1]()//3
arr[2]()//3

所以输出都是3,因为最终 i = 3

变量提升

上面说过,JavaScript中,存在函数声明和变量声明总是会被解释器悄悄地被"提升"到方法体的最顶部,这就是我们常说的变量提升,注意:

  • 只有声明的变量会提升,值不会。
  • 严格模式下不存在变量提升。
  • letconst也存在变量提升,但是letconst定义的变量会造成暂时性死区,定义变量后一开始就形成了封闭作用域,凡是在声明之前就使用这些变量,就会报错。

varlet的声明提升:

console.log(a) //undefined
var a = 1

var b = 1
{
  //报错,如果没有提升,不是应该显示成1?,所以是有提升
  console.log(b)
  let b = 2
}

当前作用域下只要存在变量,就算是变量提升得到,也相当于找到了,不会再去父作用域中找:

var a = 1
function foo(){
    console.log(a)
  	var a = 2
}
foo()//undefined

函数定义也存在变量提升,而且是整体提升,如果是函数变量则看定义的关键字是var还是其他,这和上文保持一致:

console.log(age);
var age = 20
console.log(age);

// 1.提升到最前面
function age() {}
// 2.将function改成匿名函数的方式,则不会提升
//var age = function(){}

console.log(age);
// 1. 函数提升输出
//f age(){}
// 20
// 20

//2. 将function改成匿名函数的方式,则不会提升(注意要重新在一个新的环境下运行)
//undefined
//20
//ƒ age(){}

参考

262.ecma-international.org/15.0/index.…

了解词法环境吗?它和闭包有什么联系?