JavaScript执行上下文与作用域

208 阅读14分钟

执行上下文(Execution context)

ECMAScript6规范,定义了四类可运行代码(Executable Code) ,运行这些代码时候会创建执行上下文(Execution Contexts)。

  • global code 整个JS程序源代码文件中所有不在函数体中的代码。
  • function code 函数体中的代码。
  • eval code 传给内置eval函数的代码字符串。
  • module code 模块代码。

执行上下文的类型

根据之前提到的可运行代码可以得知在JavaScript中有三种执行上下文。

全局执行上下文——这是默认也是最基本的执行上下文,任何不在函数内部的代码都位于全局执行上下文中。它执行两件事:

  • 创建一个全局对象,它是一个window对象(在浏览器的情况下)。
  • 将this指向设为全局对象。

函数执行上下文——每次调用函数时,都会为该函数创建一个全新的执行上下文。

Eval函数执行上下文——在Eval函数内部执行的代码也会获得它自己的执行上下文,但开发人员通常不使用Eval,所以在这里不讨论它。

执行栈(Execution Stack)

执行栈用于存储代码执行期间创建的所有执行上下文,采用的是先进后出、后进先出的数据结构。

当JS引擎执行JS文件时,它会创建一个全局执行上下文,并将其推入当前执行栈。函数调用时,它就会为该函数创建一个新的函数执行上下文,并将其推到栈的顶部。当这个函数执行结束后,它的执行上下文会被销毁(出栈)。

执行上下文的生命周期

创建阶段

执行上下文在创建阶段会做两件事情:

  • 创建LexicalEnvironment(词法环境)。
  • 创建VariableEnvironment(变量环境)。

用伪代码可以表示如下:

ExecutionContext = {
      LexicalEnvironment = <ref. to LexicalEnvironment in memory>,
      VariableEnvironment = <ref. to VariableEnvironment in  memory>
}

LexicalEnvironment是一种规范类型,用于定义标识符与变量,函数的关联。之所以叫词法环境,是因为它和源程序的结构(源码的文字结构)对应,在写代码的时候词法环境就已经确定了。

简单来说词法环境就是用来登记变量名和函数名的,比如以下代码:

var a = 20;
var b = 40;
function foo() {
  console.log('bar');
}

它的词法环境如下,可以发现在创建阶段对var声明的变量进行了初始化,初始化值为undefined,而函数声明的初始化值是一个函数对象

lexicalEnvironment = {
  a: undefined,
  b: undefined,
  foo: function(){}
}

词法环境和五个类型的代码结构相对应:

  • Global code(全局代码) 源文件代码就是一个词法环境
  • function code(函数代码) 一个函数块是一个词法环境
  • eval 进入eval调用的代码有时会创建一个词法环境
  • with结构 一个with结构块是一个词法环境
  • catch结构 一个catch结构块内是一个词法环境

切记:词法环境是在进入上述几种代码结构之前之前创建的,并且with和catch结构的词法环境只用来检索,不用来登记,意味着with和catch结构的var声明和函数声明是在其他词法环境上登记绑定的。

词法环境有三个组成部分:

  • Environment Record(环境记录)
  • Reference to the outer environment(指向外部词法环境的引用,值可能为null。)
  • This binding(this绑定)

环境记录

环境记录(Environment Record): 是词法环境登记变量名和函数名的地方,可以理解为一种标识符与变量关系的映射表,当我们需要调用变量的时候,就在这里面查找对应的值。

环境记录器有三类:声明性环境记录器,对象环境记录器和全局环境记录器。

声明式环境记录器

声明式环境记录(Declarative environment record):储存了其作用范围内变量,const,let,class,module,import,function的声明,相当于记录了变量名和其对应的声明值。注意该环境记录中是不包括var声明的。

并且它还可以细分为:函数环境记录和模块环境记录。

  • 函数环境记录:用于保存外层函数的定义。如果当前函数不是一个箭头函数,会提供一个this绑定,并且还会还提供super关键字。除此之外它还包含一个arguments对象,它是一个类数组,包含传递给函数的索引和参数之间的映射,以及传递给函数的参数的长度(数量)。
  • 模块环境记录: 表示的就是ES6 Module环境中的变量信息
对象式环境记录

对象式环境记录器(Object Environment Record)它有一个关联的绑定对象(binding object),记录var声明、函数声明等与对象属性相绑定的标识符。

可以理解为对象式环境记录器中的标识符绑定对象属性名是一一对应的。

 person: {
     name: "Tang",
     age: 18
 }
 
 // Object Environment Record
 {
     "name" : "Tang"
     "age" : 18
 }

全局环境记录

全局环境记录(Global Environment Record)表示最外层script标签所包裹的代码环境,这个环境记录项目里面包含了JS内置对象的属性全局对象的属性以及所有在script中的顶级声明(var声明和函数声明)

他是一个独立的记录,但实际上它是封装了对象式环境记录声明式环境记录的一个复合记录。它的基础对象是相关领域记录的全局对象

并且在规范中,它有 [[ObjectRecord]][[DeclarativeRecord]] 字段指向前两种环境记录器

综上可以得到一个简单的结论,它实际上就是声明式环境记录对象式环境记录的缝合。

在浏览器中,window 就是它所绑定的全局对象。即如果在全局环境中使用 var 来声明变量或者进行函数声明,就会通过全局环境记录指向的对象式环境记录绑定到 window 上,所以我们可以通过 window 来访问它们。

var a = 1;
function b(){};
// 可以通过window对象访问a和b变量
console.log(window.a); // 1
console.log(window.b); // f b(){}

// 同时用let声明的变量进行对比,会发现无法通过window访问,因为let声明的变量不是绑定在该对象中的。
let c = 'hello';
console.log(c); // 'hello'
console.log(window.c); // undefined

外部词法环境应用

Reference to the Outer Environment 指向外部词法环境,这个外部词法环境是在代码形成时确定的,比如在全局代码中书写的函数声明,它的外部词法环境就是全局词法环境。

  • 全局代码的词法环境中的Reference to the Outer Environment指向null
  • 函数代码的外部词法环境在函数创建时保存在函数对象的一个[[environment]]属性中,之后函数调用时,函数代码的词法环境中的Reference to the Outer Environment指向该外部词法环境。

值得注意的是用new Function()方式创建的函数,函数对象中的[[environment]]永远指向全局词法环境,比如以下代码中的innerTwo函数执行输出的是1。

var a = 1;

function foo() {

    var a = 2;
    function innerOne(){
        console.log(a);
    }

    var innerTwo = new Function("console.log(a)");

    var innerTree =  function (){
        console.log(a);
    }

    innerOne(); // 2
    innerTwo(); // 1
    innerTree(); // 2
}
foo();

this绑定

This binding this绑定

  • 在全局执行上下文中,this指向全局对象(在浏览器中,它指向的是window对象)。

  • 在函数执行上下文中,this的指向取决于函数的调用方式,具体参考函数那篇文章。

变量环境

VariableEnvironment变量环境 也是一个词法环境,它的环境记录器(EnvironmentRecord)保存由VariableStatements(var声明)的绑定。

如上所述,变量环境也是一种词法环境,因此它具有上述定义的词法环境的所有属性和组件。

在ES6中,词法环境(LexicalEnvironment)和变量环境(VariableEnvironment)之间的一个区别是,前者用于存储函数声明和变量(letconst)绑定,而后者仅用于存储变量(var)绑定。

变量环境只有全局和函数作用域,词法环境则是有全局、块,函数。

执行阶段

完成所有变量的登记后,按照代码语句进行变量的赋值工作。

销毁阶段

函数代码执行完,执行上下文便会销毁,再次调用函数时会生成新的执行上下文。

全局代码执行完,比如关闭浏览器窗口,执行上下文便会销毁,再次在浏览器中运行JS文件时,生成新的全局执行上下文。

作用域

MDN中的定义是The scope is the current context of execution in which values and expressions are "visible" or can be referenced.

作用域是当前执行上下文,其中的值和表达式是“可见”的或可被引用的。

  • 全局作用域就是全局执行上下文
  • 函数作用域就是函数的当前执行上下文

作用域链

通俗的说作用域链就是访问外部词法环境的规则,值得一提的是这个规则是在函数创建时确定的,而不是函数调用时确定的。

以下代码可以看出虽然bar函数是在foo函数中调用的,但是它打印的是全局词法环境中的变量a。这是因为bar函数是在global词法环境下创建的,bar函数的[[scope]]属性中保存的是global词法环境,调用时自身的词法环境没有并没有a,所以从global词法环境中查找a变量。

var a = 1;
function foo(){
    var a = 2;
    bar();
}

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

注意匿名函数的特殊情况,匿名函数作为实参传递给了test2的形参fn,此时test2调用时,匿名函数打印出的a值为2,说明匿名函数是在test1函数的词法环境下创建的。

var a = 1;

function test4(){
    console.log(a);
}

function test1(){
    var a = 2;
    function test2(fn){
        var a = 3;
        fn();
    }
    test2(function(){
        console.log(a); // 2
    });

    test2(test4); // 1
}

test1();

块级作用域

块级作用域是一种特殊的局部作用域,基于letconst声明形成的。

ES5 只有全局作用域和函数作用域,没有块级作用域,这带来很多不合理的场景。

第一种场景,内层变量可能会覆盖外层变量。

下面代码的原意是,if代码块的外部使用外层的tmp变量,内部使用内层的tmp变量。但是,函数f执行后,输出结果为undefined,原因在于变量提升,导致内层的tmp变量覆盖了外层的tmp变量。

var tmp = new Date();
function f() {
    console.log(tmp);
    if (false) {
        var tmp = 'hello world';
    }
}
f(); // undefined

第二种场景,用来计数的循环变量泄露为全局变量。

下面代码中,变量i只用来控制循环,但是循环结束后,它并没有消失,泄露成了全局变量。

var s = 'hello';
for (var i = 0; i < s.length; i++) {
}
console.log(i); // 5

ES6中的块级作用域

let为 JavaScript 新增了块级作用域,因为let所声明的变量,只在let命令所在的代码块内有效。

下面的函数有两个代码块,都声明了变量n,运行后输出 5。这表示外层代码块不受内层代码块的影响。如果两次都使用var定义变量n,最后输出的值才是 10。

function f1() {
    let n = 5;
    if (true) {
        let n = 10;
    }
    console.log(n); // 5
}

循环体中的块级作用域

下面代码中,计数器i只在for循环体内有效,在循环体外引用就会报错,解决了变量泄露的问题。

for (let i = 0; i < 10; i++) {
// ...
}
console.log(i);
// ReferenceError: i is not defined

下面代码中,变量ivar命令声明的,在全局范围内都有效,所以全局只有一个变量i。每一次循环,变量i的值都会发生改变,而循环内被赋给数组a的函数内部的console.log(i),里面的i指向的就是全局的i。也就是说,所有数组a的成员里面的i,指向的都是同一个i,导致运行时输出的是最后一轮的i的值,也就是 10。

var a = [];
for (var i = 0; i < 10; i++) {
    a[i] = function () {
        console.log(i);
    };
}
a[6](); // 10

如果使用let,声明的变量仅在块级作用域内有效,最后输出的是 6。

下面代码中变量ilet声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。你可能会问,如果每一轮循环的变量i都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。

var a = [];
for (let i = 0; i < 10; i++) {
    a[i] = function () {
        console.log(i);
    };
}
a[6](); // 6

另外,for循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。

for (let i = 0; i < 3; i++) {
  let i = 'abc';
  console.log(i);
}
// abc
// abc
// abc

值得注意的还有两点

  • let是不存在变量提升的,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。这样的设计是为了让开发者养成良好的编程习惯,变量一定要在声明之后使用,否则就报错。
if (true) {
    // TDZ开始
    tmp = 'abc'; // ReferenceError
    console.log(tmp); // ReferenceError
    let tmp; // TDZ结束
    console.log(tmp); // undefined
    tmp = 123;
    console.log(tmp); // 123
}
  • let不允许在相同作用域内,重复声明同一个变量,因此也不能在函数体内用let重新声明参数。
// 报错 Uncaught SyntaxError: Identifier 'a' has already been declared
function func() {
    let a = 10;
    var a = 1;
}
// 报错 Uncaught SyntaxError: Identifier 'a' has already been declared
function func() {
    let a = 10;
    let a = 1;
}

块级作用域与函数声明

ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。

下面这种函数声明,根据 ES5 的规定都是非法的。

但是,浏览器没有遵守这个规定,为了兼容以前的旧代码,还是支持在块级作用域之中声明函数,因此下面这种情况实际能运行,不会报错。

if (true){
    function foo(){
        console.log("helloworld");
    }
}
foo(); // helloworld

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于let,在块级作用域之外不可引用。

下面代码在 ES5 中运行,会得到“I am inside!”,因为在if内声明的函数f会被提升到函数头部。

ES6 就完全不一样了,理论上会得到“I am outside!”。因为块级作用域内声明的函数类似于let,对作用域之外没有影响。但是,如果你真的在 ES6 浏览器中运行一下下面的代码,是会报错的,这是为什么呢?

function f() { console.log('I am outside!'); }
(function () {
    if (false) {
        function f() { console.log('I am inside!'); }
    }
    f();
}()); // Uncaught TypeError: f is not a function

原来,如果改变了块级作用域内声明的函数的处理规则,显然会对老代码产生很大影响。为了减轻因此产生的不兼容问题,ES6 在附录 B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式

  • 允许在块级作用域内声明函数。
  • 函数声明类似于var,即会提升到全局作用域或函数作用域的头部。
  • 同时,函数声明还会提升到所在的块级作用域的头部。

注意,上面三条规则只对 ES6 的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作let处理。

以下代码是一个较为复杂的例子,基于ES6标准运行的。

  • 函数声明参照var声明的形式,提升到全局作用域并初始化为undefined

  • 执行到if语句时,if生成了自己的执行上下文,其中函数声明整体提升到块级作用域的头部。

  • 执行到function a(){}后,会将变量a的值赋值给window.a

console.log(window.a, a); /* undefined   undefined */ 
if(true) {
    console.log(window.a, a); /* undefined   f a(){} */     
    a = 1; 
    console.log(window.a, a); /* undefined   1  */     
    function a(){}; 
    console.log(window.a, a); /* 1   1 */
    a = 21; 
    console.log(window.a, a); /* 1   21 */    
}
console.log(window.a, a); /* 1   1 */