作用域
1. 作用域是什么
1.1 编译原理
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”
-
分词/词法分析
这个过程会将由字符组成的字符串分解成有意义的代码块。var a = 2;。这段程序通常会被分解成下面这些词法单元:var、a、=、2、;。空格是否会被当做词法单元,取决于空格在这门语言中是否有意义。
-
解析/语法分析
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所泽成的代表了程序语法结构的树。这个树被称为“抽象语法树”(AST)。 var a = 2;的抽象语法树中可能会有一个叫做 VerialbleDeclaration 的顶级节点,接下来是一个叫做 Identidfier(他的值是a)的子节点,以及一个叫做 AssignmentExpression 的子节点。AssignmentExpression 节点有一个叫做 NumericLiteral(它的值是2)的子节点。
-
代码生成
将 AST 转换为可执行代码的过程称为代码生成。这个过程于语言、目标平台等息息相关。
简单来说,就是由某种方法可以将 var a = 2; 的 AST 转换为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。
javascript的不同
首先,JavaScript 引擎不会由大量的(像其他语言编译器那么多的)时间用来进行优化,因为与其他原因不同,JavaScript 的编译过程不是发生在构建之前的。
对于 JavaScript 来说,大部分编译发生在代码执行前的几个微秒(甚至更短)的时间内。在我们所要讨论的作用域背后,JavaScript 引擎用尽了各种方法(比如 JIT, 可以延迟编译甚至实施重编译)来保证性能最佳。
简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。因此,JavaScript 编译器首先会对 var a = 2; 这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。
1.2 理解作用域
- 引擎(从头到尾负责整个 JavaScript 程序的编译及执行过程)
- 编译器(负责语法分析及代码生成)
- 作用域(负责收集并维护所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限)
var a = 2;
编译器首先分解称词法单元,然后解析成一个树结构。但代码生成有点不同。
-
遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a;
-
接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的变量。如果是,引擎就会使用这个变量;如果否,引擎就会继续查找该变量。
小小结:
编译器做了什么
- 分解词法单元
- 解析成树结构
- 代码生成
- (var)询问作用域,忽略或声明
- 生成代码给引擎执行
引擎做了什么
- 赋值操作
- 运行时,首先询问作用域,是否存在 a 的变量
作用域做了什么
- 为编译器和引擎提供服务(收集并提供查询)
LHS(左侧查询) RHS(非左侧查询)
LHS 查询是找到变量的容器本身。
RHS 查询时找到变量的值。
console.log(a);
上面的是 RHS
function foo(a) {
console.log(a) // 2
}
foo(2);
看看上面几次 LHS 和 RHS
- foo() // RHS
- a = 2 // LHS
- console // RHS
- log // RHS
- a // RHS
- ...
小测验
- 找到其中所有的 LHS 查询
- 找到其中所有的 RHS 查询
function foo(a) {
var b = a;
return a + b;
}
var c = foo(2)
答案:
- foo() // RHS
- a = 2 // LHS
- a // RHS
- b = a // LHS
- a + b // 2个RHS
- c = // LHS
1.3 作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域为止。
小结
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。
JavaScript 引擎首先会在代码执行前对其进行编译,在这个过程中,var a = 2 会被分解成两个独立的步骤;
- 代码执行前,var a 在器作用域中声明变量
- 接下来,a = 2(LHS)进行赋值
LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要,就会像上级作用域继续查找目标标识符,这样每次上升一级作用域,最后抵达全局作用域,无论找到或没找到都将停止。
不成功的 RHS 应用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式创建一个全局变量。
词法作用域
作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域,我们会对这种作用域进行深入讨论。另外一种叫作动态作用域,仍有一些编程语言在使用(比如 Bash脚本,Perl 中的一些模式)
词法阶段
大部分标准语言编译器的第一个工作阶段叫作词法化。词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。
词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变。
// 1作用域
function foo(a) {
// 2作用域
var b = a * 2;
function bar(c) {
// 3作用域
console.log(a, b, c);
}
bar(b * 3);
}
foo(2); // 2,4,12
- 包含着整个全局作用域,其中只有一个标识符:foo
- 包含 foo 所创建的作用域,其中三个标识符:a b bar
- 包含 bar 所创建的作用域,其中一个标识符:c
作用域查找会在找到第一匹配的标识符时停止。所以在外层的同名的标识符会被 遮蔽,这叫做 遮蔽效应。
欺骗词法
- eval
- with
函数作用域和块作用域
最常见的作用域,函数作用域。
作用域里,无论标识符声明出现在作用域的何处,这个标识符所代表的变量或函数都将附属于所处作用域里。作用域外部无法访问这些标识符。
隐藏内部实现
从缩写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际就是把这些代码“隐藏”起来了。 最小授权 或 最小暴露 原则。
规避冲突
可以避免同名标识符之间的冲突。
函数作用域
匿名和具名
立即执行函数表达式
(function () {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})()
倒置代码的运行模式,将需要运行的函数放在第二位,在IIFE执行之后当作参数传递进去。
块作用域
除了函数作用域之外,还有块作用域。
for(var i=0; i<0; i++) {
console.log(i)
}
var foo = true;
if (foo) {
// ....
}
但在块作用域里面声明的变量,会污染全局函数。
let
let 的出现,解决了块作用域的声明污染全局。
let 关键字可以将变量绑定在所在的任意作用域中(通常是 {} 内部)。换句话说,let 为其声明的变量隐式地了所在的块作用域。
用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的。
使用 let 进行的声明不会在块作用域中提升。声明的代码被运行之前,声明比不“存在”。
-
垃圾收集 块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。
-
let 循环 for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
const
除了 let 以外,ES6 还引入了 const,同样可以用来创建作用域变量,但其值是固定的。之后任何视图修改值的操作都会引起错误。
小结
-
作用域是什么 为编译器和引擎提供服务(收集并提供查询),约等于变量的词典。
- 分词/词法分析
- 解析/语法分析
- 代码生成
LHS,RHS
-
函数作用域 当局部作用域找不到变量时,会向上查找,最后找到全局作用域。 外层的作用域不能访问内层的作用域。
-
块作用域 块作用域里面用 var,声明会影响到全局作用域。
let,const