JavaScript 部分语法和作用域详解

在本章中,我们将继续探索 JavaScript 的语法和构造。 我们将深入研究表达式、语句、块、作用域和闭包的基础知识。 这些是语言中较不显眼的部分。 大多数程序员认为他们已经很好地掌握了诸如表达式和作用域之类的东西是如何工作的,但是,正如我们所看到的,我们对事物应该如何工作的直觉可能并不总是与它们真正的工作方式一致。 我们将在本章中学习的构造是我们程序的关键的更大的构建块,所以在我们探索更抽象的概念(如控制流和设计模式)之前,完全理解它们是至关重要的。

Why are we learning this now?We've now got a solid grasp of what types are available in JavaScript and how to manipulate them via operators. The next logical step is to study syntactic scaffolding components, where we can place these types and operations, and how these scaffolding components behave. The end goal here is a high level of fluency in JavaScript so that we are better able to write clean code.

在本章中,我们将涵盖以下主题:

JavaScript 中大致存在三种语法容器:表达式、语句和块。 它们都是容器,因为它们都包含其他语法片段,并且都有值得区分的不同行为。

There are additional constructs that you can call containers, such as functions or modules, but for now we're only interested in the types of syntax that you would find within these. As we continue to explore the language, we are slowly zooming out all the way from granular operators and expressions to the much larger and more complex functions and programs in which they reside.

最好将程序的各个语法部分形象化为一个层次结构:

在这里,我们可以看到单个的表达式(带有下边框)被包装在语句中,要么是regular,要么是block。 在我们的头脑中始终保持这种语言的层次观是很有用的,因为这是机器解析和理解代码的方式。 当然,我们不需要像解析器那样查看代码,但知道代码将如何解析无疑是有用的。

这种语言的层次观也将帮助我们编写程序,以便更好地与我们的程序员同事交流它们的意图。 层次结构不仅是句法上的问题,也是人的问题。 当我们编写一个程序时,我们通常会在不同的抽象层上建模问题:程序的每个部分都包含在另一个部分中,从所有这些单独的部分中,我们可以构建一个包含许多不同复杂性层的程序。

在我们研究 JavaScript 的语法部分时,有必要记住程序语法的各个元素(表达式和语句)如何与问题域的各个元素和层具有自然的对称性。

表达式是最细粒度的语法容器类型。 我们已经在表达方面做了很多工作。 即使是表达一个文字值,比如数字1,也会生成一个表达式:

1 // <= An expression containing the literal value 1

使用操作符也可以形成一个表达式:

'hi ' + 'there'

事实上,我们可以把运算符看作是应用于表达式的东西。 所以加法运算符的语法可以这样理解:

EXPRESSION + EXPRESSION

表达式可以像文字值或变量引用一样简单,但也可能很复杂。 下面的表达式包含一系列操作,并分散在几行中:

(
  'this is part of' +
  ' ' +
  ['a', 'very', 'long', 'expression'].join(' ')
)

表达式不限于基本类型或简单的文字值。 类定义、函数表达式、数组字面量和对象字面量都可以出现在表达式的上下文中。 要知道某事物是否为表达式,最简单的方法是看它是否可以放在组操作符(即括号)中而不会导致SyntaxError:

(class Foo {});   // Legal Expression
(function() {});  // Legal Expression
([1, 2, 3]);      // Legal Expression
({ a: 1, b: 2 }); // Legal Expression

(if (a) {});      // ! SyntaxError (Not an Expression!)
(while (x) {});   // ! SyntaxError (Not an Expression!)

任何程序的语法构建块都涉及不同的语法结构层。 我们有单独的值和引用:如果我们稍微缩小一点,我们有表达式,如果我们进一步缩小,我们有语句,我们现在将探讨。

语句包含一个表达式,因此是另一种语法容器。 了解 JavaScript 如何将表达式与语句区分开来,对于避免该语言的各种陷阱和特性非常有帮助。

陈述是在各种情况下形成的。 其中包括:

  • 当您用分号(1 + 2;)结束表达式时
  • 当您使用forwhileswitchdo..whileif结构时
  • 当你通过函数声明创建一个函数时(function Something() {})
  • 它们由语言的自然自动分号插入(ASI)自动形成

The syntax of a function declaration (function name() {}) will always form a statement unless it appears in the context of an expression, in which case it'll naturally be a *named function expressio*n. For the nuanced differences between these, please revisit Chapter 6, Primitive and Built-In Types.

当我们把一个表达式放在另一个表达式之后时,我们倾向于用分号结束每个表达式。 通过这样做,我们形成了一个声明。 显式终止语句可以确保 JavaScript 解析器不必自动终止语句。 如果您不使用分号,那么解析器将通过一个称为 ASI 的进程猜测在哪里插入分号。 这个过程依赖于新行(即\n)的位置。

由于 ASI 是自动的,它不会总是提供你想要的结果。 例如,考虑以下情况,其中有一个函数表达式后面跟着一个打算作为组的语法(即由括号分隔的表达式):

(function() {})
(
 [1, 2, 3]
).join(' ')

这将导致一个神秘的TypeError,说:Cannot read property join of undefined。 这是因为,从解析器的角度来看,下面是我们正在做的事情:

(function() {})([1, 2, 3]).join(' ')

在这里,我们创建了一个内联匿名函数,然后立即调用它,将[1, 2, 3]数组作为唯一的参数,然后我们试图在返回的任何内容上调用join方法。 但是当我们的函数返回undefined时,那里没有join方法,因此我们接收到一个错误。 这是一个罕见的情况,但这个问题的变化确实不时出现。 避免使用分号的最佳方法是始终如一地使用分号来终止语句行,如下代码所示:

(function() {});
(
 [1, 2, 3]
).join(' ');

ASI 也可以在其他方面咬你。 一个常见的例子是,当您试图在函数中使用return语句时,预期的返回值在下一行。 在这种情况下,你会大吃一惊:

function sum(a, b) {
  return
    a + b;
}
sum(a, b); // => undefined (odd!)

JavaScript 的 ASI 机制会假设,如果在同一行中没有其他内容,return语句就会终止,所以下面的内容更接近 JavaScript 引擎运行代码时看到的内容:

function sum(a, b) {
  return;
  a + b;
}

为了解决这个问题,我们可以将a + breturn语句放在同一行,或者使用组操作符来包含缩进表达式:

function sum(a, b) {
  return (
    a + b
  );
}

没有必要知道 ASI 的每一条规则,但知道它的存在是非常有用的。 与 ASI 合作的最佳方式是尽可能避免它。 如果您明确说明语句的终止,那么您就不需要依赖晦涩的 ASI 规则,也不需要依赖知道这些规则的程序员同事。

如果我们把语句看作是表达式的容器,那么我们就可以把块看作是语句的容器。 在其他语言中,它们有时被称为复合语句,因为它们允许多个语句同时存在。

Strictly speaking, blocks are statements. From a language-design perspective, this is a useful thing because it allows statements that form part of other constructs to be expressed as either single-line statements or entire blocks containing several statements—for example, following if(...) or for(...) constructs.

块是由用花括号分隔零个或多个语句组成的:

{
  // I am inside a block
  let foo = 123;
}

块很少被用作完全独立的代码单元(这样做的好处非常有限)。 你通常会在ifwhileforswitch语句中找到它们,如下:

while (somethingIsTrue()) {
  // This is a block
  doSomething();
}

while循环的{...}部分在这里是一个块。 它不是while语法的固有部分。 如果我们愿意,我们可以完全排除块,在它的位置上只使用一个常规的单行语句:

while (somethingIsTrue()) doSomething();

这将与我们使用块的版本相同,但显然,如果我们打算添加更多的迭代逻辑,这将是有限制的。 因此,在这种情况下先发制人地使用块通常是更好的选择。 这样做还有一个额外的好处,那就是使缩进合法化并包含迭代逻辑。

块不仅是语法容器。 它们通过提供自己的作用域来影响我们代码的运行时,这意味着我们可以通过constlet语句声明变量。 请注意我们是如何在if块中声明变量的,而在该块之外又如何声明变量不可用:

if (true) {
  let me = 'here';
  me; // => "here"
}

me; // ! ReferenceError 

范围是一个我们不能掉以轻心的话题。 这是很难理解的,所以接下来我们会用一整节的时间来探索它的本质和细微差别。

给定变量的作用域可以被认为是程序中该变量可以被访问的区域。

当我们在模块的开头(在所有函数之外)声明一个变量时,我们认为这个变量应该可以被模块中的所有函数访问,这是很自然的:

var hello = 'hi';

function a() {
  hello; // a() can "see" the hello variable
}

function b() {
  hello; // b() can "see" the hello variable
}

如果我们在函数中定义一个变量,那么我们希望所有内部函数都能访问它:

var value = 'I exist';

function doSomething() {
  value; // => "I exist"
}

事实上,我们可以在这里的doSomething函数中访问value,这要归功于它的作用域。 给定变量的作用域取决于如何声明它。 当您通过var声明声明一个变量时,它将具有与通过let声明创建的变量不同的潜在作用域。 我们将很快讨论这些差异,但首先,更清楚地了解作用域如何在内部运行是有用的。

在内部,当您声明变量时,JavaScript 将在词法环境中创建并存储该变量,该环境包含标识符到值的映射。 一个典型的 JavaScript 程序可以被认为有四种词法环境,如下表所示:

  • 全局环境:只有一个范围,它被认为是所有其他范围的外部范围。 它是所有其他环境(即作用域)都存在的全局上下文。 全局环境镜像一个全局对象,该对象可以在浏览器中被windowself引用,在 Node.js 中被global引用。
  • :该环境将为作为单一 Node.js 进程的一部分运行的每个不同的 JavaScript 模块或浏览器中的每个<script type="module">创建。
  • :该环境将对每个运行的函数有效,无论它是声明或调用。
  • 块环境:该环境将对程序中的每个块({...})有效,无论它是否遵循另一种语言结构,如if(...)while(...),或位于独立位置。

如您所知,函数和块都可以存在于其他函数和块中。 考虑下面这段表达各种环境(作用域)的代码:

function setupApp(config) {

  return {
    setupUserProfileMenu() {

      if (config.isUserProfileEnabled) {

        const onDoneRendering = () => {
          console.log('Done Rendering!');
        };

        // (Do some rendering here...)
        onDoneRendering();

      }

    }
  };

}

setupApp({ isUserProfileEnabled: true }).setupUserProfileMenu();

Done Rendering!被记录的地方,我们可能会期望环境的层次结构看起来像这样:

Browser Global Environment
\--> Function Environment (setupApp)
     \--> Block Environment (if block)
          \--> Function Environment (onDoneRendering)

这种环境层次结构将在给定程序的运行时发生变化。 如果一个函数被运行到完成,并且它的内部作用域不再用于任何公开的内部函数(称为闭包),那么词法环境将被破坏。 从本质上讲,当一个范围被保证不再需要时,JavaScript 就可以自由地摆脱它。

变量声明是通过一个var关键字,后跟一个有效的标识符或赋值a = b形式实现的:

var foo;
var baz = 123;

We call things declared via var keyword variable declarations, but it's important to note that, in popular terminology, declarations made by both let and const are also considered variables. 

通过var声明的变量作用域是最近的函数、模块或全局环境——也就是说,它们不是块作用域的。 在解析时,将收集给定范围内的变量声明,然后在执行时,这些声明的变量将被提升到其执行上下文的顶部,并使用undefined值进行初始化。 这意味着,在给定的作用域内,从技术上讲,你可以在赋值之前访问一个变量,但它将是undefined:

foo; // => undefined
var foo = 123;
foo; // => 123

The execution context is a name given to the top of the call stack, meaning the currently running function, script, or module. It is a concept that is only seen when code is run, and will change as the program progresses. You can usually think of it as simply the currently-running function (or outer module or <script>). var declarations are always hoisted to the top of their execution context and initialized to undefined.

var的提升行为与通过letconst声明的变量相反,如果你试图在声明之前访问它们,它们将产生ReferenceError:

thing; // ! ReferenceError: Cannot access 'thing' before initialization
let thing = 123; 

如果不小心,var的提升行为可能会导致一些意想不到的结果。 例如,可能会出现这样一种情况:你试图引用一个存在于外部作用域中的变量,但由于当前作用域中的变量声明被挂起,你无法这样做:

var config = {};

function setupUI() {
  config; // => undefined
  var config;
}

setupUI();

在这里,内部作用域的变量声明config将被提升到其作用域的顶部,这意味着,从setupUI的第一行开始,config就是undefined

由于变量声明会被提升到执行上下文的最顶端,即使是块内的变量声明也会被提升,就像它们是在块外初始化的一样:

// This:
// (VariableDeclaration inside a Block)
if (true) {
  var value = 123;
} 

// ... Is equivalent to:
// (VariableDeclaration preceding a Block)
var value;
if (true) {
  value = 123
};

总之,变量声明创建的变量的作用域是最近的函数、模块或全局环境。 在浏览器中,没有模块环境,所以它的作用域要么是其函数作用域,要么是全局作用域。 在执行之前,变量声明将被提升到其各自的执行上下文的顶部。 这可能是函数、模块(在 Node.js 中)或<script>(在浏览器中)。 由于最近引入了constlet声明,变量声明已经不再受欢迎,这两个声明都是块作用域的,并且没有任何奇怪的提升行为。

谢天谢地,Let 声明比var声明简单得多。 它们的作用域将限定在离它们最近的环境中(无论是块、函数、模块还是全局环境),并且没有复杂的提升行为。

它们的作用域是一个块,这意味着块中的 let 声明不会对outer函数作用域产生影响。 在下面的代码中,我们可以看到三个不同的环境(作用域),每个环境中都有一个各自的place变量:

let place = 'outer';

function foo() {
  let place = 'function';

  {
    let place = 'block';
    place; // => "block"
  }

  place; // => "function"
}

foo();
place; // => "outer"

这向我们展示了两件事:

  • outer作用域中,通过let声明不会覆盖或改变同名的变量
  • 通过let声明将允许每个作用域有自己的变量,对于outer作用域来说是不可见的

当在for(;;)for...infor...of构造中使用let时,即使在下面的块之外,let声明的作用域也会像在块内部一样。 这是很直观的:当我们用 let 声明初始化一个for循环时,我们自然地希望那些作用域是在for循环本身而不是在它外面:

for (let i = 0; i < 5; i++) {
  console.log(i); // Logs: 0, 1, 2, 3, 4
}
console.log(i); // ! ReferenceError: i is not defined

我们应该只使用let,如果我们期望变量在稍后的某个点被重新分配。 如果没有新的任务发生,那么我们应该选择const,因为它给了我们一点额外的内心平静。

const声明具有与let相同的特征,除了一个关键的区别:通过const声明的变量是不可变的,这意味着变量不能被重新赋值:

const pluto = 'a planet';
pluto = 'a dwarf planet'; // ! TypeError: Assignment to constant variable.

需要注意的是,这并不影响值本身的可变性。 因此,如果该值是任何类型的对象,那么它的所有属性将保持其可变性:

const pluto = { designation: 'a planet' };

// Assignment to a property:
pluto.designation = 'a dwarf planet';

// It worked! (I.e. the object is mutable)
pluto.designation; // => "a dwarf planet"

尽管const并没有保护值不受所有的可变性影响,但它确实保护了我们不受一些常见错误和不良实践的影响,比如重用一个变量来引用几个不同的概念,或者由于输入错误而意外地重新分配一个变量。 代码短语const通常比let使用更安全,现在被认为是声明所有变量的最佳实践,除非您明确需要在声明后对变量重新赋值。

当在for...offor...in迭代结构中声明变量时,你也可以使用const,例如以下情况:

for (const n of [4, 5, 6]) console.log(n);
// Logs 4, 5, 6

人们经常错误地选择在这里使用let,因为他们认为循环结构将有效地重新分配变量,使const不合适。 但实际上,for(...)内的声明将在每次迭代时绑定到一个新的块作用域,因此const变量将在每次迭代时在这个新作用域内重新初始化。

在作用域方面,函数声明的行为类似于变量声明(即var)。 它们将被限定在与其最近的功能、模块或全局环境中,并将被提升到各自的执行上下文的顶部。

然而,与变量声明不同的是,函数声明也会导致对其标识符的Function实际赋值被悬挂,这意味着Function在声明之前是有效可用的:

myFunction(); // => "This works!"
function myFunction() { return 'This works!' }

这种行为是相当模糊的,因此是不可取的,除非非常明显的myFunction的定义来自于调用。 程序员通常希望函数的定义存在于调用它的位置之上(或在之前的某个时间点作为依赖项导入),所以这可能会令人困惑。

如果我们考虑函数声明驻留在一个有条件激活的块中的可能性(警告:不要这样做! ):

giveMeTheBestNumber; // => (Varies depending on implementation!)
if (something) {
  function giveMeTheBestNumber() { return 76; }
} else {
  function giveMeTheBestNumber() { return 42; }
}

不幸的是,ECMAScript 以前的版本没有规定函数声明在块中的行为。 这导致各种浏览器实现选择自己独特的方式来处理这种情况。 随着时间的推移,实现已经开始趋于一致。 ECMAScript 2015 规范明确禁止giveMeTheBestNumber函数的值被提升。 但是,声明本身仍然可以悬挂,这意味着,如前所述,giveMeTheBestNumber在声明之前的行上是undefined(类似于var)。 在编写本文时,这是大多数(但不是所有)实现的普遍行为。

由于晦涩和实现之间的不一致性,强烈建议你不要在块中使用函数声明。 理想情况下,最好不要依赖它们的提升行为(通过引用函数声明),除非您确信这样做不会被那些必须阅读您的代码的人误解。

*For more information on how functions produced by function declarations differ from other ways of creating functions (for example, function expressions or arrow functions), please revisit the Functions section in Chapter 6Primitive and Built-In Types*.

正如我们所见,内部作用域可以访问外部作用域的变量:

function outer() {
  let thing = 123;
  function inner() {
    // I can access `thing` within here!
    thing; // => 123
  }
  inner();
}
outer();

由此自然产生的是闭包的概念。 闭包是 JavaScript 使您能够继续访问inner函数的作用域,而不管它在何时何地被调用。

It's simplest to think of a closure as simply a retained scope. A closure is a wrapped-up or enclosed scope that is passed around alongside the function, invisibly. When you call the function, it has implicit access to its scope provided by this closure.

考虑下面的函数(fn),它返回另一个函数。 它有自己的作用域,我们在其中声明了coolNumber变量:

function fn() {
  let coolNumber = 1;
  return function() {
    console.log(`
      I have access to ${coolNumber} 
      wherever and whenever I am called
    `);
  };
}

正如我们所期望的那样,我们返回的内部函数可以访问coolNumber 变量。 当我们调用fn()时,它的作用域实际上是活的,因此,当我们最终调用inner函数时,它仍然能够访问coolNumber

下面是另一个例子,我们通过在调用内部函数时重新赋值并返回局部变量来继续访问保留的作用域(即闭包):

function valueIncrementer() {
  let currentValue = 0;
  return function() {
    return currentValue++;
  };
}

const increment = valueIncrementer();
increment(); // => 0
increment(); // => 1
increment(); // => 2

闭包的概念通常过于复杂,所以冒着这样做的风险,我将非常简单地说明问题。 闭包并不是一件奇怪的事情,真的:它是我们应该期望一个范围如何工作的自然扩展。 所有函数都可以访问给定的作用域,所以在初始定义之后如何传递这些函数并不重要。 他们将继续访问相同的范围,并在他们认为合适的范围内自由访问或更改变量。 一个函数总是固定在它最初定义的地方,因此无论它是立即调用还是在一千年后调用,它都将访问相同的作用域(即,相同的词汇环境集)。

在这一章中,我们继续探索 JavaScript 语言,从前几章中缩小到考虑更大的语法片段,如表达式、语句和块。 这些是可编程的脚手架组件,我们可以在其中放置我们之前学习过的类型和操作。 我们还介绍了范围、提升和关闭的复杂机制。 理解这些概念是如何协同工作的,对于理解其他人的 JavaScript 程序和构建自己的 JavaScript 程序至关重要。

在下一章中,我们将探索如何在 JavaScript 中控制流。 这将允许我们以一种干净的方式将表达式和语句编织到更大的逻辑体中。 然后,我们将通过学习设计模式来探索抽象设计的艺术。 虽然单独学习这些主题的过程可能会显得很辛苦,但在本书结束时,您将对 JavaScript 有一个全面而强大的理解,这将使您能够少关注语言的奇怪之处,而更多地关注代码的整洁性。***

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

左耳听风 -〔陈皓〕

深入剖析Kubernetes -〔张磊〕

OpenResty从入门到实战 -〔温铭〕

分布式技术原理与算法解析 -〔聂鹏程〕

张汉东的Rust实战课 -〔张汉东〕

etcd实战课 -〔唐聪〕

Python自动化办公实战课 -〔尹会生〕

容量保障核心技术与实战 -〔吴骏龙〕

说透元宇宙 -〔方军〕