2022 年 6 月 13 日

变量作用域、闭包

JavaScript 是一种非常面向函数的语言。它给了我们很大的自由度。函数可以在任何时候创建,作为参数传递给另一个函数,然后从代码中完全不同的位置调用。

我们已经知道函数可以访问其外部的变量(“外部”变量)。

但是,如果在创建函数之后外部变量发生了变化,会发生什么情况?函数将获取较新的值还是较旧的值?

如果一个函数作为参数传递,并从代码的另一个位置调用,它是否可以访问新位置的外部变量?

让我们扩展我们的知识,以了解这些场景和更复杂的场景。

我们将在本文中讨论 let/const 变量

在 JavaScript 中,有 3 种声明变量的方法:letconst(现代方法)和 var(过去的残余)。

  • 在本文中,我们将在示例中使用 let 变量。
  • 使用 const 声明的变量的行为相同,因此本文也适用于 const
  • 旧的 var 有一些显着差异,它们将在文章 旧的“var” 中介绍。

代码块

如果变量在代码块 {...} 中声明,则它仅在该块中可见。

例如

{
  // do some job with local variables that should not be seen outside

  let message = "Hello"; // only visible in this block

  alert(message); // Hello
}

alert(message); // Error: message is not defined

我们可以使用它来隔离执行其自身任务的代码块,其中包含仅属于它的变量

{
  // show message
  let message = "Hello";
  alert(message);
}

{
  // show another message
  let message = "Goodbye";
  alert(message);
}
如果没有块,就会出现错误

请注意,如果没有单独的块,如果我们对现有变量名使用 let,则会出现错误

// show message
let message = "Hello";
alert(message);

// show another message
let message = "Goodbye"; // Error: variable already declared
alert(message);

对于 ifforwhile 等,在 {...} 中声明的变量也仅在内部可见

if (true) {
  let phrase = "Hello!";

  alert(phrase); // Hello!
}

alert(phrase); // Error, no such variable!

这里,在 if 完成后,下面的 alert 不会看到 phrase,因此会出现错误。

这很好,因为它允许我们创建特定于 if 分支的块局部变量。

对于 forwhile 循环,类似的情况也成立

for (let i = 0; i < 3; i++) {
  // the variable i is only visible inside this for
  alert(i); // 0, then 1, then 2
}

alert(i); // Error, no such variable

从视觉上看,let i{...} 之外。但 for 结构在这里是特殊的:在其中声明的变量被认为是块的一部分。

嵌套函数

当函数在另一个函数内部创建时,它被称为“嵌套”。

使用 JavaScript 可以轻松做到这一点。

我们可以使用它来组织我们的代码,如下所示

function sayHiBye(firstName, lastName) {

  // helper nested function to use below
  function getFullName() {
    return firstName + " " + lastName;
  }

  alert( "Hello, " + getFullName() );
  alert( "Bye, " + getFullName() );

}

这里,嵌套函数 getFullName() 是为方便而创建的。它可以访问外部变量,因此可以返回全名。嵌套函数在 JavaScript 中非常常见。

更有趣的是,可以返回嵌套函数:作为新对象的属性或作为结果本身。然后可以在其他地方使用它。无论在哪里,它仍然可以访问相同的外部变量。

在下面,makeCounter 创建“counter”函数,该函数在每次调用时返回下一个数字

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2

尽管很简单,但该代码的稍加修改的变体具有实际用途,例如,作为 伪随机数生成器,为自动化测试生成随机值。

这是如何工作的?如果我们创建多个计数器,它们是否会独立?这里的变量发生了什么?

理解这些事情对于 JavaScript 的整体知识非常有帮助,并且有利于更复杂的场景。所以让我们深入一点。

词法环境

此处有龙!

深入的技术解释即将到来。

虽然我想尽量避免低级语言细节,但没有这些细节的任何理解都是有缺陷和不完整的,所以做好准备。

为了清楚起见,解释被分成多个步骤。

步骤 1. 变量

在 JavaScript 中,每个正在运行的函数、代码块 {...} 和整个脚本都有一个内部(隐藏)关联对象,称为词法环境

词法环境对象包含两部分

  1. 环境记录 - 一个将所有局部变量存储为其属性的对象(以及一些其他信息,例如 this 的值)。
  2. 外部词法环境的引用,该引用与外部代码相关联。

“变量”只是特殊内部对象 环境记录 的一个属性。“获取或更改变量”意味着“获取或更改该对象的属性”。

在这个没有函数的简单代码中,只有一个词法环境

这就是所谓的全局词法环境,与整个脚本相关联。

在上面的图片中,矩形表示环境记录(变量存储),箭头表示外部引用。全局词法环境没有外部引用,这就是箭头指向 null 的原因。

随着代码开始执行和继续,词法环境会发生变化。

这是一段稍长的代码

右侧的矩形展示了全局词法环境在执行期间如何变化

  1. 当脚本启动时,词法环境会预先填充所有已声明的变量。
    • 最初,它们处于“未初始化”状态。这是一个特殊的内部状态,这意味着引擎知道该变量,但直到使用 let 声明它之前,都无法引用它。这几乎与变量不存在相同。
  2. 然后出现 let phrase 定义。还没有赋值,所以它的值为 undefined。我们可以从现在开始使用该变量。
  3. phrase 分配一个值。
  4. phrase 更改值。

现在一切都看起来很简单,对吧?

  • 变量是与当前执行的块/函数/脚本相关联的特殊内部对象的属性。
  • 使用变量实际上就是使用该对象的属性。
词法环境是一个规范对象

“词法环境”是一个规范对象:它只在语言规范中“理论上”存在,以描述事物的工作原理。我们无法在代码中获取此对象并直接对其进行操作。

JavaScript 引擎也可能对其进行优化,丢弃未使用的变量以节省内存并执行其他内部技巧,只要可见行为保持如描述的那样。

步骤 2. 函数声明

函数也是一个值,就像变量一样。

不同之处在于函数声明会立即完全初始化。

当创建词法环境时,函数声明会立即成为一个可立即使用的函数(与 let 不同,后者在声明之前无法使用)。

这就是为什么我们可以在函数声明中声明函数,甚至在声明本身之前使用它。

例如,当我们添加函数时,这是全局词法环境的初始状态

当然,此行为仅适用于函数声明,而不适用于我们将函数分配给变量的函数表达式,例如 let say = function(name)...

步骤 3. 内部和外部词法环境

当函数运行时,在调用开始时,会自动创建一个新的词法环境来存储调用的局部变量和参数。

例如,对于 say("John"),它看起来像这样(执行在用箭头标记的行中)

在函数调用期间,我们有两个词法环境:内部环境(用于函数调用)和外部环境(全局)

  • 内部词法环境对应于 say 的当前执行。它有一个属性:name,即函数参数。我们调用 say("John"),所以 name 的值为 "John"
  • 外部词法环境是全局词法环境。它有 phrase 变量和函数本身。

内部词法环境引用 outer

当代码想要访问变量时,首先搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,依此类推,直到全局环境。

如果在任何地方都找不到变量,那么在严格模式下会出现错误(如果没有 use strict,对不存在变量的赋值会创建一个新的全局变量,以兼容旧代码)。

在此示例中,搜索过程如下

  • 对于 name 变量,say 中的 alert 会立即在内部词法环境中找到它。
  • 当它想要访问 phrase 时,则没有 phrase 本地,因此它遵循对外部词法环境的引用并在那里找到它。

步骤 4. 返回函数

让我们回到 makeCounter 示例。

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

在每次 makeCounter() 调用开始时,都会创建一个新的词法环境对象,以存储此 makeCounter 运行的变量。

因此,我们有两个嵌套的词法环境,就像上面的示例中一样

不同之处在于,在 makeCounter() 执行期间,会创建一个仅有一行的微小嵌套函数:return count++。我们还没有运行它,只是创建它。

所有函数都记住它们被创建时的词法环境。从技术上讲,这里没有魔法:所有函数都有一个名为[[Environment]]的隐藏属性,该属性保留对创建函数的词法环境的引用

因此,counter.[[Environment]]引用{count: 0}词法环境。这就是函数记住它创建的位置的方式,无论它在哪里被调用。[[Environment]]引用在函数创建时设置一次并永久存在。

稍后,当调用counter()时,将为调用创建一个新的词法环境,并且其外部词法环境引用将从counter.[[Environment]]获取

现在,当counter()内部的代码查找count变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后搜索外部makeCounter()调用的词法环境,在那里它找到并更改它。

变量在其所在的词法环境中更新。

以下是执行后的状态

如果我们多次调用counter(),则count变量将在同一位置增加到23等。

闭包

有一个通用的编程术语“闭包”,开发人员通常应该知道。

闭包是一个记住其外部变量并可以访问它们的函数。在某些语言中,这是不可能的,或者应该以特殊方式编写函数来实现它。但如上所述,在 JavaScript 中,所有函数都是天然的闭包(只有一个例外,将在“new Function”语法中介绍)。

也就是说:它们自动记住使用隐藏的[[Environment]]属性创建的位置,然后它们的代码可以访问外部变量。

在面试中,前端开发人员会遇到关于“什么是闭包?”的问题,一个有效的答案是对闭包的定义,以及对 JavaScript 中所有函数都是闭包的解释,以及关于技术细节的更多内容:[[Environment]]属性以及词法环境的工作原理。

垃圾回收

通常,在函数调用完成后,词法环境会连同所有变量一起从内存中移除。这是因为没有对它的引用。作为任何 JavaScript 对象,它仅在可访问时保留在内存中。

但是,如果有一个嵌套函数在函数结束之后仍然可访问,那么它具有引用词法环境的 [[Environment]] 属性。

在这种情况下,即使在函数完成后,词法环境仍然可访问,因此它会一直存在。

例如

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // g.[[Environment]] stores a reference to the Lexical Environment
// of the corresponding f() call

请注意,如果 f() 被调用多次,并且保存了结果函数,那么所有对应的词法环境对象也将保留在内存中。在下面的代码中,它们全部 3 个

function f() {
  let value = Math.random();

  return function() { alert(value); };
}

// 3 functions in array, every one of them links to Lexical Environment
// from the corresponding f() run
let arr = [f(), f(), f()];

词法环境对象在变得不可访问时会消失(就像任何其他对象一样)。换句话说,它仅在至少有一个嵌套函数引用它时才存在。

在下面的代码中,在嵌套函数被移除之后,其封闭的词法环境(因此还有 value)会从内存中清除

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // while g function exists, the value stays in memory

g = null; // ...and now the memory is cleaned up

实际优化

正如我们所见,理论上在函数存在期间,所有外部变量也会被保留。

但在实践中,JavaScript 引擎会尝试优化它。它们分析变量使用情况,如果从代码中明显看出外部变量未被使用,则会将其移除。

V8(Chrome、Edge、Opera)中的一个重要副作用是,此类变量在调试中将不可用。

尝试在 Chrome 中运行下面的示例,同时打开开发者工具。

当它暂停时,在控制台中键入 alert(value)

function f() {
  let value = Math.random();

  function g() {
    debugger; // in console: type alert(value); No such variable!
  }

  return g;
}

let g = f();
g();

正如你所见,没有这样的变量!理论上,它应该是可访问的,但引擎对其进行了优化。

这可能会导致一些有趣(如果不是很耗时)的调试问题。其中之一是,我们可能会看到同名的外部变量,而不是预期的变量

let value = "Surprise!";

function f() {
  let value = "the closest value";

  function g() {
    debugger; // in console: type alert(value); Surprise!
  }

  return g;
}

let g = f();
g();

了解 V8 的此功能很重要。如果你使用 Chrome/Edge/Opera 进行调试,迟早会遇到它。

这不是调试器中的错误,而是 V8 的一个特殊功能。也许它会在某个时候改变。你始终可以通过运行此页面上的示例来检查它。

任务

重要性:5

函数 sayHi 使用外部变量 name。当函数运行时,它将使用哪个值?

let name = "John";

function sayHi() {
  alert("Hi, " + name);
}

name = "Pete";

sayHi(); // what will it show: "John" or "Pete"?

此类情况在浏览器和服务器端开发中都很常见。函数可能会被安排在创建之后执行,例如在用户操作或网络请求之后。

因此,问题是:它是否会获取最新更改?

答案是:Pete

函数获取外部变量时,它们是现在的状态,它使用最近的值。

旧的变量值不会保存在任何地方。当函数需要一个变量时,它会从自己的词法环境或外部词法环境获取当前值。

重要性:5

下面的函数 makeWorker 创建另一个函数并返回它。该新函数可以在其他地方调用。

它是否可以访问其创建位置或调用位置的外部变量,或者两者都可以访问?

function makeWorker() {
  let name = "Pete";

  return function() {
    alert(name);
  };
}

let name = "John";

// create a function
let work = makeWorker();

// call it
work(); // what will it show?

它将显示哪个值?“Pete”还是“John”?

答案是:Pete

下面的代码中,work() 函数通过外部词法环境引用从其源位置获取 name

因此,结果是 "Pete"

但是,如果 makeWorker() 中没有 let name,那么搜索将到外部进行,并从上面的链中获取全局变量。在这种情况下,结果将是 "John"

重要性:5

这里我们使用相同的 makeCounter 函数创建两个计数器:countercounter2

它们是否独立?第二个计数器将显示什么?0,12,3 或其他什么?

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();
let counter2 = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1

alert( counter2() ); // ?
alert( counter2() ); // ?

答案:0,1.

函数 countercounter2 是通过 makeCounter 的不同调用创建的。

因此,它们有独立的外部词法环境,每个词法环境都有自己的 count

重要性:5

这里使用构造函数创建了一个计数器对象。

它会起作用吗?它会显示什么?

function Counter() {
  let count = 0;

  this.up = function() {
    return ++count;
  };
  this.down = function() {
    return --count;
  };
}

let counter = new Counter();

alert( counter.up() ); // ?
alert( counter.up() ); // ?
alert( counter.down() ); // ?

当然,它会正常工作。

两个嵌套函数都在相同的外部词法环境中创建,因此它们可以访问相同的 count 变量

function Counter() {
  let count = 0;

  this.up = function() {
    return ++count;
  };

  this.down = function() {
    return --count;
  };
}

let counter = new Counter();

alert( counter.up() ); // 1
alert( counter.up() ); // 2
alert( counter.down() ); // 1
重要性:5

查看代码。最后一行调用的结果是什么?

let phrase = "Hello";

if (true) {
  let user = "John";

  function sayHi() {
    alert(`${phrase}, ${user}`);
  }
}

sayHi();

结果是错误

函数 sayHiif 内声明,因此它只存在于 if 内。外部没有 sayHi

重要性:4

编写函数 sum,其工作方式如下:sum(a)(b) = a+b

是的,就是这种方式,使用双括号(不是错别字)。

例如

sum(1)(2) = 3
sum(5)(-1) = 4

为了让第二个括号起作用,第一个括号必须返回一个函数。

如下所示

function sum(a) {

  return function(b) {
    return a + b; // takes "a" from the outer lexical environment
  };

}

alert( sum(1)(2) ); // 3
alert( sum(5)(-1) ); // 4
重要性:4

这段代码的结果是什么?

let x = 1;

function func() {
  console.log(x); // ?

  let x = 2;
}

func();

P.S. 此任务中有一个陷阱。解决方案并不明显。

结果是:错误

尝试运行它

let x = 1;

function func() {
  console.log(x); // ReferenceError: Cannot access 'x' before initialization
  let x = 2;
}

func();

在此示例中,我们可以观察到“不存在”变量和“未初始化”变量之间的特殊差异。

正如您在文章 变量作用域、闭包 中所读到的,当执行进入代码块(或函数)时,变量从“未初始化”状态开始。它保持未初始化状态,直到相应的 let 语句出现。

换句话说,变量在技术上是存在的,但在 let 之前不能使用。

上面的代码对此进行了演示。

function func() {
  // the local variable x is known to the engine from the beginning of the function,
  // but "uninitialized" (unusable) until let ("dead zone")
  // hence the error

  console.log(x); // ReferenceError: Cannot access 'x' before initialization

  let x = 2;
}

变量暂时无法使用的这个区域(从代码块的开头到 let)有时称为“死区”。

重要性:5

我们有一个用于数组的内置方法 arr.filter(f)。它通过函数 f 筛选所有元素。如果它返回 true,则该元素将在结果数组中返回。

制作一组“即用型”筛选器

  • inBetween(a, b) – 在 ab 之间或等于它们(包括在内)。
  • inArray([...]) – 在给定的数组中。

用法必须如下所示

  • arr.filter(inBetween(3,6)) – 仅选择 3 到 6 之间的值。
  • arr.filter(inArray([1,2,3])) – 仅选择与 [1,2,3] 的成员之一匹配的元素。

例如

/* .. your code for inBetween and inArray */
let arr = [1, 2, 3, 4, 5, 6, 7];

alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6

alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

使用测试打开沙箱。

筛选 inBetween

function inBetween(a, b) {
  return function(x) {
    return x >= a && x <= b;
  };
}

let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6

筛选 inArray

function inArray(arr) {
  return function(x) {
    return arr.includes(x);
  };
}

let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

在沙箱中使用测试打开解决方案。

重要性:5

我们有一个要排序的对象数组

let users = [
  { name: "John", age: 20, surname: "Johnson" },
  { name: "Pete", age: 18, surname: "Peterson" },
  { name: "Ann", age: 19, surname: "Hathaway" }
];

通常的做法是

// by name (Ann, John, Pete)
users.sort((a, b) => a.name > b.name ? 1 : -1);

// by age (Pete, Ann, John)
users.sort((a, b) => a.age > b.age ? 1 : -1);

我们能使它更简洁,如下所示吗?

users.sort(byField('name'));
users.sort(byField('age'));

因此,不要编写函数,只需输入 byField(fieldName)

编写可用于此目的的函数 byField

使用测试打开沙箱。

function byField(fieldName){
  return (a, b) => a[fieldName] > b[fieldName] ? 1 : -1;
}

在沙箱中使用测试打开解决方案。

重要性:5

以下代码创建了一个 shooters 数组。

每个函数都旨在输出其数字。但有些地方不对...

function makeArmy() {
  let shooters = [];

  let i = 0;
  while (i < 10) {
    let shooter = function() { // create a shooter function,
      alert( i ); // that should show its number
    };
    shooters.push(shooter); // and add it to the array
    i++;
  }

  // ...and return the array of shooters
  return shooters;
}

let army = makeArmy();

// all shooters show 10 instead of their numbers 0, 1, 2, 3...
army[0](); // 10 from the shooter number 0
army[1](); // 10 from the shooter number 1
army[2](); // 10 ...and so on.

为什么所有射手都显示相同的值?

修复代码,使其按预期工作。

使用测试打开沙箱。

让我们检查一下 makeArmy 中到底发生了什么,这样解决方案就会显而易见了。

  1. 它创建了一个空数组 shooters

    let shooters = [];
  2. 通过循环中的 shooters.push(function) 用函数填充它。

    每个元素都是一个函数,因此生成的数组如下所示

    shooters = [
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); }
    ];
  3. 数组从函数中返回。

    然后,稍后,对任何成员的调用,例如 army[5]() 将从数组中获取元素 army[5](它是一个函数)并调用它。

    现在,为什么所有这些函数都显示相同的值 10

    这是因为 shooter 函数内部没有局部变量 i。当调用这样的函数时,它从其外部词法环境中获取 i

    那么,i 的值是多少?

    如果我们查看源代码

    function makeArmy() {
      ...
      let i = 0;
      while (i < 10) {
        let shooter = function() { // shooter function
          alert( i ); // should show its number
        };
        shooters.push(shooter); // add function to the array
        i++;
      }
      ...
    }

    我们可以看到所有 shooter 函数都是在 makeArmy() 函数的词法环境中创建的。但是当调用 army[5]() 时,makeArmy 已经完成了它的工作,并且 i 的最终值为 10while 停止于 i=10)。

    结果,所有 shooter 函数从外部词法环境中获取相同的值,即最后一个值 i=10

    如上所示,在 while {...} 块的每次迭代中,都会创建一个新的词法环境。因此,为了解决这个问题,我们可以将 i 的值复制到 while {...} 块中的一个变量中,如下所示

    function makeArmy() {
      let shooters = [];
    
      let i = 0;
      while (i < 10) {
          let j = i;
          let shooter = function() { // shooter function
            alert( j ); // should show its number
          };
        shooters.push(shooter);
        i++;
      }
    
      return shooters;
    }
    
    let army = makeArmy();
    
    // Now the code works correctly
    army[0](); // 0
    army[5](); // 5

    这里 let j = i 声明了一个“迭代局部”变量 j 并将 i 复制到其中。基本类型是“按值”复制的,因此我们实际上得到了 i 的一个独立副本,属于当前循环迭代。

    射手工作正常,因为 i 的值现在离得更近了一点。不在 makeArmy() 词法环境中,而是在与当前循环迭代相对应的词法环境中

    如果我们一开始就使用 for,也可以避免这样的问题,如下所示

    function makeArmy() {
    
      let shooters = [];
    
      for(let i = 0; i < 10; i++) {
        let shooter = function() { // shooter function
          alert( i ); // should show its number
        };
        shooters.push(shooter);
      }
    
      return shooters;
    }
    
    let army = makeArmy();
    
    army[0](); // 0
    army[5](); // 5

    这本质上是相同的,因为 for 在每次迭代中都会生成一个新的词法环境,并带有它自己的变量 i。因此,在每次迭代中生成的 shooter 引用它自己的 i,从该迭代中。

现在,既然你已经投入了如此多的精力来阅读本文,而最终的秘诀又是如此简单——只需使用 for,你可能会想——这值得吗?

好吧,如果你能轻松回答这个问题,你也不会阅读解决方案。因此,希望此任务可以帮助你更好地理解一些事情。

此外,确实存在一些情况下,人们更喜欢 while 而不是 for,以及其他此类问题真实存在的场景。

在沙箱中使用测试打开解决方案。

教程地图

评论

在评论之前先阅读此内容……
  • 如果你有改进建议,请 提交 GitHub 问题 或提交拉取请求,而不是评论。
  • 如果你无法理解文章中的某些内容,请详细说明。
  • 要插入一些代码,请使用 <code> 标签,对于多行代码,请将它们包装在 <pre> 标签中,对于超过 10 行的代码,请使用沙盒 (plnkrjsbincodepen……)