读《你不知道的JavaScript》笔记(一)
常听别人提到 《你不知道的JavaScript》这本书,抽时间读了一下,感觉这的确是一本很值得推荐的书,用通俗易懂的语言讲解了Javascript的基础知识,最难得的是,本书的讲解重点恰恰是Javascript最难懂的部分。很多在以前难以理解的知识点在读了这本书之后都变得豁然开朗,特把读书过程中的个人认为比较重要的部分整理如下,以供分享和自己以后参考。
因篇幅较长,Javascript又分上中下三卷,所以相关笔记会整理成一个系列,本文是系列(一)
本文目录:
- 第一部分 作用域和闭包
- 第1章 作用域是什么
- 第2章 词法作用域
- 第3章 函数作用域和块作用域
- 第4章 提升
- 第5章 作用域闭包
第一部分 作用域和闭包
第1章 作用域是什么
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编 译”。
- 分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代 码块被称为词法单元(token)。例如,考虑程序 var a = 2;。这段程序通常会被分解成 为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在 这门语言中是否具有意义。 - 解析/语法分析(Parsing)
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。 - 代码生成
将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息 息相关。 抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指 令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。
比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在 语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化 等。
LHS 和 RHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是“= 赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最 好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头 (RHS)”。
考虑下面的程序,其中既有 LHS 也有 RHS 引用:
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
最后一行 foo(..) 函数的调用需要对 foo 进行 RHS 引用,意味着“去找到 foo 的值,并把 它给我”。并且 (..) 意味着 foo 的值需要被执行,因此它最好真的是一个函数类型的值。
这里还有一个容易被忽略却非常重要的细节。
代码中隐式的 a=2 操作可能很容易被你忽略掉。这个操作发生在 2 被当作参数传递给 foo(..) 函数时,2 会被分配给参数 a。为了给参数 a(隐式地)分配值,需要进行一次 LHS 查询。
第2章 词法作用域
词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写 代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域 不变(大部分情况下是这样的)。
第3章 函数作用域和块作用域
看下面的代码输出:
var before = 1;
before += 1;
(function () {
console.log(before) //2
before += 1
console.log(before) //3
var inner = 'inner1'
}())
before += 1
console.log(before) //4
console.log(inner) //ReferenceError
1.因为在js中语句的结束并不一定需要有分号,所以立即执行函数的前面一定要加上分号,防止出现不必要的错误
2.立即执行函数里面可以获得外面的变量,并且可以对变量进行操作
3.因为IIFE会直接运行代码,所以在IIFE后面对变量进行的修改并不会对IIFE里面获取到的变量有任何影响
注意:如果给立即执行
如果把代码修改成下面这样,那三处console会分别打印出什么呢?
var before = 1;
before += 1;
(function (before) {
console.log(before)
before += 1
console.log(before)
}())
before += 1
console.log(before)
答案是
undefined
NaN
3
因为此时IIFE的形参before相当于重新定义了一个内部变量before,但是并没有赋值,所以第一处打印出来的自然是undefined,用undefined+1,结果是NaN。
而第三处打印的before依然是全局变量before
如果把代码再进行修改,三处打印值又会是什么呢?
var before = 1;
before += 1;
(function (before) {
console.log(before)
before += 1
console.log(before)
}(before))
before += 1
console.log(before)
答案是
2
3
3
此时IIFE的形参before不但进行了定义,而且在自调用的时候通过实参获取到了全局变量before,并把全局变量before的值赋值给了形参before,同时IIFE的内部变量before和全局变量before虽然名字一样,但是他俩是互不相关的。
在实际开发中运用到的IIFE,如果需要使用到全局变量,通常也都是用这种方式实现IIFE和外部环境的完全解耦。
第4章 提升
变量声明的提升是提升到所在的作用域的最上方,比如
function foo() {
console.log( a );
var a = 2;
}
就相当于
function foo() {
var a;
console.log( a );
a = 2;
}
另外要注意,函数声明会被提升,但是函数表达式却不会被提升。
foo();
var foo = function bar() { // ... };
此时控制台会报TypeError而不是 ReferenceError
同时也要记住,即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() { // ... };
这个代码片段经过提升后,实际上会被理解为以下形式:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {// ... }
函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个 “重复”声明的代码中)是函数会首先被提升,然后才是变量。
foo(); // 1
var foo;
function foo() {
console.log(121233232);
}
foo = function () {
console.log(2);
};
函数声明会直接上面的变量声明,同时函数因为不存在赋值一说,所以会上面代码会输出 1 而不是 2 !这个代码片段会被引擎理解为如下形式:
function foo() {
console.log(1);
}
foo(); // 1
foo = function () {
console.log(2);
};
尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。比如下面这段代码,输出值是3
foo(); // 3
function foo() {
console.log(1);
}
var foo = function () {
console.log(2);
};
function foo() {
console.log(3);
}
虽然这些听起来都是些无用的学院理论,但是它说明了在同一个作用域中进行重复定义是 非常糟糕的,而且经常会导致各种奇怪的问题。
声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。比如var a = function foo(){ ... }
,变量a会被提升,但是function foo(){ ... }依旧会待在原地。
要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候(名字相同的情况下,普通变量的声明会被函数声明无情覆盖),否则会引 起很多危险的问题!
第5章 作用域闭包
在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使 用了回调函数,实际上就是在使用闭包!
下面是前面讲过的IIFE模式的代码(立即执行函数)
var a = 2;
(function IIFE() { console.log( a ); })();
上面这段IIFE的代码看上去像是闭包,但严格来说并不是,因为函数(示例代码中 的 IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而 外部作用域,也就是全局作用域也持有 a)。a 是通过普通的词法作用域查找而非闭包被发 现的
尽管 IIFE 本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建 可以被封闭起来的闭包的工具。因此 IIFE 的确同闭包息息相关,即使本身并不会真的使用闭包。
面试经典问题
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。 但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。接下来只能对代码进行改造
for (var i = 1; i <= 5; i++) {
(function () {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
})();
}
IIFE每次执行都会创建独立的作用域,但是上面的代码并不能解决问题,因为如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的 IIFE 只是一 个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。它需要有自己的变量,用来在每个迭代中储存 i 的值:
for (var i = 1; i <= 5; i++) {
(function (j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}
第 3 章介绍了 let 声明,可以用来劫 持块作用域,并且在这个块作用域中声明一个变量。 本质上这是将一个块转换成一个可以被关闭的作用域。因此,下面这些看起来很酷的代码就可以正常运行了:
for (var i = 1; i <= 5; i++) {
let j = i; // 是的,闭包的块作用域!
setTimeout(function timer() {
console.log(j);
}, j * 1000);
}
但是,这还不是全部!for 循环头部的 let 声明还会有一 个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随 后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
下面展示的代码在Javascript被称为模块,最常见的实现模块模式的方法通常被称为模块暴露。
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
模块模式需要具备两个必要条件
- 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块 实例)。
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并 且可以访问或者修改私有的状态。
模块模式的一个简单但强大的变化用法是,命名将要作为公共 API 返回的对象:
var foo = (function CoolModule(id) {
function change() { // 修改公共 API
publicAPI.identify = identify2;
}
function identify1() {
console.log(id);
}
function identify2() {
console.log(id.toUpperCase());
}
var publicAPI = {
change: change,
identify: identify1
};
return publicAPI;
})("foo module");
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE
通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修 改,包括添加或删除方法和属性,以及修改它们的值。
Javascript的词法作用域和动态作用域区别很大,典型例子如下:
function foo() {
console.log(a); // 2
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a,因此会输出 2。而动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调 用。因此,如果 JavaScript 具有动态作用域,理论上,下面代码中的 foo() 在执行时将会输出 3,而事实是输出2。所以函数对数据的调用是在声明的时候就已经决定了。
主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定 的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
下面的代码很让人疑惑
var obj = {
id: "酷",
cool: function () {
console.log(this.id);
}
};
var id = "不酷"
obj.cool(); // 酷
setTimeout(obj.cool, 1000); // 不酷
本来还很酷的,为什么1秒后就不酷了呢,因为在setTimeout的回调中,丢失了cool() 函数丢失了同 this 之间的绑定。我们尝试下在定义obj的cool方法时使用箭头函数
var obj = {
id: "酷",
cool: () => {
console.log(this.id);
}
};
var id = "不酷"
obj.cool(); // 不酷
setTimeout(obj.cool, 1000); // 不酷
这次的结果更糟糕,两次的输出都是“不酷”,因为箭头函数没有自己的this,在cool方法中的this指定的就是全局对象window,正确的方法应该是在setTimeout的回调中,通过bind将cool中的this强制锁定为obj,下面的结果是我们想要的
var obj = {
id: "酷",
cool: function () {
console.log(this.id);
}
};
var id = "不酷"
obj.cool(); // 酷
setTimeout(obj.cool.bind(obj), 1000); // 酷