JavaScript 代码执行过程分析(执行上下文、词法作用域)

621 阅读8分钟

JavaScript 代码是如何执行的?

当面试官给你一段代码让你告诉他结果,这个时候我们的大脑就要负责去运行这些代码。因此深入了解代码运行的规则是学习一门语言基础中的基础。只有很好的掌握了代码运行规则才能减少平时代码的bug。

先看一张图让我们对这些全新的概念有一个宏观的认识。

很明显,有一个非常重要的概念“执行上下文”,我们就先从它开始讲解。

执行上下文

执行上下文就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中运行任何的代码都是在执行上下文中运行

我们先看一段代码:

var name = "jack";
var age = 18;

function foo(i){
    var bar = "bar";
    function f1(){
        console.log('f1');
    }
    f1(); // 'f1'
    console.log(i,name,age); // 10 "jack" 18
}

foo(10);

过程分析:

1、初始化时有一个空的执行环境栈

executionContextStack = []

2、 初始化时会产生一个全局上下文globalExecutionContext(全局上下文有且只有一个)

globalExecutionContext = {    
    VO: {    
        name: undefined,    
        age: undefined,
        foo: function foo(i){...}
    },    
    scopeChain: [],    
    this: { ... }   
} 

此时执行环境栈的值为:

executionContextStack = [globalExecutionContext]

3、执行全局代码时

globalExecutionContext = {    
    AO: {    
        name: "jack",    
        age: 18,
        foo: function foo(i){...}
    },
    scopeChain: [AO],    
    this: { ... }   
} 

4、代码执行到 foo(i){...} 定义时,并且发现有调用函数的代码 foo(10);(还未执行foo(10)前)

先创建一个上下文对象

  1. 创建arguments对象,检查当前上下文的参数,建立该对象下的属性和属性值
  2. 扫描上下文的函数申明,指向该函数在内存中的地址(如果函数名在VO中已经存在,对应的属性值会被新的引用覆盖)
  3. 扫描上下文的变量申明,初始化为undefined(如果该变量名在VO中已经存在,则直接跳过继续扫描)
  4. 初始化作用域链
  5. 确定上下文中this的指向
fooExecutionContext = {    
        VO: {    
            arguments: {    
                0: 10,    
                length: 1    
            },    
            i: 10,    
            f1: pointer to function f1(),    
            bar: undefined
        },    
        scopeChain: [globalExecutionContext.AO],    
        this: { ... } 
    } 

此时执行环境栈的值为:

executionContextStack = [fooExecutionContext,globalExecutionContext]

4、代码执行foo(10)

  1. 执行foo函数体中的代码,给VO中的变量赋值
  2. 同时扫描到f1函数并且该函数有相应的调用f1();此时会创建f1函数上下文

第一步:给VO中的变量赋值

fooExecutionContext = {    
        AO: {    
            arguments: {    
                0: 10,    
                length: 1    
            },    
            i: 10,    
            f1: pointer to function f1(),    
            bar: "bar"
        },    
        scopeChain: [AO,globalExecutionContext.AO],    
        this: { ... } 
    } 

foo函数内部需要使用变量name 与 age 时会在scopeChain作用域链中一级一级的查找,直到找到为止,如果没有找到则抛出错误。

第二步:创建f1函数上下文

f1ExecutionContext = {    
        variableObject: {    
            arguments: {    
                length: 0  
            }
        },    
        scopeChain: [fooExecutionContext.AO,globalExecutionContext.AO],    
        this: { ... } 
    } 

此时执行环境栈的值为:

executionContextStack = [f1ExecutionContext,fooExecutionContext,globalExecutionContext]

5、执行f1函数调用

f1ExecutionContext = {    
        AO: {    
            arguments: {    
                length: 0  
            }
        },    
        scopeChain: [AO,fooExecutionContext.AO,globalExecutionContext.AO],    
        this: { ... } 
    } 

6、f1 函数执行完毕,f1 函数上下文从执行上下文栈中弹出

此时执行环境栈的值为:

executionContextStack = [fooExecutionContext,globalExecutionContext]

7、foo 函数执行完毕,foo 函数上下文从执行上下文栈中弹出

此时执行环境栈的值为:

executionContextStack = [globalExecutionContext]

8、当浏览器关闭时清空执行环境栈executionContextStack = []

小结:

  1. 调用函数时会为其创建执行上下文,并压入执行环境栈的栈顶,执行完毕 弹出,执行上下文被销毁,随之VO也被销毁
  2. 执行上下文分创建阶段和代码执行阶段
  3. 创建阶段初始变量值为undefined,执行阶段才为变量赋值
  4. 函数申明先于变量申明

【特别说明】以上列举的执行上下文对象是ES3时的规范,为什么要用那么早的规范来讲呢,原因就是简单方便理解。我们也只是理解执行过程,并不需要完完整整去理解执行的每一个细节。

执行上下文在 ES3 中,包含三个部分

  • scope:作用域,也常常被叫做作用域链。
  • variable object:变量对象,用于存储变量的对象。
  • this value:this 值。

在 ES5 中,我们改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。

  • lexical environment:词法环境,当获取变量时使用。
  • variable environment:变量环境,当声明变量时使用。
  • this value:this 值。

在 ES2018 中,执行上下文又变成了这个样子,this 值被归入 lexical

  • environment,但是增加了不少内容。
  • lexical environment:词法环境,当获取变量或者 this 值时使用。
  • variable environment:变量环境,当声明变量时使用。
  • code evaluation state:用于恢复代码执行位置。
  • Function:执行的任务是函数时使用,表示正在被执行的函数。
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
  • Realm:使用的基础库和内置对象实例。
  • Generator:仅生成器上下文有这个属性,表示当前生成器。

词法作用域和动态作用域

作用域链是一个相对比较好理解的概念,但想要完全理解作用域链,就必须得理解词法作用域和动态作用域。

JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。

而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。

var value = 1;

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

function bar() {
    var value = 2;
    foo();
}

bar();

词法作用域:

执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。

动态作用域:

执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。

由于 JavaScript 是采用词法作用域(也可叫静态作用域)因此输出是1。

再来看一个《JavaScript权威指南》中的例子:

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

两段代码都会打印:local scope。原因也很简单,因为JavaScript采用的是词法作用域,函数的作用域基于函数创建的位置。

从执行环境栈的角度来分析:

第一个:

executionContextStack.push('checkscope 上下文');
executionContextStack.push('f 上下文');
ECStack.pop(); // 弹出 'f 上下文'
ECStack.pop(); // 弹出 'checkscope 上下文'

第二个:

executionContextStack.push('checkscope 上下文');
ECStack.pop(); // 弹出 'checkscope 上下文'
executionContextStack.push('f 上下文');
ECStack.pop(); // 弹出 'f 上下文'

虽然获取到的结果是一致的,但是计算机的处理过程却是不一样的。

this

JavaScript 的 this 总是指向一个对象,而具体指向哪个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。

除去不常用的with和eval的情况,具体到实际应用中,this的指向大致可以分为以下4种。

  • 作为对象的方法调用。
  • 作为普通函数调用。
  • 构造器调用。
  • Function.prototype.call或Function.prototype.apply调用。

作为对象的方法调用

var obj = {
    a: 1,
    getA: function(){
        alert ( this === obj );    // 输出:true
        alert ( this.a );    // 输出: 1
    }
};

obj.getA();

当函数作为对象的方法被调用时,this指向该对象

作为普通函数调用

window.name = 'globalName';

var getName = function(){
    return this.name;
};

console.log( getName() );    // 输出:globalName

当函数不作为对象的属性被调用时,也就是我们常说的普通函数方式,此时的this总是指向全局对象。在浏览器的JavaScript里,这个全局对象是window对象。

嵌套的函数独立调用时,this默认绑定到window

<html>
    <body>
        <div id="div1">我是一个div</div>
    </body>
    <script>

    window.id = 'window';

    document.getElementById( 'div1' ).onclick = function(){
        alert ( this.id );        // 输出:'div1'
        var callback = function(){
            alert ( this.id );        // 输出:'window'
        }
        callback();
    };

    </script>
</html>

解决方案:使用变量保存this

document.getElementById( 'div1' ).onclick = function(){
    var that = this;    // 保存div的引用
    var callback = function(){
        alert ( that.id );    // 输出:'div1'
    }
    callback();
};

严格模式下this不指向全局对象

function func(){
    "use strict"
    alert ( this );    // 输出:undefined
}

func();

构造器调用

当用new运算符调用函数时,该函数总会返回一个对象,通常情况下,构造器里的this就指向返回的这个对象

var MyClass = function(){
    this.name = 'sven';
};

var obj = new MyClass();
alert ( obj.name );     // 输出:sven

但用new调用构造器时,还要注意一个问题,如果构造器显式地返回了一个object类型的对象,那么此次运算结果最终会返回这个对象,而不是我们之前期待的this

var MyClass = function(){
    this.name = 'sven';
    return {    // 显式地返回一个对象
        name: 'anne'
    }
};

var obj = new MyClass();
alert ( obj.name );     // 输出:anne

call 和 apply 调用

var obj1 = {
    name: 'sven',
    getName: function(){
        return this.name;
    }
};

var obj2 = {
    name: 'anne'
};

console.log( obj1.getName() );     // 输出: sven
console.log( obj1.getName.call( obj2 ) );    // 输出:anne

call 和 apply 可以动态地改变传入函数的this。

以上便是this的使用总结。从执行上下文的角度来看待this的话,那么是当编译器扫描到函数的调用创建ExecutionContext时就确定好了,并且保存在上下文对象中。