之前第一遍看javascript高级程序设计教程,看到函数作用域的概念,感觉终于可以解释之前程序中出现的种种关于闭包,作用域的奇怪现象了,这个假象一直维持到最近。

you don’t know js。

no,I think i know.

but the truth is: I really don’t know

起源问题

对我自己的困惑来自于这个问题:

var a = 10;
function bar(){
   console.log(a);
}
function foo(){
  var a=5;
  bar();
}
foo();  //10

bar作为内部函数,可以访问到外部变量a,也就是foo中定义的a,所以应该输出5。然而结果是10,道理很简单,只是当时的我混淆了两个概念,执行上下文和函数作用域。(个人认为js高级程序设计中这两个概念就混的很厉害)。

作用域的概念

es6之前,没有块级作用域,只有函数作用域和全局作用域。这里只讨论es6之前的。函数的作用域在函数定义的时候就已经决定了,而不是在执行的时候决定。也就是说,上面的bar函数在定义的时候,它的作用域就已经确定了,在该函数内部能够访问到的变量就是它自己定义的变量和上一层作用域中的变量。

执行上下文

js代码都是运行在执行上下文中的,最开始,程序直接进入全局的执行上下文(也就是window,或者node里面的global),如果碰到函数function1的执行(不是定义),就进入函数的执行上下文。在函数执行之前,要做以下几件事来决定函数对变量的访问权。

  1. 一个指针指向function1的作用域链,作用域链中的变量都可以进行访问,优先级由内至外。
  2. 创建一个variable object,也就是变量对象。这个变量里面的属性存储遵循下面几个原则:
    1. 先给变量对象建立一个arguments对象,如果函数调用时传入了变量,则初始化为那个变量,如果变量未传入,则初始化为undefined,如果是函数,则传递一个指向函数的指针,同时arguments还具有callee,length等属性也一并创建好
    2. 扫描当前函数里面的声明语句,如果找到了函数声明语句,就给变量对象中增加一个以函数名为属性名,指向函数的指针作为属性值。如果有重复的,直接属性值替换。
    3. 如果找到了变量声明语句,判断变量名是否已经存在在变量对象中的属性,则直接忽略,如果不存在,就新增一个属性,初始化为undefined
  3. 确定this的指向

关于变量对象是否会合并到自己的作用域链上,js高级程序设计中是将变量对象作为最高级的作用域,外层作为下一个作用域进而构成一个作用域链,我认为是否合并不重要,重要的是变量对象的优先级最高

当上述行为完成之后,才开始执行代码。能够访问到上述出现的所有代码,变量对象中的优先级最高,函数作用域链上越近优先级越高。
举个例子更实在一点:

function foo(i) {
    var a = 'hello';
    var b = function privateB() {

    };
    function c() {

    }
}

foo(22);

上述代码执行前,会做上述的3件事,之后,它的执行上下文对应的一个变量看起来是这样子:

fooExecutionContext = {
    scopeChain: { 全局变量们,foo },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        a: undefined,
        b: undefined
        c: pointer to function c()
    },
    this: { window(浏览器) or undefined(node)}
}

执行上下文是存储在栈的,也就是说,当前上下文执行过程中,遇到新的函数要执行, 就将新的执行上下文放入栈顶,决定了新的函数的执行上下文,执行结束之后出站,回复之前的执行上下文,从而在之前的执行上下文中继续执行。举个例子:

var a=10;
function   bar(){
    console.log(a)//undefined
    var a=5;
    function foo(){
       a=3;
       console.log(a);//3
    }
    foo();
    console.log(a);//3
}
bar();
console.log(a);//10

当前函数开始运行,构建全局执行上下文,遇到bar(),要开启一个新的上下文并进行堆栈,执行上述的3件事之后,新的上下文看起来像这样:

barFunctionContext{
    scopechain{全局的:可以访问到bar,a}
    variable object{
      arguments:{
          length:0,
          callee:(bar)
      },
      a:undefined,
      foo: 指向函数的指针(此时foo输出就是该函数),
    }
    this: window in browser or undefined in node
}

所以在执行的过程中,console.log(a)就会从当前的执行上下文中寻找a,进而输出undefined,而遇到a=5的时候,改变执行上下文中的a为5,当遇到foo()时,新建一个执行上下文,经过上面三件事之后,新的执行上下文大概像这样:

fooFunctionContext{
    scopechain{bar的执行上下文(a已经修改成5,别的不变)}
    variable object{
      arguments:{
          length:0,
          callee:(foo)
      },
    }
    this: window in browser or undefined in node
}

所以修改a的时候会从当前变量对象中查找,没有,就去作用域链中查找,在bar中的执行上下文中发现了a,修改a 的数值,然后输出3.接下来执行上下文出站,继续在bar的上下文中执行,console.log(a),已经被修改,输出3.bar执行上下文出站,执行console.log(a),此时是全局的a,也就是10。

总结

  1. 执行上下文采取堆栈的形式,这样才可以递归,函数嵌套而不会混乱
  2. 执行上下文的构建基本采用上述的3件事,之后才会去执行代码,这也是变量名提升的根本原因
  3. 理解了执行上下文,才能对js的一些复杂功能,闭包,柯里化(函数嵌套)真正理解

上述的文字大部分是看了下面的英文文献之后自己理解的,英文文献中还有很多的辅助图可以参考。理解的不到位的地方还请指出。

参考文章

  1. What is the Execution Context & Stack in JavaScript?
  2. ECMA-262-3 in detail. Chapter 2. Variable object.
  3. Javascript Closures