【译】深入理解 ES2015,第一趴:块作用域 let 和 const

1,866 阅读11分钟

ES2015 最大的特性之一就是有了一个全新的作用域。在这个章节里,我们将开始学习什么是作用域。我们将继续学习如何创建新的作用域类型,以及给我们代码带来的好处

快速了解作用域

作用域描述为一个变量,函数,标识符可以被访问的区域。JavaScript 传统上有两种作用域类型:全局作用域和函数作用域,你定义变量的位置会影响其他代码是否可以访问。让我们来看一个简单的例子来阐述作用域的概念。想象一下,你的 JavaScript 文件只包含以下代码:

var globalVariable = 'This is global';

function globalFunction1() {
  var innerVariable1 = 'Non-global variable 1';
}

function globalFunction2() {
  var innerVariable2 = 'Non-global variable 2';
}

在上面的代码中,我们首先声明了一个变量 globalVariable。这个语句不在函数内部,所以会自动存到全局作用域中。浏览器用 window 对象创建了一个全局作用域,除了可以用 globalVariable 访问,我们还可以通过挂在 window 对象上的 window.globalVariable 访问。我们可以在文件的任何地方访问这个变量,这两个函数的之前或之后,甚至是在函数的内部(这就是为什么我们说全局变量是 “隐藏的”,我们可以在任何地方正确的访问他们),甚至是在附在同一页面的其他 JavaScript 文件

在全局作用域里,我们定义了两个函数,globalFunction1globalFunction2,就像全局变量一样,他们是 “可见的” 并且可以在这个文件的任何地方调用,也可以被同一页面的其他 JavaScript 文件调用。然而,当 JavaScript 引擎解析这些函数时,会分别创建他们自己的作用域。因吹斯听,这两个新的函数作用域被嵌套在全局作用域下,成为子作用域。这也就意味着函数内的代码可以访问全局变量,就像是和在函数 “内部的” 定义变量一样

当我们试图访问 JavaScript 里的标识符时,浏览器会首先在当前作用域中查找。如果没有找到,浏览器会在当前作用域的父作用域中查找,并且继续向上查找,直到找到这个变量,或者到达全局作用域为止。如果这个变量在全局作用域里依旧没有找到的话,那么浏览器会抛出一个 ReferenceError 错误。这种嵌套的作用域被称作作用域链,而这个检查当前作用域和父作用域的过程被称作变量查找。这种查找只会向上查找作用域链,它永远不会在它的子作用域里查找

在上面的作用域链查找方向我们得知,例子中的 innerVariable1 变量只能在 globalFunction1 函数内部被访问,innerVariable2 变量只能在 globalFunction2 函数内部被访问。innerVariable1 变量不能在 globalFunction2 函数内部或全局作用域内被访问,innerVariable2 变量也不能在 globalFunction1 函数内部或全局作用域内被访问

下面的图片是上面代码中作用域的抽象表示:

js-scopes

全局作用域包含了 globalVariable 以及两个内嵌的函数作用域。每个内嵌的函数作用域又包含自己的变量,但是这些变量不能被全局作用域访问。虚线表示的是作用域链的查找方向

让我们来看下另一个简短的代码示例,彻底的了解下到目前为止我们所介绍到的作用域概念。假设 JavaScript 文件只包含如下代码:

function outer() {
  var variable1;

  function inner() {
    var variable2;
  }
}

在这段代码里,我们在全局作用域里声明了一个叫 outer 的函数。因为它是一个函数,所以它创建了一个函数作用域,嵌套在全局作用域下。在这个作用域下,我们又声明了一个叫 variable1 的变量和 一个叫 inner 的函数。因为 inner 也是一个函数,所以一个新的作用域又被创建了,嵌套在 outer 函数的作用域下

inner 函数中,我们既可以访问 variable2 也可以访问 variable1。当我们在 inner 函数中访问 variable1 时,浏览器首先会在它的作用域里查找这个变量;当这个变量没有被找到时,会继续向上在父作用域里查找(也就是 outer 函数的作用域)。代码里作用域如下图所示:

js-scopes2

函数作用域可以嵌套在其他的函数作用域里,但是作用域链查找规则是一样的,因此在 inner 作用域下可以访问到 variable1variable2,但是在 outer 作用域下只能访问 variable1

这个示例中的作用域链比较长,从 inner 函数延伸到 outer 函数,直到全局对象 window

JavaScript 的新作用域

在 JavaScript 中,一个块是由一个或多个语句用大括号包裹起来的。诸如 ifforwhile 的条件表达式,都是用块基于特定的条件来执行块语句

其他流行的常见的编程语言都有块作用域,JavaScript 作用域中,直到如今却只有全局作用域和函数作用域,因此使我们变得很困惑。ES2015 在 JavaScript 新增了块作用域,对于我们的代码来说有很大的影响,并且对于那些熟悉其他编程语言的开发者来说变得更直观

块作用域意味着一个块可以创建它自己的作用域,而不是简单的存在于它最近到父级函数作用域或全局作用域下。让我们在认识块作用域是如何工作的之前,先来了解下传统上块里的 JavaScript 是如何工作的:

function fn() {
  var x = 'function scope';

  if (true) {
    var y = 'not block scope';
  }

  function innerFn() {
    console.log(x, y); // function scope not block scope
  }
  innerFn();
}

var 语句是不能够创建块作用域的,即使是在块里,因此 console.log 语句可以访问到 xy 变量。 fn 函数创建了一个函数作用域而且 xy 变量都是可以通过作用域内的作用域链访问到

声明提升

理解提升的概念是理解 JavaScript 如何工作的基础。JavaScript 有两个阶段:解析阶段(JavaScript 引擎读取所有的代码)、执行阶段(执行已解析的代码)。大多数的事情都发生在第二阶段;例如,当你使用 console.log 语句时,实际的日志消息会在执行阶段打印到控制台

然而,一些重要的事情也会在解析阶段发生,包括变量的内存分配、作用域创建。提升这个术语指的是 JavaScript 引擎在遇到标识符,如变量、函数声明时所发生到事情;当发生声明提升时,它的行为就像是把它定义的字面量提升到当前作用域的顶部。鉴于此,上面到代码示例实际会变成如下情况:

function fn() {
  var x;
  var y;

  x = 'function scope';

  if (true) {
    y = 'not block scope';
  }

  function innerFn() {
    console.log(x, y); // function scope not block scope
  }
  innerFn();
}

只有变量到声明会提升到它的作用域的顶部;在这个例子的 if 语句中,变量赋值依然发生在我们所赋值的地方。当然,我们到变量并不会移动,而是引擎行为表现如此,因此这样可以更好的帮助我们理解代码

除了变量,函数声明也会被提升。结果就是,从 JavaScript 引擎到角度来看,代码实际上看起来是这样的:

function fn() {
  var x;
  var y;
  function innerFn() {
    console.log(x, y); // function scope not block scope
  }

  x = 'function scope';

  if (true) {
    y = 'not block scope';
  }
  innerFn();
}

innerFn 的声明也被提升到了它的作用域的顶部。但是,记住它仅仅是函数声明被提升了,函数调用没有被提升。上面的代码并不会报任何错,因为 innerFnxy 赋值之前并没有被调用

使用 let

即使使用了 ES2015,var 声明也不会创建块作用域。为了创建块作用域,我们需要在块里使用 letconst 声明。我们一会再看 const,首先来看下 let

表面上,letvar(我们用它来声明变量)的行为很相似:

function fn() {
  var variable1;
  let variable2;
}

在这个简单的例子中,varlet 声明都做了相同的事情(在 fn 创建的作用域下初始化了一个新的变量)。为了创建一个新的块作用域,我们需要在块里使用 let

function fn() {
  var variable1 = 'function scope';

  if (true) {
    let variable2 = 'block scope';
  }

  console.log(variable1, variable2); // Uncaught ReferenceError: variable2 is not defined
}
fn();

在这个代码示例中,抛出了一个引用错误(reference error);让我们来探索下为什么会这样。fn 函数创建了一个新作用域,里面声明了变量 variable1。然后我们在 if 语句的块里,声明了变量 variable2。然而,因为我们在块里使用了 let 声明,因此一个新的块作用域在 fn 的作用域下被创建了

如果 console.log 语句也在 if 块中的话,那么它就和 variable2 在相同的作用域下了,也能够通过作用域链找到 variable1。但是因为 console.log 在外头,因此它不能访问 variable2,所以会抛出一个引用错误

块作用域和函数作用域的行为相同,但是他们是为块创建的,而不是函数

暂时性死区

当一个用 var 声明的常规变量被创建时,会被提升到它的作用域的顶部,然后并初始化一个 undefined 值,这样就允许我们能够在它赋值之前引用一个常规变量

console.log(x); // undefined
var x = 10;

记住,由于存在声明提升,代码实际看起来是这样的:

var x = undefined;
console.log(x); // undefined
x = 10;

这个行为会阻止抛出引用错误 ReferenceError

let 声明的变量也被提升了,但重要的是,他们并不会自动初始化值 undefined,因此意味着下面的代码会产生一个错误:

console.log(x); // Uncaught ReferenceError: x is not defined
let x = 10;

这个错误是由暂时性死区(TDZ)引起的。TDZ 存在于作用域初始化到变量声明期间。为了修复这个错误(ReferenceError),我们需要在访问它前声明它:

译者注:TDZ

let x;
console.log(x); // undefined
x = 10;

TDZ 这样设计是为了使开发更容易(试图引用一个还没声明的变量通常视为一个错误,而不是故意为之),因此这个错误可以立即提醒我们

使用 const

新的 const 被用来声明一个不可再次赋值的变量。它和 let 的在 TDZ 的行为非常相似,但是,const 变量必须初始化一个值

const VAR1 = 'constant';

从现在开始, 变量 VAR1 的值将永远是 “constant” 这个字符串。如果我们试图再次对它赋值,我们会得到一个错误:

TypeError: Assignment to constant variable

如果我们试图创建一个没有初始化的 const 变量,我们将看到一个语法错误:

SyntaxError: Missing initializer in const declaration

相似地,一个 const 变量不能被再次声明。如果我们试图再次用 const 声明一个相同变量时,我们将得到一个不同类型的语法错误

SyntaxError: Identifier ‘VAR1′ has already been declared

和其他编程语言一样,常量是被用来保存我们的程序在生命周期里不希望改变的值

记住 letconst 都是 JavaScript 的保留词,因此在严格模式下,是不能被用作标识符名称的(变量名,函数名等)。随着 ES2015 越来越普遍,letconst 优于 var 已形成一个共识,因为变量创建的作用域更与其他现代编程语言看齐,并且代码的行为也更好预测。 因此,在大多数情况下尽可能的避免使用 var

不可变性

const 声明的变量不能被再次赋值的,但是 const 声明的变量并不是完全不可变的。如果我们用对象或数组初始化了一个 const 变量,我们依然可以修改对象的属性和增加删除数组的元素

练习

  1. for 循环里用 let 来初始化计数器变量
  2. 修复下面 const 的错误:
const VAR1 = 'constant';
const VAR1 = 'constant2';
const VAR2;
VAR2 = 'constant';

成功是通过不断的练习和知识的积累,而非智力


  • 本文仅代表原作者个人观点,译者不发表任何观点
  • Markdown 文件由译者手动整理,如有勘误,欢迎指正
  • 译文和原文采用一样协议,侵删