玩命加载中 . . .

执行上下文


前言

我们首先来看一个例子:

function f1() {
  console.log('111');
};
f1();

function f1() {
  console.log('666');
};
f1(); 

按照代码书写顺序,应该先输出 111,再输出 666 才对,但是很遗憾,两次输出均为 666。
如果我们将上述代码中的函数声明改为函数表达式,结果又不太一样:

var f1 = function () {
  console.log('111');
};
f1(); //111

var f1 = function() {
  console.log('666');
};
f1(); //666

是不是很意外,这其中的奥秘其实就在于JS的执行上下文里,看完下面的内容,你就会理解为什么了。

什么是执行上下文

JS 代码在执行之前,JS 引擎会先做一下“准备工作”,也就是创建对应的执行上下文。
执行上下文有且只有三类:全局执行上下文函数上下文,与eval上下文。由于eval一般不会使用,就不深入探究了。

插一句,在 JavaScript 中,运行环境主要包含了全局环境和函数环境。
而 JavaScript 代码运行过程中,最先进入的是全局环境,而在函数被调用时则进入相应的函数环境。
全局环境和函数环境所对应的执行上下文我们分别称为全局(执行)上下文和函数(执行)上下文。
下面进入正题:

  1. 全局执行上下文
  • 全局执行上下文只有一个,在客户端中一般由浏览器创建,也就是我们熟知的 window 对象,我们能通过 this 直接访问到它。
    console.log(this);
  • 全局对象 window 上预定义了大量的方法和属性,我们在全局环境的任意处都能直接访问这些属性方法,如:
    console.log(this.Math.random())
  • window 对象是 var 声明的全局变量的载体。我们通过 var 创建的全局对象,都可以通过 window 直接访问。
    var a = 1;  
    window.a; // 1
  1. 函数执行上下文
    每个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建。(即使是调用同一个函数)
    每次调用函数时,都会为该函数创建一个新的执行上下文。
    于是我们很容易得知函数执行上下文可存在无数个。

综上,执行上下文可以理解为代码在被解析以前或者在执行时候所处的环境。之所以这么理解,是因为全局上下文是在代码被解析前就已经由浏览器创建好了的,函数上下文是在函数调用时创建的。

Q:接下来问题来了,我们写的函数多了去了,如何管理创建的那么多函数上下文呢?
A1:所以 JavaScript 引擎创建了执行上下文栈(Execution context stack,ECStack)【是一种拥有 LIFO(后进先出)数据结构的栈】来管理执行上下文。
A2:当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出

既如此,当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候(比如关闭网页或退出浏览器),ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 globalContext

执行上下文的三个重要属性

变量对象(Variable Object)

是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。

因为不同执行上下文下的变量对象稍有不同,所以来理一下全局上下文中的变量对象和函数上下文中的变量对象。

  • 全局上下文中的变量对象就是全局对象!
    • 全局对象是由 Object 构造函数实例化的一个对象。
      console.log(this instanceof Object);
  • 在函数上下文中,用活动对象来表示变量对象。
    • 活动对象(activation object, AO)和变量对象其实是同一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。
    • 活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。

再看下执行过程:执行上下文的代码会分成两个阶段进行处理:创建(代码预编译)阶段 和 执行(代码执行)阶段

  • 1.预编译阶段(进入执行上下文,这时候还没有执行代码)
    先进行语法分析,没有问题以后,在预编译阶段对JavaScript代码中变量的内存空间进行分配(变量提升就是在这个阶段完成的)。
    • 变量对象会包括:
      • 1)函数的所有形参 (如果是函数上下文)
        • 由名称和对应值组成的一个变量对象的属性被创建
        • 没有实参,属性值设为 undefined
      • 2)函数声明【由名称和对应值(函数对象)组成一个变量对象的属性被创建】
      • 3)变量声明【由名称和对应值(undefined)组成一个变量对象的属性被创建】

举个栗子:

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

   b = 3;
}
foo(1);

// 在进入执行上下文后,这时候的AO是:
AO = {
   arguments:{
       0:1,
       length:1
   },
   a:1, // 函数调用时创建的函数上下文,所以为 1
   b:undefined,
   c:reference to function c() {},
   d:undefined
}
```        

> [执行上下文创建阶段的另一种参考解释1](https://www.cnblogs.com/echolun/p/11438363.html)  
[执行上下文创建阶段的另一种参考解释2](https://blog.csdn.net/qq_33718648/article/details/90754331)  
[执行上下文创建阶段的另一种参考解释3](https://juejin.cn/post/6844903682283143181#heading-4)

- 2.代码执行阶段(执行代码逻辑,修改变量对象的值)
  ```js                
  // 还是上面的栗子,当代码执行完之后,这时候的AO是:
  AO = {
      arguments:{
          0:1,
          length:1
      },
      a:1,
      b:3,
      c:reference to function c() {},
      d:reference to FunctionExpression "d"
  }

总结上述所说:

  • 全局上下文的变量对象初始化是全局对象
  • 函数上下文的变量对象初始化只包括 Arguments 对象
  • 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  • 在代码执行阶段,会再次修改变量对象的属性值,同时执行上下文在这个阶段会全部创建完成

    作用域链(Scope Chain)

    作用域链是指由当前上下文和上层上下文的一系列变量对象组成的层级链。

我们已经知道,执行上下文分为创建和执行两个阶段,在执行上下文的执行阶段,当需要查找某个变量或函数时,会先在当前上下文的变量对象(活动对象)中进行查找,若是没有找到,则会依靠当前上下文中的作用域链,沿着上层上下文的变量对象进行查找,直到全局上下文中的变量对象(全局对象)

Q:既然如此,那作用域链又是怎么创建的?
A:我们都知道,JavaScript 中主要包含了全局作用域和函数作用域,而函数作用域是在函数被声明的时候确定的
每一个函数都会包含一个 [[scope]] 内部属性,在函数被声明的时候,该函数的 [[scope]] 属性会保存其上层上下文的变量对象,形成包含上层上下文变量对象的层级链。**[[scope]] 属性的值是在函数被声明的时候确定的**。
当函数被调用的时候,其执行上下文会被创建并入栈。在创建阶段生成其变量对象后,会将该变量对象添加到作用域链的顶端并将 [[scope]] 添加进该作用域链中。而在执行阶段,变量对象会变为活动对象,其相应属性会被赋值。
所以,作用域链是由当前上下文变量对象及上层上下文变量对象组成的:
SC = AO + [[scope]]

看个栗子:

var a = 1;
function fn1() {
  var b = 1;
  function fn2() {
    var c = 1;
  }
  fn2();
}
fn1();

// 分析如下:
// 在 fn1 函数上下文中,fn2 函数被声明,所以
fn2.[[scope]]=[fn1_EC.VO, globalObj]

// 当 fn2 被调用的时候,其执行上下文被创建并入栈,此时会将生成的变量对象添加进作用域链的顶端,并且将 [[scope]] 添加进作用域链
fn2_EC.SC=[fn2_EC.VO].concat(fn2.[[scope]])
=>
fn2_EC.SC=[fn2_EC.VO, fn1_EC.VO, globalObj]

下面用个例子总结一下函数执行上下文中作用域链和变量对象的创建过程:

var scope = "global scope";
function checkscope() {
    var scope2 = 'local scope';
    return scope2;
}
checkscope();
  • ①由于先处理函数声明。于是checkscope 函数被创建,保存作用域链到内部属性[[scope]]([[scope]] 属性会保存其上层上下文的变量对象(也就是全局对象))

    checkscope.[[scope]] = [
      globalContext.VO
    ];
  • ②执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

    ECStack = [
      checkscopeContext,
      globalContext
    ];
  • ③checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链

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

读到这里可能会有以下疑问:

Q1:checkscope函数被创建时保存到[[scope]]的作用域链,和 checkscope执行前的准备工作中复制函数[[scope]]属性创建的作用域链有什么不同?
A1:checkscope函数创建的时候,保存的是根据词法所生成的作用域链。checkscope执行的时候,会复制这个作用域链,作为自己作用域链的初始化,然后根据环境生成变量对象,然后将这个变量对象,添加到这个复制的作用域链,这才完整的构建了自己的作用域链。

Q2:为什么会有两个作用域链?
A2:因为在函数创建的时候并不能确定最终的作用域的样子。而为什么会采用复制的方式而不是直接修改呢?应该是因为函数会被调用很多次吧。

  • ④第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
    AO: {
      arguments:{
        length:0
      },
      scope2:undefined
    },
  • ⑤第三步:将活动对象压入checkscope 作用域顶端
    checkscopeContext = {
      AO: {
        arguments:{
          length:0
        },
        scope2:undefined
      },
      Scope:[AO, [[Scope]]]
    }
  • ⑥准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
    checkscopeContext = {
      AO: {
        arguments:{
          length:0
        },
        scope2:'local scope'
      },
      Scope:[AO, [[Scope]]]
    }
  • ⑦查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
    ECStack = [
      globalContext
    ];
    至此,作用域链的知识点over :)

    this

    首先需要清楚,this 是执行上下文的一个属性,而不是某个变量对象的属性。this 的指向也不是如常识一般指向某某,而是依据调用栈和执行位置决定的(即取决于函数在哪里被调用)。【 this 是在运行时绑定的,并不是在编写时绑定

    this 绑定有五种场景:默认绑定、隐式绑定、显式绑定、new绑定、箭头函数绑定

  • 默认绑定
    即函数调用时无任何调用前缀。默认绑定时,不管函数在何处调用, this 指向全局对象 window(非严格模式);在严格模式下,默认绑定的 this 指向 undefined

    function fn() {
      console.log(this); // window
      console.log(this.num); // 666
    };
    
    function fn1() {
      "use strict";
      console.log(this); // undefined
      console.log(this.num);
    };
    
    var num = 666;
    
    fn(); // --> 默认绑定
    fn1() // Uncaught TypeError: Cannot read property 'num' of undefined

    温馨提示:在严格模式下调用不在严格模式中的函数,并不会影响this指向,如下:

    var name = 'yfz';
    function fn() {
      console.log(this); // window
      console.log(this.name); // yfz
    };
    
    (function () {
      "use strict";
      fn();
    }());
  • 隐式绑定
    如果函数调用时,前面存在调用它的对象,那么this就会隐式绑定到这个对象上

    function fn() {
      console.log(this.num);
    };
    let obj = {
      num: 666,
      func: fn
    };
    obj.func() // 666
    
    // 上面代码中,this 指向 obj,obj 有 num 属性,所以输出 666

    如果函数调用前存在多个对象,this 指向距离调用自己最近的对象

    function fn() {
      console.log(this.num);
    };
    let obj = {
      num: 666,
      func: fn,
    };
    let obj1 = {
      num: 111,
      o: obj
    };
    obj1.o.func() // 666

    这里稍微拓展一下,如果将 obj 对象的 name 属性注释掉,却会输出 undefined

    function fn() {
      console.log(this.name);
    };
    let obj = {
      func: fn,
    };
    let obj1 = {
      name: 'yfz',
      o: obj
    };
    obj1.o.func() // undefined

    obj 对象虽然是 obj1 的属性,但它们两个的原型链并不相同,并不是父子关系,由于 obj 未提供 name 属性,所以是 undefined 。注意不要将作用域链和原型链弄混淆了,如果有小伙伴不能弄清楚,也可以看看我的另一篇博客:原型链

    既然说到这里了,索性再理清一下作用域链与原型链的区别:
    –> 当访问一个变量时,解释器会先在当前作用域查找标识符,如果没有找到就去父作用域找,作用域链顶端是全局对象 window ,如果 window 都没有这个变量则报错。
    –> 当在对象上访问某属性时,首先会查找当前对象,如果没有就顺着原型链往上找,原型链顶端是 null ,如果全程都没找到则返一个 undefined ,而不是报错。

  • 显式绑定
    指通过call、apply、bind以及js API中的部分方法改变this指向

    // call、apply、bind
    let obj1 = {
      num: 111
    };
    let obj2 = {
      num: 666
    };
    let obj3 = {
      num: 999
    }
    
    function fn() {
      console.log(this.num);
    };
    fn.call(obj1); // 111
    fn.apply(obj2); // 666
    fn.bind(obj3)(); // 999
    
    // API
    let obj = {
      num: 666
    };
    [1, 2, 3].forEach(function () {
      console.log(this.num); // 打印 3 次 666
    }, obj);

    注意,如果在使用 call 之类的方法改变this指向时,指向参数提供的是 null 或者 undefined ,那么 this 将指向全局对象。

  • new绑定

    function Fn(){
      this.num = 666;
    };
    let echo = new Fn();
    console.log(echo.num) // 666

    在上方代码中,构造调用创建了一个新对象 echo ,而在函数体内,this 将指向新对象 echo 上

如果一个函数调用存在多种绑定方法,this最终指向谁呢?
这里给出前面四种绑定方法的优先级:
显式绑定 > 隐式绑定 > 默认绑定
new绑定 > 隐式绑定 > 默认绑定

为什么显式绑定不和new绑定比较呢?因为不存在这种绑定同时生效的情景,如果同时写这两种代码会直接抛错。

  • 箭头函数this指向:
    箭头函数中没有自己的 this ,箭头函数的 this 指向取决于外层作用域中的 this :外层作用域或函数的 this 指向谁,箭头函数中的 this 便指向谁;最终保障是指向 window 。

参考学习资料:this的指向问题
另外可学习冴羽大神之不同的角度看this:JavaScript深入之从ECMAScript规范解读this

说到这里,执行上下文的三个属性终于说完了,以上。

执行上下文栈和执行上下文的具体变化过程

还是那个例子:

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
    }
  • 初始化的同时,checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]]
    checkscope.[[scope]] = [
      globalContext.VO
    ];
  1. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
    ECStack = [
      checkscopeContext,
      globalContext
    ];
  2. checkscope 函数执行上下文初始化:
  • 1)复制函数 [[scope]] 属性创建作用域链,
  • 2)用 arguments 创建活动对象,
  • 3)初始化活动对象,即加入形参、函数声明、变量声明,
  • 4)将活动对象压入 checkscope 作用域链顶端。
  • 同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]
    checkscopeContext = {
      AO:{
        arguments:{
          length:0
        },
        scope:undefined,
        f:reference to function f() {}
      },
      Scope:[AO, globalContext.VO],
      this:undefined
    }
  1. 执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈
    ECStack = [
      fContext,
      checkscopeContext,
      globalContext
    ];
  2. f 函数执行上下文初始化, 以下跟第 4 步相同:
  • 1)复制函数 [[scope]] 属性创建作用域链
  • 2)用 arguments 创建活动对象
  • 3)初始化活动对象,即加入形参、函数声明、变量声明
  • 4)将活动对象压入 f 作用域链顶端
    fContext = {
      AO: {
      arguments: {
          length:0
        }
      },
      Scope: [AO, checkscopeContext.AO, globalContext.VO],
      this: undefined
    }
  1. f 函数执行,沿着作用域链查找 scope 值,返回 scope 值
  2. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出
    ECStack = [
      checkscopeContext,
      globalContext
    ];
  3. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
    ECStack = {
      globalContext
    };

以上就是执行上下文的全部知识点,以及其底层实现过程,希望对大家有所帮助。

最后注明

学习资料参考冴羽大神的博客:


文章作者: hcyety
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hcyety !
评论
  目录