翻回看过的书,整理笔记,方便温故而知新。这是一本很不错的书,分为两部分,第一部分主要讲解了作用域、闭包,第二部分主要讲解this、对象原型等知识点。
第一部分 作用域和闭包
第1章 作用域是什么
1.1编译原理
传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”:
- 分词/词法分析:这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。
- 解析/语法分析:这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
- 代码生成:将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。
JavaScript 引擎要复杂得多,不会有大量的(像其他语言编译器那么多的)时间用来进行优化,编译过程不是发生在构建之前的。对于Javascript来说,大部分情况下编译发生在代码执行前的几微妙的时间内。
1.2理解作用域
变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。
当变量出现在赋值操作左侧时进行LHS查询,出现在右侧时进行RHS查询。
LHS:赋值操作的目标是谁(试图找到变量的容器本身);(获取地址)
RHS:谁是赋值操作的源头(retrieve his source value);(获取值)
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
(1)foo(..) 函数的调用需要对 foo 进行 RHS 引用
(2)隐式的 a=2 操作,为了给参数 a(隐式地)分配值,需要进行一次LHS 查询
(3)console 对象进行 RHS 查询,并且检查得到的值中是否有一个叫作 log 的方法
(4)进 log(..)(通过变量 a 的 RHS 查询)。
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
- 找到其中所有的 LHS 查询。(这里有 3 处!)
- 找到其中所有的 RHS 查询。(这里有 4 处!)
1.3 作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
1.4 异常
为什么区分 LHS 和 RHS 是一件重要的事情?
因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。
- 如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常。
- 当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎(前提是程序运行在非“严格模式”下)。
ReferenceError 同作用域判别失败相关,而TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的(比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的属性)。
1.5 小结
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。
var a = 2 这样的声明会被分解成两个独立的步骤:
(1)var a 在其作用域中声明新变量。(是代码执行前进行)
(2)a = 2 会查询(LHS 查询)变量 a 并对其进行赋值。
第2章 词法作用域
作用域共有两种主要的工作模型:词法作用域(大多数编程语言所采用)、动态作用域(Bash 脚本、Perl 中的一些模式等)
词法阶段
- 词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的(词法作用域就是定义在词法阶段的作用域)。
- 没有任何函数可以部分地同时出现在两个父级函数中。
- 作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。
欺骗词法
JavaScript 中有两个机制可以“欺骗”词法作用域(不要使用它们):
eval(..):对一段包含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)
with:通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。
第3章 函数作用域和块作用域
3.1 函数中的作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。
3.2 隐藏内部实现
最小授权或最小暴露原则:在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。
规避冲突:
(1)全局命名空间
在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。
(2)模块管理
从众多模块管理器中挑选一个来使用,通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。
3.3 函数作用域
var a = 2;
function foo() { // <-- 添加这一行
var a = 3;
console.log( a ); // 3
} // <-- 以及这一行
foo(); // <-- 以及这一行
console.log( a ); // 2
在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。它并不理想,因为会导致一些额外的问题:
(1)必须声明一个具名函数,意味着这个名称本身“污染”了所在作用域。
(2)必须显式地通过函数名调用这个函数才能运行其中的代码。
解决方案:
var a = 2;
(function foo(){ // <-- 添加这一行
var a = 3;
console.log( a ); // 3
})(); // <-- 以及这一行
console.log( a ); // 2
包装函数的声明以 (function... 而不仅是以 function... 开始。函数会被当作函数表达式而不是一个标准的函数声明来处理。
第一个片段中 foo 被绑定在所在作用域中,可以直接通过foo() 来调用它。
第二个片段,(function foo(){ .. }) 作为函数表达式意味着 foo 只能在 .. 所代表的位置中被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
匿名和具名
匿名函数表达式书写起来简单快捷,但是它也有几个缺点需要考虑:
(1)匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
(2) 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee 引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
(3) 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
立即执行函数表达式
- IIFE,代表立即执行函数表达式(Immediately Invoked Function Expression),由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个( ) 可以立即执行这个函数,比如 (function foo(){ .. })()。
- 相较于传统的 IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){ .. }())。这两种形式在功能上是一致的。选择哪个全凭个人喜好。
- IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。
var a = 2;
(function IIFE( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2
3.4 块作用域
变量的声明应该距离使用的地方越近越好,并最大限度地本地化。
var foo = true;
if (foo) {
var bar = foo * 2;
bar = something( bar );
console.log( bar );
}
bar 变量仅在 if 声明的上下文中使用,因此如果能将它声明在 if 块内部中会是一个很有意义的事情。但是,当使用 var 声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。
-
with:
用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效 -
try/catch:
catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。 -
let:
let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let为其声明的变量隐式地了所在的块作用域。 -
const:
同样可以用来创建块作用域变量,但其值是固定的(常量)。
3.5 小结
- 函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。
- 但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部)。
第4章 提升
函数作用域和块作用域的行为是一样的,可以总结为:任何声明在某个作用域内的变量,都将附属于这个作用域。
到底是声明在前,还是赋值在前?
- 引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。
- 因此,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理
var a = 2; 实际上会将其看成两个声明:var a; 和 a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。
这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。
- 只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。
- 每个作用域都会进行提升操作。
- 函数声明会被提升,但是函数表达式却不会被提升。
- 函数会首先被提升,然后才是变量。
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 );
};
var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。
尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。(在同一个作用域中进行重复定义是非常糟糕的,而且经常会导致各种奇怪的问题)
foo(); // 3
function foo() {
console.log( 1 );
}
var foo = function() {
console.log( 2 );
};
function foo() {
console.log( 3 );
}
一个普通块内部的函数声明通常会被提升到所在作用域的顶部:
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}
第5章 作用域闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。
- bar() 在自己定义的词法作用域以外的地方执行。
- 在 foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。(bar() 本身在使用这个内部作用域)
- bar() 所声明的位置拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。
- bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!
var a = 2;
(function IIFE() {
console.log( a );
})();
严格来讲它并不是闭包。
因为函数(示例代码中的 IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而外部作用域,也就是全局作用域也持有 a)。a 是通过普通的词法作用域查找而非闭包被发现的。
循环和闭包
// 预期是分别输出数字 1~5,每秒一次,每次一个
// 实际运行时会以每秒一次的频率输出五次 6
// 它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行。
// 依旧不能(IIFE 只是一个什么都没有的空作用域。)
for (var i=1; i<=5; i++) {
(function() {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
})();
}
// 行了!它能正常工作了!
for (var i=1; i<=5; i++) {
(function() {
var j = i;
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})();
}
// 代码进行一些改进:
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})( i );
}
模块
function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
- 这个模式在 JavaScript 中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。
- 这个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共 API。
模块模式需要具备两个必要条件:
(1)必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
(2)封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
模块有两个主要特征:
(1)为创建内部作用域而调用了一个包装函数;
(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包
一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
现代的模块机制
多数模块依赖加载器 / 管理器本质上都是将这种模块定义封装进一个友好的 API。
未来的模块机制
ES6 中为模块增加了一级语法支持。但通过模块系统进行加载时,ES6 会将文件当作独立的模块来处理。每个模块都可以导入其他模块或特定的API 成员,同样也可以导出自己的API 成员。
基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们的 API 语义只有在运行时才会被考虑进来。因此可以在运行时修改一个模块的 API。
相比之下,ES6 模块 API 更加稳定(API 不会在运行时改变)。由于编辑器知道这一点,因此在(的确也这样做了)编译期检查对导入模块的 API 成员的引用是否真实存在。
// bar.js
function hello(who) {
return "Let me introduce: " + who;
}
export hello;
//foo.js
// 仅从 "bar" 模块导入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(hello( hungry ).toUpperCase());
}
export awesome;
// baz.js
// 导入完整的 "foo" 和 "bar" 模块
module foo from "foo";
module bar from "bar";
console.log(bar.hello( "rhino" )); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO
- import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是 hello)。
- module 会将整个模块的 API 导入并绑定到一个变量上(在我们的例子里是 foo 和 bar)。
- export 会将当前模块的一个标识符(变量、函数)导出为公共 API。
这些操作可以在模块定义中根据需要使用任意多次。
附录A 动态作用域
JavaScript 中的作用域就是词法作用域(大部分语言都是基于词法作用域的)。
词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段。
动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。
function foo() {
console.log( a );
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
- 词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a,因此会输出 2。
- 如果 JavaScript 具有动态作用域,理论上, foo() 在执行时将会输出 3。因为当 foo() 无法找到 a 的变量引用时,会顺着调用栈在调用 foo() 的地方查找 a,而不是在嵌套的词法作用域链中向上查找。
- 事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。但是 this 机制某种程度上很像动态作用域。
主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
附录 B 块作用域的替代方案
- catch 分句具有块作用域,因此它可以在 ES6 之前的环境中作为块作用域的替代方案。
- 工具可以将 ES6 的代码转换成能在 ES6 之前环境中运行的形式。
let (a = 2) {
console.log( a ); // 2
}
console.log( a ); // ReferenceError
这种 let 的使用方法,它被称作 let 作用域或 let 声明(对比前面的 let 定义)。let 声明并不包含在 ES6 中。官方的 Traceur 编译器也不接受这种形式的代码。
两种解决方案:
(1)使用合法的 ES6 语法并且在代码规范性上做一些妥协。
/*let*/ { let a = 2;
console.log( a );
}
console.log( a ); // ReferenceError
(2)编写显式 let 声明,然后通过工具将其转换成合法的、可以工作的代码。
为什么不直接使用 IIFE 来创建作用域?
(1)try/catch 的性能的确很糟糕,但技术层面上没有合理的理由来说明 try/catch 必须这么慢,或者会一直慢下去。自从 TC39 支持在 ES6 的转换器中使用 try/catch 后,Traceur 团队已经要求 Chrome 对 try/catch 的性能进行改进。
(2)IIFE 和 try/catch 并不是完全等价的,因为如果将一段代码中的任意一部分拿出来用函数进行包裹,会改变这段代码的含义,其中的 this、return、break 和 contine 都会发生变化。IIFE 并不是一个普适的解决方案,它只适合在某些情况下进行手动操作。
附录 C this词法
箭头函数在涉及 this 绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通 this 绑定的规则,取而代之的是用当前的词法作用域覆盖了 this 本来的值。