前段时间在每天往返的地铁上抽空将 《你不知道的JavaScript(上卷)》读了一遍,这本书很多部分写的很是精妙,对于接触前端时间不太久的人来说,就好像是叩开了JavaScript的另一扇门,很多内容醍醐灌顶!所以决定将这本书分四个部分整理出来,同时也这本书强烈推荐给正在进阶的小伙伴们。这篇博文主要整理第一部分 作用域。
词法作用域
理解作用域
首先要介绍下JS参与程序 var a = 2的处理过程的演员表:
-
引擎
从头到尾负责整个JavaScript 程序的编译及执行过程。
-
编译器
引擎的好朋友之一,负责语法分析及代码生成等脏活累活
-
作用域
引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
在处理过程中,引擎会为变量a 进行LHS 查询。另外一个查找的类型叫作RHS。当变量出现在赋值操作的左侧时进行LHS 查询,出现在右侧时进行RHS 查询。
console.log(a) // RHS查询
引擎与作用域的对话
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
引擎:我说作用域,我需要为foo 进行RHS 引用。你见过它吗?
作用域:别说,我还真见过,编译器那小子刚刚声明了它。它是一个函数,给你。
引擎:哥们太够意思了!好吧,我来执行一下foo。
引擎:作用域,还有个事儿。我需要为a 进行LHS 引用,这个你见过吗?
作用域:这个也见过,编译器最近把它声名为foo 的一个形式参数了,拿去吧。
引擎:大恩不言谢,你总是这么棒。现在我要把2 赋值给a。
引擎:哥们,不好意思又来打扰你。我要为console 进行RHS 引用,你见过它吗?
作用域:咱俩谁跟谁啊,再说我就是干这个。这个我也有,console 是个内置对象。
给你。
引擎:么么哒。我得看看这里面是不是有log(..)。太好了,找到了,是一个函数。
引擎:哥们,能帮我再找一下对a 的RHS 引用吗?虽然我记得它,但想再确认一次。
作用域:放心吧,这个变量没有变动过,拿走,不谢。
引擎:真棒。我来把a 的值,也就是2,传递进log(..)。
作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
两个常见异常
-
如果RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常。值得注意的是,ReferenceError 是非常重要的异常类型。
-
如果RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或着引用null 或undefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作TypeError。
ReferenceError 同作用域判别失败相关,而TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
大部分标准语言编译器的第一个工作阶段叫作词法化,**词法作用域就是定义在词法阶段的作用域.**
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12
上面例子中,有三个嵌套的作用域:
- 整个全局作用域,其中只有一个标识符:foo。
- 包含着foo 所创建的作用域,其中有三个标识符:a、bar 和b。
- 包含着bar 所创建的作用域,其中只有一个标识符:c。
作用域查找会在找到第一个匹配的标识符时停止
######欺骗词法
有些函数会在运行时修改词法作用域,但是欺骗词法作用域会导致性能下降。
- 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,并遮蔽了外部(全局)作用域中的同名变量。但是在严格模式下,eval(..) 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。
-
with
function foo(obj) { with (obj) { a = 2; } } var o2 = { b: 3 }; foo( o2 ); console.log( o2.a ); // undefined console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!
当我们将o2 作为作用域时,其中并没有a 标识符,因此进行了正常的LHS 标识符查找。o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符a,因此当a=2 执行时,自动创建了一个全局变量(因为是非严格模式)。
eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。不推荐使用eval(..) 和with 的原因是会被严格模式所影响(限制)。with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用eval(..) 也被禁止了。
函数作用域与块作用域
函数作用域
在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。
-
函数声明与函数表达式
区分函数声明和表达式最简单的方法是看function 关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
匿名与具名
setTimeout( function() {
console.log("I waited 1 second!");
}, 1000 );
这叫作匿名函数表达式,因为function().. 没有名称标识符。函数表达式可以是匿名的,而函数声明则不可以省略函数名——在JavaScript 的语法中这是非法的。
匿名函数的弊端:
-
匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
-
如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee 引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身
-
匿名函数省略了对于代码可读性/ 可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
setTimeout( function timeoutHandler() { // <-- 快看,我有名字了! console.log( "I waited 1 second!" ); }, 1000 );
立即执行函数表达式(IIFE)
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 );
}
// 为什么要把一个只在for 循环内部使用(至少是应该只在内部使用)的变量i 污染到整个函数作用域中呢?
常用的块级作用域:
-
with
-
try/catch
try { undefined(); // 执行一个非法操作来强制制造一个异常 } catch (err) { console.log( err ); // 能够正常执行! } console.log( err ); // ReferenceError: err not found
-
let/const (这两为ES6最基本的关键字,就不多介绍了,但是很重要!)
提升
先有鸡还是先有蛋
a = 2;
var a;
console.log( a );
上面代码你认为会输出什么?很多开发者会认为是undefined,因为var a 声明在a = 2 之后,他们自然而然地认为变量被重新赋值了,因此会被赋予默认值undefined。但是,真正的输出结果是2。包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
当你看到var a = 2; 时,可能会认为这是一个声明。但JavaScript 实际上会将其看成两个声明:var a; 和a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。
刚才的代码会被处理为:
var a; //提升
a = 2;
console.log( a );
先有蛋(声明)后有鸡(赋值)。
函数优先
函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。考虑以下代码:
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
会输出1 而不是2 !这个代码片段会被引擎理解为如下形式:
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};
最后提一点:js只有词法作用域,无动态作用域。但是this 机制某种程度上很像动态作用域。他们主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。