深入理解 JavaScript
请支持本书:购买捐赠
(广告,请不要屏蔽。)

4 环境:变量的幕后机制



在本章中,我们将深入探讨 ECMAScript 语言规范如何处理变量。

4.1 环境:用于管理变量的数据结构

环境是 ECMAScript 规范用来管理变量的数据结构。它是一个字典,其键是变量名,其值是这些变量的值。每个作用域都有其关联的环境。环境必须能够支持与变量相关的以下现象:

我们将使用示例来说明如何针对每种现象实现这一点。

4.2 通过环境实现递归

我们先来解决递归问题。请看以下代码:

function f(x) {
  return x * 2;
}
function g(y) {
  const tmp = y + 1;
  return f(tmp);
}
assert.equal(g(3), 8);

对于每个函数调用,您都需要为被调用函数的变量(参数和局部变量)提供新的存储空间。这是通过所谓的*执行上下文*堆栈来管理的,执行上下文是对环境的引用(就本章而言)。环境本身存储在堆上。这是必要的,因为它们偶尔会在执行离开其作用域后继续存在(我们将在探索*闭包*时看到这一点)。因此,它们本身不能通过堆栈来管理。

4.2.1 执行代码

在执行代码时,我们会在以下位置暂停:

function f(x) {
  // Pause 3
  return x * 2;
}
function g(y) {
  const tmp = y + 1;
  // Pause 2
  return f(tmp);
}
// Pause 1
assert.equal(g(3), 8);

会发生以下情况:

图 1:递归,暂停 1 – 在调用 g() 之前:执行上下文堆栈有一个条目,指向顶级环境。在该环境中,有两个条目;一个用于 f(),一个用于 g()
图 2:递归,暂停 2 – 在执行 g() 时:执行上下文堆栈的顶部指向为 g() 创建的环境。该环境包含参数 y 和局部变量 tmp 的条目。
图 3:递归,暂停 3 – 在执行 f() 时:现在,顶部执行上下文指向 f() 的环境。

4.3 通过环境实现嵌套作用域

我们使用以下代码来探讨如何通过环境实现嵌套作用域。

function f(x) {
  function square() {
    const result = x * x;
    return result;
  }
  return square();
}
assert.equal(f(6), 36);

这里,我们有三个嵌套的作用域:顶级作用域、f() 的作用域和 square() 的作用域。观察结果:

因此,每个作用域的环境都通过一个名为 outer 的字段指向外围作用域的环境。当我们在查找变量的值时,我们首先在当前环境中搜索其名称,然后在外围环境中搜索,然后在外围环境的外围环境中搜索,依此类推。整个外围环境链包含当前可以访问的所有变量(减去被遮蔽的变量)。

当您进行函数调用时,您会创建一个新的环境。该环境的外围环境是创建该函数的环境。为了帮助设置通过函数调用创建的环境的 outer 字段,每个函数都有一个名为 [[Scope]] 的内部属性,该属性指向其“诞生环境”。

4.3.1 执行代码

这些是我们执行代码时所做的暂停:

function f(x) {
  function square() {
    const result = x * x;
    // Pause 3
    return result;
  }
  // Pause 2
  return square();
}
// Pause 1
assert.equal(f(6), 36);

会发生以下情况:

图 4:嵌套作用域,暂停 1 – 在调用 f() 之前:顶级环境有一个条目,用于 f()f() 的诞生环境是顶级环境。因此,f[[Scope]] 指向它。
图 5:嵌套作用域,暂停 2 – 在执行 f() 时:现在有一个用于函数调用 f(6) 的环境。该环境的外围环境是 f() 的诞生环境(索引 0 处的顶级环境)。我们可以看到 outer 字段被设置为 f[[Scope]] 的值。此外,新函数 square()[[Scope]] 是刚刚创建的环境。
图 6:嵌套作用域,暂停 3 – 在执行 square() 时:重复了之前的模式:最新环境的 outer 是通过我们刚刚调用的函数的 [[Scope]] 设置的。通过 outer 创建的作用域链包含当前处于活动状态的所有变量。例如,如果我们想访问 resultsquaref,我们可以这样做。环境反映了变量的两个方面。首先,外围环境链反映了嵌套的静态作用域。其次,执行上下文堆栈反映了动态地进行了哪些函数调用。

4.4 闭包和环境

为了了解如何使用环境来实现闭包,我们使用以下示例:

function add(x) {
  return (y) => { // (A)
    return x + y;
  };
}
assert.equal(add(3)(1), 4); // (B)

这里发生了什么?add() 是一个返回函数的函数。当我们在 B 行进行嵌套函数调用 add(3)(1) 时,第一个参数用于 add(),第二个参数用于它返回的函数。这是有效的,因为在 A 行创建的函数在离开其作用域时不会丢失与其诞生作用域的连接。关联的环境通过该连接保持活动状态,并且该函数仍然可以访问该环境中的变量 xx 在函数内部是自由的)。

这种嵌套调用 add() 的方式有一个优点:如果您只进行第一次函数调用,您将获得一个 add() 的版本,其参数 x 已经填写完毕:

const plus2 = add(2);
assert.equal(plus2(5), 7);

将具有两个参数的函数转换为两个嵌套的函数(每个函数有一个参数)称为*柯里化*。add() 是一个柯里化函数。

仅填写函数的部分参数称为*偏函数应用*(该函数尚未完全应用)。函数的 .bind() 方法执行偏函数应用。在前面的示例中,我们可以看到,如果一个函数是柯里化的,那么偏函数应用就很简单。

4.4.0.1 执行代码

在执行以下代码时,我们做了三次暂停:

function add(x) {
  return (y) => {
    // Pause 3: plus2(5)
    return x + y;
  }; // Pause 1: add(2)
}
const plus2 = add(2);
// Pause 2
assert.equal(plus2(5), 7);

会发生以下情况:

图 7:闭包,暂停 1 – 在执行 add(2) 期间:我们可以看到 add() 返回的函数已经存在(见右下角),并且它通过其内部属性 [[Scope]] 指向其诞生环境。请注意,plus2 仍处于其时间死区,尚未初始化。
图 8:闭包,暂停 2 – 在执行 add(2) 之后:plus2 现在指向 add(2) 返回的函数。该函数通过其 [[Scope]] 保持其诞生环境(add(2) 的环境)处于活动状态。
图 9:闭包,暂停 3 – 在执行 plus2(5) 时:plus2[[Scope]] 用于设置新环境的 outer。这就是当前函数如何访问 x 的方式。