理解 JavaScript 中的作用域

851 阅读9分钟
原文链接: www.fragmentwall.com

作用域是 JavaScript 中的一个重要而又模糊的概念。只有正确使用 JavaScript 作用域,才能使用优秀的设计模式,帮助你规避副作用。本文中,我们将会详细分析 JavaScript 的不同类型的作用域,以及为了写出更好的代码,介绍它们是如何工作的。

作用域的简单定义是编译器需要变量和函数时去查找它们的地方。听起来很容易对吗?我们来看看它们到底是什么。

JavaScript 解释器

在解释作用域是什么之前,我们需要先讨论一下 JavaScript 解释器是什么,以及它是如何影响不同作用域的。当你执行你的 JavaScript 代码时,解释器会遍历两次代码。

第一次遍历代码 - 也就是代码编译环节 - 是对作用域影响最大的。编译器遍历代码查找变量和函数声明,并且将他们移动到当前作用域的顶部。值得一提的是,只有声明会被提升,分配的空间仍然按照原样,在第二轮 - 也就是代码执行环节 - 进行。

为了更好的理解这个问题,我们使用以下代码段帮助说明:

'use strict'

var foo = 'foo';
var wow = 'wow';

function bar (wow) {
  var pow = 'pow';
  console.log(foo); // 'foo'
  console.log(wow); // 'zoom'
}

bar('zoom');
console.log(pow); // ReferenceError: pow is not defined

在编译环节结束后,上述代码会变得像下面这样:

'use strict'
// 变量都被提升到当前作用域的顶端了
var foo;
var wow;

// 函数声明也按照原样被提升到当前作用域的顶端
function bar (wow) {
  var pow;
  pow = 'pow';
  console.log(foo);
  console.log(wow);
}

foo = 'foo';
wow = 'wow';

bar('zoom');
console.log(pow); // ReferenceError: pow is not defined

这里要重点了解的是,声明会被提升到其当前作用域的顶端。这是理解 JavaScript 作用域的关键,本文随后也会专门解释该内容。

例如,变量pow是在函数bar而不是父作用域中声明的,因为这个函数就是它的作用域。

函数bar的参数wow也是在函数作用域中声明的。实际上,所有函数参数都是在函数作用域中隐式声明的,这就是第9行的console.log(wow)会输出zoom而不是wow的原因。

词法作用域(静态作用域)

我们已经了解到 JavaScript 解释器是如何工作的了,并且简要介绍了变量提升,我们还可以深入探究一下作用域到底是什么。让我们由词法作用域开始,也即编译时作用域。换句话说,作用域的定义实际上是在编译时确定的。当代码中使用了evalwith时,该规则将不适用,但是出于本文介绍作用于的目的,我们将会忽略这一例外,因为任何情况下我们都不会使用这种代码。

解释器的第二轮运行就是变量分配和函数执行的时候。在上述样例代码中,就是第12行代码bar()执行的地方。由于第一轮执行之后,我们已经知道bar会在文件顶部被声明,因此解释器可以找到它并执行。

我们看一下第8行代码console.log(foo);,解释器在执行这行代码之前需要找到变量foo的声明。它再次需要首先在此刻的当前作用域(也即函数bar的作用域)而不是全局作用域中查找。foo是在这个函数的作用域中声明的吗?并不是。那么,它就会继续向上查找父作用域,函数的外层作用域是全局作用域。那么foo是在这个作用域声明的吗?是的,因此解释器就找到并正确执行该函数。

总结说来,词法作用域意味着作用域是在第一轮执行后确定后的,当解释器需要查找变量或函数声明时,它将会先在当前作用域寻找,如果没有找到,就会向上层作用域继续查找。它查找的最高层作用域就是全局作用域

如果在全局作用域也没有找到,解释器就会抛出Reference Error的错误。

另外,由于解释器总是先在当前作用域中查找声明,然后才去父层作用域查找,所以 JavaScript 词法作用域引入了一个概念,变量覆盖。意思是,如果当前的函数作用域中声明了一个变量foo,那它就会覆盖 - 或者说隐藏 - 其父层作用域中声明的同名比那辆。我们来看以下代码,以更好地理解覆盖的含义:

'use strict'

var foo = 'foo';

function bar () {
  var foo = 'bar';
  console.log(foo);
}

bar();

上述代码打印的是bar而不是foo,因为第6行foo的声明覆盖了第三行的同名变量的声明。

变量覆盖是一种设计模式,在我们想要遮盖变量并防止在特殊作用域中访问该变量时十分有用。也就是说,我个人趋向于避免使用它,除非绝对必要,因为我认为使用相同的变量名会给团队带来疑惑,有时会导致开发者认为该变量有与其本身不同的取值。

函数作用域

正如我们在词法作用域中看到的,解释器在当前作用域声明变量,也为这函数中声明的某变量会在函数作用域当中。这种作用域限制于函数本身及其内部定义的其他函数。

我们无法在外部访问到一个函数作用域中声明的变量。这是一种非常强大的模式,你可以通过它来创建私有属性,并且只能从函数作用域内部访问到它,看以下代码:

'use strict'

function convert (amount) {
   var _conversionRate = 2; // O只能在该函数作用域中访问
   return amount * _conversionRate;
}

console.log(convert(5));
console.log(_conversionRate); // ReferenceError: _conversionRate is not defined

块级作用域

块级作用域函数作用域相似,但是块级作用域仅限于块而不是函数。

ES3中,try / catch 中的 catch 语句拥有块级作用域,这意味着它有其自身的作用域。值得一提的是,try 语句并没有块级作用域,只有 catch 语句才有。为了更好地理解,我们来看以下代码段:

'use strict'

try {
  var foo = 'foo';
  console.log(bar);
}
catch (err) {
  console.log('In catch block');
  console.log(err);
}

console.log(foo);
console.log(err);

上述代码第5行,当我们尝试访问 bar 时会抛出错误,解释器会进入 catch 语句。该语句块中声明了变量 err,从 catch 外部访问不到。事实上,当我们在最后一行:console.log(err); 尝试打印 err 时会报错。这段代码的运行结果输出如下:

In catch block
ReferenceError: bar is not defined
    (...Error stack here...)
foo
ReferenceError: err is not defined
    (...Error stack here...)

注意到从 try / catch 外部可以访问到 foo 但是访问不了 err

ES6中,letconst 定义的便来那个都显式地声明了当前作用域为块级作用域而不是函数作用域。也就是说,这些变量只能在声明它们的当前所属的块中访问,这些块可以由 iffor语句或函数生成。下面的例子可以更好地展示这个概念:

'use strict'

let condition = true;

function bar () {
  if (condition) {
    var firstName = 'John'; // 整个函数中可访问
    let lastName = 'Doe'; // 只能在 if 作用域中访问
    const fullName = firstName + ' ' + lastName; // 只能在 if 作用域中访问
  }

  console.log(firstName); // John
  console.log(lastName); // ReferenceError
  console.log(fullName); // ReferenceError
}

bar();

letconst 声明的变量允许我们使用最少非闭包原则,这意味着一个变量只能在可能的最小的作用域中访问到。在 ES6 之前,开发者习惯于在 IIFE 中使用 var 声明变量,但是现在我们可以在 ES6 中使用letconst 函数式地加强这一点。该原则主要优势之一就是避免不正确访问变量并因此产生潜在的bug,也使得我们一旦跳出块级作用域时垃圾回收机制可以清除这些未使用过的变量。

IIFE

立即执行函数表达式(IIFE)是一种非常流行的 JavaScript 模式,它允许函数创建新的块级作用域。IIFE仅仅是函数表达式,解释器一旦经过该函数时就会立即执行它。以下是 IIFE 的示例:

'use strict'

var foo = 'foo';

(function bar () {
  console.log('in function bar');
})()

console.log(foo);

上述代码会在输出 foo 之前输出 in function bar,因为函数 bar 是立即执行的,不需要通过 bar() 来显式调用它。 原因是:

  • 关键词 function 前的半开括号 (说明它是一个函数表达式而不是函数声明。

  • 末尾的括号()代表函数表达式会立即执行。

正如我们之前看到的那样,这使得外部作用域访问不到被隐藏的变量,也不会因不必要的变量污染外部作用域。

当你执行异步操作并且想要保存 IIFE 作用域中的变量的状态时,IIFE也非常有用。以下示例代码可以说明这一点:

'use strict'

for (var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log('index: ' + i);
  }, 1000);
}

尽管我们的第一个假设是会输出0, 1, 2, 3, 4, 实际上这个运行了异步操作(setTimeout)的for循环会打印如下结果:

index: 5
index: 5
index: 5
index: 5
index: 5

原因是,当1000毫秒过后,for循环完成了,变量i的值实际上是5.

反而,如果我们想要打印出0, 1, 2, 3, 4,我们需要使用 IIFE 来保存我们想要的作用域,如下所示:

'use strict'

for (var i = 0; i < 5; i++) {
  (function logIndex(index) {
    setTimeout(function () {
      console.log('index: ' + index);
    }, 1000);
  })(i)
}

In this sample, we are passing the value of i to the IIFE, which will have its own scope and will not be affected by the for loop anymore. The output of this code is: 在本例中,我们传递了i的值给 IIFE,它拥有自身的作用域,并且不再会被 for 循环影响到。这份代码的输出结果如下:

index: 0
index: 1
index: 2
index: 3
index: 4

结论

关于 JavaScript 作用域还有更多的东西值得讨论,本文对于作用域是什么,作用域的不同类型,以及我们如何使用一些设计模式来利用作用域的优势做了详尽的介绍。

在下一篇文章中,我将会谈到 JavaScript 中的 contextthis, 什么是显式硬性绑定,以及关键词 new代表什么。

我希望本文能帮你明晰作用域是什么,如果你有任何问题或建议,欢迎评论。