作用域与闭包

编译器理论

尽管 JavaScript 被划分到 “动态” 或 “解释型” 语言的范畴,但是其实他是一个编译型语言。它不像许多传统意义上的编译型语言那样预先被编译好,编译的结果也不能在各种不同的分布式系统间移植。

传统的编译型语言 “编译” 步骤:

  1. 分词/词法分析:将一连串字符打断成有意义的片段,称为 token(记号)。分词和词法分析区别在于 token 是否以 “无状态” 和 “有状态” 的方式被识别。
  2. 解析:将一个 token 的流(数组)转换为一个嵌套元素的树,它综合地表示了程序的语法结构。这棵树被称为 “抽象语法树(AST -- Abstract Syntax Tree)”。
  3. 代码生成:这个处理将 “抽象语法树” 转为可执行的代码。

对于 JavaScript 来说,在许多情况下,编译发生在代码被执行前的仅仅几微妙(甚至更少)。为了确保最快的性能,JavaScript 引擎将使用所有的招数(比如 JIT,它可以懒编译甚至是热编译,等等)。

编译器处理一个简单的示例:

var a = 2;
  1. 遇到 var a,编译器让作用域去查看对于这个特定的作用域集合,变量 a 是否已经存在了。如果是,编译器就忽略这个声明并继续前进;否则,编译器就让作用域集合声明一个称为 a 的变量。
  2. 然后编译器为引擎生成稍后执行的代码,来处理赋值 a = 2,。引擎运行的代码首先让作用域去查看在当前作用域集合中是否有一个称为 a 的变量可以访问。如果有,引擎就使用这个变量;如果没有,引擎就查看其他地方。
  3. 如果引擎最终找到变量 a,就会将 2 赋值给 a。否则引擎就会抛出一个异常。

编译器术语

在上面的示例中,引擎将会对变量 a 进行一个 “LHS(left-hand side)” 查询,另一种查询称为 “RHS(right-hand side)”。总得来说,就是变量出现在赋值操作左边时,进行 “LHS” 查询,当变量出现在赋值操作右边时,进行 “RHS” 查询。准确来说,“RHS” 是查询变量的值,“LHS” 查询则是找到变量的容器本身,从而可以对其赋值。

术语示例说明(更形象的对话open in new window):

console.log(a);
// 这个指向 `a` 的引用是一个 RHS 引用,因为此处没有东西赋值给 `a`,而是在查询 `a` 并取得它的值。

a = 2;
// 这里指向 `a` 的引用是一个 LHS 引用,因为我们实际上不关心当前的值是什么,我们只是想找到这个变量,将它作为 `= 2` 赋值操作的目标。

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

foo(2);
// 这段程序既有 LHS 引用,也有 RHS 引用。
// 调用 `foo()` 的最后一行作为一个函数调用指向 `foo` 的 RHS 引用,意味着 “去找到 foo 的值,并把它给我”,另外 `()` 意味着 `foo` 的值应当被执行,所以它最好实际上是一个函数。
// 代码中还隐含着 `a = 2` 的操作,即当值 2 作为参数传递给 `foo()` 函数时,值 2 被赋值给了参数 `a`,此处进行了 LHS 查询。
// 最后的 `console.log(a)` 同上。 

失败的 ”RHS“ 引用会导致抛出 ReferenceError 异常,如查询成功,但进行不正确的操作,例如引用 null 或 undefined 的属性,则会抛出 TypeError。

非严格模式下,失败的 "LHS" 引用会导致隐式地创建一个全局变量;严格模式下, 失败的 "LHS" 引用会抛出 ReferenceError 异常。

词法作用域

JavaScript 也采用词法作用域open in new window(Lexical Scope 又称 Static Scope,另外一种是动态作用域 Dynamic Scope),也就是说,函数的执行依赖于变量作用域,这个作用域是在函数定义时决定的,而不是函数调用时决定的。为了实现这个词法作用域,JavaScript 函数对象的内部状态不仅包含函数的代码逻辑,还必须引用当前的作用域链。函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学中称为 “闭包open in new window”,又称为 “词法闭包(Lexical Closure)”。

个人理解:

词法作用域定义了如何解析函数中嵌套的变量名,即使父(函数)已经返回(执行完毕),子函数依然保有父作用域。

从技术角度上来说,所有的 JavaScript 函数都是闭包:当调用函数时作用域链和定义函数时的作用域链不是同一个作用域链时。

所有函数都是闭包?

// 借助一个常见的示例:
var a = 1;
function foo() {
  console.log(a);
}

foo();
// 此处 foo 函数可以访问变量 a,但是 a 并非 foo 定义时作用域链中的变量
// 即构成了一个闭包

作用域链:作用域链被描述为一个对象列表,而非绑定的栈。

每次调用 JavaScript 函数的时候,都会为之创建一个新的对象用来保存局部变量,把这个对象添加至作用域链中。当函数返回的时候,就从作用域链中将这个绑定变量的对象删除。如果不存在嵌套函数,也没有其他引用指向这个绑定对象,它就会被当做垃圾回收掉。如果定义了嵌套的函数,每个嵌套的函数都各自对应一个作用域链,并且这个作用域链指向一个变量绑定对象。但如果这些嵌套的函数对象在外部函数中保存下来,那么它们也会和所指向的变量绑定对象一样被垃圾回收。但是如果这个函数顶一个嵌套的函数,并将它作为返回值返回或者存储在某处的属性里,这时就会有一个外部引用指向这个嵌套的函数。它就不会被当做垃圾回收,并且它所指向的变量绑定对象也不会被当做垃圾回收。

1. 词法分析

function foo(a) {
  var b = a * 2;
  function bar(c) {
    console.log( a, b, c );
  }

  bar(b * 3);
}

foo( 2 ); // 2 4 12

以上存在三个作用域:

  1. 全局作用域,一个标识符:foo
  2. foo 作用域,三个标识符:abarb
  3. bar 作用域,一个标识符:c

执行上下文

执行上下文是执行 JavaScript 代码环境的抽象概念。

1. 类型

JavaScript 中存在三种执行上下文类型:

  1. 全局执行上下文:默认的上下文,其执行会创建一个全局的 window 对象(浏览器下,V8 下为 global),并且设置 this = window
  2. 函数执行上下文:每当一个函数被调用时,都会为该函数创建一个新的上下文。
  3. Eval 函数执行上下文:同上。

2. 执行栈

其拥有栈的性质。

当 JavaScript 开始解释执行代码(进入 JS 文件)时,首先遇到的自然是全局代码,所以全局执行上下文永远在栈底,直到关闭浏览器时最后被抛出。

编译在先

1. 变量提升示例

console.log(a); // undefined
var a = 2;

// 上面常常被误认为结果是抛出一个 ReferenceError

要理解上面的示例,就需要回顾下之前的编译器理论,引擎实际上将会在它解释执行你的 JavaScript 代码前编译它。编译过程的一部分就是找到所有声明(当前作用域内的声明?待验证),变将它们关联在合适的作用域上。即,在你的代码的任何部分被执行之前,所有的声明、变量和函数都会首先被处理。

因此,示例的实际执行为:

  1. 编译:

    var a;
    
  2. 执行:

    console.log(a);
    a = 2;
    

由此就产生了 “变量提升” 这个概念。

注意:只有声明被提升了,而任何赋值或其他的执行逻辑都会被留在原处。

补充:函数声明也是声明,并且函数声明优先于变量声明。

2. 函数提升示例

foo(); // TypeError: foo is not a function
bar(); // ReferenceError: bar is not defined

var foo = function bar() {
  // ...
};

将其解析一下便很容易得出原因:

var foo;

foo(); // TypeError
bar(); // ReferenceError

foo = function() {
  var bar = ...self...
  // ...
}

定时器影响

function wait(message) {
  setTimeout(function timer() {
    console.log(message);
  }, 1000);
}

wait("Hello, closure!"); // Hello, closure!

for (var i=1; i<=5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000 );
}

// 6 6 6 6 6

第二段代码与预期的 12345 不一致,是由于作用域的问题,闭包都作用在全局作用域上了,所以所有的 console.log(i)i 都指向全局同一个变量 i

可以利用 IFEE 为每个 console.log 创建独立的作用域:

for (var i=1; i<=5; i++) {
  (function (i) {
    setTimeout(function timer() {
      console.log(i);
    }, i * 1000 );
  })(i);
}

由于是作用域的问题,所以也可以利用新增的 letconst 生成块级作用域。