JavaScript 作用域
LHS & RHS
编译器在遇到一个变量
a
时,会去查询作用域中是否存在a
。但是有两种不同查询方式,考虑如下代码:
function(){
a = 5;
}
console.log(a) //5
a 是一个未声明的变量,实际上会隐含地创建一个全局变量,再看另一个:
let b = a; //TypeError: a is not defined
问题:同样是查询没有声明的变量,为什么结果不一样?
答:对于变量,编译器有两种处理方式:LHS(left hand side)、RHS(right hand side)
LHS
赋值号左边的查询,实际上是找一个名叫 b 的容器,如果在当前作用域没有找到,
就向上一级作用域查询,直到顶级作用域,如果顶级作用域还没有,那就隐式创建一个并返回
RHS
赋值号右边的查询,“RHS”意味着“取得他/她的源(值)”,暗示着 RHS 的意思是“去取……的值”。
考虑如下代码:
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
调用 foo(..)
的最后一行作为一个函数调用要求一个指向 foo
的 RHS 引用,意味着,“去查询 foo
的值,并把它交给我”。另外,(..) 意味着 foo
的值应当被执行,所以它最好实际上是一个函数!
这里有一个微妙但重要的赋值。你发现了吗?
你可能错过了这个代码段隐含的 a = 2
。它发生在当值 2 作为参数值传递给 foo(..)
函数时,值 2 被赋值 给了参数 a
。为了(隐含地)给参数 a
赋值,进行了一个 LHS 查询。
这里还有一个 a
的值的 RHS 引用,它的结果值被传入 console.log(..)。console.log(..) 需要一个引用来执行。它为 console 对象进行一个 RHS 查询,然后发生一个属性解析来看它是否拥有一个称为 log 的方法。
小结:
JavaScript 引擎 在执行代码之前首先会编译它,因此,它将 var a = 2; 这样的语句分割为两个分离的步骤:
- 首先,var a 在当前 作用域 中声明。这是在最开始,代码执行之前实施的。
- 稍后,a = 2 查找这个变量(LHS 引用),并且如果找到就向它赋值。
LHS 和 RHS 引用查询都从当前执行中的 作用域 开始,如果有需要(也就是,它们在这里没能找到它们要找的东西),它们会在嵌套的 作用域 中一路向上,一次一个作用域(层)地查找这个标识符,直到它们到达全局作用域(顶层)并停止,既可能找到也可能没找到
词法作用域
作用域的工作方式有两种占统治地位的模型。其中的第一种是最最常见,在绝大多数的编程语言中被使用的。它称为 词法作用域,我们将深入检视它。另一种仍然被一些语言(比如 Bash 脚本,Perl 中的一些模式,等等)使用的模型,称为 动态作用域。
词法作用域是 JavaScript 所采用的作用域模型。
看代码:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12
这里有三层作用域: 最外面一层、foo、bar
在上面的代码段中,引擎 执行语句 console.log(..) 并开始查找三个被引用的变量 a,b 和 c。它首先从最内部的作用域气泡开始,也就是 bar(..) 函数的作用域。在这里它找不到 a,所以它向上走一层,到外面下一个最近的作用域气泡,foo(..) 的作用域。它在这里找到了 a,于是它就使用这个 a。同样的事情也发生在 b 身上。但是对于 c,它在 bar(..) 内部就找到了
一旦找到第一个匹配,作用域查询就停止了
注意:全局变量也自动地是全局对象(在浏览器中是 window,等等)的属性,所以不直接通过全局变量的词法名称,而通过将它作为全局对象的一个属性引用来间接地引用,是可能的
window.a
欺骗词法作用域 eval()
词法作用域是由函数被声明的位置唯一定义的,而且这个位置完全是一个编写时的决定。
但是! 不完全是这样!
JavaScript 中的 eval(..) 函数接收一个字符串作为参数值,并将这个字符串的内容看作是好像它已经被实际编写在程序的那个位置上。
换句话说,你可以用编程的方式在你编写好的代码内部生成代码,而且你可以运行这个生成的代码,就好像它在编写时就已经在那里了一样
function foo(str, a) {
eval( str ); // 作弊!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1 3
-
默认情况下,如果 eval(..) 执行的代码字符串包含一个或多个声明(变量或函数)的话,这个动作就会修改这个 eval(..) 所在的词法作用域。
-
当 eval(..) 被用于一个操作它自己的词法作用域的 strict 模式程序时,在 eval(..) 内部做出的声明不会实际上修改包围它的作用域
function foo(str) { "use strict"; eval( str ); console.log( a ); // ReferenceError: a is not defined } foo( "var a = 2" );
JavaScript 引擎 在编译阶段期行许多性能优化工作。其中的一些优化原理都归结为实质上在进行词法分析时可以静态地分析代码,并提前决定所有的变量和函数声明都在什么位置,这样在执行期间就可以少花些力气来解析标识符。
但是!使用eval()会让编译器假定自己知道的所有的标识符的位置可能是无效的,因为它不可能在词法分析时就知道你将会向eval(..)传递什么样的代码来修改词法作用域
尽可能避免使用 eval() ,为了性能!
函数与块儿作用域
隐藏于普通作用域
拿你所编写的代码的任意一部分,在它周围包装一个函数声明,这实质上“隐藏”了这段代码。
有多种原因驱使着这种基于作用域的隐藏。它们主要是由一种称为“最低权限原则”的软件设计原则引起的note-leastprivilege
,有时也被称为“最低授权”或“最少曝光”
将变量和函数“隐藏”在一个作用域内部的另一个好处是,避免两个同名但用处不同的标识符之间发生无意的冲突。冲突经常导致值被意外地覆盖。
函数作为作用域
我们可以拿来一段代码并在它周围包装一个函数,而这实质上对外部作用域“隐藏”了这个函数内部作用域包含的任何变量或函数声明
var a = 2;
function foo() { // <-- 插入这个
var a = 3;
console.log( a ); // 3
} // <-- 和这个
foo(); // <-- 还有这个
console.log( a ); // 2
虽然这种技术“可以工作”,但它不一定非常理想。它引入了几个问题。首先是我们不得不声明一个命名函数 foo(),这意味着这个标识符名称 foo 本身就“污染”了外围作用域(在这个例子中是全局)。我们要不得不通过名称(foo())明确地调用这个函数来使被包装的代码真正运行。
幸运的是,JavaScript 给这两个问题提供了一个解决方法--IIFE
var a = 2;
(function foo(){ // <-- 插入这个
var a = 3;
console.log( a ); // 3
})(); // <-- 和这个
console.log( a ); // 2
当然也可以这样写:
var a = 2;
(function IIFE( global ){
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2
块儿作为作用域
我们经常不经意间声明了全局变量:
for (var i=0; i<10; i++) {
console.log( i );
}
console.log(i) // 10
let
let 关键字将变量声明附着在它所在的任何块儿(通常是一个 { .. })的作用域中。换句话说,let 为它的变量声明隐含地劫持了任意块儿的作用域。
var foo = true;
if (foo) {
{ // <-- 明确的块儿
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}
console.log( bar ); // ReferenceError
注意:var 会有声明提前,然而,使用 let 做出的声明将 不会 在它们所出现的整个块儿的作用域中提升。如此,直到声明语句为止,声明将不会“存在”于块儿中
{
console.log( bar ); // ReferenceError!
let bar = 2;
}
关于 var 和 let
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000)
} // 隔一秒输出, 5 个 6
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000)
} // 隔一秒输出 1,2,3,4,5
上面的代码展示了 var 和 let 的不同。 let 声明时保存了当时的环境,保留了每一个值,下一次循环再在(另一个"块")创建一个另外的变量
而 var 从头到尾只有一个变量,并且输出时值为 6