Javascript执行上下文和作用域

135 阅读14分钟

Javascript执行上下文和作用域

执行上下文

JavaScript 中的执行上下文(Execution Context)概括就是代码(全局代码、函数代码)执行前进行的准备工作,也称之为“执行上下文环境”。

是JavaScript 引擎创建的一个内部数据结构,用来管理函数执行过程中的变量、作用域、this 指向等信息。每当执行 JavaScript 代码时,当代码执行进入一个环境时,就会为该环境创建一个执行上下文,并将其推入执行上下文栈(Execution Context Stack,简称:执行栈,或函数调用栈)中。

JavaScript 中执行环境

  1. 全局环境
  2. 函数环境
  3. eval 函数环境 (已不推荐使用)

那么与之对应的执行上下文类型同样有 3 种:

  1. 全局执行上下文
  2. 函数执行上下文
  3. eval 函数执行上下文 (已不推荐使用)

JavaScript 运行时首先会进入全局环境,对应会生成全局上下文。程序代码中基本都会存在函数,那么调用函数,就会进入函数执行环境,对应就会生成该函数的执行上下文。

当代码中声明多个函数,对应的函数执行上下文也会存在多个,就通过执行上下文栈存取方式来管理执行上下文。

执行上下文栈(执行栈)

栈中放入/取出,称为入栈/出栈

栈数据结构的特点:

  1. 后进先出,先进后出
  2. 出口在顶部,且仅有一个

image.png

执行栈中的执行上下文

程序执行进入一个执行环境时,它的执行上下文就会被创建,并被推入执行栈中(入栈);程序执行完成时,它的执行上下文就会被销毁,并从栈顶被推出(出栈),控制权交由下一个执行上下文。

因为JavaScript在执行代码时最先进入全局环境,所以处于栈底的永远是全局环境的执行上下文。而处于栈顶的是当前正在执行函数的执行上下文。当函数调用完成后,它就会从栈顶被推出(理想的情况下,闭包会阻止该操作,闭包在后面会进行仔细讲解);全局执行上下文当页面被关闭之后它才会从执行栈底被推出。

<script>
    // 全局上下文global
    var name = 'Tom';
    function foo () { 
        // 函数上下文foo
        function bar () {   
            // 函数上下文bar
            console.log('function bar');
        }
        return bar();
    }
    foo();
</script>

在上面js代码中:

  • 进入script标签中即生成全局执行上下文
  • 当调用foo时生成函数执行上下文foo Context
  • 在函数foo中调用bar时,生成函数上下文bar Context

image.png

执行上下文的数量限制(执行栈溢出)

执行上下文可存在多个,虽然没有明确的数量限制,但如果超出栈分配的空间,会造成堆栈溢出。常见于递归调用,没有终止条件造成死循环的场景

例:

function foo() {
    foo(); // 递归调用自身
}
foo();
// 报错: Uncaught RangeError: Maximum call stack size exceeded

执行上下文的生命周期

开头介绍中我们有提到,运行JavaScript代码时,当代码执行进入一个环境时,就会为该环境创建一个执行上下文,它会在你运行代码前做一些准备工作。具体会做哪些准备工作,和执行上下文的生命周期有关。

执行上下文的生命周期有两个阶段:

  1. 创建阶段(进入执行上下文):函数被调用时,进入函数环境,为其创建一个执行上下文,此时进入创建阶段。
  2. 执行阶段(代码执行):执行函数中代码时,此时执行上下文进入执行阶段。

创建阶段

创建阶段要做的事情主要如下:

  1. 创建变量对象(VO:variable object)

    • 确定函数的形参(并赋值)
    • 函数环境会初始化创建 Arguments 对象(并赋值)
    • 确定函数声明(并赋值)
    • 变量声明,函数表达式声明(未赋值)
  2. 确定this指向(作为函数直接调用为window,作为方法调用指向调用者

  3. 确定作用域(词法环境决定,哪里声明定义,就在哪里确定

变量对象

当处于执行上下文的建立阶段时,我们可以将整个上下文环境看作是一个对象。该对象拥有 3 个属性,如下:

executionContext = {
   variableObject : {}, // 变量对象,里面包含 Arguments 对象,形式参数,函数和局部变量
   scopeChain : {},// 用于解析变量和函数查找的规则,由当前执行环境的变量对象和所有外层执行环境的作用域链组成。
   this : {}// 上下文中 this 的指向对象
}

在函数的建立阶段,首先会建立 Arguments 对象。然后确定形式参数,检查当前上下文中的函数声明,每找到一个函数声明,就在 VO对象 下面用函数名建立一个属性,属性值就指向该函数在内存中的地址的一个引用。

如果上述函数名已经存在于 VO对象 下面,那么对应的属性值会被新的引用给覆盖。

最后,是确定当前上下文中的局部变量,如果遇到和函数名同名的变量,则会忽略该变量(个人理解为函数声明优先级高,无法覆盖)。

执行阶段

  1. 变量对象赋值
    • 变量赋值
    • 函数表达式赋值
  2. 调用函数
  3. 顺序执行其它代码

举例

const foo = function(i){
    var a = 'Hello';
    var b = function b() {}; // 函数表达式
    function c() {}; // 函数声明
}
foo(10);

在调用函数foo时,执行上下文foo Context创建阶段:

foo Context = {
    variableObject: {
        arguments: [10], // 确定 Arguments 对象
        i: 10, // 确定参数
        c: Function c, // 确定函数声明
        a: undefined, // 变量声明
        b: undefined, // 变量声明
    },
    scopeChain: {}, // 作用域,在函数foo声明时已经创建
    this: {}, // 作为函数直接调用,指向window
}

在建立阶段,除了确定Arguments,形参,函数的声明,并赋予了具体的属性值外,其它的变量属性默认的都是undefined。并且函数声明的的提升是在变量的上面的。这其实也就解释了变量提升的原理

一旦上述创建阶段结束,引擎就会进入代码执行阶段,这个阶段完成后,上述执行上下文对象如下,变量会被赋上具体的值。

在调用函数foo时,进入执行上下文foo Context执行阶段:

foo Context = {
    variableObject: {
        arguments: [10], // 确定 Arguments 对象
        i: 10, // 确定参数
        c: Function c, // 确定函数声明
        a: 'Hello', // 变量声明
        b: Function b, // 变量声明
    },
    scopeChain: {}, // 作用域,在函数foo声明时已经创建
    this: {}, // 作为函数直接调用,指向window
}

我们看到,只有在代码执行阶段,局部变量才会被赋予具体的值。

运用

理解了执行期上下文,一些面试中询问输出的题就变得极其简单:

(function () {
    console.log(typeof foo); // Function foo
    console.log(typeof bar); // undefined
    var foo = 'Hello';
    var bar = function () { // bar被赋值 function(){}
        return 'World';
    }

    function foo() {
        return 'good';
    }
    console.log(foo, typeof foo); // Hello, string  (被var foo覆盖)
})()

总结

JavaScript 中的执行上下文(Execution Context)概括就是代码(全局代码、函数代码)执行时进行的准备工作。当代码执行进入一个环境时,就会为该环境创建一个执行上下文,用来管理函数执行过程中的变量、作用域、this 指向等信息

作用域

JavaScript 中的作用域(Scope)是运行时代码中某些特定部分中变量,函数和对象的可访问性。

例:

function test() {
    var name = 'Tom';
}
test();
console.log(name); // ReferenceError: name is not defined

在函数test声明name变量,在函数外部无法访问。

作用域就是一个独立的地盘,让变量不会外泄、暴露出去。作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。

作用域分类

全局作用域

在代码中任何地方都能访问到的对象拥有全局作用域,一般来说以下几种情形拥有全局作用域:

  • 最外层函数和在最外层函数外面定义的变量拥有全局作用域
<script>
    var outVariable = '我是最外层变量'; // 最外层变量
    function outFun() { // 最外层函数
        var inVariable = "内层变量";
        function innerFun() { // 内层函数
            console.log(inVariable); 
        }
        innerFun();
    }
    console.log(outVariable); // 我是最外层变量
    outFun(); // 外层函数
    console.log(inVariable); // inVariable is not defined
    innerFun(); // innerFun is not defined
</script>
  • 所有未定义直接赋值的变量自动声明为拥有全局作用域
<script>
    function outFun() {
        variable = '未定义直接赋值的变量';
        var inVariable2 = '内层变量2';
    }
    outFun();// 需要要先执行这个函数
    console.log(variable); //未定义直接赋值的变量全局可访问
    console.log(inVariable2); //inVariable2 is not defined
</script>
  • 所有window对象的属性拥有全局作用域

一般情况下,window对象的内置属性都拥有全局作用域,例如 window.location 等。

全局作用域有个弊端:如果我们写了很多行JS代码,变量定义都没有用函数包括,那么它们就全部都在全局作用域中。

例如开发中两个程序员在一个全局作用域中,命名了相同的变量这样就会污染全局命名空间,容易引起命名冲突(后者变量值覆盖前者)。

// A写的代码中
var name = 'Tom';

// B写的代码中
var name = 'Sam';

函数作用域

函数作用域,是指声明在函数内部的变量,和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到,最常见的例如函数内部。

function outFun() {
    var name = 'Tom';
    function inFun() {}
    console.log(name); // Tom
    inFun();
}
console.log(name); // name is not defined
inFun(); // inFun is not defined

例如jQuery库的源码,所有的代码都会放在(function(){....})( ) 中。

因为放在里面的所有变量,都不会被外泄和暴露,不会污染到外面,不会对其他的库或者 JS 脚本造成影响。这是函数作用域的一个体现。

块级作用域

块级作用域可通过ES6新增命令 letconst 声明,所声明的变量在指定块的作用域外无法被访问。

块级作用域在如下情况被创建:

  1. 在一个函数内部
function test() {
    // 有let命令声明
    let name = 'Tom';
}
  1. 在一个代码块(由一对花括号{}包裹)内部

遇到花括号,使用了let/const就会创建一个块级作用域,花括号结束,销毁块级作用域

{
    // 有let命令声明
    let name = 'Tom';
}

let 声明的语法与 var 的语法一致。你基本上可以用 let 来代替 var 进行变量声明,但会将变量的作用域限制在当前代码块中。块级作用域有以下几个特点:

  • 声明变量不会提升到代码块顶部

let、const 声明并不会被提升到当前代码块的顶部(上文执行上下文准备阶段提及),因此你需要手动将let、const声明放置到顶部,以便让变量在整个代码块内部可用。

例如:

function getName(condition) {
    // name 在此处 Cannot access 'name' before initialization
    if (condition) {
        let name = 'Tom';
        return name;
    } else {
        // name 在此处 Cannot access 'name' before initialization
        return null;
    }
    // name 在此处 Cannot access 'name' before initialization
}
  • 禁止重复声明

如果一个标识符已经在代码块内部被定义,那么在此代码块内使用同一个标识符进行 let、const 声明就会导致抛出错误。

例如:

var name = 'Tom';
let name = 'Sam'; //  SyntaxError: Identifier 'name' has already been declared
let name = 'Tom';
let name = 'Sam'; // SyntaxError: Identifier 'name' has already been declared
var name = 'Tom';
const name = 'Sam'; // SyntaxError: Identifier 'name' has already been declared

在示例中:name 变量被声明了两次,let、const 不能在同一作用域内重复声明一个已有标识符,就会抛出错误。但如果在嵌套的作用域内使用 let、const 声明一个同名的新变量,则不会抛出错误。

var name = 'Tom';
if (condition) {
    let name = 'Sam'; // 不会抛出错误
}
  • 循环中的绑定块作用域的妙用

开发者可能最希望实现 for 循环的块级作用域了,因为可以把声明的计数器变量限制在循环内。

在循环中,用let声明的循环变量,会特殊处理,每次进入循环体,都会开启一个新的作用域,并且将循环变量绑定到该作用域(每次循环,使用的是一个全新的循环变量);在循环中使用let声明的循环变量,在循环结束后会销毁。

作用域分层

作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行

// 全局作用域
var out = 'out';
function test() {
    // 函数作用域
    var func = 'function';
    console.log(out); // out
}
{
    var block2 = 'block2';
    // let块级作用域
    let block = 'block';
    console.log(out); // out
    console.log(func); // func is not defined
}
console.log(out); // out
console.log(func); // func is not defined
console.log(block2); // block2
console.log(block); // block is not defined

值得注意的是:块语句(大括号“{ }”中间的语句),如 *if* 和 *switch* 条件语句或 *for* 和 *while* 循环语句,不像函数,它们不会创建一个新的作用域。区块中存在letconst命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域,称为“暂时性死区”,所以块级作用域只跟let、const声明的变/常量有关,。

暂时性死区

只要存在letconst命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。

作用域链

首先认识一下什么叫做自由变量

自由变量

当前的作用域中使用的没有定义某个变量。

什么是作用域链

当前的作用域中使用的没有定义某个变量,一层一层向外层作用域寻找,直到找到全局作用域还是没找到,就宣布放弃。这种一层一层的关系,就是作用域链 。

var a = 100;
function test() {
    var b = 200;
    function test2() {
        var c = 300;
        console.log(a); // 100 自由变量,顺作用域链向外层作用域找
        console.log(b); // 200 自由变量,顺作用域链向外层作用域找
        console.log(c); // 300 本作用域的变量
    }
    test2();
}
test();

自由变量的取值

关于自由变量的值,上文提到要到外层作用域中取,有时候这会理解错误。

例如:

var x = 10
function out() {
    console.log(x)
}
function test(f) {
    var x = 20;
    (function () {
        f() // 10,而不是 20
    })()
}
test(out)

out 函数中,取自由变量 x 的值时,要到哪个作用域中取 ?

要到 创建out 函数的那个作用域中取,无论 out 函数将在哪里调用。这就是所谓的"静态作用域"。

再来看一个例子:

const name = 'Tom';
const test = function () {
    console.log(name);
};
(function () {
    const name = 'Sam';
    test(); // Tom
})();

在本示例中,最终打印的结果为 Tom。因为对于 test 函数来说,创建该函数时它的父级上下文为全局上下文,所以 name 的值为 Tom。

如果我们将代码稍作修改,改成如下:

const name = 'Tom';
(function () {
    const name = 'Sam';
    const test = function () {
        console.log(name);
    };
    eat(); // Sam
})();

这个时候,打印出来的值就为 Sam。因为对于 test 函数来讲,创建它的时候父级上下文为 立即执行函数,所以 name 的值为 Sam。

作用域与执行上下文

上文分开介绍执行上下文和作用域的概念,可能存在误认为它们是相同的概念,但事实并非如此。

我们知道 JavaScript 属于解释型语言,JavaScript 的执行分为:解释和执行两个阶段,这两个阶段所做的事并不一样。

解释阶段

  • 词法分析
  • 语法分析
  • 作用域规则确定

执行阶段

  • 创建执行上下文
  • 执行函数代码
  • 垃圾回收

JavaScript 解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的。

执行上下文最明显的就是 this 的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。

作用域和执行上下文之间最大的区别是:

执行上下文在运行时确定,随时可能改变,作用域在定义时就确定,并且不会改变