一个作用域中包含了一系列标识符(函数、变量)的定义。那这些作用域的边界是如何形成的呢?只有函数会生成作用域的边界吗?
众所周知JavaScript有着全局作用域,这片文章会来探讨其他的两种:函数作用域和块级作用域。
函数作用域
对于上文中提到的问题,答案是 - 你只说对了一半。没错,函数会形成作用域,但并不只有函数才会生成作用域。
这一节我们先来探讨函数作用域。
function foo() {
var a = 2
// ... some code
function bar() {
var b = 3
// ... more code
}
var c = 4
}
在上边的代码中,我们自下而上的看一下不同作用域的“边界”。
- 首先最外层的全局作用域只有一个标识符
foo。 - 而函数
foo中也形成了一个作用域,里边有foo(没错在自身作用域里可以访问自己),a,bar,c这些标识符。 - 最后是
foo内部的函数bar它除了自己作用域内部的标识符bar和c之外,还可以访问到foo作用域内以及全局作用域的标识符。
函数作用域的含义是指:属于这个函数的全部变量都可以整个函数的范围内使用(包括嵌套的作用域内)。
为什么要有函数作用域
隐藏内部实现
从所写的代码中挑选出任意的片段,使用函数声明对这些代码片段进行包装,实际上就把这些代码“隐藏”起来了。为什么需要隐藏这些变量和函数呢?
在软件设计中有一个原则叫最小特权原则,是指应最小限度的暴露必要内容,而将其他内容“隐藏”起来。延伸到函数作用域,如果所有变量都存在于全局作用域中,所有的内部嵌套作用域都能访问的到它们,这将是极不安全的。
例如:
function calcSalary(base, level) {
sum = base * 12 + calcBonus(level)
console.log(sum)
}
function calcBonus(level) {
return (level / 6) * 20000
}
var sum
calcSalary(15000, 3) // 190000
在这个代码片段中,变量 sum 和函数 calcBonus(...) 应该是 calcSalary(...) 的内部具体实现的“私有”内容,给予外部作用域对它们的访问权限不仅没有必要,而且很危险!更合理设计是让这些具体内容只在 calcSalary 内部可见,对外隐藏。
function calcSalary(base, level) {
function calcBonus(level) {
return (level / 6) * 20000
}
var sum = base * 12 + calcBonus(level)
console.log(sum)
}
calcSalary(15000, 3) // 190000
重构代码利用函数作用域,现在 sum 和 clacBouns 都无法从外部访问了。
规避冲突
函数作用域带来的另一个好处是可以避免同名标识符之间的冲突,我们在编程的时候一定都被命名冲突困扰过。而有时冲突还会导致变量的值被意外覆盖。
function foo() {
function bar(a) {
i = 3
console.log(i + a)
}
for (var i=0; i<10; i++) {
bar(i * 2) // 无限循环了!!
}
}
foo()
bar(...) 内部的表达式 i = 3 意外的覆盖了for循环内部的i,导致i永远等于3。
使用函数作用域来隐藏内部声明“隐藏”内部声明是一个很好的选择。
块级作用域
尽管函数作用域是最常见的作用域单元,这个比函数作用域更加细化的作用域单元可以实现更加可维护、简洁的代码。
有一个很经典的例子
for (var i=0; i<10; i++) {
console.log(i)
}
for循环中定义了变量i,通常我们只是希望在循环内部使用i,但i却被绑定在了for循环所处的作用域中。
块级作用域是一个对最小授权原则细化的工具,将之前只能通过函数隐藏信息细化到了在块中也能隐藏信息。而表面上看JavaScript并没有块级作用域的相关功能。
但有几处例外!
ES5及以前
with
在之前讨论词法作用域时提到过的with就是块级作用域的一个例子,用with从对象中创建出的作用域(对象中原本就存在的属性)仅在with声明中有效。
function assign(obj) {
with(obj) {
console.log(a) // 1
a = 2
}
console.log(a) // ReferenceError: a is not defined
}
assign({ a: 1 })
try/catch
ES3规范中规定try/catch的catch分句会创建一个块级作用域,其中声明的变量(此例中是err)只在catch内部有效。
try {
throw new Error('You are too hot!')
} catch (err) {
var hint = 'Something went wrong: '
console.log(hint + error) // Something went wrong: Error: You are too hot
}
console.log(hint) // Something went wrong:
console.log(err) // ReferenceError
ES6及以后
let
到了ES6标准,终于引入了显示定义块级作用域的方式。let关键字可以将变量绑定在任意的作用域中(通常是 { } 内部)。我们常说let为其声明的变量隐士的劫持了所在的块级作用域。
在let被声明的块的外部访问会抛出 ReferenceError。
这里的劫持是一种形象的说法,let告诉当前块级作用域,不可以向别的作用域透露我声明的变量的信息,不然你就完蛋了!同时要注意的是,let声明仅隐藏了它自身声明的变量而不影响别的标识符。
function test() {
if (true) {
let hide = 'hide'
var show = 'show'
showAgain = 'showAgain'
}
console.log(show) // show
console.log(showAgain) // showAgain
console.log(hide) // ReferenceError: hide is not defined
}
test()
let的第二个特点是不会在块作用域中进行提升,即声明的代码被运行前,声明并不“存在”。关于变量提升会在后边的文章讲述,在本篇的内容中不做过多拓展。
{
console.log(foo) // ReferenceError
let foo = 'bar'
}
let循环
let发挥优势的典型例子就是用在循环中:
for (let i=0; i<10; i++) {
console.log(i)
}
console.log(i) // ReferenceError: i is not defined
for循环头部的let声明不仅是将i绑定到了循环的块中,事实上在每一个迭代的块中都重新绑定了i。关于如何对每个迭代进行重新绑定会在后边闭包的文章中介绍。
const
除了let以外,ES6中还引入了const。它于let“劫持”块级作用域的行为相同,而它们的区别是const用来声明常量,之后任何试图修改它的值的操作都会抛出异常。
function test() {
if(true) {
const foo = 'bar'
foo = 'baz' // TypeError: Assignment to constant variable
}
}
test()
小结
函数作用域是JavaScript中最常见的作用域单元,但函数作用域不是唯一的作用域单元。
ES3开始,with以及try/catch的catch分句中具有块级作用域。
ES6开始,使用let和const关键字进行声明会形成块级作用域。