在变量中存储值是编程中的一个基本概念。变量的“范围”决定了它在整个程序中何时可用和不可用。理解 JavaScript 中的变量作用域是在语言中打下坚实基础的关键之一。
本文将解释 JavaScript 的作用域系统是如何工作的。您将了解声明变量的不同方式、局部作用域和全局作用域之间的区别,以及称为“提升”的东西——一种 JavaScript 怪癖,可以将看似无辜的变量声明变成一个微妙的错误。
变量范围
在 JavaScript 中,变量的范围由变量声明的位置控制,它定义了可以访问特定变量的程序部分。
目前,在 JavaScript 中声明变量的方法有三种:使用 oldvar关键字,以及使用 newlet和const关键字。在 ES6 之前,使用var关键字是声明变量的唯一方法,但现在我们可以使用letand const,它有更严格的规则,并且代码更不容易出错。我们将在下面探讨所有三个关键字之间的差异。
范围规则因人而异。JavaScript 有两个作用域:global和local。本地作用域有两种变体:旧的函数作用域和ES6 引入的新块作用域。值得注意的是,函数作用域实际上是块作用域的一种特殊类型。
全球范围
在脚本中,最外层的作用域是全局作用域。在此范围内声明的任何变量都将成为全局变量,并且可以从程序中的任何位置访问:
// Global Scope
const name = "Monique";
function sayHi() {
console.log(`Hi ${name}`);
}
sayHi();
// Hi Monique
正如这个简单的示例所示,该变量name是全局变量。它是在全局范围内定义的,并且可以在整个程序中访问。
但是,尽管这看起来很方便,但在 JavaScript 中不鼓励使用全局变量。例如,这是因为它们可能会被其他脚本或程序中的其他地方覆盖。
本地范围
在块内声明的任何变量都属于该特定块并成为局部变量。
varJavaScript 中的函数定义了使用,let和声明的变量的范围const。在该函数中声明的任何变量只能从该函数和任何嵌套函数中访问。
代码块(if,for等)仅为使用letandconst关键字声明的变量定义范围。该var关键字仅限于函数作用域,这意味着只能在函数内部创建新作用域。
letandconst关键字具有块作用域,它为声明它们的任何块创建一个新的本地作用域。您还可以在 JavaScript 重新定义独立的代码块,它们类似地划定一个范围:
{
// standalone block scope
}
函数和块作用域可以嵌套。在这种情况下,使用多个嵌套范围,可以在其自己的范围内或从内部范围访问变量。但在其范围之外,该变量是不可访问的。
帮助可视化范围的简单示例
为了清楚起见,让我们使用一个简单的比喻。我们世界上的每个国家都有边界。这些边界内的一切都属于国家的范围。每个国家都有很多城市,每个城市都有自己的城市范围。国家和城市就像 JavaScript 函数或块。他们有自己的本地范围。各大洲也是如此。尽管它们的大小很大,但它们也可以定义为语言环境。
另一方面,世界海洋不能被定义为具有局部范围,因为它们实际上包裹了所有局部对象——大陆、国家和城市——因此,它们的范围被定义为全球。让我们在下一个示例中对此进行可视化:
var locales = {
europe: function() { // The Europe continent's local scope
var myFriend = "Monique";
var france = function() { // France country's local scope
var paris = function() { // The Paris city's local scope
console.log(myFriend); // output: Monique
};
paris();
};
france();
}
};
locales.europe();
在这里,myFriend变量可以从paris函数中获得,因为它是在france函数的外部作用域中定义的。如果我们交换myFriend变量和控制台语句,我们会得到ReferenceError: myFriend is not defined,因为我们无法从外部作用域到达内部作用域。
现在我们了解了本地和全局范围是什么以及它们是如何创建的,现在开始学习 JavaScript 解释器如何使用它们来查找特定变量的时候了。
回到给定的比喻,假设我想找到一个名叫 Monique 的朋友。我知道她住在巴黎,所以我从那里开始寻找。当我在巴黎找不到她时,我会上一层楼,在整个法国扩大我的搜索范围。但又一次,她不在那里。接下来,我通过更上一层楼再次扩大我的搜索范围。最后,我在意大利找到了她,在我们的案例中,意大利是欧洲的本地范围。
在前面的示例中,我的朋友 Monique 有变量 表示myFriend。在最后一行我们调用europe()函数,它调用france(),最后当paris()函数被调用时,搜索开始。JavaScript 解释器从当前执行的作用域开始工作,直到找到有问题的变量为止。如果在任何范围内都找不到该变量,则会引发异常。
这种类型的查找称为词法(静态)范围。程序的静态结构决定了变量范围。变量的范围由其在源代码中的位置定义,嵌套函数可以访问在其外部范围中声明的变量。无论从哪里调用函数,甚至如何调用它,它的词法范围都只取决于函数的声明位置。
现在让我们看看新的块作用域是如何工作的:
function testScope(n) {
if (true) {
const greeting = 'Hello';
let name = n;
console.log(greeting + " " + name); // output: Hello [name]
}
console.log(greeting + " " + name); // output: ReferenceError: greeting is not defined
}
testScope('David');
在这个例子中,我们可以看到用 and 声明的andgreeting变量name在块外是不可访问的。constletif。
现在让我们替换andconst看看会发生什么:letvar。
function testScope(n) {
if (true) {
var greeting = 'Hello';
var name = n;
console.log(greeting + " " + name); // output: Hello [name]
}
console.log(greeting + " " + name); // output: Hello [name]
}
testScope('David');
如您所见,当我们使用var关键字时,变量在整个函数范围内都是可访问的。
在 JavaScript 中,可以在多层嵌套范围内指定同名变量。在这种情况下,局部变量优先于全局变量。如果你声明了一个同名的局部变量和一个全局变量,当你在函数或块中使用它时,局部变量将优先。这种类型的行为称为遮蔽。简单地说,内部变量遮蔽了外部变量。
这就是 JavaScript 解释器试图查找特定变量时使用的确切机制。它从当时正在执行的最内层范围开始,一直持续到找到第一个匹配项,无论外层级别中是否存在其他同名变量。让我们看一个例子:
var test = "I'm global";
function testScope() {
var test = "I'm local";
console.log (test);
}
testScope(); // output: I'm local
console.log(test); // output: I'm global
即使名称相同,局部变量在函数执行后也不会覆盖全局变量testScope()。但情况并非总是如此。让我们考虑一下:
var test = "I'm global";
function testScope() {
test = "I'm local";
console.log(test);
}
console.log(test); // output: I'm global
testScope(); // output: I'm local
console.log(test); // output: I'm local (the global variable is reassigned)
这一次,局部变量test覆盖了同名的全局变量。当我们在testScope()函数内部运行代码时,全局变量被重新分配。如果一个局部变量在没有首先用关键字声明的情况下被赋值var,它就变成了一个全局变量。为避免此类不良行为,您应始终在使用局部变量之前声明它们。在函数中使用关键字声明的任何变量var都是局部变量。声明变量被认为是最佳实践。
注意:在严格模式下,如果没有先声明变量就给变量赋值是错误的。
吊装
JavaScript 解释器在幕后执行许多操作,其中之一就是“提升”。如果您不知道这种“隐藏”行为,可能会引起很多混乱。考虑 JavaScript 变量行为的最佳方式是始终将它们可视化为由两部分组成:声明和初始化/赋值:
var state; // variable declaration
state = "ready"; // variable assignment
var state = "ready"; // declaration plus assignment
在上面的代码中,我们首先声明了变量state,然后我们给它赋值"ready"。而在最后一行代码中,我们看到这两个步骤可以合并。但是您需要记住的是,即使它们看起来像一个语句,实际上 JavaScript 引擎也会将该单个语句视为两个单独的语句,就像示例的前两行一样。
我们已经知道在一个范围内声明的任何变量都属于该范围。但我们还不知道的是,无论变量在特定范围内声明的位置,所有变量声明都会移动到其范围的顶部(全局或局部)。这称为提升,因为变量声明被提升到范围的顶部。请注意,提升只会移动声明。任何分配都留在原地。让我们看一个例子:
console.log(state); // output: undefined
var state = "ready";
如您所见,当我们记录 的值时state,输出为undefined,因为我们在实际赋值之前引用了它。您可能期望 aReferenceError被抛出,因为state尚未声明。但你不知道的是,变量是undefined在幕后用默认值声明和初始化的。以下是 JavaScript 引擎解释代码的方式:
var state; // moved to the top
console.log(state);
state = "ready"; // left in place
重要的是要注意变量没有物理移动。吊装只是一个模型,描述了 JS 引擎在幕后所做的事情。
现在,让我们看看提升是如何与let变量一起工作的:
{
// Temporal dead one (TDZ) starts at the beginning of the scope
console.log(state); // output: "ReferenceError: Cannot access 'state' before initialization
let state = "ready"; // end of TDZ. TDZ ends at actual variable declaration
}
在此示例中,控制台输出不是undefined,但会引发引用错误。为什么?let与变量相比,var变量在完全初始化之前无法读取/写入。它们仅在代码中实际声明的地方才被完全初始化。因此,let变量声明被提升但未使用undefined值初始化,变量就是这种情况var。从块开始到实际变量声明的部分称为Temporal Dead Zone。这是一种确保更好的编码实践的机制,强制您在使用变量之前声明它。如果我们将控制台语句移出 TDZ,我们将得到预期的输出:ready。
{
// Temporal dead one (TDZ) starts at the beginning of the scope
let state = "ready"; // end of TDZ. TDZ ends at actual variable declaration
console.log(state); // output: ready
}
用关键字声明的变量与变量const具有相同的行为let。
职能
提升也会影响函数声明。但在我们看一些例子之前,让我们先了解一下函数声明和函数表达式之间的区别:
function showState() {} // function declaration
var showState = function() {}; // function expression
区分函数声明和函数表达式的最简单方法是检查单词function在语句中的位置。如果function是语句中的第一件事,那么它就是一个函数声明。否则,它是一个函数表达式。
函数声明被完全提升。这意味着整个函数的主体被移动到顶部。这允许您在声明函数之前调用它:
showState(); // output: Ready
function showState() {
console.log("Ready");
}
var showState = function() {
console.log("Idle");
};
上述代码有效的原因是 JavaScript 引擎将showState()函数的声明及其所有内容移到了作用域的开头。代码解释如下:
function showState() { // moved to the top (function declaration)
console.log("Ready");
}
var showState; // moved to the top (variable declaration)
showState();
showState = function() { // left in place (variable assignment)
console.log("Idle");
};
您可能已经注意到,只有函数声明被提升,但函数表达式没有。将函数分配给变量时,规则与变量提升的规则相同(仅移动声明,而分配保留在原地)。
在上面的代码中,我们看到函数声明优先于变量声明。在下一个示例中,我们将看到,当我们有一个函数声明与一个变量赋值时,最后一个优先:
var showState = function() {
console.log("Idle");
};
function showState() {
console.log("Ready");
}
showState(); // output: Idle
这一次,我们showState()在代码的最后一行调用了函数,这改变了情况。现在我们得到输出"Idle"。下面是它在被 JavaScript 引擎解释时的样子:
function showState(){ // moved to the top (function declaration)
console.log("Ready");
}
var showState; // moved to the top (variable declaration)
showState = function(){ // left in place (variable assignment)
console.log("Idle");
};
showState();
注意:箭头函数与函数表达式的工作方式相同。
课程
类声明也以与用let语句声明的变量类似的方式提升:
// Using the Person class before declaration
var user = new Person('David', 33); // output: ReferenceError: Cannot access 'Person' before initialization
// Class declaration
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
在这个例子中,我们可以看到Person在声明之前使用类会产生类似于在let变量中的引用错误。为了解决这个问题,我们必须Person在声明之后使用类:
// Class declaration
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
// Using the Person class after declaration
var user = new Person('David', 33);
console.log(user);
也可以使用类表达式、 usingvar或let变量const声明语句来创建类:
// Using the Person class
console.log(typeof Person); // output: undefined
var user = new Person('David', 33); // output: TypeError: Person is not a constructor
// Class declaration using variable statement
var Person = class {
constructor(name, age) {
this.name = name;
this.age = age;
}
};
在这个例子中,我们可以看到这个Person类被提升为一个函数表达式,但它不能被使用,因为它的值是undefined. 同样,为了解决这个问题,我们必须Person在声明之后使用类:
// Using the Person class
console.log(typeof Person); // output: undefined
// Class declaration using variable statement
var Person = class {
constructor(name, age) {
this.name = name;
this.age = age;
}
};
// Using the Person class after declaration
var user = new Person('David', 33);
console.log(user);
要记住的事情
- var变量是函数范围的。
- let并且const变量是块范围的(这也包括函数)。
- 在执行代码的任何部分之前,所有声明(类、函数和变量)都被提升到包含范围的顶部。
- 首先提升函数,然后提升变量。
- 函数声明优先于变量声明,但不高于变量赋值。