「面试官」聊一聊执行上下文

1,733 阅读12分钟

前言

本博客需要感谢@冴羽,从他的博客github中收获甚多,以下是我的知识整理。谈一谈到底什么是执行上下文。

JS代码准备工作?

JS的代码会按照顺序执行,例如

var foo = function () {

    console.log('foo1');

}

foo();  // foo1

var foo = function () {

    console.log('foo2');

}

foo(); // foo2

如果把代码换成这样

function foo(){

     console.log('foo1');

}

foo() //foo2


function foo(){

    console.log('foo2')

}

foo() //foo2

全部打印foo2了,原因在于第一个代码示例中,是变量提升,也就是foo提升了。而第二个代码示例属于函数提升,也就是第二个函数foo覆盖了第一个foo。这个题目也许很多面试题中都会有,在这里不过多讨论,使用这两个例子,只是为了说明,JS的代码在运行时,JS引擎会做一些准备工作。

那么JS引擎遇到怎样的代码才会做这样的准备工作呢?

执行上下文

这就要说到 JavaScript 的可执行代码(executable code)的类型有哪些了。

可执行代码有三种,分别是全局代码、函数代码、eval代码

eval现在在规范中已经不再使用,所以不在讨论之内。

比如说:当执行到一个函数的时候,就会进行准备工作,这里的“准备工作”,让我们用个更专业一点的说法,就叫做"执行上下文(execution context)"。

函数的执行上下文栈

因为代码中函数很多,如何管理这么多的执行上下文呢?JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。

既然叫栈,那么它的数据结构有点明朗了,它属于先进后出的数据结构,我们可以使用一个数组来模拟调用栈。

ECStack=[]

当遇到全局代码时,执行上下文栈会压入一个全局上下文,我们使用globalContext来表示

ECStack.push(globalContext)

只有当整个程序运行结束,执行上下文栈才会被清空,所以程序结束之前,在ECStack中始终有globalContext。

现在,JS引擎遇到函数代码了

function fun3() {
    console.log('fun3')
}

function fun2() {
    fun3();
}

function fun1() {
    fun2();
}

fun1();

ESCStack在fun1函数调用时会做以下事情

//每一个函数执行时都会创建一个执行上下文并被压入执行上下文栈中
//fun1执行了,创建一个context
//  压栈 
ECStack.push(<fun1 context>) //发现内部还有`fun2`调用
ECStack.push(<fun2 context>) //发现内部还有`fun3`调用
ECStack.push(<fun3 context>) //发现内部还有log函数调用
ECStack.push(<log context>) //里面没了
打印fun3 //代码执行完了,该弹栈了
ECStack.pop(<log context>)
ECStack.pop(<fun3 context>)
ECStack.pop(<fun2 context>)
ECStack.pop(<fun1 context>)

此时ECStack还剩下[globalContext]
// 继续处理其他代码
// globalContext在程序结束前一直会存在

小练习

下面我们来写一下以下代码的执行上下文栈

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();
//遇到全局上下文
ECStack.push(globalContext) //此时的调用栈[globalContext]

//调用checkscope,进入checkscope的函数内
ECStack.push(<checkscope context>)  //压栈

//内部没有函数调用,返回函数f
ECStack.pop(<checkscope context>)  //弹栈

//返回的f被调用了
ECStack.push(<f context>)  //压栈
ECStack.pop(<f context>)  //弹栈

变量对象

JS执行到一段可执行代码时(全局代码、函数代码、eval)就会创建执行上下文,执行上下文内有三个重要属性:

  • 变量对象
  • this
  • 作用域链 变量对象是与执行上下文相关的数据作用域,它的作用是保存上下文中定义的变量声明与函数声明。

不同执行上下文中的变量对象是不同的,下面介绍一下全局变量对象和函数变量对象。

全局对象

MDN的解释:

一个全局对象是一个永远存在于 global scope 的 object。window 对象是浏览器中的全局对象。

任何全局变量或者全局函数都可以通过 window 的属性来访问。

在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。

举例

console.log(this) //window
var a=1 //挂到window上的属性
window.a //1

在顶层作用域(全局上下文)上的变量对象就是全局对象

活动对象

在函数上下文中,我们用活动对象(activation object, AO)来表示变量对象

活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object ,而只有被激活的变量对象,他上面的各种属性才能被访问。

活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。

执行过程

执行上下文的代码会分成两个阶段进行处理:分析和执行,我们也可以叫做:

1、进入执行上下文

2、代码执行

进入执行上下文

当进入执行上下文阶段,这时候还没有执行代码

变量对象包括

1、函数的所有形参(如果是函数上下文)

  • 由名称和对应值组成的一个变量对象的属性被创建
  • 由于没有实参,所以属性值为undefined

2、函数声明

  • 由名称和对应值组成的一个变量对象的属性被创建
  • 如果变量对象存在相同名称的属性,则覆盖其属性

3、变量声明

  • 由名称和对应值(undefined)组成的一个变量对象的属性被创建
  • 如果变量名称跟已经声明的形参或函数相同,则变量声明不会干扰已经存在的这类属性

举个例子

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;

}
foo(1)

在调用函数foo并进入函数执行上下文后,这时候的 AO 是:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined
}

代码执行

在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值

还是上面的例子,当代码执行完后,这时候的 AO 是:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(){},
    d: reference to FunctionExpression "d"
}

到这里变量对象的创建过程就介绍完了,让我们简洁的总结我们上述所说:

  • 全局上下文的变量对象初始化是全局对象

  • 函数上下文的变量对象初始化只包括 Arguments 对象

  • 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值

  • 在代码执行阶段,会再次修改变量对象的属性值

思考

第一题

function foo() {
    console.log(a);
    a = 1;
}

foo(); // ???

function bar() {
    a = 1;
    console.log(a);
}
bar(); // ???

第一个foo函数会报错:Uncaught ReferenceError: a is not defined

第二个bar函数会打印:1

原因是第一个函数进入执行上下文后,没有var声明,此时它的上下文是这样的

AO = {
    arguments: {
        length: 0
    },
}

当函数执行时,找不到a声明,所以就会报错

第二个函数进入执行上下文时,依然没有a的声明,但是函数里的代码执行时,能够从全局作用域里找到a,所以就打出1

第二题

console.log(foo);//???

function foo(){
    console.log("foo");
}

var foo = 1;
console.log(foo);//???

第一个log会打印出函数体,而不是undefined,可见var声明提升被函数声明提升取代了 第二个log会打印出1

原因是会优先处理函数声明,再处理变量声明。如果变量名称跟已经声明的形参或函数相同,则变量声明不会干扰已经存在的这类属性,我们结合过程来分析:

在进入执行上下文时,此时变量对象是这样的

{
foo:undefined
}

代码执行时是这样的,优先处理函数声明,此时变量对象变成:

{
foo:reference to function foo(){}
}

当执行到第二个log时,foo又被另外赋值了,此时变量对象变成:

{
foo:1
}

第三题

var foo = 1;
console.log(foo);//??
function foo(){
    console.log("foo");
};

打印的结果为:1 我们来分析一下其执行过程,在进入上下文时,变量对象变成

{
foo:undefined
}

在执行代码时,变量对象先变成函数再变成1

{
foo:reference to function foo(){}
}
//  foo=1
{
foo:1
}
console.log(foo) // 所以结果就是1

作用域链

当查找变量时,首先从当前上下文中的变量对象查找,如果没有就会往上查找父级作用域中的变量对象,最后的终点是访问最外层上下文中的变量对象,如果没有就报错。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

当执行一段全局代码时,就会生成一个执行上下文,里面会包含全局变量对象

var a=123

globalContext.VO={
     a:123
}

函数书写

当书写一段函数代码时,就会创建一个词法作用域,这个作用域是函数内部的属性,我们用[[scope]]表示,它里面保存父变量对象,所以[[scope]]就是一条层级链。

function fn(){
}
/*
fn.[[scope]]=[
     globalContext.VO
]
*/

函数调用

当函数调用,就意味着函数被激活了,此时创建函数上下文,创建活动对象,然后将活动对象(AO)推到作用域链的前端。 我们用scope来表示此时的作用域

fnContext={
     Scope:[AO,fn.[[scope]]]
}

结合例子

我们来分析以下代码函数上下文中的变量对象和作用域的创建过程

var scope = "global scope";
function checkscope(){
    var scope2 = 'local scope';
    return scope2;
}
checkscope();

1、全局上下文创建,生成全局变量对象VO,checkscope函数创建,生成内部属性[[scope]],并且把父变量对象放进去。

checkscope.[[scope]]=[
     globalContext.VO
]

2、函数调用了,创建函数上下文并压入执行栈

ECStack=[globalContext,checkscopeContext]

3、函数调用的分析阶段,做准备工作,第一步:复制函数[[scope]]属性创建作用域链

checkscopeContext={
     Scope:checkscope.[[scope]]
}

4、第二步:创建活动对象,初始化活动对象,加入形参、函数声明、变量声明

checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: checkscope.[[scope]],
}

5、第三步:将活动对象压入 checkscope 作用域链顶端

checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: [AO, [[Scope]]]
}

6、准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值

checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: 'local scope'
    },
    Scope: [AO, [[Scope]]]
}

7.查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出

ECStack = [
    globalContext
];

总结所有知识

我们结合以下代码谈谈具体处理过程 第一题:

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();

1、执行全局代码,生成全局上下文,并且压入执行栈

ECStack=[
     globalContext
]

2、全局上下文初始化

globalContext={
     VO=[global],
     this:globalContext.VO,
     Scope:[globalContext.VO]
}

3、创建函数[[scope]]属性,并将全局变量对象存入其中

checkscope.[[scope]]={
     globalContext.VO
}

4、调用函数,创建函数上下文,压栈

ECStack=[
     globalContext,
     checkscopeContext
]

5、此时函数还未执行,进入执行上下文

  • 复制函数 [[scope]] 属性创建作用域链
  • 用arguments属性创建活动对象
  • 初始化变量对象,加入变量声明、函数声明、形参
  • 活动对象压入作用域链顶端
    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope: undefined,
            f: reference to function f(){}
        },
        Scope: [AO, globalContext.VO],
        this: undefined
    }

6、f函数被创建生成[[scope]]属性,并保存作用域链条

f.[[scope]]=[
     checkscopeContext.AO,
     globalContext.VO
]

7、f函数调用,生成f函数上下文,压栈

ECStack=[
     globalContext,
     checkscopeContext,
     fContext
] 

8、此时f函数还未执行,初始化执行上下文

  • 复制函数 [[scope]] 属性创建作用域链
  • 用arguments属性创建活动对象
  • 初始化变量对象,加入变量声明、函数声明、形参
  • 活动对象压入作用域链顶端
fContext = {
     AO: {
            arguments: {
                length: 0
            },
        },
        Scope: [fContext.AO, checkscopeContext.AO, globalContext.VO],
        this: undefined
    }

9、f 函数执行,沿着作用域链查找 scope 值,返回 scope 值

10、弹栈

// f函数弹栈
ECStack=[
     globalContext,
     checkscopeContext
]
// checkscope函数弹栈
ECStack=[
     globalContext
]
  ……

第二题

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();

1、执行全局代码,创建全局上下文,压栈

ECStack=[
     globalContext,
]

2、初始化全局上下文

globalContext={
     VO:[global],
     Scope:[globalContext.VO]
     this:globalContext.VO
}

3、checkscope函数生成内部[[scope]]属性,并存入全局上下文变量对象

checkscope.[[scope]]=[
     globalContext.VO
]

4、调用checkscope函数,创建函数上下文,压栈

ECStack=[
     globalContext,
     checkscopeContext
]

5、checkscope函数还未执行,上下文初始化

  • 复制函数的[[scope]]属性创建作用域链
  • 用arguments创建活动对象
  • 初始化活动对象,加入形参、函数声明、变量声明
  • 将活动对象压入checkscope的作用域链
    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope: undefined,
            f: reference to function f(){}
        },
        Scope: [AO, globalContext.VO],
        this: undefined
    }

6、checkscope函数执行阶段,赋值

    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope: "local scope",
            f: reference to function f(){}
        },
        Scope: [AO, globalContext.VO],
        this: undefined
    }

7、遇到到f函数,内部[[scope]]属性产生,并放入父变量对象

f.[[scope]]=[checkscopeContext.AO,globalContext.VO]

8、返回函数f,此时checkscope函数执行完成,弹栈

ECStack=[
     globalContext
]

9、执行f函数,创建f函数上下文,压栈

ECStack=[
     globalContext,
     fContext
]

10、初始化f函数的上下文

  • 复制函数的[[scope]]属性创建作用域链
  • 用arguments创建活动对象
  • 初始化活动对象,加入形参、函数声明、变量声明
  • 将活动对象压入f函数的作用域链
fContext={
     AO:{
          arguments:{
               length:0
      }
   },
     Scope:[AO,checkscopeContext.AO,globalContext.VO]
     this:undefined
}

11、checkscope函数执行阶段,赋值

12、找到scope变量,返回,此时f函数执行完毕,弹栈

ECStack=[
     globalContext,
]