执行上下文和作用域链

前言

因为和我对接的后台同学的服务器不在状态,所以我突然多了一天的摸鱼时间,突然想起还有这么个知识点,于是我决定花点时间整理下,最后发现这东西的坑比我想象的大,所以就有了这篇博客

本文同步更新于我的掘金博客

ES3

因为一些概念已经不通用,所以把文章分成几个部分,先从ES3时代开始说起。

中英名词对照:

  • 执行上下文:Execution Contexts
  • 执行栈:Execution Stack
  • 变量对象:variable object
  • 激活对象:Activation Object
  • 作用域链:Scope Chain

执行上下文和执行栈

在JS代码执行前,JS引擎会为这部分代码创建一个执行环境,这个环境叫做执行上下文(Execution Contexts),这个环境中包含了代码运行时需要的数据,执行上下文有两种

  • 全局执行上下文:在所有的JS代码开始执行前,JS引擎会先创建全局执行上下文,全局执行上下文是唯一的,并且始终处于执行栈的底部,一直到程序运行结束才会销毁
  • 函数执行上下文:函数执行上下文会在函数被调用时创建,每一个函数调用都会产生新的执行上下文,即使是重复调用
  • eval函数执行上下文: 执行在eval函数内部的代码也会有它属于自己的执行上下文,但eval函数一般不会用,在这里不做分析

这些执行上下文会被放到一个叫执行栈(Execution Stack)的栈中,下面这段代码演示了他们的关系

let a = 'Hello World!';
function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}
function second() {
  console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');

当这段代码加载到浏览器中时,JS引擎会创建一个全局执行上下文并将其推入执行栈。 当first函数被调用时,JS引擎会为first函数创建一个新的执行上下文,并将其推入当前执行栈的顶部。

当从first函数中调用second函数时,JS引擎将为second函数创建一个新的执行上下文,并将其推入当前执行栈的顶部。 当second函数完成时,它的执行上下文将从执行栈中弹出,代码的运行权也重新回到其下方的执行上下文,即first函数的执行上下文。

当first完成时,它的执行上下文将从执行栈中弹出,并将控制权移至全局执行上下文。

执行上下文的内容

执行上下文中包含四个方面的内容,分别是

  • this的指向
  • 变量对象(variable object), 简称VO
  • 活动对象(Activation Object), 简称AO
  • 作用域链(Scope Chain)

this指向

原文:There is a this value associated with every active execution context. The this value depends on the caller and the type of code being executed and is determined when control enters the execution context. The this value associated with an execution context is immutable.

每个处于活动期间的执行上下文的都和一个this值相关联,this的值取决于函数调用方和正在执行的代码的类型,它会在控制权进入执行上下文时确定。 与执行上下文关联的this值是不可变的。

这里不展开说明this的规则,就放一张图吧

变量对象

原文:Every execution context has associated with it a variable object. Variables and functions declared in the source text are added as properties of the variable object. For function code, parameters are added as properties of the variable object.

每个执行上下文都有一个变量对象。 源代码中声明的变量和函数将作为属性被添加到变量对象上。对于函数的执行上下文,参数也会被添加到变量对象中

变量对象也有两种

  • 全局变量对象(global object) : 全局上下文中的变量对象,在浏览器环境中就是window,也叫GO
  • 普通变量对象 : 函数的执行上下文中的变量对象

在普通VO创建时会进行下面的操作

  • 把所有的形参和arguments添加到VO上,形参值设置为实参值
  • 把函数里所有通过var声明的变量添加到VO上,值设置为undefined,如果VO中已经有该属性,直接跳过
  • 把通过函数声明的函数添加到VO上,如果VO中已经存在该属性,进行覆盖

关于操作的第二点,如果VO中已经有该属性,直接跳过这个的验证如下

function func(x, y) {
    console.log(x, y, z, arguments);
    var x, z;
    x = 20;
    y = 20;
    z = 20;
    console.log(x, y, z, arguments);
}

func(10,10);

打印出来的结果是这样的

arguments的值和形参有映射关系,当x,y被修改时,arguments里的值也被修改了,所以x,y是形参的x和y,如果是下面声明的x和y,那arguments里的值不会发生变化

关于操作的第三点,函数声明会覆盖var声明的变量,这个验证也很简单

console.log(x);  // ƒ x() {}
var x = 10;
console.log(x); // 10
function x() {}

从打印结果可以看出,function的声明覆盖了变量的声明

GO没有普通VO创建的第一步,也就是形参设置。GO创建时的第一步是会挂载Math, Date这些内置对象到自身上。

活动对象

原文:When control enters an execution context for function code, an object called the activation object is created and associated with the execution context.[...]
The activation object is then used as the variable object for the purposes of variable instantiation.
The activation object is purely a specification mechanism. It is impossible for an ECMAScript program to access the activation object

当控制权进入函数的执行上下文时,将创建一个称为激活对象的对象添加到执行上下文中。然后激活对象将用作变量对象,以实现变量实例化。激活对象纯粹是一种规范机制。ECMAScript程序不能访问激活对象。

说实话这里的文档我也看不太懂,然后我去StackOverflow上找了一下相关的问答

然后点赞最高的回答是这个

我点进原文章看了一下

最后的结论是,在函数执行上下文中,激活对象(AO)会被当做(VO)使用,在全局执行上下文中,因为没有AO,所以全局对象(GO)会被当成VO使用

画张图来看是这样的
VO和AO在函数执行上下文中指向同一块内存空间,全局上下文中没有AO,所以GO单独指向一块内存

作用域链

原文:Every execution context has associated with it a scope chain. A scope chain is a list of objects that are searched when evaluating an Identifier. When control enters an execution context, a scope chain is created and populated with an initial set of objects, depending on the type of code.

每个执行上下文都包含一个作用域链,这个作用域链会用于变量的查找。当控制权进入执行上下文时,作用域链就会被创建。

作用域链用于查找变量,在查找变量时,会从作用域链的顶端开始查找,当查找完整个作用域链仍然找不到变量时,就会抛出异常。在函数创建时,会定义一个内部属性[[scope]]保存到函数上,这个属性会保存当前执行上下文的作用域链,在函数执行时,[[scope]]属性会和当前执行上下文的VO一起组成当前执行上下文的作用域链

举个例子, 有下面的代码

var a = 10;
function func1() {
    var b = 20;
    return function fun2() {
        console.log(b);
        var c = 30
        return function fun3 () {
            console.log(c);
            var d = 40;
        }
    }
}
var func2 = func1();
var func3 = func2();
console.log(func3);

然后在运行结束时,我们可以查看func3的情况

如图,func3实际上是在func2中被创建的,所以func3[[Scopes]]保存了func2执行时的作用域链,在func3执行时,会在这个作用域链的顶端加上func3的VO,由此构建func3的作用域链,这就解释了闭包的形成

也就是

Scope = [VO].concat([[Scope]]);

执行上下文的结构

执行上下文的结构类似于这种

executionContext:{
    [variable object | activation object]:{
        arguments,
        variables|functions : [...]
    },
    scope chain: [VO].concat([[Scope]])
    thisValue: context object
}

ES5

ES5调整了执行上下文中的部分概念,去除了AO,VO的概念,添加了词法环境(Lexical Environments)和变量环境(VariableEnvironment)这两个新概念,当然总体思路没有太大变化,如果你看懂了上面ES3的部分,这份也不是什么难事。

中英名词对照

  • 词法环境:Lexical Environment
  • 变量环境:VariableEnvironment
  • 环境记录:Environment Records
  • 外部词法环境:The outer environment reference

执行上下文的结构

执行上下文有两个关键属性,词法环境LexicalEnvironmen和变量环境VariableEnvironment

用代码表示是这样的

ExecutionContext = {
  LexicalEnvironment = {...},
  VariableEnvironment = {...}
}

接下来我们来看看LexicalEnvironment和VariableEnvironment的详细内容

词法环境(Lexical Environment)

原文:A Lexical Environment is a specification type [...]. A Lexical Environment consists of an Environment Record and a possibly null reference to an outer Lexical Environment. Usually a Lexical Environment is associated with some specific syntactic structure of ECMAScript code such as a FunctionDeclaration, a BlockStatement, or a Catch clause of a TryStatement and a new Lexical Environment is created each time such code is evaluated.

词法环境是一种规范类型,由环境记录(Environment Record)和对外部词法环境(outer Lexical Environment)的引用组成。通常,词汇环境与ECMAScript代码的一些特定语法结构相关联,比如函数声明、代码块或Try语句的Catch子句,每次执行这些代码时都会创建一个新的词汇环境。

也许你暂时看不懂上面的话,没关系,我会先介绍环境记录和外部词法环境,最后结合起来一起讲解

环境记录(Environment Records)

原文 [...] Declarative Environment Records and object Environment Records. Declarative Environment Records are used to define the effect of ECMAScript language syntactic elements such as FunctionDeclarations, VariableDeclarations.[...]

环境记录用于保存词法环境中的变量和函数,你可以把它理解成VO和差不多的东西,但是环境记录还另外记录了当前词法环境的this

也就是说环境记录的结构大概是这样

EnvironmentRecord: {
    // 只有在函数的词法环境中才有
    arguments: [...],
    // 变量和函数
    variables|functions : [...],
    // 绑定的this
    ThisBinding: <Global Object>,
},

外部词法环境的引用(The outer environment reference)

原文:The outer environment reference is used to model the logical nesting of Lexical Environment values. The outer reference of a (inner) Lexical Environment is a reference to the Lexical Environment that logically surrounds the inner Lexical Environment. An outer Lexical Environment may, of course, have its own outer Lexical Environment. A Lexical Environment may serve as the outer environment for multiple inner Lexical Environments. For example, if a FunctionDeclaration contains two nested FunctionDeclarations then the Lexical Environments of each of the nested functions will have as their outer Lexical Environment the Lexical Environment of the current evaluation of the surrounding function.

外部环境引用为词法环境的逻辑嵌套建模。(内部)词法环境的外部指称是指在逻辑上围绕着内部词法环境的词法环境。当然,外部词法环境可能有它自己的外部词法环境。一个词法环境可以作为多个内部词法环境的外部环境。例如,如果一个函数声明包含两个嵌套函数声明,那么每个嵌套函数的外部词法环境都是最外面函数的词法环境。

源文档讲的东西比较绕,你可以直接把它当成和作用域链类似的东西,正是因为内部词法环境有了外部词法环境的引用,内部词法环境才能访问外部词法环境里定义的变量和函数

词法环境的举例

介绍完了环境记录和外部词法环境的引用,我们可以继续讲词法环境了,我们用一段代码来演示这些关系

let a = 10;
if (a === 10) {
    let b = 20;
    console.log(b);
    if (b === 20) {
        let c = 30;
        console.log(c);
    }
}

执行console.log(b)的时候,执行上下文是这样的

ExecutionContext = {
  LexicalEnvironment = {
   // 环境记录
    EnvironmentRecord: {
        ThisBinding: window,
        b : 20
    },
    // 外部词法环境
    outerEnvironment : {
        // 外部词法环境的环境记录
        EnvironmentRecord: {
            a : 10,
            ThisBinding: window,
        },
        outerEnvironment : null
    }
  },
  VariableEnvironment = {...}
}

在执行console.log(c)的时候变成了这样

ExecutionContext = {
    LexicalEnvironment: {
        // 环境记录
        EnvironmentRecord: {
            ThisBinding: window,
            c: 30
        },
        // 外部词法环境
        outerEnvironment: {
            // 外部词法环境的环境记录
            EnvironmentRecord: {
                b: 20,
                ThisBinding: window,
            },
            outerEnvironment: {
                // 外部词法环境的环境记录
                EnvironmentRecord: {
                    a: 10,
                    ThisBinding: window,
                },
                outerEnvironment: null
            }
        },
        VariableEnvironment = {...}
    }
}

显然,LexicalEnvironment随着代码的执行(比如进入一个新的语句块时),会不断变化,环境记录也会随着变化,记录当前作用域内声明的变量,外部环境的引用也会随之变化,作为变量查找的依据。

变量环境(VariableEnvironment)

原文: A var statement declares variables that are scoped to the running execution context’s VariableEnvironment. Var variables are created when their containing Lexical Environment is instantiated and are initialized to undefined when created.

变量环境在文档中没有确切的定义,不过变量环境的特性还是很明显的

  • 在代码运行中,词法环境会随着代码运行而变化,而变量环境不会,变量环境只会修改它内部变量的值
  • 用var声明的变量会放到变量环境中,用let和const声明的变量会放到词法环境中

比如下面的代码


var a = 1;
let b = 2;
if (true) {
    var c = 3;
    let d = 4;
    console.log(b);
}

执行上下文在即将进入if时是这样的

ExecutionContext:
    LexicalEnvironment:
        b -> nothing
        outerEnvironment: null
    VariableEnvironment:
        a -> undefined, c -> undefined
        outerEnvironment: null
    ...

进入if后是这样的

ExecutionContext:
    LexicalEnvironment:
        d -> nothing
        outerEnvironment:
            LexicalEnvironment
                b -> 2
                outerEnvironment: null
    VariableEnvironment:
        a -> 1, c -> undefined
        outerEnvironment: null
    ...

离开if后, 执行上下文就变成了这样

ExecutionContext:
    LexicalEnvironment
        b -> 2
        outer: null
    VariableEnvironment:
        a -> 1, c -> 3
        outer: null

通过区分词法环境和变量环境,我们可以理解为什么let,const和var的特性是不同的,为什么var可以变量提升进行访问,为什么在声明前访问let和const会得到一个引用错误

概念补充

我之所以把这部分放到最后,是因为前面的新概念已经很多了,要是这部分混在里面一起写估计读起来会比较困难,而且这部分不是很重要,只是一些概念的延伸和细化。

词法环境的分类

  • 全局词法环境:没有外部环境的词法环境,全局环境的外部环境引用为 null
  • 函数词法环境:用户在函数中定义的变量被存储在环境记录中。对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。

环境记录的分类

原文:There are two primary kinds of Environment Record values used in this specification: declarative Environment Records and object Environment Records.
Declarative Environment Records are used to define the effect of ECMAScript language syntactic elements such as FunctionDeclarations, VariableDeclarations, and Catch clauses that directly associate identifier bindings with ECMAScript language values.
Object Environment Records are used to define the effect of ECMAScript elements such as WithStatement that associate identifier bindings with the properties of some object.

  • 声明性环境记录:存储声明的变量、函数和参数。
  • 对象环境记录:用于定义在全局执行上下文中出现的变量和函数的关联。在with语句中也会创建

声明型环境记录很好理解,我们上面使用的一直都是声明型环境记录,他会存储所有的变量声明和函数声明。

对象环境记录稍微难理解一点,我们来举个例子

let obj = { foo: 42 };

with (obj) {
    foo = foo / 2;
}

console.log(obj); // 21

在这段代码执行到with时,会创建一个Object Environment Records对象


    name        value
------------------------
     foo         42

有了这个对象,在with语句中就不用手动指定foo为obj.foo了,也就是说,Object Environment Records是用于把对象的属性添加到环境中的,这也就是为什么你能在直接使用alert(1)而不用写成window.alert(1)的原因,因为代码就是运行在一个有Object Environment Records的环境中。

参考

ES3语言标准
ES5语言标准
面试官:说说执行上下文吧
理解 Javascript 执行上下文和执行栈
Understanding Execution Context and Execution Stack in Javascript
Understanding JavaScript Execution Context and How It Relates to Scope and the this Context
Learn JavaScript Fundamentals-Global Scope
Activation and Variable Object in JavaScript?
Variable Environment vs lexical environment
What really is a declarative environment record and how does it differ from an activation object?
Javascript Closures

后记

那么这次的分享就到这里啦~欢迎在评论区留言交流(有疑问也欢迎提出,如果我会我一定回复)~如果有什么错误,欢迎大佬指正,那么我们下次再见~

点赞

发表评论

昵称和uid可以选填一个,填邮箱必填(留言回复后将会发邮件给你)
tips:输入uid可以快速获得你的昵称和头像

Title - Artist
0:00