《你不知道的 JavaScript》是一个前端学习必读的系列,让不求甚解的JavaScript开发者迎难而上,深入语言内部,弄清楚JavaScript每一个零部件的用途。这本书介绍了该系列的两个主题:作用域和闭包、this和对象原型等
第1章作用域(编译原理、理解作用域、作用域嵌套、异常)
作用域是什么?
是用来存储变量当中的值且能在之后对这个值进行访问或修改的一套设计良好的规则
作用域何时生成?
编译原理
传统编译语言流程中,在源代码执行之前会经过三个步骤,最终生成可执行的机器代码,统称为“编译”
JavaScript一直被称为解释型语言,但事实上它是一门编译型语言。其它大多数编译语言的编译过程是在构建前,JavaScript不同,它是在代码执行前进行编译(几微秒),然后做好准备马上就会执行
理解作用域
示例
定义:var a = 2; 会分为两步执行:预编译、执行
编译器在编译时处理(LHS查找,当前作用域添加声明): 遇到var a,编译器会在当前作用域集合中声明一个变量并命名为a。如果之前声明过,编译器会忽略该声明,继续进行编译为引擎生成运行时所需的代码(用来处理a = 2的赋值操作)
引擎在运行时处理(RHS查找,嵌套作用域查找声明): 执行a = 2 操作,引擎首先会查找当前作用域及外层作用域中叫做 a 的变量,找到会将 2 赋值给它。否则引擎就会抛出一个异常! 总结:执行var a = 2; 时,首先 var a,在当前作用域进行LHS查找,没有则声明;其次 a = 2,在嵌套作用域进行RHS查找,赋值为 2 (不用关心当前 a 的值是什么)
作用域嵌套/作用域链
当一个块或函数嵌套在另一个块或函数中时,就会形成作用域的嵌套
表现形式:[{ 当前作用域 }, { 上一级作用域 }, … , {全局作用域}]
查找规则:引擎会从当前作用域开始查找变量,如果找不到,就会去上一级查找。当抵达最外层全局作用域时,未找到则抛出异常
异常
LHS 和 RHS 查找到顶层(全局作用域),还未找到目标变量的不同表现形式
小结
如果查找的目的是对变量进行赋值,那么就会使用LHS查询;如果目的是获取变量的值,就会使用RHS查询
不成功的RHS引用会导致抛出ReferenceError异常
不成功的LHS引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用LHS引用的目标作为标识符,或者抛出ReferenceError异常(严格模式下)
第2章词法作用域(词法阶段、欺骗词法)
作用域共有两种主要的工作模型:
词法作用域(最为普遍的,被大多数编程语言所采用的如:javascript)
动态作用域(仍有一些编程语言在使用如 Bash 脚本、Perl 中的一些模式等)
词法阶段
词法作用域就是定义在词法阶段的作用域
也就是说词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变
色块1:包含着整个全局作用域,其中只有一个标识符:foo
色块2:包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b
色块3:包含着 bar 所创建的作用域,其中只有一个标识符:c
查找
作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置
以上述例子为例:
当引擎执行 console.log(…) 声明,并查找 a、b 和 c 三个变量的引用
首先从最内部的作用域,也就是 bar(…) 函数的作用域气泡开始查找
如果引擎无法在这里找到 a,因此会去上一级到所嵌套的 foo(…) 的作用域中继续查找
对于b,c也是同样的方式查找
作用域查找会在找到第一个匹配的标识符时停止
欺骗词法
修改(欺骗)词法作用域的两种机制:eval 以及 with,但是欺骗词法作用域会导致性能下降,所以一般不建议使用(不具体讲解)
第3章函数作用域和块作用域(函数中的作用域、隐藏内部实现、函数作用域、块作用域)
函数中的作用域
函数作用域的含义是指,属于这个函数的任何声明(变量或函数)都可以在这个函数的范围内使用及复用(包括这个函数嵌套内的作用域)
为什么要有这些作用域?
当我们用作用域把代码包起来的时候,其实就是对它们进行了“隐藏”,让我们对其有控制权,想让谁访问就可以让谁访问,想禁止访问也很容易
想像一下,如果所有的变量和函数都在全局作用域中,当然我们可以在内部的嵌套作用域中访问它们,但是因为暴露了太多的变量或函数,它们可能被有意或者无意的篡改,以非预期的方式使用,这就导致我们的程序会出现各种各样的问题,严重会导致数据泄露,造成无法挽回的后果
隐藏内部实现
隐藏内部实现的意义
遵循软件设计中“最小暴露”原则,保持变量原有的私有特性
规避同名标识符之间的冲突,避免其导致的值错误覆盖等问题
隐藏内部实现的常用方法
函数作用域
通过以上我们知道,我们可以把一段代码封装到一个函数中,以达到私有化,不会轻易被外部访问的目的。但虽然这样做可以解决一些问题,但是声明的包裹函数会污染所在的作用域,用函数去隐藏代码有两个问题
(1)声明的具名函数会“污染作用域”
(2)必须显示的调用函数,这个函数才会执行
javascript提供了能够解决以上问题的方法(立即执行函数(IIFE))
写法一:(function foo(){ var a = 3; console.log( a ); })();//3
写法二:(function foo(){ var a = 3; console.log( a ); }());//3
作为函数表达式意味着foo只能在…所代表的的位置中被访问,
外部作用域则不行,即foo变量名被隐藏在自身中,不会污染外部作用域
块作用域
作用域分为两种:全局作用域、局部作用域
局部作用域在区分:函数作用域、块作用域
ES5 及以前 JavaScript 中具有块作用域的只有 with(不推荐使用) 和 try…catch 语句
在 ES6 及以后的版本添加了具有块作用域的变量标识符 let 和 const
try…catch
try {
throw 2;
} catch(err) {
console.log(err); // 2
}
console.log(err); // ReferenceError
该语句创建块级作用域,catch(err)的err属于当前块级作用域中的变量
丑陋的代码,在向ES6过渡时,使用代码转换工具将ES6生成兼容ES5代码,大部分使用的该方案)
let、cons
let举例:循环
// 循环的每个迭代的块级作用域,都生成了i标识符
for(let i = 0; i < 10; i++) {
.....
}
console.log(i); // ReferenceError
let和const声明均会附属于一个新的作用域
第4章提升(先有鸡还是先有蛋、编译器再度来袭、函数优先)
声明与赋值谁在前?(先有鸡还是先有蛋)
例1: 例2:
a = 2; console.log(a) // undefined
var a; var a = 2;
console.log(a) // 2
例1:为什么结果不是 undefined
例2:为什么结果不是 抛出 ReferenceError 异常
那为什么会出现这个结果呢?
编译器再度来袭
为了弄清这个问题,再次需要了解编译器的内容
引擎会在解释 JavaScript 代码之前首先对其进行编译,编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。
因此,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理
上面的两个例子就很好理解了
以例2为例:
console.log(a) // undefined
var a = 2;
先编译 后执行 等价于
var a
console.log(a)
a = 2;
这个过程就好像变量和函数声明从它们在代码中出现的位置被移动到了最上面,这个过程就叫作提升
注意:每个作用域都会进行提升操作,声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升
函数优先
函数声明和变量声明都会被提升(有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量
例1:
foo(); // 1 该代码被引擎理解为:
var foo; function foo() {
function foo() { console.log(1);
console.log(1); };
}; foo(); // 1
foo = function(){ foo = function(){
console.log(2); console.log(2);
} };
注意:var foo尽管出现在function foo()的声明之前,但它是重复声明的(被忽略掉了),函数声明会被提升到普通变量之前。避免重复声明,尤其是当普通的 var 声明和函数声明混合在一起,否则会引起很多问题
第5章作用域闭包(启示、实质问题、现在我懂了、循环和闭包、模块)
闭包的定义
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用 域之外执行
可以理解为闭包是指有权访问另一个函数作用域中的变量的函数
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 —— 这就是闭包的效果
在foo()执行后,通常认为垃圾回收机制会将foo()的整个内部作用域都被销毁;而闭包可以阻止这样事情发生,让其内部作用域依然存在。因为bar()处于foo()内部,它拥有涵盖foo()作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用
bar()依然持有对该作用域的引用,而这个引用就叫作 闭包
简言之:当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包
for循环和闭包
我们再看一下其他情况下的闭包使用
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i * 1000 );
}
// 期望:每秒一次的频率输出1~5
// 结果:每秒一次的频率输出五次6
原因是因为setTimeout所在的词法作用域是空的,所以{…}中并没有变量可以用来保存。
但是他们都处在同一个全局作用域下,所以当setTimeout中的函数执行时,查找不到当前所在词法作用域的变量,只能到全局查找 i,由于i是全局变量,此时早已被for循环更改为6,所以最后都输出的是6
那么如果输出我们的预期值呢?当前所在的词法作用域没有变量,那么声明一个对i进行保存不就好了
for (var i=1; i<=5; i++) {
let j = i;
setTimeout( function timer() {
console.log( j );
}, j * 1000 );
}
// 结果:每秒一次的频率输出1~5
如上我们对 i 的值进行了一个引用,并将其绑定在{…}块作用域中
那么通常我们不会这么写,而是直接
for 循环头部的 let 声明还会有一个特殊的行为。 这个行为指出变量在循环过程中不止被声明一次, 每次迭代都会声明。 随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i * 1000 );
}
模块
还有其他的代码模式利用闭包的强大威力,但从表面上看,它们似乎与回调无关。下面一起来研究其中最强大的一个:模块
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
这几行代码很容易看懂,函数coolModule有return,形成了闭包,而coolModule其实就是一个模块。这样做的好处就是屏蔽了内部的属性,并且向外暴露了接口,可以有效的解决无意中产生的变量名重复等问题。这个模式在JavaScript中被称为模块
上面的这种模块是可以创建多个模块实例,更多情况下我们只需要创建一个模块实例,如下
var foo = (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
};
})();//利用自调用函数创建唯一一个对象。
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
如果我们把变量foo命名为$,里面函数是对DOM做一些操作,那么这不是就和JQuery类似了吗?
其实,JQuery就是引用了模块机制
未来模块机制
未来模块机制就是ES6自带的export和import,ES6会将文件当做独立的模块,每个模块都可以导入其他模块或特定的API成员,同样也可以导出自己的API成员,有兴趣的可以参考ES6相关文章
画图工具:https://excalidraw.com/
书籍:《你不知道的javascript》上卷