• 日常搜索
  • 百度一下
  • Google
  • 在线工具
  • 搜转载

在JavaScript中探索作用域

准确了解javascript引擎如何“思考”作用域将使您避免编写提升可能导致的常见错误,让您准备好围绕闭包进行思考,并让您更接近永远不再编写 错误。

...好吧,无论如何,它会帮助您了解提升和关闭。 

在本文中,我们将看看:

  • JavaScript 范围的基础知识

  • 解释器如何决定哪些变量属于什么范围

  • 吊装的 真正工作原理

  • ES6 关键字如何 let改变 const游戏规则

词法作用域

如果你以前写过一行 javaScript,你就会知道 定义变量的位置决定了可以 使用它们的位置。变量的可见性取决于源代码的结构这一事实称为词法 范围。

在 JavaScript 中创建作用域的三种方法:

  1. 创建一个函数. 在函数内部声明的变量仅在该函数内部可见,包括在嵌套函数中。

  2. let用代码块或 const 在代码块内声明变量 。此类声明仅在块内可见。

  3. 创建一个 catch 块。信不信由你,这确实 创造了一个新的范围!

"use strict";
var mr_global = "Mr Global";

function foo () {
    var mrs_local = "Mrs Local";
    console.log("I can see " + mr_global + " and " + mrs_local + ".");
    
    function bar () {
        console.log("I can also see " + mr_global + " and " + mrs_local + ".");
    }
}

foo(); // Works as expected

try {
    console.log("But /I/ can't see " + mrs_local + "."); 
} catch (err) {
    console.log("You just got a " + err + ".");
}

{
    let foo = "foo";
    const bar = "bar";
    console.log("I can use " + foo + bar + " in its block...");
}

try {
    console.log("But not outside of it.");   
} catch (err) {
    console.log("You just got another " + err + ".");
}

// Throws ReferenceError!
console.log("Note that " + err + " doesn't exist outside of 'catch'!")

上面的代码片段演示了所有三种作用域机制。您可以在node或 Firefox 中运行它,但 chrome 还不能很好地与 let.

我们将详细讨论每一个。让我们从详细了解 JavaScript 如何确定哪些变量属于哪个范围开始。

编译过程:鸟瞰

当你运行一段 JavaScript 时,会发生两件事让它工作。

  1. 首先,您的源代码被编译。

  2. 然后,编译的代码被执行。

期间 编译步骤,JavaScript 引擎:

  1. 记下所有变量名

  2. 在适当的范围内注册它们

  3. 为自己的价值观保留空间

只有在 执行期间,JavaScript 引擎才会真正将变量引用的值设置为等于它们的赋值值。在那之前,他们是undefined。 

第 1 步:编译

// I can use first_name anywhere in this program 
var first_name = "Peleke";

function popup (first_name) {
    // I can only use last_name inside of this function
    var last_name = "Sengstacke";
    alert(first_name + ' ' + last_name);
}

popup(first_name);

让我们逐步了解编译器的作用。

首先,它读取行 var first_name = "Peleke"。接下来,它确定 将变量保存到的范围。因为我们在脚本的顶层,所以它意识到我们在 全局范围内。然后,它将变量保存 first_name到全局范围并将其值初始化为undefined.

其次,编译器读取带有function popup (first_name). 因为function关键字是第一行,它为函数创建了一个新的作用域,将函数的定义注册到全局作用域,并在里面窥视以找到变量声明。

果然,编译器找到了一个。由于我们 var last_name = "Sengstacke"在函数的第一行有,编译器将变量保存 last_name到 范围popup——不是全局范围——并将其值设置为 undefined. 

由于函数内部不再有变量声明,编译器会退回到全局范围。并且由于那里没有更多的变量声明 ,这个阶段就完成了。

请注意,我们实际上还没有 运行任何东西。编译器此时的工作只是确保它知道每个人的名字。它不关心他们做什么。 

此时,我们的程序知道:

  1. first_name在全局范围内调用了一个变量 。

  2. popup在全局范围内调用了一个函数。

  3. last_name在 的范围内 调用了一个变量popup。

  4. first_name和 的值 last_name 都是 undefined。

它并不关心我们是否在代码的其他地方分配了这些变量值。引擎在执行时会处理这些问题。

第 2 步:执行

在下一步中,引擎再次读取我们的代码,但这一次 执行它。 

首先,它读取行,  var first_name = "Peleke". 为此,引擎会查找名为 的变量 first_name。由于编译器已经使用该名称注册了一个变量,因此引擎会找到它,并将其值设置为"Peleke".

接下来,它读取行,  function popup (first_name). 由于我们没有在此处执行该函数,因此引擎不感兴趣并跳过它。

最后,它读取行 popup(first_name)。由于我们 在这里执行一个函数,引擎:

  1. 查找的值 popup

  2. 查找的值 first_name

  3. popup作为函数 执行,将 的值first_name作为参数传递

当它执行时popup,它会经历同样的过程,但这次是在函数内部 popup。它:

  1. 查找名为的变量 last_name

  2. 设置 last_name的值等于"Sengstacke"

  3. 查找 alert,将其作为函数执行,并"Peleke Sengstacke"作为其参数

事实证明,引擎盖下发生的事情比我们想象的要多得多!

既然您了解了 JavaScript 如何读取和运行您编写的代码,我们就准备好解决一些离家更近的问题:提升的工作原理。

显微镜下的吊装

让我们从一些代码开始。

bar();

function bar () {
    if (!foo) {
        alert(foo + "? This is strange...");
    }
    var foo = "bar";
}

broken(); // TypeError!
var broken = function () {
    alert("This alert won't show up!");
}

如果你运行这段代码,你会注意到三件事:

  1. 您可以foo 在分配之前  参考 它,但它的值是undefined.

  2. 您 可以在定义之前 调用 broken 它,但您会得到一个 TypeError.

  3. 您可以在定义它之前调用 bar它,它可以按需要工作。

提升是指 JavaScript 使我们声明的所有变量名在其范围内的任何地方都可用 - 包括 在我们分配给它们之前。

代码段中的三种情况是您在自己的代码中需要注意的三种情况,因此我们将逐一介绍它们。

提升变量声明

请记住,当 JavaScript 编译器读取类似 的行时 var foo = "bar",它:

  1. 将名称注册 foo到最近的范围

  2. 将 的值设置 foo为未定义

我们可以foo在分配之前使用它的原因是,当引擎查找具有该名称的变量时,它确实存在。这就是为什么它不抛出 ReferenceError. 

相反,它会获取 value  undefined,并尝试使用它来执行您要求的任何操作。通常,这是一个错误。

记住这一点,我们可能会想象 JavaScript 在我们的函数bar中看到的更像是这样的:

function bar () {
    var foo; // undefined
    if (!foo) {
        // !undefined is true, so alert
        alert(foo + "? This is strange...");
    }
    foo = "bar";
}

如果您愿意,这是 提升的第一条规则:变量在其范围内可用,但在 undefined您的代码分配给它们之前具有值。

一个常见的 JavaScript 习惯用法是将所有 var声明写在其作用域的顶部,而不是在您第一次使用它们的地方。套用 Doug Crockford 的话说,这有助于您的代码 阅读起来更像是 运行。

当您考虑它时,这是有道理的。很清楚为什么bar当我们以 JavaScript 读取代码的方式编写代码时的行为方式,不是吗?那么为什么不一直这样写呢?  

提升函数表达式

TypeError我们在定义之前尝试执行时 得到了一个事实,broken这只是提升第一规则的一个特例。

我们定义了一个变量,称为 broken,编译器在全局范围内注册并设置为 undefined。当我们尝试运行它时,引擎会查找 的值 broken,发现它是 undefined,并尝试 undefined作为函数执行。

显然,undefined is 不是一个函数——这就是为什么我们得到一个 TypeError!

提升函数声明

最后,回想一下,我们能够 bar在定义它之前调用它。这是由于提升的第二条规则:当 JavaScript 编译器找到一个函数声明时,它会将其名称 和定义都放在其作用域的顶部。再次重写我们的代码:

function bar () {
    if (!foo) {
        alert(foo + "? This is strange...");
    }
    var foo = "bar";
}

var broken; // undefined

bar(); // bar is already defined, executes fine

broken(); // Can't execute undefined!

broken = function () {
    alert("This alert won't show up!");
}

 同样,当您像 JavaScript读取那样编写 时,它更有意义,您不觉得吗?

回顾:

  1. 变量声明和函数表达式的名称在它们的范围内都是可用的,但它们的 值 直到undefined赋值。

  2. 函数声明的名称 和定义在其范围内都可用,甚至在 定义之前。

现在让我们来看看两种工作方式略有不同的新工具: let和 const.

let,  const, & 时间死区

与 var 声明不同,使用声明的变量 let 不会 const 被 编译器提升。

至少,不完全是。 

还记得我们如何能够跟注,但却因为我们试图执行 broken而得到一个 吗?如果我们用 定义,我们会得到一个 ,而不是:TypeErrorundefinedbrokenletReferenceError

"use strict"; 
// You have to "use strict" to try this in Node
broken(); // ReferenceError!
let broken = function () {
    alert("This alert won't show up!");
}

当 JavaScript 编译器在第一次传递中将变量注册到它们的作用域时,它会以 let不同 const 的方式 处理var. 

当它找到一个 var声明时,我们将变量的名称注册到它的作用域并立即将它的值初始化为 undefined.

但是,使用 let时,编译器 会将变量注册到其作用域,但 不会 将其值初始化为 undefined。相反,它使变量未初始化, 直到 引擎执行您的赋值语句。访问未初始化变量的值会抛出 a  ReferenceError,这解释了为什么上面的代码片段在我们运行时会抛出。

声明范围顶部 的开头let 和赋值语句 之间的空间称为时间死区。这个名字来源于这样一个事实,即使引擎知道foo在 的范围顶部 调用 bar的变量,该变量是“死的”,因为它没有值。

...也因为如果您尝试尽早使用它,它会杀死您的程序。

关键字的 const工作方式与 相同 let,但有两个主要区别:

  1. 声明时必须 赋值 const。

  2. 您 不能将值重新分配给用 . 声明的变量 const。

这保证了 const它将 始终 具有您最初分配给它的值。

// This is legal
const react = require('react');

// This is totally not legal
const crypto;
crypto = require('crypto');

块范围

let并且与 另一种方式const不同 var:它们的范围大小。

当你用 声明一个变量时 var,它是可见的 在作用域链上尽可能高——通常在最近的函数声明的顶部,或者在全局作用域中,如果你在顶层声明它。 

let但是,当您使用or 声明变量时 ,它尽可能在本地const可见 - 仅 在最近的块内。

块是由大括号括起来的  一段代码,正如您在 if/else 块、 for 循环和显式“阻塞”的代码块中看到的那样,就像在这个片段中一样。

"use strict";

{
  let foo = "foo";
  if (foo) {
      const bar = "bar";
      var foobar = foo + bar;

      console.log("I can see " + bar + " in this bloc.");
  }
  
  try {
    console.log("I can see " + foo + " in this block, but not " + bar + ".");
  } catch (err) {
    console.log("You got a " + err + ".");
  }
}

try {
  console.log( foo + bar ); // Throws because of 'foo', but both are undefined
} catch (err) {
  console.log( "You just got a " + err + ".");
}

console.log( foobar ); // Works fine

const如果您在块中或 在块内声明变量 let,则它 仅在块内可见,并且 只有在您分配它之后才可见。

var然而, 用 声明的变量在 尽可能远的地方是可见的——在这种情况下,是在全局范围内。

let如果您对and 的细节感兴趣const,请查看 Rauschmayer 博士在Exploring ES6: Variables and Scoping中对它们的看法,并查看有关它们的 MDN 文档。  

词汇 this和箭头函数

从表面上看, this似乎与范围没有太大关系。而且,事实上,JavaScript 并 没有this根据我们在这里讨论的范围规则来解析 的含义 。

至少,通常不会。众所周知,JavaScript 不会 根据this 您使用它的位置来 解析关键字的含义 :

var foo = {
    name: 'Foo',
    languages: ['Spanish', 'French', 'Italian'],
    speak : function speak () {
        this.languages.forEach(function(language) {
            console.log(this.name + " speaks " + language + ".");
        })
    }
};

foo.speak();

我们大多数人都希望 在循环内this表示 ,因为这就是在循环外的意思foo。 forEach换句话说,我们希望 JavaScript 能够解析this lexically的含义。

但事实并非如此。

相反,它会在您定义的每个函数中创建一个新 this函数,并根据您调用该函数的方式来决定它的含义 ——而不是 您定义它的位置。

第一点类似于 在子范围内重新定义任何变量的情况:

function foo () {
    var bar = "bar";
    function baz () {
        // Reusing variable names like this is called "shadowing" 
        var bar = "BAR";
        console.log(bar); // BAR
    }
    baz();
}

foo(); // BAR

替换 bar为 this,整个事情应该立即清除!

传统上,要 this像我们期望的普通旧词法范围变量那样工作,需要以下两种解决方法之一:

var foo = {
    name: 'Foo',
    languages: ['Spanish', 'French', 'Italian'],
    speak_self : function speak_s () {
        var self = this;
        self.languages.forEach(function(language) {
            console.log(self.name + " speaks " + language + ".");
        })
    },
    speak_bound : function speak_b () {
        this.languages.forEach(function(language) {
            console.log(this.name + " speaks " + language + ".");
        }.bind(foo)); // More commonly:.bind(this);
    }
};

在speak_self中,我们将 的含义保存 this到变量self中,并使用 该 变量来获取我们想要的引用。在中, speak_bound我们使用 永久bind 指向给定对象。this

ES2015 为我们带来了一个新的选择:箭头函数。

与“普通”函数不同,箭头函数不会this通过设置自己的值来隐藏其父作用域的值。相反,它们通过 词汇来解析其含义。 

换句话说,如果你 this在箭头函数中使用,JavaScript 会像查找任何其他变量一样查找它的值。

首先,它检查本地范围的this值。由于箭头函数不设置一个,它不会找到一个。接下来,它检查 父作用域的 this值。如果它找到一个,它将使用它,而不是。

这让我们可以像这样重写上面的代码:

var foo = {
    name: 'Foo',
    languages: ['Spanish', 'French', 'Italian'],
    speak : function speak () {
        this.languages.forEach((language) => {
            console.log(this.name + " speaks " + language + ".");
        })
    }
};

如果您想了解有关箭头函数的更多详细信息,请查看 Envato Tuts+ 讲师Dan Wellman 的JavaScript ES6 Fundamentals优秀课程 ,以及有关箭头函数的 MDN 文档。

结论

到目前为止,我们已经覆盖了很多领域!在本文中,您了解到:

  • 变量在 编译期间注册到它们的作用域,并在执行期间与它们的赋值相关联 。

  • 引用在赋值时或 之前声明的变量会抛出 a  ,并且这些变量的作用域是最近的块。 letconstReferenceError

  • 箭头函数 允许我们实现 的词法绑定 this,并绕过传统的动态绑定。

您还看到了提升的两条规则:

  • 提升的 第一条规则:函数表达式和 var 声明在定义它们的范围内都可用,但在 undefined 赋值语句执行之前具有值。

  • 提升的 第二条规则:函数声明的名称 及其 主体在定义它们的范围内都是可用的。



文章目录
  • 词法作用域
  • 编译过程:鸟瞰
    • 第 1 步:编译
    • 第 2 步:执行
  • 显微镜下的吊装
    • 提升变量声明
    • 提升函数表达式
    • 提升函数声明
    • let, const, 时间死区
    • 块范围
    • 词汇 this和箭头函数
  • 结论