【深度剖析】JavaScript 中块级作用域与函数作用域
前言
系列首发于公众号『前端进阶圈』 ,若不想错过更多精彩内容,请“星标”一下,敬请关注公众号最新消息。
面试官必问系列:深入理解 JavaScript 块和函数作用域
在 JavaScript 中,究竟是什么会生成一个新的作用域,只有函数才会生成新的作用域吗?那 JavaScript 其他结构能生成新的作用域吗?
3.1 函数中的作用域
在之前的词法作用域中可见 JavaScript 具有基于函数的作用域,这也就意味着一个函数都会创建一个新的作用域。但其实并不完全正确,看以下例子:
以上代码片段中,foo() 的作用域中包含了标识符 a, b, c 和 bar。无论表示声明出现在作用域中的何处,这个标识符所代表的变量和函数都附属于所处作用域的作用域中。
bar() 中也拥有属于自己的作用域,全局作用域也有属于自己的作用域,它只包含了一个标识符: foo()
由于标识符 a, b, c 和 bar 都附属于 foo() 的作用域内,因此无法从 foo() 的外部对它们进行访问。也就是说,这些标识符在全局作用域中是无法被访问到的,因此如下代码会抛出 ReferenceError:
但标识符 a, b, c 和 bar 可在 foo() 的内部被访问的。
函数作用域的含义:
属于这个函数的全部变量都可以在整个函数的范围内使用及复用(在嵌套的作用域中也可以使用)
。这种设计方案可根据需要改变值类型的 "动态" 特性。
3.2 隐藏内部实现
我们对函数的传统认知就是先声明一个函数,然后再向里面添加代码,但
反过来
可带来一些启示:从所写的代码中挑选出一个任意片段,然后就用函数声明的方式对它进行包装,实际上就是把这些代码 "隐藏" 起来了。
实际的结果就是在这个代码片段的周围创建了一个新的作用域
,也就是说这段代码中的任何声明(变量或函数)都将绑定在这个新创建的函数作用域中,而不是先前所在的作用域中。换句话说,可把变量和函数包裹在一个函数的作用域中,然后用这个作用域来 "隐藏" 他们。为什么 "隐藏" 变量和函数是一个有用的技术?
上述代码片段中,变量 b 和函数 doSomethingElse(..) 应该是 doSomething(..) 内部具体实现的 "私有" 内容。而上述代码将变量 b 和函数 doSomethingElse(..) 的访问权限放在了外部作用域中,这可能是 "危险" 的。更 "合理" 的设计应该是将这些私有内容放在 doSomething(...) 的内部。
如下:
规避冲突
"隐藏" 作用域中的变量和函数的另一个好处是可避免同名标识符的冲突
,两个标识符名字相同但用途不同,无意间可能会造成命名冲突,而冲突会导致变量的值被意外覆盖。例如:
bar(...) 内部的赋值表达式 i = 3 意外地覆盖了声明在 foo(..) 内部 for 循环中的 i。在这个例子中将会导致无限循环,因为 i 被固定设置为 3,永远满足小于 10 这个条件。
规则冲突的方式:
全局命名空间:在全局作用域中声明一个足够独特的变量,通常为一个对象,如下:
3.3 函数作用域
现在知道,在任意代码片段外部添加包装函数,可将内部的变量和函数定义 "隐藏" 起来,外部作用域无法访问包装函数内部的任何内容。
如下:
上述代码会导致一些额外的问题,首先,必需先声明一个具名函数 foo(), 这就意味着 foo 这个名称本身 "污染" 了所在作用域(上述代码为全局作用域)。其次,必须显式地通过 foo() 来调用这个函数。
如果函数不需要函数名(或者至少函数名可以不污染所在作用域),且能够自行运行,这将会更理想。
JavaScript 提供了两种方案来解决:
在上述代码中,包装函数的声明以
(function...
而不仅是以function...
开始。函数会被当做函数表达式而不是一个标准的函数声明来处理。
如何区分函数声明和表达式?
最简单的方式就是看
function 关键字出现在声明中的位置
(不仅仅是一行代码,而是整个声明中的位置)。如果 function 为声明中的第一个关键字,那它就是一个函数声明,否则就是一个函数表达式。
函数声明和函数表达式之间最重要的区别就是他们的名称标识符将会绑定在何处。
比较一下前面两个代码片段。第一个片段中 foo 被绑定在所在作用域中,可以直接通过 foo() 来调用它。第二个片段中 foo 被绑定在函数表达式自身的函数中而不是所在作用域中。
换句话说,
(function foo(){...})
作为函数表达式意味着foo
只能在...
所代表的位置中被访问,外部作用域则不行。
3.3.1 匿名和具名
对于函数表达式最熟悉的就是回调参数了,如下:
这叫作
匿名函数表达式
,因为function()..
没有名称标识符。函数表达式可以是匿名的,而函数声明则不可以省略函数名——在 JavaScript 的语法中这是非法的。匿名函数表达式的缺点:
匿名函数在栈追踪中不会显示出有意义的函数名
,这使调试很困难。如果没有函数名,当函数需要引用自身时只能通过已经
过期
的arguments.callee
来引用。匿名函数对
代码可读性
不是很友好。上述代码的改造结果:
3.3.2 立即执行函数表达式
由于函数被包含在一对( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个( ) 可以立即执行这个函数,比如(function foo(){ .. })()。第一个( ) 将函数变成表达式,第二个( ) 执行了这个函数。
立即执行函数表达式的术语为:IIFE(Immediately Invoked Function Expression);
IIFE 的应用场景:
除了上述传统的 IIFE 方式,还有另一个方式,如下:
3.4 块作用域
如下:
在 for 循环中定义了变量 i,通常是想在 for 循环内部的上下文中使用 i, 而忽略 i 会绑定在外部作用域(函数或全局)中。
修改后:
上述代码中,变量 bar 仅在 if 的上下文中使用,将它声明在 if 内部中式非常一个清晰的结构。
当使用 var 声明变量时,它写在哪里都是一样的,因为它最终都会属于外部作用域。(这也就是变量提升)
3.4.1 with
在词法作用域中介绍了 with 关键字,它不仅是一个难于理解的结构,同是也是一块作用域的一个例子(块作用域的一种形式),
用 with 从对象中创建出的作用域仅在 with 所处作用域中有效
。
3.4.2 try/catch
很少有人注意,JavaScript 在 ES3 规范
try/catch
的 catch 分句会创建一个块作用域,其中声明的变量仅会在 catch 内部有效。
error 仅存在于 catch 分句内部,当视图从别处引用它时会抛出错误。
关于 catch 分句看起来只是一些理论,但还是会有一些有用的信息的,后续文章会提到。
3.4.3 let
JavaScript 在 ES6 中引入了 let 关键字。
let 关键字将变量绑定到所处的任意作用域中(通常是 { ... } 内部)。换句话说,let 声明的变量隐式地了所在的块作用域。
使用 let 进行的声明不会再块作用域中进行提升。声明的代码被运行前,声明并不 "存在"。
1. 垃圾收集
另一个块作用域很有用的原因和闭包中的内存垃圾回收机制相关。
如下代码:
click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process(..) 执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体实现)。
修改后:
2. let 循环
代码如下:
for 循环中的 let 不仅将 i 绑定了 for 循环内部的块中,事实上他将其重新绑定到了循环的每一次迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
下面通过另一种方式来说明每次迭代时进行重新绑定的行为;
let 声明附属与一个新的作用域而不是当前的函数作用域(也不属于全局作用域)。
考虑一下代码:
这段代码可以简单地被重构成下面的同等形式:
但是在使用块级作用域的变量时需要注意以下变化:
3.4.4 const
ES6 还引入了 const, 同样可用来创建块级作用域,但其值是固定的(常量), 不可修改。
3.5 小结
函数时 JavaScript 中最常见的作用域单元。
块作用域值的是变量和函数布局可以属于所处的作用域,也可以属于某个代码块(通常指 {...} 内部)
从
ES3
开始,try/catch 结构在 catch 分句中具有块作用域
。从
ES6
引入了let,const 关键字来创建块级作用域
。
版权声明: 本文为 InfoQ 作者【控心つcrazy】的原创文章。
原文链接:【http://xie.infoq.cn/article/aac055a04852531961cdb9fde】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论