这篇博客主要对词法作用域与欺骗词法作用域、函数作用域与块级作用域、函数内部的变量提成原理进行详细的分析,在这篇博客之前,关于作用域、编译原理、浏览器引擎的原理及关系在javaScript的作用域和闭包(一)有详细的阐述,而今天这篇博客是在其基础上对作用域的工作原理进行深入的分析,所有如果有对编译和引擎原理的是很清楚的客官可以查阅一下前面的博客。
一、词法作用域
在大部分标准语言编译器的第一部分工作就是词法化(单词化),词法化的过程会对源代码中的字符串进行检查,如果是有状态的解析过程,还会赋予单词语义。这个概念是理解词法作用域及其名称的来历的基础。简单的来说,词法作用域就是定义在词法化阶段的作用域。
词法作用域也可理解为就是代码编辑产生的作用域块的原始嵌套关系。编译时的词法化就是在这一基础上对代码进行编译。
(大家应该还记得下面这个图)
1.1.在编译过程中,对变量的LHS和RHL引用时所遍历的作用域链,就是在源码的块级结构上来实现的。从当前作用域往父级作用域进行查找,直到全局作用域。不会在同级作用域中跨界处理程序。
1.2.作用域查询会在找到第一个匹配的标识符时停止,在多层嵌套的作用域中可以定义同名的标识符,这叫做“遮蔽效应”。内部的标识符遮蔽了外面的标识符,在一定程度上提高了程序的灵活性和高效性,但同时也带来了一个问题,就是外部的标识符无法被访问。
1.3.全局变量会自动成为全局对象的属性,因此可以不通过全局的词法名称,而是间接的通过全局对象属性的引用来对其进行访问。
1.4.欺骗词法作用域
在前面我们谈论了词法作用的基本内部机制,并且通过图形来理解了词法作用域的工作方式,它是基于源码的代码块嵌套来实现其基本的运行逻辑,那有没有能在运行程序的时候“修改”(也可以说是欺骗)词法作用域呢?
JavaScript中有两种机制来实现这个目的。虽然这两个改变词法作用域的方式很不受欢迎,但是它们是真实存在的。(欺骗词法作用域会导致性能下降)
1.4.1.eval
JavaScript中的eval(...)函数可以接收一个字符串为参数,并将其中的内容视为在书写程序时就存在的代码。
function foo(str,a){ eval(str);//欺骗 console.log(a,b); } var b = 2; foo("var b = 3;",1);//1 3
a.在执行eval(...)之后,会将传入的字符串看做正常的程序代码并进行编译执行,引擎会正常的进行词法作用域查找。
所以上面的示例代码打印的b的值是引擎编译str字符后声明的b并赋值3的结果。
在示例中,为了方便理解传入的是一个固定的字符串,在实际开发时我们通常会应用eval来实现动态代码添加插入,代码中可能会包含一个或多个变量声明,无论是变量还是函数,都会对所在的词法作用域进行修改。这种修改词法作用域的行为会对程序性能产生很大的影响。
b.但是,除了改变词法作用域和改变词法作用域带来的性能问题以外,在严格模式下的eval(...)会有自己的词法作用域,这就意味着其中的声明就无法修改所在的词法作用域。
function foo(str){ "use strict"; eval(str); console.log(a);//ReferenceError:a is not defined } foo("var a = 2");
c.除了eval(...)函数以外,在JavaScript中还有一些功能与eval(...)非常类似,比如setTimeot(...)和setInterval(...)的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的函数代码。new Function(...)的最后一个参数也可以接收一个字符串代码,并将其转换成为动态生成的函数(前面的参数是这个新生成函数的形参),这种构建函数的语法比eval的方式安全一些,但也要尽量避免。
1.4.2with
JavaScript中的with(...){...}可以接收一个对象为参数,并在with所在的作用域内创建一个属于自己的词法作用域,将对象的属性作为这个词法作用域的词法标识符。但是在with代码块中,通过var 声明的变量却会执行普通作用域块的词法,变量被提升到with所在的作用域中。然后通过对象生成的词法标识符又遵循独立词法作用域规则,不会被提升到上一级作用域。(这里关于变量提升有点超出词法作用域的知识点,如果有不明白的地方没关系,在这片博客的后面内容里会有详细的解析)
var obj = { a:1, b:2, c:3 } function foo(obje){ console.log(obje);//Object {a: 1, b: 2, c: 3} console.log(a);//ReferenceError: a is not defined console.log(e);//undefined with(obje){ var e = 4; console.log(e);//4 console.log(b);//2 } console.log(e);//4 console.log(c);//ReferenceError: c is not defined } foo(obj);
关于with(...){...}欺骗词法作用域这个问题已经不需要讨论的,上面的代码已经是最好的解释了,它与eval有所不同,eval是插入代码,with是将一个普通作用域块修改成一个独立的词法作用域,并在这个作用域上添加词法标识符。而又对通过var声明的新变量保持普通作用域的特性,这样的一个混淆着多种词法分析规则的编译行为,最后被严格模式干掉了,在严格模式下with被完全禁用。(eval在保留核心功能的前提下,间接或非安全的使用eval(...)也被禁止。)
但是,既然在这里已经对with展开讨论了,我觉得有必要再深入的探讨下with的内部变量处理问题,毕竟还是有一些程序在使用with实现功能。
function foo(obj){ with(obj){ a = 2; } } var o1 = { a:3 } var o2 = { b:3 } foo(o1); console.log(o1.a);//2 console.log(a);//a is not defined
从上面的代码执行来看,with又有惊喜给我们了,我们通常理解的词法作用域在修改作用域链上的变量值时,是对最近的有效变量进行修改,但是从前面我们理解的with中,我们将它视为一个独立的词法作用域,当我们在该词法作用域内修改当前词法作用域的变量值时,却修改了由with传入的参数(对象)。
foo(o2); console.log(o2.a);//undefined console.log(a);//2
但是当在with内进行LHS查询时,如果在它自己的独立词法作用域上没有找到对应的标识符时,并不是在传入的对象上添加一个属性,而是遵循了词法作用域的查询规则,在全局上声明了一个新的变量,并进行赋值操作。
通过以上对with的分析过后,让我们认识了一个调皮的with,最后被严格模式送进了js的历史牢笼中。但是可能会有一些剑走偏锋的高手会认为,那又怎么样,with和eval可以在一定程度上实现更为复杂的功能,并且代码更具有扩展性,难道不是非常好的功能吗?我任务答案是否定的。
JavaScript引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态的分析,并预先确定所有变量和函数的定义位置,才能再执行过程中快速找到标识符。但是如果引擎在代码中发现eval和with时,它只能简单地假设关于标识符位置的判断都是无效的。因为无法在词法分析阶段知道eval会接收到什么代码,也无法知道with创建新的词法作用域的内容是什么。最悲观的是,如果出现了eval和with,所有的优化可能都是无意义的,因此最简单的做法就是完全不做任何优化。
二、函数作用域和块级作用域
2.1.函数作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(函数作用域的后辈作用域也可以使用)。
2.2函数作用域的应用
2.2.1隐藏内部实现:通常对于函数的认知就是先声明一个函数,然后再向里面添加代码。但反过来想,我们也可以从所写的函数中挑选一个任意的片段,然后用函数声明对这段代码进行包装,实际上就是把这些代码隐藏起来。
为什么要“隐藏“变量和函数也是一种技术?
在软件设计中,有一种设计原则是最小限度的暴露必要内容,而将其他内容隐藏起来,比如某个模块或者对象的API,这种原则就是最小授权或最小暴露原则。
function doSomething(a){ b = a + doSomethingElse(a * 2); console.log(b); } function doSomethingElse(a){ return a - 1; } var b; doSomething(2);//15
上面这段代码就可以采用最小暴露原则进行优化:
function doSomething(a){ function doSomethingElse(a){ return a - 1; } var b; b = a + doSomethingElse(a * 2); console.log(b); } doSomething(2);//15
2.2.2.规避冲突:“隐藏”作用域中的变量和函数所带来的另一个好处,是可以规避同名标识符之间的冲突,两个标识符可能有相同的名字单用途不一样,无意间可能造成冲突,冲突会导致变量的值被覆盖。
function foo(){ function bar(a){ i = 3;//修改for循环所属作用域中的i console.log(a + i); } for(var i = 0; i < 10; i++){ bar(i * 2);//无限循环 } } foo();
我们知道for中声明的var i实际上是在foo的作用域上实现的,而foo又是bar的父级作用域,还记得在词法作用域中我们我们讲过“遮蔽变量”,所以这里我们可以将bar中的i在bar的内部var i = 3;重新声明一次,这样就解决了变量冲突的问题。
变量冲突最典型的的例子就是存在于全局作用域中。当程序中加载了多个第三方库时,如果没有妥善的处理内部的私有变量就可能会污染全局变量,就会引发冲突。
解决全局命名冲突的问题主要有两种方式,一种依赖管理工具来实现,另一种就是采用模块模式来解决,就是通过应用闭包来实现的一种处理方式。(模块模式在“JavaScript的作用域和闭包(三)”中会有详细的介绍。)
2.3.立即执行函数表达式与匿名函数
在隐藏内部实现,我们提到了通过声明一个函数来包装变量和方法,这种处理方式又会产生另外一个问题,声明的函数名从本质上来说又污染了所在的作用域。
JavaScript有提供了两个解决方案,分别是立即执行函数和匿名函数。
2.3.1匿名函数我们有通常称它为函数表达式,但函数表达式不只是匿名函数,所以我们通常又把匿名函数叫做匿名函数表达式。
setTimeout(function(){ //一些代码 },1000);
我们常见的匿名函数表达式就是回调函数,但是匿名函数表达式也是存在缺陷的:
a.匿名函数在栈追踪中不会显示出有意义的函数名,使得调试难度增加。
b.如果没有函数名,当函数需要引用自身时只能使用过期的arguments.call引用,比如在递归中。另一个应用场景就是在事件触发后时间监听器需要解绑自身。
c.匿名函数省略了对代码可读性、可理解性很重要的函数名。
综合以上问题,给函数命名还是最好的选择。
2.3.2立即执行函数表达式
(function foo(){ var a = 3; console.log(a); })();
上面的示例是立即执行函数的一种写法,它相对完美的解决了“隐藏”内部实现的程序设计方式。函数声明和函数表达式之间最重要的区别是它们的名称将会绑定在何处,立即执行函数表达式的函数名将绑定在吱声的函数中,而不是所在的作用域中,所以也就不会污染所在的作用域了。
关于立即执行函数,还有另一种写法,这种写法也是比较受欢迎的一种写法:
(function foo(){ var a = 3; console.log(a); }());
这两种写法功能上是一样的,凭个人喜好,但也有人说第二种写法更符合一个独立模块的设计方式。
而关于立即执行函数还有两种实际的应用,第一种就是将命名不是很规范的外部变量传入,在内部使用自己的形参名称。第二种就是保持undefined的值始终不变,我们知道undefined的参数是可以修改的,这绝对是一个大坑,我们可以在立即执行行数形参上设置一个名称为undefined的变量,但不传入参数,这样就实现了立即执行行数的内部的undefined始终保持正确的值了。
2.4块作用域
块级作用域就是除了函数作用域写在大括号中的一些逻辑程序块,我们把这类在一定程度上有区别函数作用域的代码块叫做块作用域。常见的块作用域有:if(){}else{}、for(){},还有with和try/catch。
通常情况下块作用域声明的变量会被编译器保存在块作用域所在的函数作用域上,通常是这样。但是也有特殊情况,像with块中隐式的通过对象属性声明的变量就存在于它独有的作用域上,而主动在with块内通过var声明的变量如果在内部没有隐式的声明同名的变量的话,这个主动声明的变量又会被保存到函数作用域上,这一个非常特殊的例子。还有就是catch分句也会声明一个独立的作用域,声明的变量只在内部有效。
2.4.1.let
在ES6中,除了var声明变量以外,引入了新的let关键字,与var的区别就在于let声明的变量将绑定到所在的任意作用域中(通常是{....}内部)。换句话说,let为其声明的变量隐式的劫持了所在的块作用域。
var foo = true; if(foo){ let bar = foo * 2; console.log(bar);//2 } console.log(bar);//ReferenceError
需要注意的是,在for(...)内部声明的变量,通过关键字var声明的变量会被保存到函数作用域上,通过let声明的变量隐式的构建一个独立的作用域块来保存变量。其内部还将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
{ let j; for(j = 0; j < 10; j++){ let i = j; console.log(i); } }
2.4.2.垃圾收集
块作用域非常有用的原因是和闭包及回收内存垃圾的回收机制相关。考虑一下代码:
function process(data){ //... } var someReallyBigData = {...}; process(someReallyBigData); var btn = document.getElementById("my_button"); btn.addEventListener("click",function click(evt){ console.log("button clicked"); },/*capturingPhase=*/false);
click函数的点击回调并不需要someReallyBigData变量。理论上讲当process(...)执行后,在内存中占用大量空间的数据结构就可以被当做垃圾回收了。但是由于click函数形成了一个覆盖整个作用域的闭包,JavaScr引擎极有可能依然保存着这个结构。所以这里我们可以认为的通过块作用域的机制实现,将这部分不在需要的数据在内存中实现回收处理。
function process(data){ //... } //在这块代码中定义的内容完事后可以销毁。 { let someReallyBigData = {...}; process(someReallyBigData); } var btn = document.getElementById("my_button"); btn.addEventListener("click",function click(evt){ console.log("button clicked"); },/*capturingPhase=*/false);
为变量显式的声明添加块作用域,并对变量进行本地绑定。
2.4.3.const
除了let以外,ES6还引入了const,同样可以用来创建块作用域变量,但其是固定的(常量),声明赋值之后的任何试图修改值都会报错。
var foo = true; if(foo){ var a = 2; const b = 3;//包含在if中的块作用域常量 a = 3; //正常 b = 4; //报错 } console.log(a); //3 console.log(b); //ReferenceError!
(本来想把函数的内部提升机制在这篇博客中继续解析阐述,没想到这部分内容总结下回的篇幅已经如此之长了,所以我只能对JavaScript的作用域和闭包(二)再写一个续篇,这些知识点的连贯性很强,一定要放到一起理解)