面向急切程序员的 JavaScript(ES2022 版)
请支持本书:购买捐赠
(广告,请勿屏蔽。)

11 变量和赋值



这些是 JavaScript 中声明变量的主要方式

在 ES6 之前,还有 var。但它有一些怪癖,所以在现代 JavaScript 中最好避免使用它。您可以在 Speaking JavaScript 中阅读有关它的更多信息。

11.1 let

通过 let 声明的变量是可变的

let i;
i = 0;
i = i + 1;
assert.equal(i, 1);

您也可以同时声明和赋值

let i = 0;

11.2 const

通过 const 声明的变量是不可变的。您必须始终立即初始化

const i = 0; // must initialize

assert.throws(
  () => { i = i + 1 },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

11.2.1 const 和不可变性

在 JavaScript 中,const 仅表示绑定(变量名和变量值之间的关联)是不可变的。值本身可能是可变的,如下例中的 obj

const obj = { prop: 0 };

// Allowed: changing properties of `obj`
obj.prop = obj.prop + 1;
assert.equal(obj.prop, 1);

// Not allowed: assigning to `obj`
assert.throws(
  () => { obj = {} },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

11.2.2 const 和循环

您可以将 constfor-of 循环一起使用,其中每次迭代都会创建一个新的绑定

const arr = ['hello', 'world'];
for (const elem of arr) {
  console.log(elem);
}
// Output:
// 'hello'
// 'world'

但是,在普通的 for 循环中,您必须使用 let

const arr = ['hello', 'world'];
for (let i=0; i<arr.length; i++) {
  const elem = arr[i];
  console.log(elem);
}

11.3 在 constlet 之间做出选择

我建议使用以下规则来决定使用 const 还是 let

  练习:const

exercises/variables-assignment/const_exrc.mjs

11.4 变量的作用域

变量的作用域是程序中可以访问它的区域。请考虑以下代码。

{ // // Scope A. Accessible: x
  const x = 0;
  assert.equal(x, 0);
  { // Scope B. Accessible: x, y
    const y = 1;
    assert.equal(x, 0);
    assert.equal(y, 1);
    { // Scope C. Accessible: x, y, z
      const z = 2;
      assert.equal(x, 0);
      assert.equal(y, 1);
      assert.equal(z, 2);
    }
  }
}
// Outside. Not accessible: x, y, z
assert.throws(
  () => console.log(x),
  {
    name: 'ReferenceError',
    message: 'x is not defined',
  }
);

每个变量都可以在其直接作用域和嵌套在该作用域内的所有作用域中访问。

通过 constlet 声明的变量称为块级作用域,因为它们的作用域始终是最内层的包围块。

11.4.1 变量遮蔽

您不能在同一级别两次声明同一个变量

assert.throws(
  () => {
    eval('let x = 1; let x = 2;');
  },
  {
    name: 'SyntaxError',
    message: "Identifier 'x' has already been declared",
  });

  为什么要使用 eval()

eval() 会延迟解析(以及 SyntaxError),直到执行 assert.throws() 的回调函数。如果我们不使用它,那么在解析此代码时,我们已经会收到一个错误,并且 assert.throws() 甚至不会被执行。

但是,您可以嵌套一个块,并使用与块外部使用的变量名 x 相同的变量名

const x = 1;
assert.equal(x, 1);
{
  const x = 2;
  assert.equal(x, 2);
}
assert.equal(x, 1);

在块内部,内部 x 是唯一可以访问的具有该名称的变量。据说内部 x遮蔽了外部 x。一旦您离开块,您就可以再次访问旧值。

  测验:基础

请参阅 测验应用程序

11.5 (进阶)

所有剩余部分均为进阶内容。

11.6 术语:静态与动态

这两个形容词描述了编程语言中的现象

让我们看看这两个术语的示例。

11.6.1 静态现象:变量的作用域

变量作用域是一种静态现象。请考虑以下代码

function f() {
  const x = 3;
  // ···
}

x静态(或词法作用域的。也就是说,它的作用域是固定的,并且在运行时不会改变。

变量作用域形成一个静态树(通过静态嵌套)。

11.6.2 动态现象:函数调用

函数调用是一种动态现象。请考虑以下代码

function g(x) {}
function h(y) {
  if (Math.random()) g(y); // (A)
}

A 行中的函数调用是否发生,只能在运行时决定。

函数调用形成一个动态树(通过动态调用)。

11.7 全局变量和全局对象

JavaScript 的变量作用域是嵌套的。它们形成一棵树

根也称为全局作用域。在 Web 浏览器中,唯一可以直接位于该作用域中的位置是脚本的顶层。全局作用域的变量称为全局变量,并且可以在任何地方访问。全局变量有两种

以下 HTML 代码段演示了 globalThis 和两种全局变量。

<script>
  const declarativeVariable = 'd';
  var objectVariable = 'o';
</script>
<script>
  // All scripts share the same top-level scope:
  console.log(declarativeVariable); // 'd'
  console.log(objectVariable); // 'o'
  
  // Not all declarations create properties of the global object:
  console.log(globalThis.declarativeVariable); // undefined
  console.log(globalThis.objectVariable); // 'o'
</script>

每个 ECMAScript 模块都有自己的作用域。因此,存在于模块顶层的变量不是全局变量。图 5 说明了各种作用域之间的关系。

Figure 5: The global scope is JavaScript’s outermost scope. It has two kinds of variables: object variables (managed via the global object) and normal declarative variables. Each ECMAScript module has its own scope which is contained in the global scope.

11.7.1 globalThis [ES2020]

全局变量 globalThis 是访问全局对象的新标准方法。它的名字来源于它在全局作用域中与 this 具有相同的值。

  globalThis 并不总是直接指向全局对象

例如,在浏览器中,存在间接关系。这种间接关系通常不明显,但它确实存在,并且可以被观察到。

11.7.1.1 globalThis 的替代方案

访问全局对象的旧方法取决于平台

11.7.1.2 globalThis 的用例

全局对象现在被认为是 JavaScript 由于向后兼容性而无法摆脱的一个错误。它会对性能产生负面影响,并且通常会造成混淆。

ECMAScript 6 引入了一些功能,可以更容易地避免使用全局对象,例如

通常最好通过变量而不是通过 globalThis 的属性来访问全局对象变量。前者在所有 JavaScript 平台上的工作方式始终相同。

网络上的教程偶尔会通过 window.globVar 访问全局变量 globVar。但前缀“window.”不是必需的,我建议省略它

window.encodeURIComponent(str); // no
encodeURIComponent(str); // yes

因此,globalThis 的用例相对较少,例如

11.8 声明:作用域和激活

这是声明的两个关键方面

1 总结了各种声明如何处理这些方面。

表 1:声明的各个方面。“重复项”描述了声明是否可以对同一个名称使用两次(每个作用域)。“全局属性”描述了声明在脚本的全局作用域中执行时是否向全局对象添加属性。TDZ 表示暂时性死区(稍后解释)。(*) 函数声明通常是块级作用域的,但在 非严格模式 下是函数作用域的。
作用域 激活 重复项 全局属性
const 声明 (TDZ)
let 声明 (TDZ)
function 块 (*) 开始
class 声明 (TDZ)
import 模块 与 export 相同
var 函数 开始,部分

import§27.5 “ECMAScript 模块” 中进行了描述。以下部分将更详细地描述其他结构。

11.8.1 constlet:暂时性死区

对于 JavaScript,TC39 需要决定如果您在常量的声明之前在其直接作用域中访问它会发生什么

{
  console.log(x); // What happens here?
  const x;
}

一些可能的方法是

  1. 在当前作用域的外部作用域中解析名称。
  2. 您得到 undefined
  3. 出现错误。

方法 1 被拒绝,因为该语言中没有这种方法的先例。因此,对于 JavaScript 程序员来说,这并不直观。

方法 2 被拒绝,因为这样 x 就不是常量了——它在声明之前和之后会有不同的值。

let 使用与 const 相同的方法 3,以便两者工作方式类似,并且易于在它们之间切换。

从进入变量的作用域到执行其声明之间的时间称为该变量的暂时性死区 (TDZ)

以下代码说明了时间死区

if (true) { // entering scope of `tmp`, TDZ starts
  // `tmp` is uninitialized:
  assert.throws(() => (tmp = 'abc'), ReferenceError);
  assert.throws(() => console.log(tmp), ReferenceError);

  let tmp; // TDZ ends
  assert.equal(tmp, undefined);
}

下一个例子表明时间死区确实是与时间相关的

if (true) { // entering scope of `myVar`, TDZ starts
  const func = () => {
    console.log(myVar); // executed later
  };

  // We are within the TDZ:
  // Accessing `myVar` causes `ReferenceError`

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

即使 func() 位于 myVar 声明之前并使用了该变量,我们也可以调用 func()。但我们必须等到 myVar 的时间死区结束。

11.8.2 函数声明和提前激活

  更多关于函数的信息

在本节中,我们将在正确学习函数之前使用它们。希望一切仍然有意义。如果遇到问题,请参阅 §25 “可调用值”

函数声明总是在进入其作用域时执行,而不管它在该作用域内的位置。这使您能够在声明函数 foo() 之前调用它

assert.equal(foo(), 123); // OK
function foo() { return 123; }

foo() 的提前激活意味着前面的代码等同于

function foo() { return 123; }
assert.equal(foo(), 123);

如果通过 constlet 声明函数,则不会提前激活它。在以下示例中,您只能在其声明后使用 bar()

assert.throws(
  () => bar(), // before declaration
  ReferenceError);

const bar = () => { return 123; };

assert.equal(bar(), 123); // after declaration 
11.8.2.1 没有提前激活的提前调用

即使函数 g() 没有被提前激活,如果我们遵守以下规则,它也可以被前面的函数 f()(在相同的作用域内)调用:f() 必须在 g() 声明之后被调用。

const f = () => g();
const g = () => 123;

// We call f() after g() was declared:
assert.equal(f(), 123);

模块的函数通常在其完整主体执行后被调用。因此,在模块中,您很少需要担心函数的顺序。

最后,请注意提前激活如何自动遵守上述规则:进入作用域时,所有函数声明都会先执行,然后再进行任何调用。

11.8.2.2 提前激活的陷阱

如果您依赖提前激活在声明之前调用函数,那么您需要注意它不会访问未提前激活的数据。

funcDecl();

const MY_STR = 'abc';
function funcDecl() {
  assert.throws(
    () => MY_STR,
    ReferenceError);
}

如果在 MY_STR 声明之后调用 funcDecl(),问题就会消失。

11.8.2.3 提前激活的优缺点

我们已经看到提前激活有一个陷阱,并且您可以在不使用它的情况下获得它的大部分好处。因此,最好避免提前激活。但我对此并不强烈,而且如前所述,我经常使用函数声明,因为我喜欢它们的语法。

11.8.3 类声明不会被提前激活

尽管它们在某些方面类似于函数声明,但 类声明 不会被提前激活

assert.throws(
  () => new MyClass(),
  ReferenceError);

class MyClass {}

assert.equal(new MyClass() instanceof MyClass, true);

为什么会这样?请考虑以下类声明

class MyClass extends Object {}

extends 的操作数是一个表达式。因此,您可以执行以下操作

const identity = x => x;
class MyClass extends identity(Object) {}

对这样的表达式的求值必须在其被提及的位置进行。其他任何事情都会令人困惑。这就解释了为什么类声明不会被提前激活。

11.8.4 var:变量提升(部分提前激活)

var 是一种比 constlet(现在是首选)更古老的变量声明方式。请考虑以下 var 声明。

var x = 123;

此声明有两个部分

以下代码演示了 var 的效果

function f() {
  // Partial early activation:
  assert.equal(x, undefined);
  if (true) {
    var x = 123;
    // The assignment is executed in place:
    assert.equal(x, 123);
  }
  // Scope is function, not block:
  assert.equal(x, 123);
}

11.9 闭包

在我们探索闭包之前,我们需要了解绑定变量和自由变量。

11.9.1 绑定变量与自由变量

每个作用域都有一组被提及的变量。在这些变量中,我们区分

请考虑以下代码

function func(x) {
  const y = 123;
  console.log(z);
}

func() 的函数体中,xy 是绑定变量。z 是一个自由变量。

11.9.2 什么是闭包?

那么什么是闭包呢?

闭包是一个函数加上与其“诞生之地”存在的变量的连接。

保持这种连接的意义何在?它为函数的自由变量提供了值——例如

function funcFactory(value) {
  return () => {
    return value;
  };
}

const func = funcFactory('abc');
assert.equal(func(), 'abc'); // (A)

funcFactory 返回一个被赋值给 func 的闭包。因为 func 与其诞生之地的变量有连接,所以它在 A 行被调用时仍然可以访问自由变量 value(即使它“逃离”了它的作用域)。

  JavaScript 中的所有函数都是闭包

JavaScript 中的静态作用域是通过闭包来支持的。因此,每个函数都是一个闭包。

11.9.3 示例:一个用于创建递增器的工厂

以下函数返回递增器(我刚编造的名称)。递增器是一个在内部存储一个数字的函数。当它被调用时,它会通过将参数添加到该数字来更新该数字,并返回新值。

function createInc(startValue) {
  return (step) => { // (A)
    startValue += step;
    return startValue;
  };
}
const inc = createInc(5);
assert.equal(inc(2), 7);

我们可以看到,在 A 行创建的函数将其内部数字保存在自由变量 startValue 中。这一次,我们不仅仅是从诞生作用域读取数据,我们还使用它来存储我们更改的数据,并且这些数据在函数调用之间持久存在。

我们可以通过局部变量在诞生作用域中创建更多存储空间

function createInc(startValue) {
  let index = -1;
  return (step) => {
    startValue += step;
    index++;
    return [index, startValue];
  };
}
const inc = createInc(5);
assert.deepEqual(inc(2), [0, 7]);
assert.deepEqual(inc(2), [1, 9]);
assert.deepEqual(inc(2), [2, 11]);

11.9.4 闭包的用例

闭包有什么用?

  测验:高级

请参阅 测验应用程序