作用域是什么
概念
- 引擎
- 编译器
- 作用域
程序执行过程
执行以下代码:
var a = 2;
步骤:
- 编译器执行:遇到var a,询问作用域是否已经有一个a变量存在同一个作用域的集合中,若是,忽略声明;若否,要求作用域在当前作用域的集合中声明一个新的变量,命名为a
- 引擎执行:a=2,询问作用域是否已经有一个a变量存在同一个作用域的集合中,若是,引擎使用这个变量;若否,引擎会继续向上查找该变量。
如果引擎最终找到了 a 变量, 就会将 2 赋值给它。 否则引擎就会举手示意并抛出一个异常!
LHS & RHS
当变量出现在赋值操作的左侧时进行 LHS 查询, 出现在右侧时进行 RHS 查询。讲得更准确一点, RHS 查询与简单地查找某个变量的值别无二致, 而 LHS 查询则是试图找到变量的容器本身, 从而可以对其赋值。 从这个角度说, RHS 并不是真正意义上的“赋值操作的右侧”, 更准确地说是“非左侧”。
可以将 RHS 理解成 retrieve his source value(取到它的源值), 这意味着“得到某某的值”。
例如:
console.log(a);
这里对a没有赋予任何值,相应地,只需查找并取得a的值,再把值传给console.log(…)。
相比之下,
a = 2;
这里对a的引用则是LHS引用,因为实际上我们并不关心当前的值时什么,只是想要为*=2*这个赋值操作找到一个目标。
练习
找出下面代码中所有的LHS和RHS
function foo(a) {
var b = a;
return a + b;
}
var c = foo(2);
答案:
作用域嵌套
当一个快或函数嵌套在另一个块或函数中时,就发生了作用域嵌套。 因此, 在当前作用域中无法找到某个变量时, 引擎就会在外层嵌套的作用域中继续查找, 直到找到该变量,或抵达最外层的作用域(也就是全局作用域) 为止。
例如:
function foo(a){
return a+b;
}
var b = 2;
foo(2); // 4
对 b 进行的 RHS 引用无法在函数 foo 内部完成, 但可以在上一级作用域(在这个例子中就是全局作用域) 中完成。
遍历嵌套作用域链的规则: 引擎从当前的执行作用域开始查找变量, 如果找不到,就向上一级继续查找。 当抵达最外层的全局作用域时, 无论找到还是没找到, 查找过程都会停止。
这个建筑代表程序中的嵌套作用域链。 第一层楼代表当前的执行作用域, 也就是你所处的位置。 建筑的顶层代表全局作用域。
LHS 和 RHS 引用都会在当前楼层进行查找, 如果没有找到, 就会坐电梯前往上一层楼,如果还是没有找到就继续向上, 以此类推。 一旦抵达顶层(全局作用域), 可能找到了你所需的变量, 也可能没找到, 但无论如何查找过程都将停止。
异常
ReferenceError:如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量, 引擎就会抛出 ReferenceError异常。例如:
function foo(a){
console.log(a+b);
b = a;
}
foo(2);
相较之下, 当引擎执行 LHS 查询时, 如果在顶层(全局作用域) 中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量, 并将其返还给引擎, 前提是程序运行在非“严格模式” 下。
TypeError: 如果 RHS 查询找到了一个变量, 但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用, 或着引用 null 或 undefined 类型的值中的属性, 那么引擎会抛出TypeError。
ReferenceError 同作用域判别失败相关, 而 TypeError 则代表作用域判别成功了, 但是对结果的操作是非法或不合理的。
词法作用域
词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。 编译的词法分析阶段
基本能够知道全部标识符在哪里以及是如何声明的, 从而能够预测在执行过程中如何对它
们进行查找。
欺骗词法
eval
JavaScript 中的 eval(…) 函数可以接受一个字符串为参数, 并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。 换句话说, 可以在你写的代码中用程序生成代码并运行, 就好像代码是写在那个位置的一样。
根据这个原理来理解 eval(…), 它是如何通过代码欺骗和假装成书写时(也就是词法期)代码就在那, 来实现修改词法作用域环境的, 这个原理就变得清晰易懂了。
在执行 eval(…) 之后的代码时, 引擎并不“知道” 或“在意” 前面的代码是以动态形式插入进来, 并对词法作用域的环境进行修改的。 引擎只会如往常地进行词法作用域查找。
考虑以下代码:
function foo(str,a){
eval(str); // 欺骗
console.log(a,b);
}
var b = 2;
foo("var b = 3;",1); // 1, 3
eval(…) 调用中的 “var b = 3;” 这段代码会被当作本来就在那里一样来处理。 由于那段代码声明了一个新的变量 b, 因此它对已经存在的 foo(…) 的词法作用域进行了修改。 事实上, 和前面提到的原理一样, 这段代码实际上在 foo(…) 内部创建了一个变量 b, 并遮蔽了外部(全局) 作用域中的同名变量。
当 console.log(…) 被执行时, 会在 foo(…) 的内部同时找到 a 和 b, 但是永远也无法找到外部的 b。 因此会输出“1, 3” 而不是正常情况下会输出的“1, 2”。
在严格模式的程序中, eval(…) 在运行时有其自己的词法作用域, 意味着其中的声明无法修改所在的作用域。
with
var obj={
a: 1,
b: 2,
c: 3,
};
//修改方式一
obj.a = 2;
obj.b = 3;
obj.c = 4;
//修改方式二:with
with (obj){
a = 3;
b = 4;
c = 5;
}
考虑下面代码
function foo(obj){
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo(o1);
console.log(o1.a); // 2
foo(o2);
console.log(o2.a); // undefined
console.log(a); //2-----a被泄漏到全局作用域上了!
with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域, 因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。
尽管 with 块可以将一个对象处理为词法作用域, 但是这个块内部正常的 var声明并不会被限制在这个块的作用域中, 而是被添加到 with 所处的函数作用域中。
可以这样理解, 当我们传递 o1 给 with 时, with 所声明的作用域是 o1, 而这个作用域中含有一个同 o1.a 属性相符的标识符。 但当我们将 o2 作为作用域时, 其中并没有 a 标识符,因此进行了正常的 LHS 标识符查找。
函数作用域和块作用域
函数作用域
函数作用域的含义是指, 属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。 这种设计方案是非常有用的, 能充分利用JavaScript 变量可以根据需要改变值类型的“动态” 特性。例如:
function foo(a){
var b = 2;
function bar(){}
var c = 3;
}
bar(); // ReferenceError
console.log(a,b,c);// ReferenceError
规避冲突
“隐藏” 作用域中的变量和函数所带来的另一个好处, 是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样, 无意间可能造成命名冲突。 冲突会导致变量的值被意外覆盖。例如:
function foo(){
function bar(a){
i = 3;// 修改 for 循环所属作用域中的 i
console.log( a + i );
}
for(var i = 0; i<10; i++){
bar( i*2 );// // 无限循环了
}
}
bar(…) 内部的赋值表达式 i = 3 意外地覆盖了声明在 foo(…) 内部 for 循环中的 i。 在这个例子中将会导致无限循环, 因为 i 被固定设置为 3, 永远满足小于 10 这个条件.
解决方法一是可以在bar(…)中,var i,用来遮蔽变量;二是换一个变量名j。但是软件设计在某种情况下可能自然
而然地要求使用同样的标识符名称, 因此在这种情况下使用作用域来“隐藏” 内部声明是
唯一的最佳选择。
1.全局命名空间
变量冲突的一个典型例子存在于全局作用域中。 当程序中加载了多个第三方库时, 如果它们没有妥善地将内部私有的函数或变量隐藏起来, 就会很容易引发冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量, 通常是一个对象。 这个对象被用作库的命名空间, 所有需要暴露给外界的功能都会成为这个对象(命名空间) 的属性, 而不是将自己的标识符暴漏在顶级的词法作用域中。
var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};
2. 模块管理
另外一种避免冲突的办法和现代的模块机制很接近, 就是从众多模块管理器中挑选一个来使用。 使用这些工具, 任何库都无需将标识符加入到全局作用域中, 而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。
显而易见, 这些工具并没有能够违反词法作用域规则的“神奇” 功能。 它们只是利用作用域的规则强制所有标识符都不能注入到共享作用域中, 而是保持在私有、 无冲突的作用域中, 这样可以有效规避掉所有的意外冲突。
函数作用域
我们已经知道, 在任意代码片段外部添加包装函数, 可以将内部的变量和函数定义“隐藏” 起来, 外部作用域无法访问包装函数内部的任何内容。
如果函数不需要函数名(或者至少函数名可以不污染所在作用域), 并且能够自动运行,这将会更加理想。
1. 立即执行函数
var a = 2;
(function foo() {
var a = 3;
console.log( a ); // 3
})(); //这个
console.log( a ); // 2
接下来我们分别介绍这里发生的事情。
首先, 包装函数的声明以 (function… 而不仅是以 function… 开始。 尽管看上去这并不是一个很显眼的细节, 但实际上却是非常重要的区别。 函数会被当作函数表达式而不是一个标准的函数声明来处理。
区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位置(不仅仅是一行代码, 而是整个声明中的位置)。 如果 function 是声明中的第一个词, 那么就是一个函数声明, 否则就是一个函数表达式。
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
比较一下前面两个代码片段。 第一个片段中 foo 被绑定在所在作用域中, 可以直接通过foo() 来调用它。 第二个片段中 foo 被绑定在函数表达式自身的函数中而不是所在作用域中。
换句话说, (function foo(){ … }) 作为函数表达式意味着 foo 只能在 … 所代表的位置中被访问, 外部作用域则不行。 foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
由于函数被包含在一对 ( ) 括号内部, 因此成为了一个表达式, 通过在末尾加上另外一个( ) 可以立即执行这个函数, 比如 (function foo(){ … })()。 第一个 ( ) 将函数变成表达式, 第二个 ( ) 执行了这个函数。
函数名对 IIFE 当然不是必须的, IIFE 最常见的用法是使用一个匿名函数表达式。 虽然使用具名函数的 IIFE 并不常见, 但它具有上述匿名函数表达式的所有优势, 因此也是一个值得推广的实践。
2. 匿名和具名
对于函数表达式你最熟悉的场景可能就是回调参数了, 比如:
setTimeout(function(){
console.log("I waited 1 second");
},1000);
这叫作匿名函数表达式, 因为 function()… 没有名称标识符。 函数表达式可以是匿名的,而函数声明则不可以省略函数名——在 JavaScript 的语法中这是非法的。
匿名函数表达式书写起来简单快捷, 很多库和工具也倾向鼓励使用这种风格的代码。 但是它也有几个缺点需要考虑。
i. 匿名函数在栈追踪中不会显示出有意义的函数名, 使得调试很困难.
ii. 如果没有函数名, 当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,比如在递归中。 另一个函数需要引用自身的例子, 是在事件触发后事件监听器需要解绑自身。
iii. 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。 一个描述性的名称可以让代码不言自明。
行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。 给函数表达式指定一个函数名可以有效解决以上问题。 始终给函数表达式命名是一个最佳实践:
setTimeout(function timeoutHandler(){// 名字
console.log("I waited 1 second");
},1000);
块作用域
with
我们在第 2 章讨论过 with 关键字。 它不仅是一个难于理解的结构, 同时也是块作用域的一个例子(块作用域的一种形式), 用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。
try/catch
JavaScript 的 ES3 规范中规定 try/catch 的 catch 分句会创建一个块作
用域, 其中声明的变量仅在 catch 内部有效。例如:
try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found
正如你所看到的, err 仅存在 catch 分句内部, 当试图从别处引用它时会抛出错误。
let
let 关键字可以将变量绑定到所在的任意作用域中(通常是 { … } 内部)。 换句话说, let为其声明的变量隐式地了所在的块作用域。
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError
但是使用 let 进行的声明不会在块作用域中进行提升。 声明的代码被运行之前, 声明并不“存在”。
{
console.log( bar ); // ReferenceError!
let bar = 2;
}
1. 垃圾收集
另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。 这里简要说明一下, 而内部的实现原理, 也就是闭包的机制会在第 5 章详细解释。
考虑以下代码:
function process(data) {
// 在这里做点有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
console.log("button clicked");
}, /*capturingPhase=*/false );
click 函数的点击回调并不需要 someReallyBigData 变量。 理论上这意味着当 process(…) 执行后, 在内存中占用大量空间的数据结构就可以被垃圾回收了。 但是, 由于 click 函数形成了一个覆盖整个作用域的闭包, JavaScript 引擎极有可能依然保存着这个结构(取决于具体实现)。
块作用域可以打消这种顾虑, 可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了:
function process(data) {
// 在这里做点有趣的事情
}
// 这个块中定义的内容可以销毁了
{
var someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
console.log("button clicked");
}, /*capturingPhase=*/false );
2. let
由于 let 声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域),当代码中存在对于函数作用域中 var 声明的隐式依赖时, 就会有很多隐藏的陷阱, 如果用let 来替代 var 则需要在代码重构的过程中付出额外的精力。
const
const同样可以用来床架块作用域变量,但其是固定的,之后任何试图修改值的操作都会引起错误。对于基本类型,const固定的是值;对于复杂的类型(对象),固定的是堆中对象的地址,所以是可以修改对象的属性。