作用域

作用域的概念

存储变量中的值,并能对其进行访问和修改,是几乎所有的编程语言最基本的功能之一。而正是这种功能是程序具有了状态。那些随之而来就有很多问题了,这些变量如何存储,程序如何访问和修改。如此一来,就需要一系列的规则来指明如何存储变量,如何更方便的寻找访问变量。我们可以将这一系列的规则称为作用域。然而,在我看来,我们通常所说的一个个作用域,可以认为是包含了变量的存储访问等一系列规则的变量的集合。

JavaScript编译原理

传统的编译语言的流程中,一般可以分为三个步骤:分词/词法分析(分解为词法单元)、解析/语法分析(构建抽象语法树)、代码生成(生成可执行代码)。与传统的编译语言不同,JavaScript的编译过程不发生在构建之前,而是通常发生在代码执行前的几微妙时间内(也可称为预编译)。故,JavaScript常称为动态解释性语言。

JavaScript的处理

在处理过程中,涉及到的有引擎、编译器和作用域。

  • 引擎:负责整个JS程序的编译和执行过程
  • 编译器:负责语法分析及代码生成等dirty work
  • 作用域:负责收集和维护变量组成的一系列查询(值查询RHS、位置查询LHS),并通过一套严格的规则,确定当前执行的代码对这些变量的访问权限。

JavaScript处理实例

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

foo(2);

在上述代码中,编译器先将函数foo的定义放入作用域中;在执行foo(2)时,通过RHS查询foo变量的值,并执行;然后,对参数a进行LHS查询其位置,并将2赋值给a;通过RHS查询console变量,及其子变量log;通过RHS查询a的值,并运行。

  • 在JS执行前会先进行预编译,此时会提取var定义的变量(不进行赋值操作,故都为undefined)和函数声明,将其加到作用域。然后进行赋值操作、函数执行、条件判断等。
  • LHS为左查询,获取其位置,进行赋值操作。RHS为右查询,获取变量的值。
  • 在作用域中,定义一个变量var a时,会先在当前作用域中查找是否存在,存在则忽略,否则新建。
  • 在进行LHS时,如果在全局作用域中仍然无法找到:“严格模式”下,抛出ReferenceError,“非严格模式”下,在全局作用域中新建这个全局变量。
  • 在进行RHS时,当前作用域中找不到,则去上一层作用域找,直到在全局作用域中也找不到时,抛出ReferenceError。
  • ReferenceError同作用域判断失败有关。如果查到到相应的值,但是在其值上进行了不合理的操作,如引用null类型的值的属性,会抛出TypeError错误。

词法作用域

作用域有两种主要的工作模式,第一种为比较普遍的词法作用域,另一种为动态作用域(如bash脚本等)。JavaScript采用此法作用域。词法作用域意味着作用域由书写代码时函数声明的位置来决定,即JavaScript的嵌套作用域机制。
词法作用域是由写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变。然而使用eval和with可以欺骗词法(但这两种方法都对性能不好,轻易不要使用)。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo(str, a) {
eval( str ); // 欺骗!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

function foo(obj) {
with (obj) { //修改作用域
a = 2;
}
}
var o2 = {
b: 3
};
foo( o2 ); //o2.a未定义,a泄漏到了全局作用域

函数作用域与块作用域

在JavaScript中,函数是最常见的作用域单元。从ES3开始,try/catch结构在catch语句中具有块级作用域。另外,with也是块级作用域的一个例子。但是目前在for选好语句、if/else语句、while语句等控制语句中,任然不存在块级作用域,但是可以使用let声明在这些控制语句中声明块级变量。因为let在ES6以上版本中才可以使用,所以要使用let,就要对代码进行转换,从而使其可以转换为与let效果相同的可以在ES5中运行的代码(使用try/catch)。

闭包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}//连续输出5个6

for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})( i );
}//1、2、3、4、5

//let每次迭代都会声明,随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量
for (let i=1; i<=5; i++) {
let j = i; // 是的,闭包的块作用域!
setTimeout( function timer() {
console.log( j );
}, j*1000 );
}//1、2、3、4、5

上面的例子为经常出现的问题。ES6的let可以更好的解决这个问题。在上面的例子中,timer()依然持有对该作用域的引用,而这个引用就称为闭包,如果作用域中还有这种引用,那么这个作用域包含其外层作用域就不会被回收。所以,在闭包使用完成后,要对闭包进行回收,就要将其设置为null引用。
根据《你不知道的JS》一书的定义,Closure is when a function is able to remember and access its lexical scope even when that function is executing outside its lexical scope.
用任何形式的函数类型的值进行传递,且该函数在别处调用时就会产生闭包。另外,在定时器、事件监听器、AJAX请求等任务中,只要使用了回调函数,其实就是在使用闭包。

模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//现代的模块机制
var MyModules = (function Manager() {
var modules = {};

function define(name, deps, impl) {
for (var i=0; i<deps.length; i++) {
deps[i] = modules[deps[i]];
}
modules[name] = impl.apply( impl, deps );
}

function get(name) {
return modules[name];
}

return {
define: define,
get: get
};
})();