第一部分:作用域和闭包
1.作用域是什么
1.1 编译原理
javascript通常归类于动态解释执行语言,但是事实上依旧是一门编译语言,但是不是提前编译的,编译结果也不能在分布式系统中进行移植。
- 传统编译语言的流程:源代码在执行前会经历编译的三个步骤:
-
分词,词法分析
词法单元token,通过有状态和无状态的方式进行 -
解析,语法分析
转换成由元素逐级嵌套所组成的树,代表了程序的语法结构(抽象语法树AST) -
代码生成
将AST转换成可以执行代码的过程,与语言,和目标平台等相关
-
javascript引擎比上述过程复杂,在语法分析和代码生成阶段有特定的步骤进行运行性能的优化等,对冗余元素进行优化;
但是实际上javascript引擎没有足够的时间进行优化,(javascript的编译过程没有发生在构建之前)
javascript的编译过程发生在代码执行前的几微妙,因此在作用域背后,javascript引擎使用了多种办法进行延迟编译甚至实施重编译,来保证性能最佳。
1.2 理解作用域
将作用域的过程模拟成几个人物之间的对话
- 引擎
从头到尾负责整个javascript程序的编译及执行过程 - 编译器
负责语法分析及代码生成这些脏话累活 - 作用域
负责收集并维护由所有声明的标识符组成的一系列查询,实施一套严格的规则,并确定当前执行的代码对这些标识符的访问权限
对于 var a = 2, 引擎认为,其中包含两个声明,一个由编译器进行处理,一个由引擎在运行时进行处理
假设编译器是:为变量分配一个内存,并为其命名为a,将值2保存进这个变量;
但实际上:
-
编译器首先查询在同一个作用域的集合中,是否存在该名称的变量,如果存在,则忽略该声明,继续编译,若不存在,则声明一个新的变量,命名为a;
在代码执行前进行 -
编译器为引擎生成运行时需要的代码,处理a = 2;
-
引擎运行会先查询作用域内部是否存在变量a,若存在,则赋值2,否则继续查找,或抛出异常。
查找类型:
LHS:当变量出现在赋值操作左侧时,进行LHS查询(找到变量的容器本身)赋值操作的目标是谁
RHS:当变量出现在赋值操作右侧时,进行RHS查询(receive his source value找到源值)谁是赋值操作的源头
1.3 作用域嵌套
当一个块或函数嵌套在另一个块或函数中,就发生了作用域的嵌套
在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,知道找到改变量,或抵达最外层(全局作用域)
如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎会发出ReferenceError
当对变量的值进行不合理的操作时,引擎会抛出另外的一个异常,TypeError
(表明判断成功了,但是对结果的操作是非法或不合理的)
但是LHS查询,当无法遍寻到变量时,在全局作用域会创建一个具有该名称的变量,并将其返回到引擎【非严格模式】
严格模式禁止自动或隐式的创建全局变量
2. 词法作用域
2.1词法阶段
作用域共有两种主要的工作模型:
- 词法作用域(大多数语言所采用)
- 动态作用域
词法化:对源码中的字符进行检查,如果是有状态的解析过程,则赋予单词语义。
词法作用域:定义在词法阶段的作用域,是写代码时,将变量和块作用域写在哪里决定的,因此在解析的时候会保持作用域不变。
没有任何一个函数可以部分地同时出现在两个父级函数中
遮蔽效应
作用域查找会在找到第一个匹配的标识符时停止,在多层的嵌套作用域中可以定义同名的标识符,称作遮蔽效应(内部的标识符遮蔽了外部的标识符)
通过window.进行访问被遮蔽的全局变量,若非全局变量被遮蔽了,则无法进行访问
无论函数在哪里被如何调用,词法作用域都只由函数被声明的位置决定。
词法作用域都只会查找一级标识符,如果代码中引用了foo.bar.baz,词法作用域查找只会查找foo标识符,找到这个变量之后,则按照对象访问规则进行访问。
2.2 欺骗词法
目的是为了修改词法作用域,欺骗词法作用域会导致性能下降
- eval
eval()函数接受一个字符串作为参数
将其视为:在书写时就存在与程序中这个位置的代码
function foo(str,a){
eval(str)
console.log(a,b)
}
var b=2;
foo('var b=3;',1)
1 3
在实际使用中,传入eval函数中的代码字符串往往是根据程序逻辑动态拼接的。
默认情况下,如果eval()中所执行的代码包含多个声明就会对eval()所处的词法作用域进行修改。
在严格模式下,eval函数在运行时具有自己的词法作用域,因此使用eval无法再修改其所在的作用域
new Function(……)函数的行为也类似于eval,最后一个参数可以接受代码字符串,并将其转换为动态生成的函数,但是尽量避免使用
在程序中动态生成代码的使用场景非常常见,但是它所带来的好处无法抵消性能上的消耗
- with
理解为:它如何同被它影响的词法作用域进行交互
with :通常被当做重复引用同一个对象中的多个属性的快捷方式
var obj = {
a:1,
b:2,
c:3
};
with(obj){
a=3;
b=4;
c=5;
}
with可以将一个没有或者有多个属性的对象处理成一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。
尽管with 块可以将一个对象处理为词法作用域,但是这个块内部正常的var声明,并不会被限制在这个块的作用域中,而是被添加到with所处的函数作用域中
function foo(obj){
with(obj){
a=2;
}
}
var o1 = {
a:3
}
var o2 = {
b:3
}
foo(o1)
console.log(o1.a); // 2
foo(o2)
console.log(o2.a); // undefined
console.log(a) // 2 被泄露到全局作用域
当传递o1给with时,with所声明的是o1作用域,而这个作用域中含有同o1.a属性相同的标识符,
当o2传递给with时,with声明的是o2作用域,在这个作用域中没有a标识符,因此进行LHS标识符查找。在o2作用域,foo作用域,和全局作用域都不存在,因此在非严格模式下,自动创建了一个全局变量
总结:
eval函数如果接受了含有一个或多个声明的代码,就会修改器所处的词法作用域
with声明式根据你传递的对象凭空创建了一个全新的词法作用域
目前,eval和with的使用都已经被禁止
性能:
javascript引擎会在编译阶段进行数项性能优化,依赖于
根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但是出现了eval和with 则所有的优化都是无意义的,因此最简单的做法就是不做优化
动态作用域:
作用域链是基于调用栈的,而不是代码中的作用域嵌套
this机制从某种程度上将是类似于动态作用域,
动态作用域是在运行期间被确定的,关注的是函数从何处被调用
3. 函数作用域和块作用域
3.1 函数中的作用域
函数作用域的含义:属于这个函数的全部变量都可以在整个函数的范围内使用及复用。
能够充分利用javascript变量可以根据需要改变值类型的动态特性
3.2 隐藏内部实现
对函数的传统认知:声明一个函数,在里面添加代码
启示:从所写的代码中挑选出一个任意的片段,然后应函数声明进行包装,实际上是将这部分代码进行一个隐藏
最小特权,也叫最小授权,或最小暴露原则:指在软件设计中,应该最小限度的暴露必要内容,将其他内容隐藏起来
正确的代码是阻止对一些变量或函数进行访问
规避冲突:隐藏作用域中的变量和函数所带来的另外一个好处就是避免同名标识符之间的冲突
全局命名空间:
有些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象,这个对象被用作库的命名空间。,所有需要暴露给外界的功能都会成为这个对象的属性
模块管理:
使用模块管理器,任何库都将无需标识符加入全局作用域中,而是通过依赖管理器的机制将库的标识符显式的导入到另一个特定的作用域中
利用作用域的规则强制所有标识符都不能注入到共享作用域中,保持在私有,无冲突的作用域中
3.3 函数作用域
在声明函数时,函数名本身污染了所在作用域,另外必须显示的通过函数名调用函数才能运行其中的代码。
(function foo(){
var a=3;
console.log(a);
})();
函数被当做函数表达式而不是一个标准的函数声明去处理
如果function代码中出现的第一个字,则为一个函数声明,否则不是。
上述foo函数只能在自身内部被访问,外部函数则不行,,不会非必要的污染外部作用域
setTimeout(function(){
console.log('I will wait 1 second')
},1000)
上述函数称作匿名函数表达式
function()……没有名称标识符,函数表达式是可以匿名的
函数声明中不可以省略函数名
缺点:
1.匿名函数在栈追踪中不会显示出有意义的函数名,使得调试困难
2.没有函数名,则函数需要引用自身的时候只能使用已经过期的arguments.callee
3.匿名函数省略了对代码的可读性(可理解性很重要的函数名)
可以进行如下修改:
setTimeout(function timeoutHandle(){
console.log('I will wait 1 second')
},1000)
立即执行函数表达式:
IIFE(Immediately Invkoed Function Expression)
(function foo(){
var a=3;
console.log(a);
})();
第一个括号将函数变成表达式
第二个括号执行了这个函数
- 改进形式:
(fucntion(){}())
var a=2;
(function IIFE(global){
var a=3;
console.log("里面的",a);//3
console.log("传进去的",global.a);//2
})(window)
console.log("外面的",a)//2
将window对象的引入作为参数传入立即执行函数,可以通过这种方法从外部作用域传递任何需要的参数
- 倒置代码的运行顺序:
将需要运行的函数放在第二位,在IIFE执行之后当做参数传递进去,UMD(Universial Module Definition)
var a=2;
(function IIFE(def){
def(window);
})(function def(global(){
var a=3;
console.log(a); //3
console.log(global.a); //2
}))
3.4 块作用域
变量的声明应该距离使用的地方越近越好,最大限度的本地化
但是当使用var声明变量的时候,写在哪里都是一样的,最终都属于外部作用域
块作用域是对最小授权原则进行扩展的工具
- 使用with关键字:用with从对象中创建出的作用域尽在with声明中而非外部作用域中有效
*try/catch:es3规范规定了try/catch的catch分句会创建一个块作用域,声明的变量仅在catch内有效 - let:可以将变量绑定到所在的任意作用域中,let为其声明的变量隐式的劫持了所在的块作用域
let不存在提升,,声明的代码运行前,声明并不存在
使用情况:
1.垃圾收集
块作用域有用的地方为闭包,及回收内存垃圾的回收机制相关
为变量显式声明块作用域,使用{},对变量进行本地绑定,是一个非常有用的工具
2.let循环
let声明附属一个新的作用域而不是当前的作用域
- const:值是固定的,任何试图修改值的操作都会引起错误
对于ES6出现之前,如何进行块级作用域的行成:
1.Traceur
是google维护的Traceur项目,用来将ES6代码装换成ES6之前的环境
2.隐式和显式作用域
let(a=2){
console.log(a)
}
let声明创建一个显式的作用域并将其进行绑定
工具let-er
self :通过词法作用域和闭包进行引用的标识符,使用箭头函数形式,代替了普通this的绑定规则,取而代之的是用当前的词法作用域覆盖了this本来的值
4. 提升
任何声明在某个作用域内的变量,都将附属与这个作用域,但是变量声明出现的位置有关系
4.1 原理
引擎会在解释js代码之前首先对其进行编译,编译阶段中有一部分就是找到所有的声明,并找到对应的作用域将他们关联起来
包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理
提升的整个过程就是从他们在代码中出现的位置被移动到了最上面这个过程就叫提升
先有声明后有赋值,只有声明本身会提升,其他赋值或运行逻辑会留在原地
对于函数来说:函数声明会被提升,但是函数表达式不会被提升
foo();
var foo= function bar(){
……
}
foo()由于对undefined值进行函数调用而导致非法操作,TypeError异常,而不是ReferenceError
4.2 函数优先
函数声明和变量声明都有提升,但是函数会被首先提升,之后才是变量,且后面同名函数声明会覆盖到前面的,因此尽量避免在块内声明函数
foo(); // 3
function foo(){
console.log(1);
}
var foo = function(){
console.log(2);
}
function foo(){
console.log(3)
}
5. 作用域闭包
闭包是基于词法作用域书写代码所产生的的自然结果
当函数可以记住并访问所在的所在的词法作用域时,就产生了闭包,即时函数是在当前词法作用域之外执行
function foo(){
var a = 2;
function bar(){
console.log(a)
}
return bar;
}
var baz = foo()
baz(); // 2
bar()能够访问到foo()的内部作用域,将bar本身作为一个值类型进行传递
在foo执行之后,通常会期待foo()的整个内部作用域都被销毁,
但是闭包可以阻止这件事情发生,事实上,内部作用域依然存在,bar()本身在使用,bar拥有foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar在之后的任何时间进行调用。
闭包可以使得函数可以继续访问定义时的词法作用域,当函数在别处调用时,都可以观察到闭包
- 定时器
- 事件监听器
- ajax请求
- 跨窗口通信
- webWorkers
使用回调,就是在使用闭包
5.1 循环和闭包
for(var i=1; i<=5;i++){
setTimeout(function timew(){
console.log(i)
},i*1000)
}
// 以每秒一次的频率输出5次6
延迟函数的回调会在循环结束的时候才进行
尽管5个函数都是在各自的迭代中分别定义的,但是他们都会封闭在一个共享的全局作用域中,因此只会有一个i,所有函数共享一个i的引用
改进的方式,使立即调用函数拥有自己的变量
for(var i=1; i<=5;i++){
(function(){
var j=i;
setTimeout(function timew(){
console.log(j)
},j*1000)
})()
}
更好的一种改进方式是直接将变量传入立即执行函数中
for(var i=1; i<=5;i++){
(function(j){
setTimeout(function timew(){
console.log(j)
},j*1000)
})(i)
}
// 在每次迭代都会生成一个新的作用域,变量在循环过程中也不止声明一次
因此采用块级作用域和闭包联手的行为:
for(let i=1; i<=5;i++){
setTimeout(function timew(){
console.log(i)
},i*1000)
}
5.2 模块
最常见的实现模块模式的方法通常被称为:模块暴露
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 f00 = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3
创建一个模块实例,返回的对象中含有对内部函数而不是对内部数据变量的引用,保持内部数据是隐藏并且是私有的状态。
可以将这个对象类型的返回值看做本质上是模块的公共API
可以返回一个对象,也可以返回一个内部的函数
模块模式需要两个必要条件:
1.必须有外部的封闭函数,该函数至少被调用一次
2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域能形成闭包,并且可以访问或者修改私有的状态
模块也是一个普通的函数,因此可以接受参数
模块模式另一个简单但强大的用法是命名将要作为公共API返回的对象
var foo = (function CoolModule(id){
function change(){
publicAPI.identify = identify2;
}
function identify1(){
console.log(id)
}
function identify2(){
console.log(id.toUpperCase())
}
var publicAPI = {
change:change,
identify:identify1
}
return publicAPI
})("foo moudle");
foo.identify(); //foo moudle
foo.change();
foo.identify(); //FOO MOUDLE
现代的模块机制
大多数模块依赖加载器,将这种模块定义封装进一个友好的API中
调用包装了函数定义的包装函数
并将返回值作为该模块的API
未来的模块机制
ES6在通过模块系统进行加载时,ES6会将文件作为独立的模块进行处理,每个模块都可以导入其他模块特定的API成员,也可以导出自己的API成员
基于函数的模块,并不是一个静态资源识别的模式,只有在运行时才会被考虑进来,因此可以进行运行时的修改
但是ES6模块API是静态的,编辑器会检查导入模块的API是否真实存在,不存在则直接抛出错误,而不会等到运行时再动态解析
ES6模块,一个文件一个模块,模块加载器可以在导入时同步的加载模块文件
模块文件中的内容会被当做好像包含在作用域闭包中一样进行处理