作用域是什么?
所有的编程语言都可以存储,访问,修改变量。但是这些变量如何存储,程序如何找到并且能够使用它们?这些问题需要设计一套规则,这套规则就被称为我们所熟知的作用域。
了解JavaScript
在介绍作用域之前,先来了解JavaScript这门语言,通常百科的说法是JavaScript是一种高级的,解释执行的编程语言。但事实上它也是一门编译语言。也需要经历传统编译语言的步骤。词法分析、语法分析、代码生成这三个步骤统称为“编译”。对于JavaScript来说,大部分情况下编译发生在代码执行前的几微秒的时间内。
作用域如何工作
这里就要说到JavaScript的工作原理,JavaScript工作时由引擎,编译器以及作用域共同完成。例如var a = 1;,我们来简单分析一下。
- 遇到
var a编译器首先询问作用域是否已经有一个该名称的变量存在同一个作用域的集合中。如果有,则编译器忽略该声明,继续编译;反之它会要求作用域在当前作用域的集合中声明一个新的变量a。 - 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理
a=1这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个a的变量。如果是,引擎就会使用这个变量;反之引擎继续查找该变量。 - 如果引擎找到了
a变量,就会将1赋值给它。反之引擎就会抛出一个异常。
作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量或抵达最外层的作用域(全局作用域)为止。
function foo(b) {
return a + b;
}
var a = 1;
foo(2); // 3
引擎会在foo的作用域中寻找a,没有找到该变量,继续向上层寻找也就是全局作用域,然后在全局作用域中寻找到变量a。引擎在遍历过程中,会产生一个作用域链。作用域链的用途,确保变量和函数有规则的访问。
JavaScript作用域
词法作用域
词法作用域就是定义在词法阶段阶段的作用域。直观的说法就是词法作用域是由你写代码时将变量和块作用域写在哪里来决定的。定义比较抽象,这里举例说明。
function foo(a) {
var b = a + 1;
function bar(b) {
var c = b + 1;
console.log(a, b, c);
}
bar(b);
}
var a = 1;
foo(a); //1, 2, 3
为了帮助理解,可以想象成逐级包含的气泡。如图所示。

- 1中包含着整个作用域,其中有两个标识符a,foo。
- 2中包含foo所创建的作用域,其中有两个标识符b,bar。
- 3中包含bar所创建的作用域,其中有一个标识符c。
当引擎console.log(a, b, c)执行时。它首先从最内部的作用域,也就是bar()函数的作用域气泡开始查找。引擎无法在找到a,因此会继续遍历到上层foo()函数的作用域查找。还是没有找到a,引擎继续向上遍历查找,在全局作用域中找到了a,引擎就会使用这个引用。同理b和c一样引擎重复a的方式进行查找。
作用域查找会在找到第一个匹配的标识符时停止。
此外还有2种修改词法作用域的方法eval()和with。使用这两个方法会对性能产生影响。因为JavaScript引擎会在编译阶段进行性能优化,其中一些优化依赖代码的词法分析,如果使用eval()和with其中的代码无法得到优化。这里就不展开说明官方文档都有很详细的说明eval(),with。
函数作用域
JavaScript中最常见的就是基于函数的作用域,每声明一个函数都会为其自身创建一个作用域气泡。
function foo(a) {
var b = 2;
function bar() {
var c = 3;
}
}
这个代码片段中,foo()的作用域中包含了标识符a、b、c和bar,全局作用域中包含一个标识符foo。由于标识符a、b、c和bar都属于foo()的作用域,因此无法在外部对它们进行访问。也就是说在全局作用域中进行访问,下面代码会导致错误:
console.log(a, b, c);
bar();
函数作用域的含义是指,属于这个函数的标识符都可以在整个函数的范围内使用及复用。
隐藏内部实现
对于函数的认知先声明一个函数,然后向里面添加代码。如果反过来,从代码中挑选一个片段,然后用函数声明对它进行包装。实际就是把这段代码内部“隐藏”起来。并且这个代码片段拥有自己的作用域。在实际开发中有很多情况也会使用这种作用域的隐藏方法。比如某个模块或API设计,只对外暴露方法和接口,不暴露内部的实现方法和变量。例如:
function foo(a) {
b = a + bar(1);
console.log(b * 2);
}
function bar(c) {
return c + 1;
}
var b;
foo(3);
在这段代码中变量b和函数bar()应该是函数foo()内部的具体实现内容,但是外部作用域也有访问
b和bar()的权限。因为它们有可能被有意或无意地以非预期的方式使用。这里需要更合理的设计,例如:
function foo(a) {
function bar(c) {
return c + 1;
}
var b;
b = a + bar(1);
console.log(b * 2);
}
foo(3);
现在变量b和函数bar()都无法从外部直接被访问,只能在foo()中使用,功能和结果都没有受影响。设计良好的软件都会将一些内容私有化。
变量冲突
隐藏内部实现的另一个好处就是可以避免同名标识符的冲突,这在软件设计中很常见,两个标识符可能具有相同的名字但是用途却完全不一样。无意间导致命名冲突,变量的值被意外覆盖。例如:
function foo() {
function bar(a) {
i = 5; //修改循环作用域i
console.log(a + i);
}
var i = 1;
while(i < 10) {
bar(i); //无限循环了
i ++;
}
}
foo();
bar()内部的赋值语句i = 5意外地覆盖了声明在foo()函数中的i,导致无限循环。bar()内部需要声明一个本地变量来使用或者采用一个完全不同的标识符,例如var j = 5,这样就能避免变量冲突。
块作用域
尽管大部分情况都普遍使用函数作用域,但也存在块作用域。
with
with这里不做详细说明,with可以查看mdn官方文档。
try/catch
try {
empty(); // 执行一个不存在的方法来抛出异常
} catch (error) {
console.log(error); // 能够正常执行!
}
console.log(error); // Uncaught ReferenceError: error is not defined
try/catch中catch语句会创建一个块作用域,其中声明的变量只能在catch中访问。
let
ES6引入了新的let关键字,let语句声明一个块级作用域的本地变量,并且可选的将其初始化为一个值。let关键字可以将变量绑定到所在的任意作用域中(通常用{...})。例如:
function letTest() {
let x = 1;
if (true) {
let x = 2; // 不同的变量
console.log(x); // 2
}
console.log(x); // 1
}
letTest();
const
ES6还引入了const关键字,声明一个块级作用域常量,其值是固定不可更改的,常量的值不能通过重新赋值来改变,并且不能重新声明。试图修改值的操作都会报错。
function constTest() {
if(true) {
var a = 1;
const b = 2;
a = 3;
b = 4; // Uncaught TypeError: Assignment to constant variable.
}
console.log(a);
console.log(b);// Uncaught ReferenceError: b is not defined
}
constTest();
参考
结尾
学习JavaScript也有几年了,一直都是很零碎的学习。写此文的目的一方面是写给自己看的笔记,一方面也是对知识的总结。