第五章 语句
语句就是JavaScript中的句子或命令。就像英语句子用句点来结尾和分隔一样,JavaScript语句以分号结尾。表达式被求值后产生一个值,而语句被执行后会导致某事件发生。
一种“导致某事件发生”的方式是求值一个有副效应的表达式。像赋值或函数调用这样有副效应的表达式本身就可以作为语句,在像这样使用时就被称作表达式语句。另一种与之类似的语句是声明语句,用于声明变和定义新函数。
JavaScript程序就是一系列待执行的语句。默认情况下,JavaScript解释器按照它们在源代码中的顺序逐个执行这些语句。另一种“导致某事件发生”的方式就是改变这个默认的执行顺序,为此,JavaScript提供了一些控制语句或者叫作控制解构。
5.1 表达式语句
JavaScript中最简单的一种语句就是有副效应的表达式。赋值语句是一种主要的表达式语句。
greeting="Hello"+name;
i*=3;
递增操作符++和递减操作符–都跟赋值语句有关。它们都有修改变量值的副效应,就好像执行了赋值语句一样:
counter++;
delete操作符有删除对象属性的重要副效应。因此,一般把它作为一个语句使用,而不是放在某个大的表达式中:
delete o.x;
函数调用是另一类主要的表达式语句。例如:
console.log(debugMessage)
displaySpinner(); //一个假想的函数,会在网页中显示旋转动图
==这些函数调用都是表达式,但它们有影响宿主环境或程序状态的副效应,因此在这里作为语句使用。==如果是没有副效应的函数,那像这样调用就没有意义了,除非它在一个更大的表达式中,或者在赋值语句中。例如,谁也不会像这样计算一次余弦,然后丢掉结果:
Math.cos(x);
但很有可能计算这个值之后把它赋给一个变量,以便将来使用:
cx=Math.cos(x);
注意,这些例子中的每行代码都以分号结尾。
5.2 复合语句和空语句
与逗号操作符将多个表达式组合为一个表达式一样,语句块将多个语句组合成一个复合语句。语句块其实就是一系列语句,可以放在如何期待一个语句的地方:
{
x=Math.PI;
cx=Math.cos(x);
console.log("cos(n)="+cx);
}
关于这个语句块,我们需要说明几点。第一,它没有以分号结尾。块中的单条语句都以分号结尾,但块本身没有。第二,块中的代码相对于包含它们的花括号缩进。这不是必须的,但可以让代码更清晰易懂。
就像表达式经常会包含子表达式,很多JavaScript语句也包含子语句。例如,while循环语句只包含一个作为循环体的语句。而使用语句块,可以在这个唯一的子语句中添加任意多个语句。
复合语句允许我们在JavaScript语法期待一个语句时使用多个语句。空语句正好相反,它让我们在期待一条语句的地方不包含任何语句,空语句是这样的:
;
JavaScript解释器在执行空语句时什么也不会做。空语句偶尔会有用,比如创建一个空循环体的循环。比如下面的for循环:
//初始化一个数组
for(let i=0;i<a.length;a[i++]=0);
在这个循环中,所有工作都是通过a[i++]=0完成的,不需要循环体。但JavaScript语法要求有一条语句作为循环体,此时空语句(就一个分号)可以派上用场。
意外地在for、while循环或if语句地右括号后面加上分号会导致难以发现的隐患。例如,下面的代码可能不是作者想要的:
if((a===0)||(b===0)); //这行什么也不做
o=null; //而这行始终都会执行
如果你有意使用空语句,最好通过注释说明一下你的用意。
5.3 条件语句
条件语句根据指定表达式的值执行或跳过执行某些语句,因此条件语句是代码中需要决策的地方,有时候也被称作“分支”。想象一下,JavaScript解释器沿一条路径执行代码,条件语句表示这行代码要分成两条或更多路径,而解释器必须选择其中一条。
5.3.1 if
if语句是最基本的控制语句,可以让JavaScript做出决策,更精确地说,是有条件地执行语句。这个语句有两种形式,第一种是:
if(expression)
statement
在这个形式中,expression(表达式)会被求值。如果结果是真值,statement(语句)会执行,如果表达式是假值,语句不会执行。
JavaScript语法要求在if关键字和带括号的表达式后面必须只跟一个语句,但我们可以使用语句块把多个语句组合成一个语句。因此if语句也可以是类似这样的:
if(!address){
address="";
message="Please specify a mailing address";
}
这种形式在表达式为真值时执行语句1,在表达式为假值时执行语句2。例如:
if(n===1)
console.log("You have 1 new message.")
else
console.log(`You have ${n} new messages`)
如果在嵌套的if语句中包含else语句,那么就要留心让else语句与相应的if语句相对应的if语句对应。来看下面这个例子:
i=j=1;
k=2;
if(i===j)
if(j===k)
console.log("i equals k")
else
console.log("i doesn't equal j") //错了
在这个例子中,内部的if语句构成了外部if语句语法所需的那条语句。而else语句对应哪个if并不清楚(除了缩进有所暗示以外)。但在这个例子中,缩进是错误的,以为JavaScript解释器实际上会把前面的例子解释为:
if(i===j){
if(j===k)
console.log("i equals k");
else
console.log("i doesn't equals j");
}
JavaScript的规则(与多数编程语言一样)是,默认情况下else子句属于最近的if语句。为了让这个例子更清晰、易读、易理解、易维护、易调试,应该使用花括号:
if(i===j){
if(i===j){
console.log("i equals k");
}
} else {
console.log("i doesn't equal j");
}
很多程序员都有使用花括号包装if和else语句(以及其他复合句,如while循环)的习惯,计算语句体只有一个语句。始终这么做可以避免刚才的问题,建议读者也这么做。在本书中,我始终在设法减少代码行数,因此不一定处处遵循这个建议。
5.3.3 switch
if语句在程序执行流中会创建一个分支,而使用多个else if可以实现多个分支。但是,所有分析都依赖同一个表达式的值时这并不是最好的办法。因为多个if语句重复对一个表达式进行求值太浪费了。
此时最合适的语句是switch语句。switch关键字后面跟着一个带括号的表达式和一个包含在花括号中的代码块:
switch(expression){
statements
}
不过,switch语句的完整语法比这里展示的要复杂得多。比如,其中代码块得不同位置会有case关键字开头得标签,后跟一个表达式和一个冒号。当switch执行时,它会计算表达式的值,然后对比case标签,看哪个表达式会求值为相同的值(这时的相同意味着===操作符返回true)。如果找到了相同的值,则执行相应case语句的代码块。如果没有找到,则再找标签为default:的语句。如果没有default:标签,switch语句就跳过整个代码块。
switch语句不太好用文字来解释,看个例子更容易明白。下面这个switch语句与前面多个if/else语句的例子是等价的:
switch(n){
case 1: //如果n===1,从这里开始执行
//执行第一个代码块
break; //到这里停止
case 2: //如果n===2,从这里开始执行
//执行第二个代码块
break; //到这里停止
case 3: //如果n===3,从这里开始执行
//执行第三个代码块
break; //到这里停止
default:
//执行第四个代码块
break; //到这里停止
}
注意代码中每个case末尾的break关键字。这个break语句(本章后面会介绍)将导致解释器跳到switch语句末尾(或“跑出” switch语句),继续执行后面的语句。switch语句中的case子句只指定了预期代码的起点,并没有指定终点。在没有break语句的情况下,switch语句从匹配其表达式值的case代码块开始执行,一直执行到代码块结束。这种情况偶尔是有用的,比如让代码执行流从某个case标签直接“穿透”到下一个case标签。但99%的时候还是需要注意用break语句来结束每个case (不过在函数中使用switch时,可以使用return语句而非break语句。这两个关键字都可以终止switch语句,阻止执行流进入下一个case)。
下面看一个关于switch语句的更实际的例子,这个switch语句会根据值得类型决定怎么把它转换成字符串:
function convert(x){
switch(typeof x){
case 'number': //把数值转换成16进制整数
return x.toString(16);
case 'string': //返回了引号得字符串
return '"'+x+'"';
defaul: //其他类型按常规方式转换
return String(x);
}
}
注意前面两个例子中,case关键字后面分别是数值和字符串字面量。这是实践中使用switch语句的常见方式,但要注意ECMAScript标准允许每个case后面跟任意表达式。
switch语句首先对跟在switch关键字后面的表达式求值,然后再按照顺序求值case表达式,直至遇到匹配的值。这里的匹配使用===全等操作符,而不是 == 相等操作符,因此表达式必须在没有类型转换的情况下匹配。
因为case表达式是在运行时求值的,所以JavaScript的switch语句与C、C++和Java的switch语句相比有很大差别(特别是性能更低)。在上述其他语言中,case表达式一定是相同类型的编译时常量,而switch语句通常可以编译为效率极高的跳转表。
考虑到在switch语句执行时,并不是所有case表达式都会被求值,所以应该避免使用包含副效应的case表达式,比如函数调用或赋值表达式。更可靠的做法是在case后面只写常量表达式。
正如前面解释的,如果没有与switch表达式匹配的case表达式,则switch语句就会执行标签为default:的语句。如果没有default:标签。switch语句会跳过自己的代码体,注意在前面的例子中,default:标签出现在switch体的末尾,在所有case标签后面。这个位置是符合逻辑的,也是它最常出现的位置。但事实上,default:标签可以出现在在switch语句体的任何位置。
5.4 循环语句
5.4.1 while
就像if语句是JavaScript的基本条件控制语句一样,while语句是JavaScript的基本循环语句,具有如下语法:
while(expression)
statement
执行while语句时,解释器首先会求值表达式。如果这个表达式的值是假值,则解释器会跳过作为循环体的语句,继续执行程序中的下一条语句。而如果表达式是真值,则解释器会执行语句并且重复,即跳回循环的开头再次求值表达式。另一种解释方式是,解释器会在表达式为真值时重复执行语句。注意,使用while(true)可以创造一个无穷循环。
通常我们都不希望JavaScript反复执行同样的操作。几乎在每次循环或迭代中,都会有一个或多个变量改变。因为有变量改变,所以执行语句的每次循环都可能不同。另外,如果改变的变量会影响表达式,则每次循环这个表达式的值也可能不同。这一点非常重要,否则求值为真值的表达式可能永远不会变,循环也就永远不会结束!
5.4.3 do/while
do/while循环与while循环类似,区别是对循环表达式的测试在循环底部而不是顶部。这意味着循环体始终会至少执行一次。语法如下:
do
statement
while (expression)
do/while循环的使用没有while那么频繁,因为实践中很少有需要至少执行一次循环的情况。下面是一个do/while循环的例子:
function printArray(a) {
let len = a.length,i=0;
if(len ===0){
console.log("Empty Array");
} else {
do {
console.log(a[i]);
}while(++i<len)
}
}
从语法上看,do/while循环与while循环有两个区别。首先,do循环要求使用两个关键字:do(标记循环开始)和while(标记循环结束并引入循环条件)。其次,do循环必须始终以分号终止。而while循环在循环体使用花括号时不需要分号。
5.4.3 for
for语句提供了比while语句更方便的循环结构。for语句简化了遵循常见模式的循环。多数循环都有某种形式的计数器变量,这个变量在循环开始前会被初始化,然后循环的每次迭代都会测试它的值。另外,计数器变量在循环体结束时、在被再次测试之前会递增或更新。在这种循环模式下,初始化、测试和更新是对循环变量的三个关键操作。for语句将这三个操作分别设定为一个表达式,让这些表达式成为循环语法中明确的部分:
for(initialize;test;increment)
statement
initialize、test和increment是三个表达式(以分号隔开),分别负责初始化、测试和递增循环变量。把它们放在一行让人更容易理解for循环在做什么,避免忘记初始化或递增循环变量。
解释for循环的最简单方式是对比等价的while循环:
initialize;
while(test){
statement
increment;
}
换句话说,initialize表达式只在循环开始前求值一次。为了起作用,这个表达式必须有副效应(通常是赋值)。JavaScript也允许initialize是变量声明语句,以便可以同时声明并初始化循环计数器。test表达式会在每次迭代时求值,用于控制是否执行循环体。如果test求值为真值,则作为循环体的statement就执行。执行后求值increment表达式。同样,increment必须是有副效应的表达式(这样才有作用);一般来说,要么是赋值表达式,要么使用++或–操作符。
有很多复杂的循环,而且有时候每次迭代要改变的循环变量还不止一个。这种情况是JavaScript中的逗号操作符常见的唯一用武之地。因为逗号操作符可以把多个初始化和递增表达式组合成一个表达式,从而满足for循环的语法要求:
let i,j,sum=0;
for(i=0,j=10;i<10;i++,j--){
sum+=i*j;
}
目前为止,所有循环实例中的循环变量都是数值。这种情况常见但不是必需的。以下代码使用for循环遍历了一个链表数据结构,返回了列表中的最后一个对象(即第一个没有next属性的对象):
function tail(o){
for(;o.next;o=o.next)
return o;
}
注意,这段代码中没有initialize表达式。对for循环而言,三个表达式中任何一个都可以省略,只有两个分号是必需的。如果省略了test表达式,循环会永远重复执行。因此for(;;)与while(true)一样,是另一种编写无穷循环的方式。
5.4.4 for/of
ES6定义了一个新循环语句:for/of。这种循环虽然使用for关键字,但它与常规for循环是完全不同的两种循环(for/of与5.4.5要讨论的for/in循环也是完全不同的)。
for/of循环专门用于可迭代对象。数组、字符串、集合和映射都是可迭代的。它们都是一组或一批元素,可以使用for/of循环来循环或迭代这些元素。
例如,下面这个例子演示了如何跌打一个数值数组并计算所有数值之和:
let data=[1,2,3,4,5,6,7,8,9],sum=0;
for(let element of data){
sum+=element;
}
sum
=>45
表面上看,这个语法跟常规for很像,都是for关键字后跟着一对圆括号,其中包括如何循环的细节。具体来说,这里的圆括号中包含一个变量声明(对于已经声明的变量,只包含变量名即可),然后是of关键字和一个求值为可迭代对象的表达式(比如这里的data数组)。与所有循环一样,for/of循环的循环体紧跟在圆括号之后,通常包含在花括号中。
在上面的代码中,循环体对应dat数组中的每个元素都会运行一次。在每次执行循环体之前,都会把数组的下一个元素给元素赋值。
数组迭代是“实时”的,即迭代过程中的变化可能会影响迭代的输出。如果修改前面的代码,在循环体添加一行data.push(sum);,则会创建一个无穷循环。因为迭代永远不会触及数组的最后一个元素。
for/of与对象
对象(默认)是不可迭代的。运行时尝试对常规对象使用for/of会抛出TypeError:
let o={x:1,y:2,z:3}
for(let element of o){
console.log(element)
}
VM138:1 Uncaught TypeError: o is not iterable
at <anonymous>:1:20
(anonymous) @ VM138:1
如果想迭代对象的属性,可以使用for/in循环,或者基于Object.keys()方法的解构使用for/of:
let keys=""
for(let k of Object.keys(o)){
keys+=k
}
=>"xyz"
这是因为Object.keys()返回一个对象属性名的数组,而数组是可以通过for/of来迭代的。也要注意,这种对象的键的迭代并不像上面例子那样是实时的,在循环体内修改对象o不会影响迭代。如果你不在乎对象的键,也可以像下面这样迭代每个键对应的值:
let sum=0
for(let v of Object.values(o)){
sum+=v
}
=>6
如果你既想要对象属性的键,也想要属性的值,可以基于Object.entries()和解构赋值来使用for/of:
let pairs=""
for(let [k,v] of Object.entries(o)){
pairs+=k+v
}
=>"x1y2z3"
Object.entries()返回一个数组的数组,其中每个内部数组表示对象的一个属性的键值对。这里使用解构赋值把这些内部数组拆开,并将它们的元素赋值给两个变量。
for/of与字符串
字符串在ES6中是可以逐个迭代的:
let frequency={};
for(let letter of "mississippi"){
if(frequency[letter]){
frequency[letter]++;
}else{
frequency[letter]=1;
}
}
frequency
=>{m: 1, i: 4, s: 4, p: 2}
for/of与Set和Map
ES6内置的Set(集合)和Map(映射)类是可迭代的。在使用for/of迭代Set时,循环体内对集合中的每个元素都会运行一次。可以使用类似下面的代码打印出一个文本字符串中的唯一单词:
let text="Na na na na na na na na Batman"
let wordSet=new Set(text.split(" "))
let unique=[]
for(let word of wordSet){
unique.push(word)
}
=>3
unique
=>(3) ["Na", "na", "Batman"]
Map则比较有意思,因为Map对象的迭代器并不迭代Map键或Map值,而是迭代键值对。每次迭代,迭代器都会返回一个数组,其第一个元素是健,第二个元素是对应的值。给出一个Map m,可以像下面这样迭代和解构其键/值对:
let m=new Map([[1,"one"]])
for(let [key,value] of m){
key //=>1
value //=>"one"
}
for/await与异步迭代
ES2018新增了一种迭代器,称为异步迭代器,同时新增了一种循环for/of循环,即使用异步迭代器的for/await循环。
代码实例:
// 从异步可迭代流中读取模块并将其打印出来
async function printStream(stream){
for await (let chunk of stream){
console.log(chunk)
}
}
5.4.5 for/in
for/in循环看起来很像for/of循环,只不过of关键字换成了in。与for/of循环要求of后面必须是可迭代对象不同,for/in循环的in后面可以是任意对象。for/of循环是ES6新增的,而for/in是JavaScript从一开始就有的(这也是为什么它的语法显得更自然的原因)。
for/in语句循环指定对象的属性名,语法类似如下所示:
for(variable in object)
statement
variable同时是一个变量名,但也有可能是变量声明或任何可以作为赋值表达式左值的东西。object是一个求值为对象的表达式。跟以前一样,statement是作为循环体的语句或语句块。
比如,可以这样使用for/in循环:
for(let p in o){ //将o的属性名赋值给变量p
console.log(o[p]) //打印每个属性的值
}
执行for/in语句时,JavaScript解释器首先求值object表达式。如果它求值为null或undefined,解释器会跳过循环并转移到下一个语句。否则,解释器会对每个可枚举的对象属性执行一次循环体。但在每次迭代前,解释器都会求值variable表达式,并将属性名字(字符串值)赋值给它。
==注意,for/in循环中的variable可能是任意表达式,只要能求值为赋值表达式的左值就可以。这个表达式在每次循环时都会被求值,这意味着每次求值结果可能都不同。==比如,可以用类似下面的代码把一个对象的属性复制到数组中:
let o={x:1,y:2,z:3}
let a=[],i=0
for(a[i++] in o) /* 空循环体 */
JavaScript数组其实就是一种特殊的对象,而数组索引是对象的属性,可以通过for/in循环来枚举。例如,在前面的代码后面再执行下面这行代码,会枚举出数组索引0、1、2:
for (let i in o) console.log(i);
在操作数组时,基本上只会用到for/of而不是for/in。
==for/in并不会枚举对象的所有属性,比如它不会枚举名字为符号的属性。而对于名字为字符串的属性,它只会遍历可枚举的属性。JavaScript核心定义的各种内部方法是不可枚举的。==比如,所有对象都有toString()方法,但for/in循环不会枚举toString属性。除了内部方法,内部对象的不少其他属性也是不可枚举的。默认情况下,我们手写代码定义的属性和方法都是可枚举的。
继承的可枚举属性,也可以被for/in循环枚举。这意味着如果你使用for/in循环,并且代码中会定义所有对象继承的属性,那你的循环就有可能出现意外结果。为此,很多程序员更愿意基于Object.keys()使用for/of循环,而不是使用for/in循环。
如果for/in循环的循环体删除一个尚未被枚举的属性,则该属性就不会再被枚举了。如果循环体在对象上又定义了新属性,则新属性可能会(也可能不会被枚举),关于for/in枚举对象属性的顺序,参见6.6.1节。
5.5 跳转语句
另一类JavaScript语句是跳转语句。顾名思义,跳转语句会导致JavaScript解释器跳转到源代码中的新位置。其中,break语句会让解释器跳转到循环末尾或跳转到其他语句。而continue语句会让解释器跳出循环体并返回循环顶部开始新一轮迭代。JavaScript允许给语句命名或加标签,这样break和continue就可以识别目标循环或其他语句的标签。
另外,return语句会让解释器从函数调用跳转回调用位置,同时提供调用返回的值。而yield语句是一种在生成器函数中间返回的语句。throw语句回抛出异常,设计用来与try/catch/finally语句共同使用,后者可以构成异常处理代码块。 抛出异常是一种复杂的跳转语句:当有异常被抛出时,解释器会跳转到最近的闭合异常处理程序,可能是在同一个函数内部,也可能会上溯到函数调用栈的顶端。
5.5.1 语句标签
通过前置一个标识符和一个冒号,可以为任何语句加上标签:
identifier: statement
给语句加标签之后,就相对于给它起了个名字,可以在程序的任何地方通过这个名字来引用它。任何语句都有标签,但只有那些给语句体的语句加标签时才有意义,比如循环语句和条件语句。给循环起个名字,然后在循环体中可以使用break和continue退出循环或跳到循环顶部开始下一次迭代。break和continue是JavaScript中唯一使用语句标签的语句,后面的小节会介绍它们。下面看一个给while循环加标签并通过continue语句使用这个标签的例子:
mainloop:while(token !== null){
//省略的代码
continue mainloop; //跳到命名循环的下一次迭代
//省略的其他代码
}
==这里用作语句标签的identifier可以是任何合法的JavaScript标识符(非保留字)。这些标签与变量和函数不在同一个命名空间中,因此同一个标识符即可作为语句标签,也可以作为变量和函数名。语句标签只在定义它的语句(当然包括子语句)中有效。==如果一条语句被另一条语句包含,那么它们不能使用相同的标签;如果两条语句没有嵌套关系,那么它们就可以使用相同的标签。以及有标签的语句本身也可以再加标签,这意味着任何语句都可以有多个标签。
5.5.2 break
break语句在单独使用时,会导致包含它的循环或switch语句立即退出。
由于它会导致循环或switch退出,因此这种形式的break语句只有位于上述两种语句中才合法。
JavaScript也允许break关键字后面跟一个语句标签(只有标识符,没有冒号):
break labelname;
当break后面跟一个标签时,它会跳转到具有指定标签的包含语句的末尾或终止该语句。如果没有具有指定标签的包含语句,那么这样使用break会导致语法错误,在这种形式的break语句中,命名语句不一定是可以中断任何包含语句的循环或switch:break。这里说的语句甚至可是一个用花括号包含的语句块,其唯一目的就是通过标签来给这个语句块命名。
break与labelname中间不允许出现换行符。这主要是因为JavaScript会主动插入省略的分号:如果把一个行终止符放在break和后面的标签名之间,JavaScript会假定你使用简单的、没有标签的break语句,将换行符看成一个分号。
如果想中断一个并非最接近的包含循环或switch语句,就要使用这种带标签的break语句。
let matrix=getData(); //从某个地方取得一个数值的二维数组
//现在计算矩阵中所有数值之和
let sum=0,success=false;
//从一个加标签的语句开始,如果出错可以中断
computeSum:if(matrix){
for(let x=0;x<matrix.length;x++){
let row=matrix[x];
if(!row) break computeSum;
for(let y=0;y<row.length;y++){
let cell=row[y];
if(isNaN(cell)) break computSum;
sum+=cell;
}
}
success=true;
}
//break语句跳转到这里。如果此时success==false
//那说明得到的matrix出了问题。否则,sum会包含
//这个矩阵中所有单元的和
最后要注意,无论带不带标签,break语句都不能把控制权转移到函数边界之外。比如,不能给一个函数定义加标签并在函数内部使用这个标签。
5.5.3 continue
continue语句与break语句类似,但continue不会退出循环,而是从头开始执行循环的下一次迭代。
continue语句也可以带标签。
无论带不带标签,continue语句都只能在循环体内使用。在其他地方使用continue都会导致语法错误。
执行continue语句时,包含循环的当前迭代会终止,下一次迭代开始,对不同类型的循环,结果可能有所不同。
- 对于while循环来说,循环开始指定的expression会再次被求值,如果求值为true,则会从上到下执行循环体。
- 对于do/while循环而言,执行会跳到循环底部,并在底部再次测试循环条件,然后决定是否从顶部开始重新启动循环。
- 对于for循环而言,会求值increment表达式,并再次测试test表达式,以决定是否该进行下一次迭代。
- 对于for/of或for/in循环而言,循环会从下一个被迭代的值或者下一个被赋值
给指定变量的属性名开始。
要注意continue语句在while和佛如循环中行为的差异:while循环直接返回到它的条件。但for循环会先求其increment表达式,然后再返回其条件。前面,我们曾称为for循环的行为“等价于”while循环。但因为continue语句在这两种循环中的不同表现,所以不可能单纯使用while循环来模拟for循环。
同样与break语句一样,continue语句与其labelname之间也不能出现换行。
5.5.4 return
我们知道,函数调用是表达式,而所有表达式都有值。函数中的return语句指定了函数调用的返回值。
return语句只能出现在函数体内。如果return出现在任何其他地方,都会导致语法错误。执行return语句后,包含它的函数向调用者返回expression的值。例如:
function square(x){ return x*x; } //函数有一个return语句
square(2) //=>4
如果没有return语句,函数调用会依次执行函数体中的每个语句,直至函数末尾,然后返回其调用者。此时,调用表达式求值为undefined。return语句常常是函数中的最后一条语句,但并非必须是最后一条。函数体在执行时,只要执行到return语句,就会返回其调用者,而不管这个return语句后面是否还有其他语句。
return语句后面也可以不带expression,从而导致函数向调用者返回undefined。
由于JavaScript会自动插入分号,因此不能在return关键字和它后面的表达式之间插入换行。
5.5.5 yield
yield语句非常类似于return语句,但只能用在ES6新增的生成器函数中,以回送生成的值序列中的下一个值,同时又不会真正返回:
//回送一系列整数的生成器函数
function* range(from,to){
for(let i=from;i<=to;i++){
yield i;
}
}
为了理解yield,必须理解迭代器和生成器,相关内容将在第12章介绍。(严格来说,正如12.4.2所解释的,yield是一个操作符而非语句)。
5.5.6 throw
异常是一种信号,表示发生了某种意外情形或错误。抛出错误是为了表面发生了这种错误或意外情形。捕获(catch)异常则是要处理它,即采取必要或对应的措施以从异常中恢复。在JavaScript中,每当运行发生错误时或者程序中使用throw语句时都会抛出异常。
语法:
throw expression;
expression可能求值为任何类型的值,可以抛出一个表示错误的数值,也可以抛出一个包含可读的错误消息的字符串。JavaScript解释器在抛出错误时会使用Error类及其子类,当然我们也可以在自己的代码中使用这些类。Error对象有一个name属性和一个message属性,分别用于指定错误类型和保存传入构造函数的字符串。下面这个例子会收到无效参数时抛出一个Error对象:
function factorial(x){
//如果收到的参数无效,则抛出异常
if(x<0) throw new Error("x must not be negative");
//否则,计算一个值并正常返回
let f;
for(f=1;x>1;f*=x,x--);
return f;
}
factorial(4)
=>24
factorial(-1)
=>VM1628:3 Uncaught Error: x must not be negative
at factorial (<anonymous>:3:19)
at <anonymous>:1:1
抛出异常时,JavaScript解释器会立即停止正常程序的执行并跳到最近的异常处理程序。异常处理程序是使用try/catch/finally语句中的catch子句编写的。如果发生异常的代码块没有关联的catch子句,解释器会检查最接近的上一层代码块,看是否有与之关联的异常处理程序。这个过程一直持续,直至找到处理程序。如果函数中抛出了异常,但函数体内没有处理这个异常的try/catch/finally语句,则异常会向上传播到调用函数的代码。在这种情况下,异常是沿JavaScript方法的词法结构和调用栈向上传播的。如果没有找到任何异常处理程序,则将异常作为错误报告给用户。
5.5.7 try/catch/finally
try/catch/finally语句是JavaScript的异常处理机制。这个语句的try子句用于定义要处理其中异常的代码块。try块后面紧跟着catch子句,catch是一个语句块,在try块中发生异常时会被调用。catch子句后面是finally块,其中包含清理代码,无论try块中发生了什么,这个块中的代码一定会执行。catch和finally块都是可选的,但只要有try块,就必须有它们两中的一个。try、catch和finally块都以花括号开头和结尾。花括号是语法要求的部分,即使语句中只包含一条语句也不能省略。
try{
//正常情况下,这里的代码会从头到尾执行不会出现问题。
//但有时候也可能抛出异常:
//直接通过throw语句抛出,或者由于调用了一个抛出异常的方法而抛出
}
catch(e){
//当且仅当try块抛出异常时,才会执行这个
//块中的语句。这里的语句可以使用局部变量
//e引用被抛出的Error对象。这个块可以以某种方式来处理异常,
//也可以什么都不做以忽略异常,还可以通过throw重新抛出异常
}
finally{
//无论try块中发生什么
//这个块中包含的语句都会被执行。无论try块是否终止,这些语句
//都会被执行:
// (1)正常情况下,在到达try块底部时被执行
// (2)由于break、continue或return语句而执行
// (3)由于上面的catch子句处理了异常而执行
// (4)由于异常未被处理而继续传播而执行
}
eg:
let s
try{
let x=1;
let y=2;
let z=x+y
}finally{
s="何磊恰粑粑"; s=s+'!';
}
s
=>"何磊恰粑粑!"
注意,catch关键字后面通常会跟着一个包含在圆括号中的标识符,这个标识符类似函数的参数。当捕获到异常时,与异常关联的值(比如一个Error对象)就会被赋值给这个参数。与catch子句关联的标识符具有块作用域,即只在catch块中有定义。
下面是一个实际的try/catch语句的例子。例子中用到了上一节定义的factorial()方法,以及实现输入输出的客户端JavaScript方法prompt()和alert():
try{
//清用户输入一个数值
let n=Number(prompt("Please enter a positive integer",""));
//假设输入有效,计算该数值的阶乘
let f=factorial(n);
//显示结果
alert(n+"!="+f)
}catch(ex){ //如果用户的输入无效,则会跳到这里
alert(ex); //告诉用户发生了什么错误
}
这个例子中只包含try/catch语句,没有finally子句。尽管finally没有catch所以呢得频繁,但有时候也很有用。不过,finally的行为需要再解释一下。只要执行了try块中的任何代码,finally子句就一定会执行,无论try块中的代码是怎么执行完的。因此finally子句经常用于执行完try子句之后执行代码清理。
正常情况下,JavaScript解释器会执行到try块末尾,然后再执行finally块,从而完成必要的清理工作。如果解释器由于return、continue或break语句而离开了try块,则解释器在跳转到新目标之前会执行finally块。
如果try块中发生了异常,而且有关联的catch块来处理这个异常,则解释器会先执行catch块,然后再执行finally块。如果局部没有catch块处理异常,则解释器会先执行finally块,然后再跳转到最接近的包含catch子句。
如果finally块本身由于return、continue、break或throw语句导致跳转,或者调用的方法抛出了异常,则解释器会抛弃等待的跳转,执行新跳转。例如,如果finally子句抛出异常,该异常会代替正被抛出的其他异常。如果finally子句执行了子句抛出异常,该异常会代替正被抛出的其他异常。如果finally子句执行了return语句,则相应方法正常返回,即使有被抛出且尚未处理的异常。
try和finally可以配对使用,而不带catch子句。此时,无论try块中发生了什么,finally块一定会执行,可以正常完成清理任务。前面我们说过,不能完全通过while循环来模拟for循环,因为continue在这两种循环中的行为不一样。如果使用try/finally语句,就可以用while写出for循环类似的逻辑(continue之后先increment,再test,test为真值就再次进入循环体),正确处理continue语句:
//模拟for(initialize;test;increment)循环体
initialize;
while(test){
try{ body; }
finally{ increment; }
}
不过需要注意的是,包含break语句的body在while循环与在for循环中的行为会有所不同(在while循环中,break会导致在退出循环前额外执行一次increment)。因此即使使用finally子句,也不能完全通过while来模拟for循环。
5.6 其他语句
本节介绍剩下的三个JavaScript语句:with、debugger和“use strict”。
5.6.1 with
with会运行一个代码块,就好像指定对象的属性是该代码块作用域的变量一样。它有如下语法:
with (object)
statement
这个语句创建了一个临时作用域,以object的属性作为变量,然后在这个作用域中执行statement。
with在严格模式下是被禁用的,在非严格模型下也应该认为已经废弃了。换句话说,尽可能不去使用它。使用with的JavaScript代码很难优化,与不使用with的等价代码相比运行速度明显慢得多。
使用with语句主要是为了更方便地使用深度嵌套地对象。例如,在客户端JavaScript中,要访问某个HTML表单的元素可能要这样写:
document.forms[0].address.value
如果需要写很多这样的表达式,则可以使用with语句让使用表单对象的属性像使用变量一样:
with(document.forms[0]){
//在这里直接访问表单元素。例如:
name.value="";
address.value="";
email.value="";
}
这样可以减少键盘输入,因为不用每次都写document.forms[0]了。当然,前面的代码不用with语句也很容易写成这样:
let f=document.forms[0];
f.name.value="";
f.address.value="";
f.email.value="";
注意,如果在with语句体中使用const、let或var声明一个变量或常量,那么只会创建一个普通变量,不会在指定的对象上定义新属性。
5.6.2 debugger
debugger语句一般什么也不做。不过,包含debugger的程序在运行时,实现可以(但不说必需)执行某种调试操作。实践中,这个语句就像一个断点,执行中的JavaScript会停止,我们可以使用调试器打印变量的值、检查调用栈,等等。例如,假设你在调用函数f()时没有传参数,函数就会抛出异常,而你不知道这个调用来自何处。为了调试这个问题,可以修改f(),像下面这样为它加上debugger语句:
function f(o){
if(o===undefined) debugger; //仅为调试才添加的
... //这里是函数中的其他代码
}
现在,再次调用f()而不传参数,执行就会停止,你可以使用调试器检查调用栈,找到这个错误的调用来自何处。
注意,只有调试器还不行,debugger语句并不为你打开调试器。如果你使用浏览器并且打开了开发者控制台,这个语句就会导致断点。
5.6.3 “use strict”
“use strict"是ES5引入的一个指令。指令不是语句(但非常近似,所以在这里介绍"use strict”)。"use strict"与常规语句有两个重要的区别。
- 不包含任何语言关键字:指令是由(包含在单引号或双引号中的)特殊字符串字面量构成的表达式语句。
- 只能出现在脚本或函数体的开头,位于所有其他真正的语句之前。
"use strict"指令的目的是表示(在i奥本或函数中)它后面的代码是严格代码。如果脚本中有"use strict"指令,则脚本的顶级(非函数)代码是严格代码。如果函数体是在严格代码中定义的,或者函数体中有一个"use strict"指令,那它就是严格代码。如果严格代码中调用了eval(),那么传给eval()的代码也是严格代码;如果传给eval()的字符串包含了"use strict"指令,那么传给eval()的代码也是严格代码;除了显示声明为严格的代码外,任何位于class体或ES6模块中的代码全部默认为严格代码,而无须把"use strict"指令显式地写出来。
严格代码在严格模式下执行。严格模式是JavaScript的一个受限制的子集,这个子集修复了重要的语言缺陷,提供了更强的错误检查,也增强了安全性。因为严格模式并不是默认的,那些使用语言中有缺陷的遗留特征的旧代码依然可以正确运行。严格模式与非严格模式的区别如下(前三个特别重要)。
- 严格模式下不允许使用with语句。
- 在严格模式下,所有变量都必须声明。如果把值赋给一个标识符,而这个标识符是没有严格声明的变量、函数、函数参数、catch子句参数或全局对象的属性,都会导致抛出一个ReferenceError(在非严格模式下,给全局对象的属性赋值会隐式声明一个全局变量,即给全局对象添加一个新属性)。
- 在严格模式下,函数如果作为函数(而非方法)被调用,其this值为undefined(在非严格模式下,作为函数调用的函数始终以全局对象作为this的值)。另外,在严格模式下,如果函数传给call()或apply()调用,则this就是作为第一个参数传给call()或apply()的值(在非严格模式下,null和undefined值会被替换为全局对象,而非对象值会被转换为对象)。
- 在严格模式下,给不可写的属性赋值或尝试在不可扩展的对象上创建新属性会抛出TypeError。
- 在严格模式下,传给eval()的代码不能像在非严格模式下那样在调用者的作用域中声明变量或定义函数。这种情况下定义的变量和函数会存在于一个为eval()创建的新作用域中。这个作用域在eval()返回时就会被销魂。
- 在严格模式下,函数中的Arguments对象保存着一份传给函数的值的静态副本。在非静态模式下,这个Arguments对象具有“魔法”行为,即这个数组中的元素与函数的命名参数引用相同的值。
- 在严格模式下,如果delete操作符后面跟一个未限定的标识符,比如变量、函数或函数参数,则会导致抛出SyntaxError(在非严格模式下,这样的delete表达式什么也不做,且返回false)。
- 在严格模式下,尝试删除一个不可配置的属性会导致抛出TypeError(在非严格模式下,这个尝试会失败,且delete表达式会求值为false)。
- 在严格模式下,对象字面量定义两个或多个同名属性是语法错误(在非严格模式下,不会发生错误)。
- 在严格模式下,函数声明中有两个或多个同名参数是语法错误(在非严格模式下,不会发生错误)
- 在严格模式下,不允许使用八进制整数字面量(以0开头后面没有x)(在非严格模式下,某些实现允许使用八进制字面量)
- 在严格模式下,标识符eval和arguments被当作关键字,不允许修改它们的值。不能给这些标识符赋值,不能把它们声明为变量,不能把它们用作函数名或者函数参数名,也不能把它们作为catch块的标识符使用。
- 在严格模式下,检查调用栈的能力是受限制的。arguments.caller和arguments.callee在严格模式中都会抛出TypeError。严格模式函数也有caller和arguments属性,但读取它们会抛出TypeError(某些实现在非严格函数中定义了这些非标准属性)。
5.7 声明
关键字const、let、var、function、class、import和export严格来讲并不是语句,只是看起来很像语句,本书非正式地称它们为语句,因此本章也一并在这里介绍。
这些关键字更准确地讲应该叫作声明而非语句。我们在本章开始时说过,语句会导致“某些事件发生”。声明可以定义新值并给它们命名,以便将来通过这个名字引用相应的值。声明本身不会导致太多事件发生,但通过为值提供名字,它们会为程序中的其他语句定义相应的含义,这一点非常重要。
当程序运行时,解释器会对程序中的表达式求值,而且会执行程序的语句。程序中的声明并不以同样的发生“运行”,但它们定义程序本身的结构。宽泛地说,可以把声明看成程序的一部分,这一部分会在代码运行前预先处理。
5.7.2 function
function声明用于定义函数,下面是一个函数声明的例子:
function area(radius){
return Math.PI*radius*radius;
}
函数声明会创建一个函数对象,并且把这个函数对象赋值给指定的名字(在这里是area)。然后在程序的任何地方都可以通过这个名字来引用这个函数,并且运行其中的代码。位于任何JavaScript代码块中的函数声明都会在代码运行之前被处理,而在整个代码块中函数名都会绑定到相应的函数对象。无论在作用域的什么地方声明函数,这些函数都会被“提升”,就好像它们是在该作用域顶部定义的一样。于是在程序中,调用函数的代码可能位于声明函数的代码之前。
5.7.3 class
在ES6及之后的版本中,class声明会创建一个新类并为其赋予一个名字,以便将来引用。第9章详细介绍了类。下面是一个简单的类声明:
class Circle{
constructor(radius){this.r=radius;}
area(){ return Math.PI*this.r*this.r;}
circumference(){
return 2*Math.PI*this.r;
}
}
与函数不同,类声明不会被提升。因此在代码中,不能在还没声明类之前就使用类。
5.7.4 import和export
import和export声明共同让一个JavaScript模块中定义的值可以在另一个模块中使用。一个模块就是一个JavaScript代码文件,有自己的全局作用域,完全与其他代码块无关。如果要在一个模块中使用另一个模块中定义的值(如函数或类),唯一的方式就是在定义的模块中使用export导出值,在使用值的模块中使用import导入值。
import指令用于从另一个JavaScript代码文件中导入一个或多个值,并在当前模块中为这些值指定名字。import指令有几种不同的形式。下面是几个例子:
import Circle from './geometry/circle.js';
import ( PI, TAU } from './geometry/constants.js';
import { magnitude as hypotenuse } from ' ./vectors/utils.js';
JavaScript模块中的值是私有的,除非被显式导出,否则其他模块都无法导入。export指令就是为此而生的,它声明把当前模块中定义的一个或多个值导出,因而其他模块可以导入这些值。export指令相比import指令有更多变体,下面是其中一种:
// geometry/constants.js
const PI = Math.PI;
const TAU = 2 * PI;
export ( PI, TAU );
export关键字有时候也用作其他声明的标识符,从而构成一种复合声明,在定义常量、变量、函数或类的同时又导出它们。如果一个模块只导出一个值,通常会使用特殊的export default形式:
export const TAU=2*Math.PI;
export function magnitude(x,y){ return Math.sqrt(x*x+y*y);}
export default class Circle{ /* 这里省略了定义 */}
5.8 小结
本章介绍了JavaScript语言的所有语句,它们的语法全部列举在表5-1中。