第一部分 作用域与闭包
第 1 章 作用域是什么
作用域是根据名称查找变量的一套规则
1.1 编译原理
Javascript
是一门编译语言,但我们通常将它归类为 「动态」 或者 「解释执行」 语言。
JavaScript
引擎进行编译的步骤和传统的编译语言非常相似,但某些环节可能更复杂,例如,引擎会在「语法分析」和「代码生成」阶段有特定步骤来对运行性能进行优化。
在传统编译语言中,程序中某段代码在执行前会经历三个步骤,统称为 「编译」 :
- 分词/词法分析:将字符串==分解成有意义的代码块==(称为==词法单元==),如:
var a = 2;
会被分解成这些词法单元var、a、=、2 、;
- 解析/语法分析:将词法单元流转化成由一个元素逐级嵌套组成的代表了程序语法结构的树。这个树被称为 「==抽象语法树==」(Abstract Syntax Tree,AST)) 。
- 代码生成:将
AST
转化成可执行代码的过程。
JavaScript
的编译过程不是发生在==构建之前==,大部分情况下编译发生在代码==执行前==
1.2 理解作用域
变量的赋值操作会执行两个动作:首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在==运行时==引擎会在作用域中查找该变量,如果能够找到就会对 它赋值。
RHS
查询:变量出现在赋值操作的右侧,RHS
查询可理解为查找某个==变量的值==,LHS
查询:变量出现在赋值操作的左侧,LHS
查询是找到==变量容器的本身==,从而可以对其赋值。
console.log( a );
上面代码中,对 a
的引用是 RHS
引用,因为在这里 a
没有赋予任何值,只是想==查找并取得 a
的值==
a = 2
上面代码中,对 a
的引用是 LHS
引用,因为实际上我们并不关心当前的值是什么,只是想要为 = 2
这个赋值操作找到一个目标,即==将 a 赋值为 2==。
注意: LHS
和 RHS
的含义是“「赋值操作」的左侧或右侧”并不一定意味着就是“ = 「赋值操作符」的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最 好将其理解为“赋值操作的目标是谁( LHS
)”以及“谁是赋值操作的源头
( RHS
)”。
1.3 作用域嵌套
当一个块或函数嵌套在另一个块或者函数中时,就发生了 ==作用域的嵌套==。在当前作用域找不到某个变量时,就会在外层嵌套的作用域中继续查找,直到找到该变量或者查找到最外层作用域为止。
1.4 异常
ReferenceError
:RHS
查询在所有嵌套的作用域中 ==遍寻不到== 所需的变量TypeError
:RHS
查询到一个变量,但对变量值进行不合理操作,比如对一个非函数类型的值进行函数调用,或者引用null
或者undefined
中的属性。
总结:ReferenceError
同作用域判别失败相关,而 TypeError
则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
1.5 总结
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。
- 如果查找的目的是对变量进行 ==赋值== ,那么就会使用
LHS
查询; - 如果目的是 ==获取变量的值==,就会使用
RHS
查询。
不成功的 RHS
引用会导致抛出 ReferenceError
异常
不成功的 LHS
引用:
- 会导致自动隐式地创建一个全局变量(==非严格模式下==),该变量使用 LHS 引用的目标作为标识符.
- 或者抛出
ReferenceError
异常(==严格模式下==)
第 2 章 词法作用域
词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。==词法作用域最重要的特征是它的定义过程发生在代码的「书写阶段」==(假设你没有使用
eval()
或with
)
作用域有两种主要的工作模型:
- 词法作用域(使用最为普遍)
- 动态作用域(只关心从何处调用)
2.1 词法阶段
编译的第一个阶段叫做词法化,会对源代码中的字符进行检测,如果是有状态的解析过程,还会赋予单词语义。
词法作用域:定义在词法阶段的作用域
遮蔽效应:作用域查找会在找到第一个匹配的标识符时停止(内部标识符「遮蔽」了外部标识符)。
无论函数在哪里被调用,也无论它如何被调用,它的==词法作用域==都只由函数被==声明时所处的位置==决定
2.2 欺骗词法
词法作用域是在函数声明时的位置来定义的,欺骗词法就是要在==运行时==来「修改」词法作用域。
js
中有两种机制来达到这个目的。
- eval
- with
欺骗词法作用域会导致性能下降
另外一个不推荐使用 eval(..)
和 with
的原因是会被严格模式所影响(限 制)。with
被完全禁止,而在保留核心功能的前提下,间接或非安全地使用 eval(..)
也被禁止了
2.2.1 eval
该函数可接收一个字符串参数,eval(...)
可在运行期修改书写期的词法作用域。
function foo(str, a) {
eval( str ); // 欺骗!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3
严格模式中,eval(...)
运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域
function foo(str) { "use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );
2.2.2 with
with
通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
var obj = {
a: 1,
b: 2,
c: 3
};
// 单调乏味的重复 "obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
a = 3;
b = 4;
c = 5;
}
2.2.3 性能
Javascript
引擎会在编译阶段进行性能优化,其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。如果使用了 eval
和 with
,那么对标识符的位置判断是无效的,因为无法确定 eval
会接收到什么代码,会如何对作用域进行修改,也无法确定 with
传入的内容。所以无法进行优化,导致运行变慢。
第 3 章 函数作用域和块作用域
3.1 函数中的作用域
属于这个函数的全部变量都能在整个函数范围内使用及复用。
3.2 隐藏内部实现
从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际 上就是把这些代码“隐藏”起来了
最小特权原则,又称最小授权或最小暴露原则。在软件设计中,应最小限度的暴露必要内容。因此促成了这种基于作用域的隐藏方法。
例如:
function doSomething(a) {
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething( 2 ); // 15
在这个代码片段中,变量 b
和函数 doSomethingElse(..)
应该是 doSomething(..)
内部具体实现的“私有”内容,给予外部访问变量 b
和函数 doSomethingElse(..)
是没别要的,改成如下代码更合理
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15
规避冲突
「隐藏」作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,
例:
function foo() {
function bar(a) {
i = 3; // 修改for循环所属作用域中的i
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 ); // 糟糕,无限循环了!
}
}
foo();
3.3 函数作用域
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
下面代码的 foo
被绑定在了当前作用域,foo
这个名称本身「污染」了这个作用域,然后还必须显示的通过 foo()
调用才能运行其中的代码。
var a = 2;
function foo() {
var a = 3;
console.log( a ); // 3
}
foo();
console.log( a ); // 2
下面代码中 foo
被绑定在了自身作用域中,而不是当前作用域中,意味着在外部作用域不能访问。这种方式,函数会被当作函数表达式来处理。
var a = 2;
(function foo() {
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
3.3.1 具名与匿名
函数表达式可以匿名,函数声明则不可以匿名。
匿名函数的缺点:
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的
arguments.callee
引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。 - 代码可读性/可理解性差
始终给函数表达式命名是一个最佳实践
setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
console.log( "I waited 1 second!" );
}, 1000 );
3.3.2 立即执行函数表达式(IIFE)
IIFE进阶用法:
- 把它们当作函数调用并传递参数进去
var a = 2;
(function IIFE( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2
- 解决 undefined 标识符的默认值被错误覆盖导致的异常
// 将一个参数命名为 undefined,
//但是在对应的位置不传入任何值,
//这样就可以 保证在代码块中 undefined 标识符的值真的是 undefined
undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做!
(function IIFE( undefined ) {
var a;
if (a === undefined) {
console.log( "Undefined is safe here!" );
}
})();
- 倒置代码的运行顺序
将需要运行的函数放在第二位,在
IIFE
执行之后当作参数传递进去
(function IIFE( def ) {
def( window );
})(function def( global ) {
var a = 3;
console.log( a ); // 3 console.log( global.a ); // 2
});
3.4 块级作用域
块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息 扩展为在块中隐藏信息
3.4.1 with
用
with
从对象中创建出的作用域仅在 with 声明中而非外 部作用域中有效。
3.4.2 try/catch
ES3
规范中规定 try/catch
的 catch
分句会创建一个块作
用域,其中声明的变量仅在 catch
内部有效
例:
try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found
3.4.3 let
使用 let
进行的声明不会在块作用域内进行提升。
垃圾收集
块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关
3.4.3 const
与 let
一样,用来创建块作用域变量,但其值是固定的 (常量)。
3.5 小结
函数是 js
中最常见的作用域单元,但不是唯一的作用域单元。
块作用域指的是变量和函数不仅可以属于所处的作用域,
也可以属于某个代码块(通常指 { .. }
内部).
第 4 章 提升
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。
函数声明会被提升,但是函数表达式却不会被提升。
foo(); // 不是 ReferenceError, 而是 TypeError!
var foo = function bar() { // ...
};
4.3 函数优先
函数声明和变量声明都会被提升。但是,是函数会首先被提升,然后才是变量。
后面的函数声明还是可以覆盖前面的
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
==避免在块内部声明函数!看如下代码:==
一个普通块内部的函数声明通常会被提升到所在作用域的顶部
foo(); // "b"
var a = true;
if (a) {
function foo() {
console.log("a");
}
} else {
function foo() {
console.log("b");
}
}
第 5 章 作用域闭包
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
JavaScript 中闭包是无处不在的,你只需要识别并拥抱他。
闭包是基于「词法作用域」写代码所产生的自然结果。
闭包的概念:1. 函数在当前作用域之外的地方执行;2. 闭包会阻止垃圾回收器释放不再使用的内存。
// 闭包
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2
通常情况,foo()
执行完毕后,foo()
的整个内部作用域会被销毁,垃圾回收器会对其进行回收。
事实上,上面代码中的 foo()
并未被回收,因为 bar()
本身在使用。
因为 bar
声明在 foo
的内部,他有涵盖 foo()
内部作用域的闭包,使得作用域一直存活, 供 bar()
在之后任何时间使用。
模块有两个主要特征:
- 为创建内部作用域而调用了一个包装函数;
- 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。