js 系列第一篇---执行上下文

143 阅读12分钟

前言

在讲解执行上下文之前,先来看一段代码:

console.log(myname);
showMsg();
var myname = "page_not_found";
function showMsg() {
  console.log("function console");
}

稍有 js 基础的同学都知道,这段代码并不会报错,代码会输出如下结果:

image-20220524211640508

这是因为JavaScript 引擎会将函数和变量的声明提升到作用域的顶部,这个特性我们一般称作变量提升或声明提升。所以上面代码相当于下面的代码:

//变量和函数的声明被提升到了作用域顶部
var myname; // 已声明未初始化的变量默认值为undefined
function showMsg() {
  console.log("function console");
}
//赋值&执行代码
console.log(myname);
showMsg();
myname = "page_not_found";

对于 letconst 声明的变量:

console.log(myname);
let myname = "page_not_found";

执行结果:

image-20220527101518963

let/const 声明之前的执行瞬间被称为暂时性死区,在此阶段的变量引用均会输出 ReferenceError

到这里,我们不得不发出疑问,为什么var声明的变量以及函数的声明可以被提升?为什么 letconst 声明的变量在声明前不可用?要回答这个问题,我们需要搞清楚 js 引擎是如何执行 js 代码的。

js 执行流程

js 代码的执行分为两个阶段,分别为编译阶段执行阶段。 js 代码在执行前都会被编译。

因此,变量提升以及暂时性死区形成的实际原因是:在编译阶段,js 对代码的声明做了特别的处理,如果是 var 声明的就提升,如果是 let 声明的就标记为ReferenceError

当然,这只是一个笼统的解释,想要了解内部的细节,我们必须要去了解执行上下文。

执行上下文

概念

js 代码在编译阶段,会先创建执行上下文。执行上下文是 JavaScript 执行一段代码时的运行环境,包括了代码执行的所有信息。

种类

执行上下文有三种:

  • 全局执行上下文:当 JavaScript 执行全局代码的时候,会创建全局执行上下文。它会做两件事:创建一个全局的 window 对象(浏览器的情况下),并且设置 this 的值等于这个全局对象。全局上下文只有一个。
  • 函数执行上下文:函数被调用时才被创建,每次调用函数都会创建一个新的函数执行上下文。函数执行结束后,其上下文会被销毁。
  • Eval 执行上下文:运行在 eval 函数中的代码,不建议使用。

执行上下文的属性

执行上下文主要包含三个属性:词法环境,变量环境以及 this

词法环境(lexical environment)

词法环境存储let、const声明的变量(标记为uninitialized)绑定。词法环境由环境记录对外部环境的引用两部分组成:

  1. 环境记录:存储变量和函数声明的实际位置。
  2. 对外部环境的引用:表示其可以访问的外部词法环境。

词法环境有两种类型:

  1. 全局词法环境:全局词法环境没有外部环境,故其对外部环境的引用为 null。包含被定义的全局变量以及全局对象(浏览器中为window),this 指向该全局对象。
  2. 函数词法环境:对外部环境的引用可以是全局词法环境,也可以是包裹该函数的外部函数的函数词法环境。函数词法环境包含 arguments 对象以及函数中被定义的变量。

变量环境(variable environment)

变量环境也是一个词法环境,故其具有词法环境的所有属性。不同于词法环境,变量环境存储**函数声明和 var 声明的变量(标记为 undefined)**绑定。

this值

在全局上下文中,this 值指向全局对象(浏览器中为 window);在函数执行上下文中,this 值取决于函数的调用方式。如果函数是作为一个对象的方法且被该对象调用,则 this 指向这个对象,否则 this 的值指向全局对象或者undefined(严格模式下)。

执行上下文的执行阶段

每个执行上下文都有会经历三个阶段:

  1. 创建阶段:

    会做代码执行前的准备工作,如声明提升等都是在此阶段做的。此时还没有执行代码。

    • lexical environment(词法环境)被创建。
    • variable environment(变量环境)被创建。
  2. 执行阶段:

    逐行执行代码,完成所有变量的分配。

  3. 销毁阶段

​ 销毁执行上下文。

js 是如何实现块作用域的

在 ES6 之前,js 只有两种作用域,即全局作用域和函数作用域;继 ES6,letconst被添加到 js 之后,js 新增了块级作用域。对于块级作用域,执行上下文又是如何处理的呢,接下来通过一个例子来分析其处理过程:

function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()
  1. 第一步编译并创建函数foo 的执行上下文

image-20220622115139736

由上图可知,在编译阶段:

  1. 在函数中,由 var 声明的变量放在变量环境中,初始化为undefined
  2. 在函数的非块作用域中,由 let 声明的变量放在词法环境中,被标记为uninitialized
  3. 在函数内部的块作用域中,由let 声明的变量并没有进行处理。

编译阶段就分析到这里。

  1. 第二步执行代码

image-20220622151834052

如图所示,当执行到代码块中时,变量环境中的 a 被赋值为 1,词法环境中的 b 被赋值为 2。

可以看出,在函数中,块作用域内通过 let 声明的变量和块作用域外通过 let声明的变量各自独立。在词法环境内部,通过一个栈来对变量进行管理。函数最外层的变量处于栈底。执行到块作用域时,将该作用域块的内部变量压到栈顶;块作用域执行完成后,块作用域的变量信息从栈中弹出。

引擎查找变量 a 的方式已经在图中标出:从词法环境的栈顶向下查找,词法环境中如果没有找到,则继续在变量环境中查找。

  1. 块作用域执行结束后其变量信息从栈顶弹出。

image-20220622153827218

小结:块作用域就是通过词法环境中的栈实现的,变量提升通过变量环境实现。

至此,我们对执行上下文已经有了一个比较清晰的认识了,不过现在还存在几个问题:对于执行上下文js 引擎是如何管理的?以及执行上下文之间是如何建立联系的?要弄清楚这个问题,就要去了解执行上下文栈。

执行上下文栈

管理执行上下文的栈结构叫做执行上下文栈,也称调用栈。

JavaScript 引擎处理代码文件时,它会创建一个全局执行上下文并压入调用栈。当函数被调用时,会创建一个函数执行上下文并压入调用栈中。

在 js 中,函数名是指向函数对象的指针,函数名后接小括号对时,表示函数调用。

先看下面代码:

var a = "global";
function g() {
  var a = "local";
  function f() {
    console.log(a);
  }
  return f();
}
g();

调用栈中的执行上下文的调用过程如图:

image-20220623111247742

简单分析下:js 引擎加载代码时,首先创建一个全局执行上下文并压入调用栈,函数 g被调用后,创建函数 g 的执行上下文压入栈中,在函数 g 内部又调用了函数 f,函数 f 执行完毕,从栈中弹出,紧接着函数 g 也执行结束,从栈中弹出。一旦所有代码都执行完毕,全局执行上下文也会从调用栈中弹出。

上面的代码中,函数 f 输出了变量 a,不过函数 f 内部并没有定义变量 a,这个时候引擎会去函数 f 外部查找a。不过这个时候就出现问题了,在代码中有两个变量 a,一个是全局变量 a;另一个是函数 g 内的变量 a,js 引擎到底会找到他们中的哪一个呢?这个就涉及到了 js 引擎访问变量的规则,也就是作用域。

作用域

作用域是程序源代码中定义变量的区域,本质上它是程序存储和访问变量的规则

js 作用域种类

js 有三种作用域

  • 全局作用域
  • 函数作用域
  • 块级作用域(ES6新增)

作用域模型

有两种作用域模型:词法作用域和动态作用域,二者的区别在于划分作用域的时机

  • 词法作用域:也成为静态作用域。在代码书写的时候完成划分,作用域链沿着它声明的位置向外延伸。大多数语言都属于该模型(包括 js)。
  • 动态作用域:在代码运行时完成划分,作用域链沿着它的调用栈向外延伸。比较冷门,BashPerl 等语言采用该模型。

js 是词法作用域模型,因此,在代码定义时,就已经确定好作用域了。换句话说,js 引擎会从函数声明的位置来查找变量。

结合执行上下文来理解,我们上面只分析了词法/变量环境中的环境记录,还有一个对外部环境的引用没有提到,而这个对外部环境的引用,会指向外部的执行上下文,js 引擎会根据这个引用的指向去查找变量。

函数作用域

function f() {
    console.log(a);
  }
function g() {
  var a = "local";
  f();
}
var a = "global";
g();

示意图:

image-20220623160627238

分析过程:在编译阶段,函数 f 中使用了变量 a,js 引擎首先在当前上下文中查找该变量,没有找到则沿着对外部环境的引用所指向的执行上下文中查找,以此类推,直到在全局执行上下文也没有找到时,会报出错误提醒我们该变量不存在。

作用域嵌套作用域,在内层作用域找不到的变量,js 引擎会依照函数定义的位置向上层作用域寻找,就此形成了作用域链。因此作用域链也是在编译阶段就创建好了。

块级作用域

var a = "global"
let b = 10
let test = "test1"
function f() {
  	let ly = "qu"
    var a = "local_f"
    {
        let a = "block_f"
        console.log(test)
    }
}
function g() {
    var a = "local_g"
    let test = "test2"
    {
        let test = "test3"
        f()
    }
}
g()

示意图:

image-20220623201014645

查找顺序已经标注,先沿着当前执行上下文的词法环境的栈顶向下查找,然后再去变量环境中查找,接着去函数声明的位置,即全局执行上下文中查找,仍旧是先在词法环境中查找,最后在变量环境中成功找到了变量 a

补充知识

语句和表达式

表达式和语句的区别是,一条语句执行一个动作,一个表达式会产生一个值。

一个简单的鉴别方法是,将代码放入浏览器的控制台执行,浏览器返回值的就是表达式,不返回值的就是语句;或者将代码放入 console.log()中,有输出的就是表达式,否则是语句。

如图,函数的定义是语句:

image.png

如图,赋值操作是语句:

image.png

语句和表达式的特点是:表达式永远不会在编译阶段执行

在使用 ReactJSX 语法时,JSX 的大括号内只能包括表达式,不能包括语句。

image.png

同名变量或函数

  1. 同名的二者都是变量,无疑,后声明的变量会覆盖先声明的变量。

  2. 同名的二者都是函数时,与变量相同,后声明的函数会覆盖先声明的函数。

    function f() {
        console.log('ly_qu');
    }
    f();
    function showName() {
        console.log('page_not_found');
    }
    f(); 
    //输出page_not_found
    
  3. 同名的二者既有变量又有函数时,在编译阶段,变量的声明会被忽略。

    f()  //输出 function
    var f = function() { //  函数表达式
        console.log("variable")
    }
    function f() {  //  函数声明
        console.log('function')
    }
    f()  //输出 variable
    

    要清楚一点,代码第二行用 var 定义的 f是一个变量,它保存了一个函数对象的内存地址。

    上面的代码相当于下面的写法:

    var f = undefined;
    function f() {
        console.log('function')
    }
    f()  //输出 function
    f = function() {
        console.log("variable")
    }
    f()  //输出 variable
    

深入理解调用栈

用一个对比的例子加深对调用栈的理解。这是上面讲解执行上下文栈时的例子:

var a = "global";
function g() {
  var a = "local";
  function f() {
    console.log(a);
  }
  return f();
}
g();

调用栈的出入栈操作:

ECStack.push(<g> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();

稍微改动一下:

var a = "global";
function g() {
  var a = "local";
  function f() {
    console.log(a);
  }
  return f;
}
g()();

调用栈的出入栈操作:

ECStack.push(<g> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();

两段代码都输出 local,但是调用栈的顺序却不同。

总结

js 的执行机制是先编译,再执行

在编译期,会创建相应的执行上下文,执行上下文之间的切换通过调用栈来调度。

执行上下文具有变量环境词法环境。变量环境实现了变量提升;词法环境实现了暂时性死区,且词法环境通过一个栈结构实现了块级作用域。

js 基于词法作用域模型,所以 js 引擎会从函数声明的位置去寻找变量。

了解了 js 中表达式与语句的区别,表达式不会在编译期间执行。

当声明的函数和变量同名时,编译期变量声明会被忽略。