9. 变量和作用域
目录
请支持本书:购买 (PDF, EPUB, MOBI)捐赠
(广告,请不要屏蔽。)

9. 变量和作用域



9.1 概述

ES6 提供了两种声明变量的新方法:letconst,它们在很大程度上取代了 ES5 中声明变量的方式 var

9.1.1 let

let 的工作方式与 var 类似,但它声明的变量是块级作用域的,只存在于当前代码块中。var函数级作用域的。

在下面的代码中,您可以看到 let 声明的变量 tmp 只存在于从 A 行开始的代码块中

function order(x, y) {
    if (x > y) { // (A)
        let tmp = x;
        x = y;
        y = tmp;
    }
    console.log(tmp===x); // ReferenceError: tmp is not defined
    return [x, y];
}

9.1.2 const

const 的工作方式与 let 类似,但您声明的变量必须立即初始化,并且其值在之后不能更改。

const foo;
    // SyntaxError: missing = in const declaration

const bar = 123;
bar = 456;
    // TypeError: `bar` is read-only

由于 for-of 为每次循环迭代创建一个绑定(变量的存储空间),因此可以使用 const 声明循环变量

for (const x of ['a', 'b']) {
    console.log(x);
}
// Output:
// a
// b

9.1.3 声明变量的方式

下表概述了 ES6 中声明变量的六种方式(灵感来自 kangax 的表格

  提升 作用域 创建全局属性
var 声明 函数
let 暂时性死区 块级
const 暂时性死区 块级
函数 完整 块级
块级
导入 完整 模块全局

9.2 通过 letconst 进行块级作用域

letconst 都创建了块级作用域的变量——它们只存在于包围它们的代码块中。下面的代码演示了 const 声明的变量 tmp 只存在于 if 语句的代码块中

function func() {
    if (true) {
        const tmp = 123;
    }
    console.log(tmp); // ReferenceError: tmp is not defined
}

相反,var 声明的变量是函数级作用域的

function func() {
    if (true) {
        var tmp = 123;
    }
    console.log(tmp); // 123
}

块级作用域意味着您可以在函数中遮蔽变量

function func() {
  const foo = 5;
  if (···) {
     const foo = 10; // shadows outer `foo`
     console.log(foo); // 10
  }
  console.log(foo); // 5
}

9.3 const 创建不可变变量

let 创建的变量是可变的

let foo = 'abc';
foo = 'def';
console.log(foo); // def

常量,即由 const 创建的变量,是不可变的——您不能为它们分配不同的值

const foo = 'abc';
foo = 'def'; // TypeError

9.3.1 陷阱:const 不会使值不可变

const 仅表示变量始终具有相同的值,但这并不意味着值本身是或将变为不可变的。例如,obj 是一个常量,但它指向的值是可变的——我们可以向其中添加属性

const obj = {};
obj.prop = 123;
console.log(obj.prop); // 123

但是,我们不能为 obj 分配不同的值

obj = {}; // TypeError

如果您希望 obj 的值不可变,则必须自己处理。例如,通过 冻结它

const obj = Object.freeze({});
obj.prop = 123; // TypeError
9.3.1.1 陷阱:Object.freeze() 是浅层的

请记住,Object.freeze()浅层的,它只冻结其参数的属性,而不冻结存储在其属性中的对象。例如,对象 obj 被冻结

> const obj = Object.freeze({ foo: {} });
> obj.bar = 123
TypeError: Can't add property bar, object is not extensible
> obj.foo = {}
TypeError: Cannot assign to read only property 'foo' of #<Object>

但对象 obj.foo 没有。

> obj.foo.qux = 'abc';
> obj.foo.qux
'abc'

9.3.2 循环体中的 const

一旦创建了 const 变量,就不能更改它。但这并不意味着您不能重新进入其作用域并以新值重新开始。例如,通过循环

function logArgs(...args) {
    for (const [index, elem] of args.entries()) { // (A)
        const message = index + '. ' + elem; // (B)
        console.log(message);
    }
}
logArgs('Hello', 'everyone');

// Output:
// 0. Hello
// 1. everyone

此代码中有两个 const 声明,分别在 A 行和 B 行。在每次循环迭代期间,它们的常量具有不同的值。

9.4 暂时性死区

letconst 声明的变量有一个所谓的暂时性死区 (TDZ):进入其作用域时,在执行到达声明之前,无法访问(获取或设置)它。让我们比较一下 var 声明的变量(没有 TDZ)和 let 声明的变量(有 TDZ)的生命周期。

9.4.1 var 声明的变量的生命周期

var 变量没有暂时性死区。它们的生命周期包括以下步骤

9.4.2 let 声明的变量的生命周期

通过 let 声明的变量具有暂时性死区,它们的生命周期如下所示

const 变量的工作方式与 let 变量类似,但它们必须具有初始化程序(即立即设置为一个值),并且不能更改。

9.4.3 示例

在 TDZ 中,如果获取或设置变量,则会抛出异常

let tmp = true;
if (true) { // enter new scope, TDZ starts
    // Uninitialized binding for `tmp` is created
    console.log(tmp); // ReferenceError

    let tmp; // TDZ ends, `tmp` is initialized with `undefined`
    console.log(tmp); // undefined

    tmp = 123;
    console.log(tmp); // 123
}
console.log(tmp); // true

如果存在初始化程序,则在评估初始化程序并将结果分配给变量之后,TDZ 结束

let foo = console.log(foo); // ReferenceError

以下代码演示了死区确实是暂时的(基于时间)而不是空间的(基于位置)

if (true) { // enter new scope, TDZ starts
    const func = function () {
        console.log(myVar); // OK!
    };

    // Here we are within the TDZ and
    // accessing `myVar` would cause a `ReferenceError`

    let myVar = 3; // TDZ ends
    func(); // called outside TDZ
}

9.4.4 对于暂时性死区中的变量,typeof 会抛出 ReferenceError

如果您通过 typeof 访问暂时性死区中的变量,则会收到异常

if (true) {
    console.log(typeof foo); // ReferenceError (TDZ)
    console.log(typeof aVariableThatDoesntExist); // 'undefined'
    let foo;
}

为什么?理由如下:foo 不是未声明的,而是未初始化的。您应该知道它的存在,但您不知道。因此,发出警告似乎是可取的。

此外,这种检查仅对有条件地创建全局变量有用。这不是您在普通程序中需要做的事情。

9.4.4.1 有条件地创建变量

当需要有条件地创建变量时,您有两个选择。

选项 1 - typeofvar

if (typeof someGlobal === 'undefined') {
    var someGlobal = { ··· };
}

此选项仅在全局作用域中有效(因此在 ES6 模块中无效)。

选项 2 - window

if (!('someGlobal' in window)) {
    window.someGlobal = { ··· };
}

9.4.5 为什么会有暂时性死区?

constlet 具有暂时性死区有几个原因

9.4.6 扩展阅读

本节的来源

9.5 循环头中的 letconst

以下循环允许您在其头部声明变量

要进行声明,您可以使用 varletconst。它们各自具有不同的效果,我将在接下来解释。

9.5.1 for 循环

for 循环的头部使用 var 声明变量会为该变量创建一个绑定(存储空间)

const arr = [];
for (var i=0; i < 3; i++) {
    arr.push(() => i);
}
arr.map(x => x()); // [3,3,3]

三个箭头函数体中的每个 i 都引用相同的绑定,这就是它们都返回相同值的原因。

如果您使用 let 声明变量,则每次循环迭代都会创建一个新的绑定

const arr = [];
for (let i=0; i < 3; i++) {
    arr.push(() => i);
}
arr.map(x => x()); // [0,1,2]

这一次,每个 i 都引用一个特定迭代的绑定,并保留当时的值。因此,每个箭头函数都返回不同的值。

const 的工作方式与 var 类似,但您不能更改 const 声明的变量的初始值

// TypeError: Assignment to constant variable
// (due to i++)
for (const i=0; i<3; i++) {
    console.log(i);
}

对于每次迭代都获得一个新的绑定,乍一看可能很奇怪,但当您使用循环创建引用循环变量的函数时,这非常有用,如后面的章节中所述。

9.5.2 for-of 循环和 for-in 循环

for-of 循环中,var 创建一个单一绑定

const arr = [];
for (var i of [0, 1, 2]) {
    arr.push(() => i);
}
arr.map(x => x()); // [2,2,2]

const 为每次迭代创建一个不可变绑定

const arr = [];
for (const i of [0, 1, 2]) {
    arr.push(() => i);
}
arr.map(x => x()); // [0,1,2]

let 也为每次迭代创建一个绑定,但它创建的绑定是可变的。

for-in 循环的工作方式类似于 for-of 循环。

9.5.3 为什么每次迭代绑定有用?

以下是一个显示三个链接的 HTML 页面

  1. 如果您点击“yes”,它会被翻译成“ja”。
  2. 如果您点击“no”,它会被翻译成“nein”。
  3. 如果您点击“perhaps”,它会被翻译成“vielleicht”。
<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
</head>
<body>
    <div id="content"></div>
    <script>
        const entries = [
            ['yes', 'ja'],
            ['no', 'nein'],
            ['perhaps', 'vielleicht'],
        ];
        const content = document.getElementById('content');
        for (const [source, target] of entries) { // (A)
            content.insertAdjacentHTML('beforeend',
                `<div><a id="${source}" href="">${source}</a></div>`);
            document.getElementById(source).addEventListener(
                'click', (event) => {
                    event.preventDefault();
                    alert(target); // (B)
                });
        }
    </script>
</body>
</html>

显示的内容取决于变量 target(第 B 行)。如果我们在 A 行使用 var 而不是 const,则整个循环将只有一个绑定,并且 target 的值将是 'vielleicht'。因此,无论您点击哪个链接,您总是会得到翻译 'vielleicht'

幸运的是,使用 const,我们为每个循环迭代获得一个绑定,并且翻译显示正确。

9.6 参数作为变量

9.6.1 参数与局部变量

如果您使用 let 声明一个与参数同名的变量,您将收到一个静态(加载时)错误

function func(arg) {
    let arg; // static error: duplicate declaration of `arg`
}

在块内执行相同的操作会遮蔽参数

function func(arg) {
    {
        let arg; // shadows parameter `arg`
    }
}

相反,使用 var 声明一个与参数同名的变量不会做任何事情,就像在同一作用域内重新声明一个 var 变量不会做任何事情一样。

function func(arg) {
    var arg; // does nothing
}
function func(arg) {
    {
        // We are still in same `var` scope as `arg`
        var arg; // does nothing
    }
}

9.6.2 参数默认值和时间死区

如果参数具有默认值,则它们将被视为一系列 let 语句,并受时间死区的约束

// OK: `y` accesses `x` after it has been declared
function foo(x=1, y=x) {
    return [x, y];
}
foo(); // [1,1]

// Exception: `x` tries to access `y` within TDZ
function bar(x=y, y=2) {
    return [x, y];
}
bar(); // ReferenceError

9.6.3 参数默认值看不到函数体的作用域

参数默认值的作用域与函数体的作用域是分开的(前者包围后者)。这意味着在参数默认值“内部”定义的方法或函数看不到函数体的局部变量

const foo = 'outer';
function bar(func = x => foo) {
    const foo = 'inner';
    console.log(func()); // outer
}
bar();

9.7 全局对象

JavaScript 的 全局对象(Web 浏览器中的 window,Node.js 中的 global)与其说是一个特性,不如说是一个错误,尤其是在性能方面。这就是为什么 ES6 引入一个区别是有意义的

请注意,模块的函数体不是在全局作用域中执行的,只有脚本是在全局作用域中执行的。因此,各种变量的环境形成了以下链。

9.8 函数声明和类声明

函数声明…

以下代码演示了函数声明的提升

{ // Enter a new scope

    console.log(foo()); // OK, due to hoisting
    function foo() {
        return 'hello';
    }
}

类声明…

类不被提升可能会令人惊讶,因为在底层,它们会创建函数。这种行为的基本原理是,它们的 extends 子句的值是通过表达式定义的,而这些表达式必须在适当的时候执行。

{ // Enter a new scope

    const identity = x => x;

    // Here we are in the temporal dead zone of `MyClass`
    const inst = new MyClass(); // ReferenceError

    // Note the expression in the `extends` clause
    class MyClass extends identity(Object) {
    }
}

9.9 编码风格:constletvar

我建议始终使用 letconst

  1. 优先使用 const。只要变量的值永远不会改变,您就可以使用它。换句话说:变量永远不应该出现在赋值语句的左侧或 ++-- 的操作数中。允许更改 const 变量引用的对象
     const foo = {};
     foo.prop = 123; // OK
    

    您甚至可以在 for-of 循环中使用 const,因为每次循环迭代都会创建一个(不可变的)绑定

     for (const x of ['a', 'b']) {
         console.log(x);
     }
     // Output:
     // a
     // b
    

    for-of 循环的函数体内,不能更改 x

  2. 否则,请使用 let - 当变量的初始值稍后发生更改时。
     let counter = 0; // initial value
     counter++; // change
    
     let obj = {}; // initial value
     obj = { foo: 123 }; // change
    
  3. 避免使用 var

如果您遵循这些规则,var 将只出现在遗留代码中,作为需要仔细重构的信号。

var 做了一件 letconst 做不到的事情:通过它声明的变量会成为全局对象的属性。然而,这通常不是一件好事。您可以通过赋值给 window(在浏览器中)或 global(在 Node.js 中)来达到相同的效果。

9.9.1 另一种方法

上述风格规则的另一种方法是,仅对完全不可变的事物(原始值和冻结对象)使用 const。然后我们有两种方法

  1. 优先使用 const const 标记不可变的绑定。
  2. 优先使用 let const 标记不可变的值。

我稍微倾向于#1,但#2 也可以。

下一页:10. 解构