第 2 章程序结构
我的心脏在我的薄薄的、半透明的皮肤下发出鲜红色的光芒,他们不得不给我注射 10 毫升的 JavaScript 来让我恢复。 (我对血液中的毒素反应良好。)天哪,那玩意儿会把你的腮帮子都踢出来!
在本章中,我们将开始做一些实际上可以称为编程的事情。我们将扩展我们对 JavaScript 语言的命令,超越我们迄今为止看到的名词和句子片段,达到我们可以表达有意义的散文的地步。
表达式和语句
在 第 1 章 中,我们创建了值,并对它们应用运算符以获得新值。像这样创建值是任何 JavaScript 程序的主要内容。但这种内容必须在一个更大的结构中才能发挥作用。所以,接下来我们将介绍这一点。
产生值的代码片段称为表达式。每个以字面形式编写的值(例如 22
或 "psychoanalysis"
)都是一个表达式。括号中的表达式也是一个表达式,将二元运算符应用于两个表达式或将一元运算符应用于一个表达式也是一个表达式。
这展示了基于语言的接口的美丽之处。表达式可以包含其他表达式,就像人类语言中的子句是嵌套的一样——一个子句可以包含它自己的子句,等等。这使我们能够构建描述任意复杂计算的表达式。
如果一个表达式对应于一个句子片段,那么一个 JavaScript语句对应于一个完整的句子。一个程序是一个语句列表。
1; !false;
不过,这是一个无用的程序。表达式可以满足于仅仅产生一个值,然后这个值可以被包含它的代码使用。一个语句是独立存在的,所以它只有在影响世界的时候才有意义。它可以显示一些东西在屏幕上——这算作改变世界——或者它可以以一种会影响后续语句的方式改变机器的内部状态。这些变化被称为副作用。前一个示例中的语句只是产生值 1
和 true
,然后立即丢弃它们。这完全没有给世界留下印象。当你运行这个程序时,不会发生任何可观察到的变化。
在某些情况下,JavaScript 允许你省略语句末尾的分号。在其他情况下,它必须存在,否则下一行将被视为同一语句的一部分。关于何时可以安全地省略分号的规则有点复杂且容易出错。所以在本书中,每个需要分号的语句都会始终有一个。我建议你也这样做,至少在你了解更多关于省略分号的细微之处之前。
绑定
程序如何保持内部状态?它如何记住东西?我们已经看到了如何从旧值中产生新值,但这不会改变旧值,新值必须立即被使用,否则它将再次消散。为了捕捉和保存值,JavaScript 提供了一个叫做绑定或变量的东西
let caught = 5 * 5;
这是一种语句。特殊的词(关键字)let
表示这句话将定义一个绑定。它后面跟着绑定的名称,如果我们想立即给它赋值,则后面跟着一个=
运算符和一个表达式。
前面的语句创建了一个名为 caught
的绑定,并使用它来抓住通过将 5 乘以 5 所产生的数字。
在定义绑定之后,它的名称可以用作表达式。这种表达式的值是绑定当前保存的值。以下是一个例子
let ten = 10; console.log(ten * ten); // → 100
当一个绑定指向一个值时,并不意味着它永远绑定到那个值。=
运算符可以随时用于现有绑定,使它们断开与当前值的连接,并让它们指向一个新的值。
let mood = "light"; console.log(mood); // → light mood = "dark"; console.log(mood); // → dark
你应该把绑定想象成触手,而不是盒子。它们不包含值;它们抓住它们——两个绑定可以引用同一个值。一个程序只能访问它仍然持有的值的引用。当你需要记住某些东西时,你可以长出一根触手来抓住它,或者你可以将你现有的触手重新连接到它。
让我们看另一个例子。为了记住路易吉仍然欠你的美元数额,你可以创建一个绑定。然后当他偿还了 35 美元时,你可以给这个绑定一个新的值。
let luigisDebt = 140; luigisDebt = luigisDebt - 35; console.log(luigisDebt); // → 105
当你定义一个绑定而不给它赋值时,触手就没有任何东西可以抓住,所以它就悬空了。如果你询问一个空绑定的值,你会得到值 undefined
。
let one = 1, two = 2; console.log(one + two); // → 3
单词 var
和 const
也可用于创建绑定,方式类似于 let
。
var name = "Ayda"; const greeting = "Hello "; console.log(greeting + name); // → Hello Ayda
第一个,var
(是“variable”的缩写),是 2015 年之前的 JavaScript 中声明绑定的方式。我将在 下一章 回到它与 let
的精确区别。现在,请记住,它大部分时候做的是同样的事情,但我们在这本书中很少使用它,因为它有一些令人困惑的特性。
单词 const
代表常量。它定义了一个常量绑定,它始终指向同一个值,只要它存在。这对于绑定很有用,它们为一个值命名,以便你可以轻松地引用它。
绑定名称
绑定名称可以是任何单词。数字可以是绑定名称的一部分——例如,catch22
是一个有效的名称——但名称不能以数字开头。绑定名称可以包含美元符号 ($
) 或下划线 (_
),但不能包含其他标点符号或特殊字符。
具有特殊含义的词,如 let
,是关键字,它们不能用作绑定名称。还有一些词语在未来版本的 JavaScript 中“保留供使用”,这些词语也不能用作绑定名称。关键字和保留字的完整列表相当长。
break case catch class const continue debugger default delete do else enum export extends false finally for function if implements import interface in instanceof let new package private protected public return static super switch this throw true try typeof var void while with yield
不用担心记住这个列表。当创建绑定产生意外的语法错误时,请查看你是否尝试定义一个保留字。
环境
在给定时间存在的绑定及其值的集合称为环境。当一个程序启动时,这个环境并不为空。它始终包含语言标准的一部分的绑定,而且大多数情况下,它还包含提供与周围系统交互方式的绑定。例如,在浏览器中,有一些函数可以与当前加载的网站交互,以及读取鼠标和键盘输入。
函数
默认环境中提供的大量值具有函数类型。函数是一段程序,它被包装在一个值中。这些值可以被应用以运行包装的程序。例如,在浏览器环境中,绑定 prompt
包含一个函数,该函数会显示一个小的对话框,要求用户输入。它的使用方法如下
prompt("Enter passcode");
执行函数称为调用、执行或应用它。你可以通过在产生函数值的表达式之后添加括号来调用函数。通常,你会直接使用包含函数的绑定的名称。括号之间的值会传递给函数内部的程序。在本例中,prompt
函数使用我们提供的字符串作为对话框中显示的文本。传递给函数的值称为参数。不同的函数可能需要不同数量或不同类型的参数。
prompt
函数在现代 Web 编程中很少使用,主要是因为你无法控制生成的对话框的外观,但它在玩具程序和实验中可能很有用。
console.log 函数
在示例中,我使用了 console.log
来输出值。大多数 JavaScript 系统(包括所有现代 Web 浏览器和 Node.js)都提供了一个 console.log
函数,该函数会将它的参数写入到某个文本输出设备。在浏览器中,输出会出现在 JavaScript 控制台中。浏览器的这一部分默认情况下是隐藏的,但大多数浏览器在按下 F12 或在 Mac 上按下 command-option-I 时会打开它。如果这不起作用,请在菜单中搜索名为“开发者工具”或类似的项目。
在本书的页面上运行示例(或你自己的代码)时,console.log
输出将显示在示例之后,而不是在浏览器的 JavaScript 控制台中。
let x = 30; console.log("the value of x is", x); // → the value of x is 30
虽然绑定名称不能包含句号,但 `console.log` 中却包含一个。这是因为 `console.log` 不是简单的绑定。它实际上是一个表达式,从 `console` 绑定所持有的值中检索 `log` 属性。我们将在 第 4 章 中详细了解它的含义。
返回值
显示对话框或将文本写入屏幕是一种副作用。很多函数之所以有用,是因为它们产生的副作用。函数也可以生成值,在这种情况下,它们不需要产生副作用就能发挥作用。例如,函数 `Math.max` 接受任意数量的数字参数,并返回最大值。
console.log(Math.max(2, 4)); // → 4
当函数生成一个值时,就说它返回该值。在 JavaScript 中,任何生成值的都是表达式,这意味着函数调用可以在更大的表达式中使用。这里有一个调用 `Math.min` 的例子,它是 `Math.max` 的反面,它用作加法表达式的一部分
console.log(Math.min(2, 4) + 100); // → 102
下一章 解释了如何编写你自己的函数。
控制流
当你的程序包含多个语句时,这些语句的执行就像一个故事,从上到下。这个示例程序包含两个语句。第一个语句向用户询问一个数字,第二个语句在第一个语句执行后执行,显示该数字的平方。
let theNumber = Number(prompt("Pick a number")); console.log("Your number is the square root of " + theNumber * theNumber);
函数 `Number` 将值转换为数字。我们需要这种转换,因为 `prompt` 的结果是一个字符串值,而我们想要一个数字。还有类似的函数叫 `String` 和 `Boolean`,它们将值转换为这些类型。
条件执行
并非所有程序都是直线道路。例如,我们可能希望创建一条分叉路,程序根据当前情况选择正确的分支。这被称为条件执行。
条件执行是在 JavaScript 中使用 `if` 关键字创建的。在简单的情况下,我们希望在某个条件成立时,并且只有在该条件成立时,才执行某些代码。例如,我们可能希望只在输入实际上是数字时才显示输入的平方。
let theNumber = Number(prompt("Pick a number")); if (!Number.isNaN(theNumber)) { console.log("Your number is the square root of " + theNumber * theNumber); }
有了这个修改,如果你输入“parrot”,就不会显示任何输出。
`if` 关键字根据布尔表达式的值执行或跳过语句。决定表达式写在关键字后面,括号之间,后面跟着要执行的语句。
`Number.isNaN` 函数是一个标准的 JavaScript 函数,只有当它接收到的参数为 `NaN` 时才会返回 `true`。`Number` 函数在接收到一个不代表有效数字的字符串时,恰好会返回 `NaN`。因此,条件翻译为“除非 `theNumber` 不是数字,否则执行此操作”。
在这个例子中,`if` 后面的语句用花括号(`{` 和 `}`)括起来。花括号可以用来将任意数量的语句分组为一个语句,称为块。在这种情况下,你也可以省略它们,因为它们只包含一个语句,但为了避免思考是否需要它们,大多数 JavaScript 程序员在所有类似的包裹语句中使用它们。在这本书中,我们将主要遵循这个约定,除了偶尔的单行语句。
if (1 + 1 == 2) console.log("It's true"); // → It's true
你通常不会只在条件为真时执行代码,还会执行处理其他情况的代码。这个备选路径在图中用第二条箭头表示。你可以使用 `else` 关键字,结合 `if`,创建两个独立的、备选的执行路径。
let theNumber = Number(prompt("Pick a number")); if (!Number.isNaN(theNumber)) { console.log("Your number is the square root of " + theNumber * theNumber); } else { console.log("Hey. Why didn't you give me a number?"); }
如果你有超过两条路径可供选择,你可以将多个 `if`/`else` 对“链接”在一起。这是一个例子
let num = Number(prompt("Pick a number")); if (num < 10) { console.log("Small"); } else if (num < 100) { console.log("Medium"); } else { console.log("Large"); }
程序将首先检查 `num` 是否小于 10。如果是,它选择该分支,显示 `“Small”`,然后完成。如果不是,它将进入 `else` 分支,该分支本身包含第二个 `if`。如果第二个条件(`< 100`)成立,这意味着数字至少为 10 但小于 100,并且显示 `“Medium”`。如果它不成立,则选择第二个也是最后一个 `else` 分支。
while 和 do 循环
考虑一个输出从 0 到 12 的所有偶数的程序。一种编写方法如下
console.log(0); console.log(2); console.log(4); console.log(6); console.log(8); console.log(10); console.log(12);
这可以工作,但编写程序的目的是使工作更少,而不是更多。如果我们需要所有小于 1,000 的偶数,这种方法就不可行。我们需要的是一种方法,可以多次运行一段代码。这种控制流形式被称为循环。
循环控制流允许我们返回到程序中之前的位置,并用当前的程序状态重复它。如果我们将它与一个计数绑定结合起来,我们就可以做这样的事情
let number = 0; while (number <= 12) { console.log(number); number = number + 2; } // → 0 // → 2 // … etcetera
以 `while` 关键字开头的语句创建一个循环。`while` 后面跟着一个括号中的表达式,然后是一个语句,很像 `if`。循环会不断进入该语句,直到表达式生成一个在转换为布尔值时为真的值。
`number` 绑定演示了绑定如何跟踪程序的进度。每次循环重复时,`number` 的值都会比它之前的值增加 2。在每次重复开始时,它会与数字 12 进行比较,以决定程序的工作是否完成。
作为一个真正有用的例子,我们现在可以编写一个程序来计算并显示 210(2 的 10 次方)的值。我们使用两个绑定:一个用来跟踪结果,另一个用来计算我们已经将此结果乘以 2 的次数。循环测试第二个绑定是否已经达到 10,如果没有,则更新两个绑定。
let result = 1; let counter = 0; while (counter < 10) { result = result * 2; counter = counter + 1; } console.log(result); // → 1024
计数器也可以从 `1` 开始,检查 `<= 10`,但出于将在 第 4 章 中解释的原因,最好习惯于从 0 开始计数。
`do` 循环是一个与 `while` 循环类似的控制结构。它只在一点上有所不同:`do` 循环总是至少执行一次它的主体,并且它只在第一次执行之后才开始测试是否应该停止。为了反映这一点,测试出现在循环主体的后面。
let yourName; do { yourName = prompt("Who are you?"); } while (!yourName); console.log(yourName);
这个程序会强迫你输入一个姓名。它会一遍又一遍地询问,直到它得到一个非空字符串。应用 `!` 运算符会在取反之前将值转换为布尔类型,除了 `""` 之外的所有字符串都会转换为 `true`。这意味着循环会一直持续,直到你提供一个非空姓名。
缩进代码
在示例中,我一直会在属于某个较大语句的语句前面添加空格。这些空格不是必需的——计算机在没有它们的情况下也能很好地接受程序。事实上,程序中的换行符也是可选的。如果你愿意,可以将程序写成一行长长的代码。
块内缩进的作用是使代码的结构突出。在代码中,如果新的块在其他块内打开,可能会很难看清楚一个块在哪里结束,另一个块在哪里开始。使用适当的缩进,程序的视觉形状对应于其中块的形状。我喜欢每个打开的块使用两个空格,但口味各不相同——有些人使用四个空格,有些人使用制表符。重要的是,每个新块都增加相同数量的空格。
if (false != true) { console.log("That makes sense."); if (1 < 2) { console.log("No surprise there."); } }
大多数代码编辑器程序(包括本书中的编辑器)都会通过自动缩进新行的适当数量来提供帮助。
for 循环
许多循环遵循 `while` 示例中所示的模式。首先创建一个“计数器”绑定来跟踪循环的进度。然后是一个 `while` 循环,通常带有一个测试表达式,用来检查计数器是否已达到其结束值。在循环主体结束时,计数器会更新以跟踪进度。
因为这种模式非常常见,所以 JavaScript 和类似的语言提供了一种更短、更全面的形式,即 `for` 循环。
for (let number = 0; number <= 12; number = number + 2) { console.log(number); } // → 0 // → 2 // … etcetera
这个程序与前面的偶数打印示例完全等效。唯一的变化是所有与循环“状态”相关的语句都分组在 `for` 后面。
`for` 关键字后面的括号必须包含两个分号。第一个分号之前的部分初始化循环,通常是通过定义一个绑定。第二部分是表达式,它检查循环是否必须继续。最后部分在每次迭代后更新循环的状态。在大多数情况下,这比 `while` 结构更短、更清晰。
这是使用 `for` 而不是 `while` 计算 210 的代码
let result = 1; for (let counter = 0; counter < 10; counter = counter + 1) { result = result * 2; } console.log(result); // → 1024
退出循环
循环条件产生false
并不是循环结束的唯一方式。有一个特殊的语句叫做break
,它的作用是立即跳出包围它的循环。
这个程序演示了break
语句。它找到第一个大于或等于20且能被7整除的数字。
for (let current = 20; ; current = current + 1) { if (current % 7 == 0) { console.log(current); break; } } // → 21
使用余数 (%
) 运算符是测试一个数字是否能被另一个数字整除的简单方法。如果可以,它们的除法的余数为零。
示例中的for
结构没有检查循环结束的部分。这意味着循环将永远不会停止,除非执行内部的break
语句。
如果你要删除那个break
语句,或者你意外地写了一个始终产生true
的结束条件,你的程序就会陷入无限循环。一个陷入无限循环的程序永远不会完成运行,这通常是一件坏事。
如果你在这些页面上的某个示例中创建了一个无限循环,通常会询问你是否要在几秒钟后停止脚本。如果这失败了,你将不得不关闭正在使用的选项卡,或者在某些浏览器上关闭整个浏览器才能恢复。
continue
关键字类似于break
,因为它会影响循环的进度。当在循环体中遇到continue
时,控制权将跳出循环体,并继续执行循环的下一个迭代。
简洁地更新绑定
特别是在循环时,程序通常需要“更新”一个绑定,以根据该绑定先前值保存一个值。
counter = counter + 1;
counter += 1;
类似的快捷方式适用于许多其他运算符,例如result *= 2
用于将result
加倍,或counter -= 1
用于倒数。
for (let number = 0; number <= 12; number += 2) { console.log(number); }
对于counter += 1
和counter -= 1
,还有更短的等价形式:counter++
和counter--
。
使用 switch 根据值进行分派
if (x == "value1") action1(); else if (x == "value2") action2(); else if (x == "value3") action3(); else defaultAction();
有一个名为switch
的结构旨在以更直接的方式表达这种“分派”。不幸的是,JavaScript 用于此的语法(它继承自 C/Java 编程语言系列)有点笨拙——一系列if
语句看起来可能更好。这是一个示例
switch (prompt("What is the weather like?")) { case "rainy": console.log("Remember to bring an umbrella."); break; case "sunny": console.log("Dress lightly."); case "cloudy": console.log("Go outside."); break; default: console.log("Unknown weather type!"); break; }
你可以在switch
打开的块中放置任意数量的case
标签。程序将从对应于switch
给定值的标签开始执行,或者如果没有找到匹配的值,则从default
开始执行。它将继续执行,即使跨越其他标签,直到它遇到break
语句。在某些情况下,例如示例中的"sunny"
情况,这可以用于在多个情况之间共享一些代码(它建议在晴朗和多云的天气下都外出)。但要注意——很容易忘记这样的break
,这会导致程序执行你不希望执行的代码。
大写
绑定名称可能不包含空格,但使用多个单词来清楚地描述绑定所代表的内容通常很有帮助。这些是你在用几个单词编写绑定名称时几乎可以使用的选择
fuzzylittleturtle fuzzy_little_turtle FuzzyLittleTurtle fuzzyLittleTurtle
第一种风格可能难以阅读。我比较喜欢下划线的风格,虽然这种风格打字有点痛苦。标准的 JavaScript 函数和大多数 JavaScript 程序员都遵循最下面的风格——它们将每个单词(除了第一个)都大写。适应这些小事情并不难,而代码混合命名风格会让人读起来很别扭,所以我们遵循这种惯例。
在少数情况下,例如Number
函数,绑定的第一个字母也被大写。这样做是为了将这个函数标记为构造函数。构造函数是什么将在第 6 章中变得清晰。现在,重要的是不要被这种表面上的不一致所困扰。
注释
通常,原始代码并不能传达你希望程序传达给人类读者的一切信息,或者它以一种隐晦的方式传达信息,以至于人们可能无法理解它。在其他时候,你可能只是想在你的程序中包含一些相关的想法。这就是注释的用途。
注释是程序的一部分文本,但计算机完全忽略它。JavaScript 有两种编写注释的方法。要编写单行注释,可以使用两个斜杠字符 (//
),然后是注释文本。
let accountBalance = calculateBalance(account); // It's a green hollow where a river sings accountBalance.adjust(); // Madly catching white tatters in the grass. let report = new Report(); // Where the sun on the proud mountain rings: addToReport(accountBalance, report); // It's a little valley, foaming like light in a glass.
一个//
注释只到行尾。/*
和*/
之间的任何文本都会被完全忽略,无论它是否包含换行符。这对于添加有关文件或程序块的信息块很有用。
/* I first found this number scrawled on the back of an old notebook. Since then, it has often dropped by, showing up in phone numbers and the serial numbers of products that I've bought. It obviously likes me, so I've decided to keep it. */ const myNumber = 11213;
总结
你现在知道程序是由语句组成的,而语句本身有时又包含更多语句。语句往往包含表达式,表达式本身又可以由更小的表达式构成。
将语句一个接一个地放在一起,就会得到一个从上到下执行的程序。你可以使用条件语句 (if
、else
和switch
) 和循环语句 (while
、do
和for
) 来扰乱控制流。
绑定可以用来将数据片段存放在一个名称下,它们对于跟踪程序中的状态很有用。环境是定义的绑定集合。JavaScript 系统总是将一些有用的标准绑定放入你的环境中。
函数是封装程序片段的特殊值。你可以通过编写functionName(argument1, argument2)
来调用它们。这样的函数调用是一个表达式,可能会产生一个值。
练习
如果你不确定如何测试你对练习的解决方案,请参考简介。
每个练习都从一个问题描述开始。阅读此描述并尝试解决练习。如果你遇到问题,可以考虑阅读练习后的提示。练习的完整解决方案不包含在本手册中,但你可以在网上找到它们,网址是https://eloquent.javascript.ac.cn/code。如果你想从练习中学到东西,我建议你只在解决完练习后,或者至少在尝试解决练习很久,头疼得厉害之后,再去看解决方案。
循环三角形
编写一个循环,对console.log
进行七次调用,输出以下三角形
# ## ### #### ##### ###### #######
了解如何在字符串后面写.length
来查找字符串的长度可能很有用。
let abc = "abc"; console.log(abc.length); // → 3
大多数练习都包含一段可以修改的代码来解决练习。请记住,你可以点击代码块来编辑它们。
// Your code here.
你可以从一个打印数字 1 到 7 的程序开始,你可以通过对本章前面介绍的for
循环的偶数打印示例进行一些修改来推导出它。
现在考虑数字和井号字符串之间的等价性。你可以通过加 1 (+= 1
) 从 1 到 2。你可以通过添加一个字符 (+= "#"
) 从"#"
到"##"
。因此,你的解决方案可以紧密地遵循数字打印程序。
FizzBuzz
编写一个程序,使用console.log
打印从 1 到 100 的所有数字,有两个例外。对于能被 3 整除的数字,打印"Fizz"
而不是数字;对于能被 5 整除(但不能被 3 整除)的数字,打印"Buzz"
而不是数字。
在完成这些操作后,修改你的程序,对于能被 3 和 5 都整除的数字,打印"FizzBuzz"
(并仍然对仅能被其中一个整除的数字打印"Fizz"
或"Buzz"
)。
(这实际上是一个面试问题,据说可以筛选掉相当一部分程序员候选人。所以如果你解决了它,你的劳动力市场价值就上升了。)
// Your code here.
棋盘
编写一个程序,创建一个字符串来表示一个 8×8 的网格,使用换行符来分隔行。网格的每个位置上要么是空格,要么是“#”字符。这些字符应该形成一个棋盘。
将此字符串传递给console.log
应该显示类似于下面的内容
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
当你拥有一个生成这种模式的程序时,定义一个绑定size = 8
,并修改程序,使其能够适用于任何size
,输出一个指定宽度和高度的网格。
// Your code here.