什么是作用域

理解作用域

JavaScript实际上是一门编译语言。在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为编译:

  • 分词/词法分析:
    将字符串分解成有意义的代码块,称为词法单元
  • 解析/语法分析:
    将词法单元流转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,称为抽象语法树(AST)
  • 代码生成:
    将AST转换成可执行代码

想要理解作用域,首先我们需要理解在一个变量定义的过程中,例如

1
var a = 2;

到底做了些什么。
这看起来像是完整的一句声明,但是在JS引擎看来,这是两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎处理。

编译器首先会进行以下处理:

  1. 遇到 var a,询问作用域内是否已经有一个该名称的变量存在于同一个作用域的集合中。如果有,则忽略该声明,进行后续编译。否则会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a
  2. 编译器为引擎生成运行时所需要的代码,用于处理 a = 2这个赋值语句。引擎运行时,会询问作用域,当前作用域集合中是否存在一个叫做a的变量。如果时,则使用这个变量;否,引擎将继续向上层集合中查找这个变量

引擎对于变量a进行的查找称为LHS查询,另外一个查找类型叫做RHS。

作用域嵌套

当一个块和函数嵌套在另一个块或函数中时,就会发生作用域嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,知道找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。

词法作用域

作用域共有两种主要的工作模式。第一种是最为普遍的,被大多数编程语言所采用的词法作用域。另一种则相对小众,叫做动态作用域。

词法阶段

大部分标准语言编译器的第一个阶段叫做词法化,词法化的概念是理解词法作用域及其名称来历的基础。
简单得说,词法作用域就是定义在词法阶段的作用域,由写代码时将变量和块作用域写在哪里来决定。

查找

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫做“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定

词法作用域查找只会查找一级标识符。如果代码中引用了foo.bar.baz,那么词法作用域支付则查找到foo标识符。找到这个变量后,对象属性访问规则会分别接管对bar和baz的访问。

函数作用域和块作用域

函数中的作用域

每声明一个函数,都会创建一个函数作用域。函数作用域是指,属于这个函数的全部变量都可以在整个函数的范围内使用及服用。
因此我们可以通过在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何东西。但是这种方式同样会创造一个函数名,对命名空间造成“无法”。为此,JS有两种解决方案
匿名函数表达式:

1
2
3
setTimeout(function() {
console.log('aaa');
}, 1000);

立即执行函数表达式:

1
2
3
4
5
6
var a = 2;
(function IIFE() {
var a = 3;
console.log(a); // 3
})();
console.log(a); // 2

块作用域

1
2
3
for (let i = 0; i < 10; i++) {
console.log(i); // 块作用域
}

块作用域的作用就是将变量的声明尽可能的靠近使用的地方,并最大限度地本地化。
块作用域需要使用let关键字定义,var声明的变量,最终都会是属于外部作用域的。此外还可以使用const来定义块作用域变量,它定义的变量是常量变量。

声明提升

使用var声明变量,会导致声明提示,而let和const则不会。
因为对于编译器来说,变量的声明会在编译阶段进行,而赋值声明则会被留在引擎执行阶段执行

1
2
3
4
5
6
7
console.log(a); // undefined
var a = 2

// 其实对于编译器来说
var a; // 声明提升
console.log(a); // undefined
a = 2;

函数优先

1
2
3
4
5
6
7
8
9
10
11
foo(); // 1
var foo;
function foo() {
console.log(1);
}

foo = function() {
console.log(2);
}

foo(); // 2

这样的执行结果是因为函数声明和变量声明都会提升,且函数声明会首先提升

1
2
3
4
5
6
7
8
9
10
function foo() {
console.log(1);
}
var foo; // foo已经声明,这段声明会被忽略
foo(); // 1
foo = function() {
console.log(2);
}

foo(); // 2

作用域闭包

当函数可以记住并访问在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域外执行的。

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var a = 2;

function bar() {
console.log(a);
}
return bar;
}

var baz = foo();
baz(); // 2 闭包的效果

函数bar的词法作用域能够访问foo内部的作用域。然后我们调用foo函数,将函数bar传出。这个时候foo虽然已经执行完成,但foo内部的作用域不会被销毁,因为baz所指向的bar函数仍保持对这块作用域的使用。需要等所有指向bar函数的变量被释放,bar函数被回收,foo的作用域才能得到释放。

无论通过任何手段将能访问外部作用的函数传递到所在的词法作用域外,它都会保持对原始定义作用域的引用(及形成闭包),无论在任何处执行这个函数都会使用闭包

1
2
3
4
5
6
7
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}

wait("hello world");

这里也有一个闭包,在wait函数执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait作用域的闭包。实际上,只要使用了回调函数,就是在使用闭包。

闭包一个最经典的例子就是处理下面这种循环

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

这只会输出五次六。因为在这个例子里,i和五个回调函数都在一个共享的全局作用域里,五个函数使用的是同一个i。

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

这样就形成了5个闭包,每个闭包内包裹着传入的当时的i,这也是为什么要将i以参数的形式穿进去。否则,虽然形成了5个闭包,但是闭包内是空的,导致最后执行的时候,查询到的值还是在全局作用域中的那个唯一的i。

不过有了let 以后,就不再需要这么复杂的写法了。let会在块作用域中声明变量。

1
2
3
4
5
for(let i = 0; i <=5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}

模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function CoolModule() {
var something = 'cool';
var another = [1, 2, 3];

function doSomething() {
console.log(something);
}

function doAnother() {
console.log(another.jon('-'));
}

return {
doSomething,
doAnother,
}
}

var foo = CoolModule();
foo.doSomething();

这种方式在JS中被称为模块。看起来好像并没有闭包的存在。但是,实际上返回的对象持有内部函数,而内部函数又访问了函数内部变量。所以闭包已经形成了。
模块模式需要具备两个必要条件:

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态

在ES6中,文件会被当作独立的模块处理。每个模块都可以导入其他模块或特定的API成员,也可以导出自己的API成员。