JavaScript的函数作用域和块级作用域

306 阅读4分钟

一个作用域中包含了一系列标识符(函数、变量)的定义。那这些作用域的边界是如何形成的呢?只有函数会生成作用域的边界吗?

众所周知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 它除了自己作用域内部的标识符 barc 之外,还可以访问到 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

重构代码利用函数作用域,现在 sumclacBouns 都无法从外部访问了。

规避冲突

函数作用域带来的另一个好处是可以避免同名标识符之间的冲突,我们在编程的时候一定都被命名冲突困扰过。而有时冲突还会导致变量的值被意外覆盖。

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关键字进行声明会形成块级作用域。