前言
我们大部分人对Javascript的了解,都是一知半解。并没有去真正的了解它,学习它,导致我们在遇到问题时就会退缩。学习《你不知道的JavaScript》其实就是为了叫我们更好的了解Javascript, 并掌握它。
第1章 作用域是什么
作用域是所有编程语言最基本的功能之一,通过一套设计良好的规则来存储变量,并且可以方便的找到这些变量,这套规则被称为作用域。( 本人理解, 这个是针对所有编程语言的概述。 )
编译原理
JavaScript 是一门编译语言,程序中的一段代码在执行之前会经历3个步骤,统称为编译
- 分词/词法分析(Tokenizing/Lexing)
- 解析/语法分析(Parsing)
- 代码生成
( JavaScript的引擎没有很多时间去优化,一般属于即编译即执行 )
理解作用域
- 对话(变量在编译器中的运行过程)
- 编译器在编译过程的第二步中生成了代码,引擎会对变量进行 LHS查询,对值进行RHS查询(将函数声明理解为LHS查询和赋值的形式并不合适)
a. LHS, 赋值操作的目标是谁
b. RHS, 谁是复制操作的源头
作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套,引擎从当前的执行作用域开始查找变量,如果找不到,就去查找上级作用域,直到找到或没找到
异常
严格模式,在ES5中引入了严格模式,在严格模式中,LHS如果找不到变量是不会自动创建其变量,此时因为没有生成变量,引擎会抛出异常 ReferenceError。
如果不是严格模式,会抛出异常 TypeError,因为在RHS查找中,此变量的值并未赋值。
第2章 词法作用域
词法作用域 定义在词法阶段的作用域,写代码时将变量和块作用域写在哪里来决定词法作用域。
查找
作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息。引擎用这些信息来查找标识符的位置。 作用域查找会在找到第一个匹配的标识符时停止
欺骗词法
欺骗词法作用域会导致性能下降
eval() 函数可以接受一个字符串为参数,并将其中的内容视为代码进行运行
1
2
3
4
5
6function foo(str, a){
eval(str); //欺骗
console.log(a, b);
}
var b = 2;
foo("var b = 3;", 1); //1, 3with 通常用作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20function foo(obj){
with(obj){ //可以对对象进行简单的快捷方式处理
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo(o1);
console.log(o1.a);//2
foo(o2);
//因为o2中没有a属性,因此进行了正常的LHS标识符查找,并在全局中创建了一个变量a
console.log(o2.a);//undefined
console.log(a);//2性能
因为引擎无法判断eval()及with的内容是什么,所以将不会优化此处的代码,导致正哥哥运行变得缓慢。所以尽可能不要使用他们。
第3章 函数作用域和块作用域
函数中的作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在函数的范围内使用及复用,但是如果不细心的处理那些可以在整个作用域范围内被访问的变量,可能会带来意想不到的问题。
作用域气泡, 本人的理解就是作用域范围
隐藏内部实现
作用域的隐藏方法,是从最小暴漏原则引申出来的,在软件设计中,应该最小限度的暴露必要内容,而将其他内容都隐藏起来。
例子
1 | function doSomething(a){ |
这样的话 变量b 被暴露了, 函数doSomethingElse被暴露了。按照下面的做法就将暴露减少到最少
1 | function doSomething(a){ |
避免冲突
为了避免出现同名标识符之间的冲突,因此使用作用域会更好的进行隐藏,避免这种冲突。
- 全局命名空间
很多第三方库,都会在全局作用域中声明一个名字足够独特的变量,通常是一个对象,这个对象被用作库的 命名空间
例如
1 | var MyReallyCoolLibrary{ |
- 模块管理
使用模块管理工具,之后有详细说明
函数作用域
区分函数声明和表达式最简单的方法就是看function关键字出现在声明中的位置,如果function是声明中的第一个词,那么就是函数声明,否则就是一个函数表达式。
匿名和具名
匿名函数表达式简单快捷,但是它也有几个缺点
a. 匿名函数不会显示出有意义的函数名,使得调试很困难
b. 如果没有函数名,在递归中,在事件触发后需要解绑自身
c. 匿名函数省略了对于代码可读性。立即执行函数表达式
(function foo(){})() 表示立即执行也可以写成 (function foo(){}())
除了可以立即执行外还可以传递参数
例子1
2
3
4
5
6
7
8
9var a=2;
(function foo(global){ //将参数window改名为global
var a = 3;
console.log(a);
console.log(global.a);
}(window)); //将window全局变量作为参数传入
console.log(a);
块作用域
除了函数作用域外,还有一种块作用域
歧义的块作用域
例如
1 | for(var i=0; i<10; i++){ |
不过块作用域看似很安全,其实也会污染全局变量。
with
不仅是一个难于理解的结构,也是块作用域,在with内创建的变量不会污染外部变量try/catch
catch内声明的变量尽在catch内部有效let
ES6引入的新的变量声明方式
之前我们有提到歧义的块作用域,我们来比较一下var 和 let区别let 进行声明不会在块作用域中进行提升。1
2
3
4
5
6
7
8
9
10#使用var
for(var i=0; i<10; i++){
console.log(i);
}
console.log(i); //0,1,2,..,9, 10
#使用let
for(let i=0; i<10; i++){
console.log(i);
}
console.log(i); //0,1,2,..,9, Uncaught ReferenceError: i is not definedconst
声明固定的常量,它也不会被提升到全局。
第4章 提升
Javascript编译阶段所有的声明都会在任何代码执行前首先被处理,做一个提升的处理。换句话说,先有声明后有赋值。
(只有声明本身会被提升,而赋值或其他运行逻辑会被留在原地)
例1
1 | a = 2; |
其处理过程是
1 | var a; //变量声明被提升 |
例2
1 | console.log(a); //undefined未定义 |
其处理过程是
1 | var a; |
函数优先
函数声明和变量声明都会被提升,函数会被首先提升,然后才是变量
如下例
1 | foo(); //1 |
其处理过程
1 | function foo(){ |
(var foo 因为是重复声明所以被忽略了)
第5章 作用域闭包
闭包已经成为javascript的神话级概念了。很多人使用javascript多年也不了解闭包(包括本人)。
启示
理解闭包可以看作是学习javascript的重生,javascript中闭包无处不在,你只需要能够识别并拥抱她。
闭包是基于语法作用域书写代码时所产生的自然结果。
实质问题
闭包 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行
实例1
1 | function foo(){ |
实例2
1 | function foo(){ |
实例3
1 | var fn; |
现在我懂了
1 | function wait(message){ |
循环和闭包(此章非常重要所以基本全部内容录入)
要说明闭包,for循环式最常见的例子。
1 | for(var i = 0; i<=5; i++){ |
正常情况下,我们对这段代码行为的预期是分别输出数字1~5,没秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次6。
这是为什么?
首先解释6是从哪里来的。这个循环的终止条件是i不再<=5。条件首次成立时i的指是6。因此,输出显示的是循环结束时i的最终值。
仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是setTimeout(…, 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个6出来。
这里引申出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢?
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是他们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。
这样说的话,当然所有函数共享一个i的引用。循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环,那他同这段代码是完全等价的。
下面回到正题。缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。
我们来试一下
1 | for(var i = 1; i <= 5; i++){ |
依然不行,但是为什么呢? 我们现在显然拥有更多的词法作用域了。的确每个延迟函数都会将IIFE在每次迭代中创建的作用于封闭起来。
如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下。我们的IIFE只是一个什么都没有的空作用域。他需要包含一点实质内容才能为我们所用。
它需要有自己的变量,用来在每个迭代中存储i的值:
1 | for(var i = 1; i <=5; i++){ |
行了,现在可以正常工作了。
可以对这段代码进行一些改进:
1 | for(var i = 1; i <=5; i++){ |
当然,这些IIFE也不过就是函数,因此我们可以将i传递进去,如果愿意的话可以将变量定义为j,当然也可以还叫做i。无论如何这段代码现在可以工作了。
在迭代内使用IIFE会为每个迭代都生成一个新的作用域,使得延缓函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
问题解决了!
重返块作用域
使用let将循环闭包更加顺畅
1 | for(let i = 1; i <=5; i++){ |
模块
在计算机程序的开发过程中,随着程序代码越写越多,在一个文件里代码就会越来越长,越来越不容易维护。为了编写可维护的代码,我们把很多函数分组,分别放到不同的文件里,这样,每个文件包含的代码就相对较少,很多编程语言都采用这种组织代码的方式。在Node环境中,一个.js文件就称之为一个模块(module)。
(模块其实也是利用的闭包)
示例
1 | function CoolModule(){ |
从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。(函数也是对象,也具有属性)
模块模式需要具备的条件
- 必须由外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
*一个具有函数属性的对象本身并不是真正的模块。一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
模块也是普通的函数,因此可以接受参数。
带参数的实例
1 | function CoolModule(id){ |
模块模式另一个简单但强大的变化用法是,命名将要作为公共API返回的对象
1 | var foo = (function CoolModule(id){ |
通过在模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改他们的值。