Fork me on GitHub

JS学习系列 02 - 词法作用域

static-scope

两种作用域

“作用域”我们知道是一套规则,用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。

作用域有两种主要工作模型:词法作用域动态作用域

大多数语言采用的都是词法作用域,少数语言采用动态作用域(例如 Bash 脚本),这里我们主要讨论词法作用域。

词法

大部分标准语言编译器的第一个工作阶段叫作词法化
简单地说,词法作用域是由你在写代码时将变量和函数(块)作用域写在哪里来决定的。当然,也会有一些方法来动态修改作用域,后边我会介绍。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = 2;

function foo1 () {
console.log(a);
}

function foo2 () {
var a = 10;

foo1();
}

foo2();

这里输出结果是多少呢?

注意,这里结果打印的是 2

可能会有一些同学认为是 10,那就是没有搞清楚词法作用域的概念。
前边介绍了,词法作用域只取决于代码书写时的位置,那么在这个例子中,函数 foo1 定义时的位置决定了它的作用域,通过下图理解:

词法作用域

foo1 和 foo2 都是分别定义在全局作用域中的函数,它们是并列的,所以在 foo1 的作用域链中并不包含 foo2 的作用域,虽然在 foo2 中调用了 foo1,但是 foo1 对变量 a 进行 RHS 查询时,在自己的作用域没有找到,引擎会去 foo1 的上级作用域(也就是全局作用域)中查找,而并不会去 foo2 的作用域中查找,最终在全局作用域中找到 a 的值为 2。

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

欺骗词法

JavaScript 中有 3 种方式可以用来“欺骗词法”,动态改变作用域。

第一种: eval

JavaScript 中 eval(…) 函数可以接受一个字符串作为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。

在执行 eval(…) 之后的代码时,引擎并不知道或在意前面的代码是以动态形式插入进来并对词法作用域环境进行修改的,引擎只会像往常一样正常进行词法作用域的查找。

举个例子:

1
2
3
4
5
6
7
8
9
function foo (str) {
eval(str); // "欺骗"词法

console.log(a);
}

var a = 2;

foo("var a = 10;");

如大家所想,输出结果为 10。
因为 eval(“var a = 10;”) 在 foo 的作用域中新创建了一个同名变量 a,引擎在 foo 作用域中对 a 进行 RHS 查询,找到了新定义的 a,值为 10,所以不再向上查找全局作用域中的 a,所以导致输出结果为 10,这就是 eval(…) 的作用。

严格模式下,eval(…) 在运行时有自己的词法作用域,意味着其中的声明无法修改所在的作用域。

1
2
3
4
5
6
7
8
9
10
11
'use strict;'

function foo (str) {
eval(str); // eval() 有自己的作用域,所以并不会修改 foo 的词法作用域

console.log(a);
}

var a = 2;

foo("var a = 10;");

这里输出结果为 2。

JavaScript 中还有一些功能和 eval(…) 类似的函数,例如 setTimeout(…) 和 setInterval(…) 的第一个参数可以是一个字符串,字符串的内容可以解释为一段动态生成的代码。这些功能已经过时并且不被提倡,最好不要使用它们。new Function(…) 函数的最后一个参数也可以接受代码字符串,并将其转化为动态生成的函数,也尽量避免使用。

在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损失。

第二种: with
with 通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
var obj = {
a: 2,
b: 3
};

with (obj) {
console.log(a); // 2
console.log(b); // 3
c = 4;
};

console.log(c); // 4, c 被泄露到全局作用域上

如上所示,我们对 c 进行 LHS 查询,因为在 with 引入的新作用域中没有找到 c,所以向上一级作用域(这里是全局作用域)查找,也没有找到,在非严格模式下,在全局对象中新建了一个属性 c 并赋值为 4。

with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。

尽管 with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var 声明并不会限制在这个块作用域中,而是被添加到 with 所处的函数作用域中。

严格模式下,with 被完全禁止使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
'use strict';

var obj = {
a: 2,
b: 3
};

with (obj) {
console.log(a);
console.log(b);
c = 4;
};

console.log(c);

严格模式下禁止使用with

第三种: try…catch
try…catch 可以测试代码中的错误。try 部分包含需要运行的代码,而 catch 部分包含错误发生时运行的代码。

举个例子:

1
2
3
4
5
6
7
8
9
10
try {
foo();
} catch (err) {
console.log(err);

var a = 2;
// 打印出 "ReferenceError: foo is not defined at <anonymous>:2:4"
}

console.log(a); // 2

当 try 中的代码出现错误时,就会进入 catch 块,此时会把异常对象添加到作用域链的最前端,类似于 with 一样,catch 中定义的局部变量也都会添加到包含 try…catch 的函数作用域(或全局作用域)中。

性能

JavaScript 引擎会在编译阶段进行数项性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数定义的位置,才能在执行过程中快速找到标识符。

但如果引擎在代码中发现了 eval(…)、with 和 try…catch ,它只能简单的假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval(…) 会接受到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么。

最悲观的情况是如果出现了这些动态添加作用域的代码,所有的优化可能都是无意义的,因此最简单的做法就是完全不进行任何优化。

如果代码中大量使用 eval(…) 和 with,那么运行起来一定会变得非常缓慢。

结论

很多时候我们对代码的分析出错,就是源于对词法作用域的忽略,所以让我们重新审视代码,继续努力!