4

问题

今天看笔记发现自己之前记了一个关于同名标识符优先级的内容,具体是下面这样的:

  • 形参优先级高于当前函数名,低于内部函数名
  • 形参优先级高于arguments
  • 形参优先级高于只声明却未赋值的局部变量,但是低于声明且赋值的局部变量
  • 函数和变量都会声明提升,函数名和变量名同名时,函数名的优先级要高。执行代码时,同名函数会覆盖只声明却未赋值的变量,但是它不能覆盖声明且赋值的变量
  • 局部变量也会声明提升,可以先使用后声明,不影响外部同名变量

然后我就想,为什么会有这样的优先级呢,规定的?但是好像没有这个规定,于是开始查阅资料,就有了下文

初识Execution Context

Execution ContextJavascript中一个抽象概念,它定义了变量或函数有权访问的其他数据,决定了它们各自的行为。为了便于理解,我们可以近似将其等同于执行当前代码的环境,JavaScript的可执行代码包括

  • 全局代码
  • 函数代码
  • eval()代码

每当执行流转到这些可执行代码时,就会“新建”一个Execution Context并进入该Execution Context

clipboard.png

在上图中,共有4个Execution Context,其中有一个是Global Execution Context(有且仅有一个),还有三个Function Execution Context

再识Execution Context Stack

浏览器中的JavaScript解释器是单线程的,每次创建并进入一个新的Execution Context时,这个Execution Context就会被推(push)进一个环境栈中,这个栈称为Execution Context Stack,当当前Execution Context的代码执行完之后,栈又会将其弹(pop)出,并销毁这个Execution Context,保存在其中的变量及函数定义也随之被销毁,然后把控制权返回给之前的Execution ContextGlobal Execution Context例外,它要等到应用程序退出后 —— 如关闭网页或浏览器 —— 才会被销毁)

JavaScript的执行流就是由这个机制控制的,以下面的代码为例说明:

var sayHello = 'Hello';
function name(){
    var fisrtName = 'Cao',
        lastName = 'Cshine';
    function getFirstName(){
        return fisrtName;
    }
    function getLatName(){
        return lastName;
    }
    console.log(sayHello + getFirstName() + ' ' + getLastName());
}
name();

clipboard.png

  • 当浏览器第一次加载script的时候,默认会进入Global Execution Context,所以Global Execution Context永远是在栈的最下面。
  • 然后遇到函数调用name(),此时新建并进入Function Execution Context nameFunction Execution Context name入栈;
  • 继续执行遇到函数调用getFirstName(),于是新建并进入Function Execution Context getFirstNameFunction Execution Context getFirstName入栈,由于该函数内部不会再新建其他Execution Context,所以直接执行完毕,然后出栈,控制权交给Function Execution Context name
  • 再往下执行遇到函数调用getLastName(),于是新建并进入Function Execution Context getLastNameFunction Execution Context getLastName入栈,由于该函数内部不会再新建其他Execution Context,所以直接执行完毕,然后出栈,控制权交给Function Execution Context name
  • 执行完console后,函数name也执行完毕,于是出栈,控制权交给Function Execution Context name,至此栈中又只有Global Execution Context
  • 关于Execution Context Stack有5个关键点:

    • 单线程
    • 同步执行(非异步)
    • 1个Global Execution Context
    • 无限制的函数Function Execution Context
    • 每个函数调用都会创建新的Execution Context,即使是自己调用自己,如下面的代码:

      (function foo(i) {
          if (i === 3) {
              return;
          }
          else {
              foo(++i);
          }
      }(0));

      Execution Context Stack的情况如下图所示:

      clipboard.png

亲密接触Execution Context

每个Execution Context在概念上可以看成由下面三者组成:

  • 变量对象(Variable object,简称VO
  • 作用域链(Scope Chain
  • this

变量对象(Variable object

该对象与Execution Context相关联,保存着Execution Context中定义的所有变量、函数声明以及函数形参,这个对象我们无法访问,但是解析器在后台处理数据是用到它(注意函数表达式以及没用var/let/const声明的变量不在VO中)

Global Execution Context中的变量对象VO根据宿主环境的不同而不同,在浏览器中为window对象,因此所有的全局变量和函数都是作为window对象的属性和方法创建的。

对于Function Execution Context,变量对象VO为函数的活动对象,活动对象是在进入Function Execution Context时创建的,它通过函数的arguments属性初始化,也就是最初只包含arguments这一个属性。

JavaScript解释器内部,每次调用Execution Context都会经历下面两个阶段:

  • 创建阶段(发生在函数调用时,但是内部代码执行前,这将解释声明提升现象)

    • 创建作用域链(作用域链见下文)
    • 创建变量对象VO
    • 确定this的值
  • 激活/代码执行阶段

    • 变量赋值、执行代码

其中创建阶段的第二步创建变量对象VO的过程可以理解成下面这样:

  • Global Execution Context中没有这一步) 创建arguments对象,扫描函数的所有形参,并将形参名称 和对应值组成的键值对作为变量对象VO的属性。如果没有传递对应的实参,将undefined作为对应值。如果形参名为arguments,将覆盖arguments对象
  • 扫描Execution Context中所有的函数声明(注意是函数声明,函数表达式不算)

    • 将函数名和对应值(指向内存中该函数的引用指针)组成组成的键值对作为变量对象VO的属性
    • 如果变量对象VO已经存在同名的属性,则覆盖这个属性
  • 扫描Execution Context中所有的变量声明

    • 由变量名和对应值(此时为undefined) 组成,作为变量对象的属性
    • 如果变量名与已经声明的形参或函数相同,此时什么都不会发生,变量声明不会干扰已经存在的这个同名属性。

好~~现在我们来看代码捋一遍:

function foo(num) {
    console.log(num);// 66
    console.log(a);// undefined
    console.log(b);// undefined
    console.log(fc);// f function fc() {}
    var a = 'hello';
    var b = function fb() {};
    function fc() {}
}
foo(66);
  • 当调用foo(66)时,创建阶段时,Execution Context可以理解成下面这个样子

    fooExecutionContext = {
        scopeChain: { ... },
        variableObject: {
            arguments: {
                0: 66,
                length: 1
            },
            num: 66,
            fc: pointer to function fc()
            a: undefined,
            b: undefined
        },
        this: { ... }
    }
  • 当创建阶段完成以后,执行流进入函数内部,激活执行阶段,然后代码完成执行,Execution Context可以理解成下面这个样子:

    fooExecutionContext = {
        scopeChain: { ... },
        variableObject: {
            arguments: {
                0: 66,
                length: 1
            },
            num: 66,
            fc: pointer to function fc()
            a: 'hello',
            b: pointer to function fb()
        },
        this: { ... }
    }

作用域链(Scope Chain

当代码在一个Execution Context中执行时,就会创建变量对象的一个作用域链,作用域链的用途是保证对执行环境有权访问的所有变量和函数的有序访问

Global Execution Context中的作用域链只有Global Execution Context的变量对象(也就是window对象),而Function Execution Context中的作用域链还会有“父”Execution Context的变量对象,这里就会要牵扯到[[Scopes]]属性,可以将函数作用域链理解为---- 当前Function Execution Context的变量对象VO(也就是该函数的活动对象AO) + [[Scopes]],怎么理解呢,我们继续往下看

[[Scopes]]属性

[[Scopes]]这个属性与函数的作用域链有着密不可分的关系,JavaScript中每个函数都表示为一个函数对象,[[Scopes]]是函数对象的一个内部属性,只有JavaScript引擎可以访问。

结合函数的生命周期:

  • 函数定义

    • [[Scopes]]属性在函数定义时被存储,保持不变,直至函数被销毁
    • [[Scopes]]属性链接到定义该函数的作用域链上,所以他保存的是所有包含该函数的 “父/祖父/曾祖父...” Execution Context的变量对象(OV),我们将其称为所有父变量对象(All POV
    • !!!特别注意 [[Scopes]]是在定义一个函数的时候决定的
  • 函数调用

    • 函数调用时,会创建并进入一个新的Function Execution Context,根据前面讨论过的调用Function Execution Context的两个阶段可知:先创建作用域链,这个创建过程会将该函数对象的[[Scopes]]属性加入到其中
    • 然后会创建该函数的活动对象AO(作为该Function Execution Context的变量对象VO),并将创建的这个活动对象AO加到作用域链的最前端
    • 然后确定this的值
    • 正式执行函数内的代码

通过上面的过程我们大概可以理解:作用域链 = 当前Function Execution Context的变量对象VO(也就是该函数的活动对象AO) + [[Scopes]],有了这个作用域链, 在发生标识符解析的时候, 就会沿着作用域链一级一级地搜索标识符,最开始是搜索当前Function Execution Context的变量对象VO,如果没有找到,就会根据[[Scopes]]找到父变量对象,然后继续搜索该父变量对象中是否有该标识符;如果仍没有找到,便会找到祖父变量对象并搜索其中是否有该标识符;如此一级级的搜索,直至找到标识符为止(如果直到最后也找不到,一般会报未定义的错误);注意:对于thisarguments,只会搜到其本身的变量(活动)对象为止,而不会继续按着作用域链搜素。

现在再结合例子来捋一遍:

var a = 10;
function foo(d) {
    var b = 20;
    function bar() {
        var c = 30;
        console.log(a +  b + c + d); // 110
        //这里可以访问a,b,c,d
    }
    //这里可以访问a,b,d 但是不能访问c
    bar();
}
//这里只能访问a
foo(50);
  • 当浏览器第一次加载script的时候,默认会进入Global Execution Context的创建阶段

    • 创建Scope Chain(作用域链)
    • 创建变量对象,此处为window对象。然后会扫描所有的全局函数声明,再扫描全局变量声明。之后该变量对象会加到Scope Chain
    • 确定this的值
    • 此时Global Execution Context可以表示为:

      globalEC = {
          scopeChain: {
              pointer to globalEC.VO
          },
          VO: {
              a: undefined,
              foo: pointer to function foo(),
              (其他window属性)
          },
          this: { ... }
      }
  • 接着进入Global Execution Context的执行阶段

    • 遇到赋值语句var a = 10,于是globalEC.VO.a = 10

      globalEC = {
          scopeChain: {
              pointer to globalEC.VO
          },
          VO: {
              a: 10,
              foo: pointer to function foo(),
              (其他window属性)
          },
          this: { ... }
      }
    • 遇到foo函数定义语句,进入foo函数的定义阶段,foo[[Scopes]]属性被确定

      foo.[[Scopes]] = {
          pointer to globalEC.VO
      }
    • 遇到foo(50)调用语句,进入foo函数调用阶段,此时进入Function Execution Context foo的创建阶段

      • 创建Scope Chain(作用域链)
      • 创建变量对象,此处为foo的活动对象。先创建arguments对象,然后扫描函数的所有形参,之后会扫描foo函数内所有的函数声明,再扫描foo函数内的变量声明。之后该变量对象会加到Scope Chain
      • 确定this的值
      • 此时Function Execution Context foo可以表示为

        fooEC = {
            scopeChain: {
                pointer to fooEC.VO,
                foo.[[Scopes]]
            },
            VO: {
                arguments: {
                    0: 66,
                    length: 1
                },
                b: undefined,
                d: 50,
                bar: pointer to function bar(),
            },
            this: { ... }
        }
    • 接着进入Function Execution Context foo的执行阶段

      • 遇到赋值语句var b = 20;,于是fooEC .VO.b = 20

        fooEC = {
            scopeChain: {
                pointer to fooEC.VO,
                foo.[[Scopes]]
            },
            VO: {
                arguments: {
                    0: 66,
                    length: 1
                },
                b: 20,
                d: 50,
                bar: pointer to function bar(),
            },
            this: { ... }
        }
      • 遇到bar函数定义语句,进入bar函数的定义阶段,bar[[Scopes]]`属性被确定

        bar.[[Scopes]] = {
            pointer to fooEC.VO,
            pointer to globalEC.VO
        }
      • 遇到bar()调用语句,进入bar函数调用阶段,此时进入Function Execution Context bar的创建阶段

        • 创建Scope Chain(作用域链)
        • 创建变量对象,此处为bar的活动对象。先创建arguments对象,然后扫描函数的所有形参,之后会扫描foo函数内所有的函数声明,再扫描bar函数内的变量声明。之后该变量对象会加到Scope Chain
        • 确定this的值
        • 此时Function Execution Context bar可以表示为

          barEC = {
             scopeChain: {
                 pointer to barEC.VO,
                 bar.[[Scopes]]
             },
             VO: {
                 arguments: {
                     length: 0
                 },
                 c: undefined
             },
             this: { ... }
          }
      • 接着进入Function Execution Context bar的执行阶段

        • 遇到赋值语句var c = 30,于是barEC.VO.c = 30

          barEC = {
              scopeChain: {
                  pointer to barEC.VO,
                  bar.[[Scopes]]
              },
              VO: {
                  arguments: {
                      length: 0
                  },
                  c: 30
              },
              this: { ... }
          }
        • 遇到打印语句console.log(a + b + c + d);,需要访问变量a,b,c,d

          • 通过bar.[[Scopes]].globalEC.VO.a访问得到a=10
          • 通过bar.[[Scopes]].fooEC.VO.b,bar.[[Scopes]].fooEC.VO.d访问得到b=20,d=50
          • 通过barEC.VO.c访问得到c=30
          • 通过运算得出结果110
      • bar函数执行完毕,Function Execution Context bar销毁,变量c也随之销毁
    • foo函数执行完毕,Function Execution Context foo销毁,b,d,bar也随之销毁
  • 所有代码执行完毕,等到该网页被关闭或者浏览器被关闭,Global Execution Context才销毁,a,foo才会销毁

通过上面的例子,相信对Execution Context和作用域链的理解也更清楚了,下面简单总结一下作用域链:

  • 作用域链的前端始终是当前执行的代码所在Execution Context的变量对象;
  • 下一个变量对象来自其包含Execution Context,以此类推;
  • 最后一个变量对象始终是Global Execution Context的变量对象;
  • 内部Execution Context可通过作用域链访问外部Execution Context反之不可以
  • 标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级的向后回溯,直到找到标识符为止(如果找不到,通常会导致错误);
  • 作用域链的本质是一个指向变量对象的指针列表,只引用而不实际包含变量对象。

延长作用域链

下面两种语句可以在作用域链的前端临时增加一个变量对象以延长作用域链,该变量对象会在代码执行后被移除

  • try-catch语句的catch
    创建一个新的变量对象,其中包含的是被抛出的错误对象的声明
  • with语句
    将指定的对象添加到作用域链中

    function buildUrl(){
        var qs = "?debug=true";
        with(location){
            var url = href + qs;
        }
        //console.log(href) 将会报href is not defined的错误,因为with语句执行完with创建的变量对象就被移除了
        return url;
    }

    with语句接收window.location对象,因此其变量对象就包含了window.location对象的所有属性,而这个变量对象被添加到作用域链的前端。所以在with语句里面使用href相当于window.location.href

解答问题

现在我们来解答最开始的优先级问题

  • 形参优先级高于当前函数名,低于内部函数名

    function fn(fn){
        console.log(fn);// cc
    }
    fn('cc');

    函数fn属于Global Execution Context,而形参fn属于Function Execution Context fn,此时作用域的前端是Function Execution Context fn的变量对象,所以console.log(fn)为形参的值

    function fa(fb){
        console.log(fb);// ƒ fb(){}
        function fb(){}
        console.log(fb);// ƒ fb(){}
    }
    fa('aaa');

    调用fa函数时,进入Function Execution Context fa的创建阶段,根据前面所说的变量对象创建过程:

    先创建arguments对象,然后扫描函数的所有形参,之后会扫描函数内所有的函数声明,再扫描函数内的变量声明;
    扫描函数声明时,如果变量对象VO中已经存在同名的属性,则覆盖这个属性

    我们可以得到fa的变量对象表示为:

    fa.VO = {
        arguments: {
            0:'aaa',
            length: 1
        },
        fb: pointer to function fb(),
    }

    所以console.log(fb)得到的是fa.VO.fb的值ƒ fb(){}

  • 形参优先级高于arguments

    function fn(aa){
        console.log(arguments);// Arguments ["hello world"]
    }
    fn('hello world');
    
    function fn(arguments){
        console.log(arguments);// hello world
    }
    fn('hello world');

    调用fn函数时,进入Function Execution Context fn的创建阶段,根据前面所说的变量对象创建过程:

    先创建arguments对象,然后扫描函数的所有形参,之后会扫描函数内所有的函数声明,再扫描函数内的变量声明;
    先创建arguments对象,后扫描函数形参,如果形参名为arguments,将会覆盖arguments对象

    所以当形参名为arguments时,console.log(arguments)为形参的值hello world

  • 形参优先级高于只声明却未赋值的局部变量,但是低于声明且赋值的局部变量

    function fa(aa){
        console.log(aa);//aaaaa
        var aa;
        console.log(aa);//aaaaa
    }
    fa('aaaaa');

    调用fa函数时,进入Function Execution Context fa的创建阶段,根据前面所说的变量对象创建过程:

    先创建arguments对象,然后扫描函数的所有形参,之后会扫描函数内所有的函数声明,再扫描函数内的变量声明;
    扫描函数内的变量声明时,如果变量名与已经声明的形参或函数相同,此时什么都不会发生,变量声明不会干扰已经存在的这个同名属性

    所以创建阶段之后Function Execution Context fa的变量对象表示为:

    fa.VO = {
        arguments: {
            0:'aaaaa',
            length: 1
        },
        aa:'aaaaa',
    }

    之后进入Function Execution Context fa的执行阶段:console.log(aa);打印出fa.VO.aa(形参aa)的值aaaaa;由于var aa;仅声明而未赋值,所以不会改变fa.VO.aa的值,所以下一个console.log(aa);打印出的仍然是fa.VO.aa(形参aa)的值aaaaa

    function fb(bb){
        console.log(bb);//bbbbb
        var bb = 'BBBBB';
        console.log(bb);//BBBBB
    }
    fb('bbbbb');

    调用fb函数时,进入Function Execution Context fb的创建阶段,根据前面所说的变量对象创建过程:

    先创建arguments对象,然后扫描函数的所有形参,之后会扫描函数内所有的函数声明,再扫描函数内的变量声明;
    扫描函数内的变量声明时,如果变量名与已经声明的形参或函数相同,此时什么都不会发生,变量声明不会干扰已经存在的这个同名属性

    所以创建阶段之后Function Execution Context fb的变量对象表示为:

    fb.VO = {
        arguments: {
            0:'bbbbb',
            length: 1
        },
        bb:'bbbbb',
    }

    之后进入Function Execution Context fb的执行阶段:console.log(bb);打印出fb.VO.bb(形参bb)的值'bbbbb';遇到var bb = 'BBBBB';fb.VO.bb的值将被赋为BBBBB,所以下一个console.log(bb);打印出fb.VO.bb(局部变量bb)的值BBBBB

  • 函数和变量都会声明提升,函数名和变量名同名时,函数名的优先级要高。

    console.log(cc);//ƒ cc(){}
    var cc = 1;
    function cc(){}

    根据Global Execution Context的创建阶段中创建变量对象的过程:是先扫描函数声明,再扫描变量声明,且变量声明不会影响已存在的同名属性。所以在遇到var cc = 1;这个声明语句之前,global.VO.ccƒ cc(){}

  • 执行代码时,同名函数会覆盖只声明却未赋值的变量,但是它不能覆盖声明且赋值的变量

    var cc = 1;
    var dd;
    function cc(){}
    function dd(){}
    console.log(cc);//1
    console.log(dd);//ƒ dd(){}

    Global Execution Context的创建阶段之后,Global Execution Context的变量对象可以表示为:

    global.VO = {
        cc:pointer to function cc(),
        dd:pointer to function dd()
    }

    然后进入Global Execution Context的执行阶段,遇到var cc = 1;这个声明赋值语句后, global.VO.cc将被赋值为1;然后再遇到var dd这个声明语句,由于仅声明未赋值,所以不改变global.VO.dd的值;所以console.log(cc);打印出1console.log(dd);打印出ƒ dd(){}

  • 局部变量也会声明提升,可以先使用后声明,不影响外部同名变量

每个Execution Context都会有变量创建这个过程,所以会有声明提升;根据作用域链,如果局部变量与外部变量同名,那么最先找到的是局部变量,影响不到外部同名变量

相关资料

JavaScript基础系列---变量及其值类型
Understanding Scope in JavaScript
What is the Execution Context & Stack in JavaScript?
深入探讨JavaScript的执行环境和栈
作用域原理
JavaScript执行环境 + 变量对象 + 作用域链 + 闭包


Cshine
169 声望5 粉丝

前端魔法修炼ing