5.1 语句和表达式
开发人员常常将“语句”(statement)和“表达式”(expression)混为一谈,但这里我们要将二者区别开来,因为它们在 JavaScript 中存在一些重要差别。
你应该对英语更熟悉,这里我们就借用它的术语来说明问题。
“句子”(sentence)是完整表达某个意思的一组词,由一个或多个“短语”(phrase)组成,它们之间由标点符号或连接词(and 和 or 等)连接起来。短语可以由更小的短语组成。有些短语是不完整的,不能独立表达意思;有些短语则相对完整,并且能够独立表达某个意思。这些规则就是英语的语法 。
JavaScript 的语法也是如此。语句相当于句子,表达式相当于短语,运算符则相当于标点符号和连接词。
JavaScript 中表达式可以返回一个结果值。例如:
var a = 3 * 6; var b = a; b;
这里,3 * 6 是一个表达式(结果为 18 )。第二行的 a 也是一个表达式,第三行的 b 也是。表达式 a 和 b 的结果值都是 18 。
这三行代码都是包含表达式的语句。var a = 3 * 6 和 var b = a 称为“声明语句”(declaration statement),因为它们声明了变量(还可以为其赋值)。a = 3 * 6 和 b = a (不带 var )叫作“赋值表达式”。
第三行代码中只有一个表达式 b ,同时它也是一个语句(虽然没有太大意义)。这样的情况通常叫作“表达式语句”(expression statement)。
5.1.1 语句的结果值
很多人不知道,语句都有一个结果值(statement completion value,undefined 也算)。
获得结果值最直接的方法是在浏览器开发控制台中输入语句,默认情况下控制台会显示所执行的最后一条语句的结果值。
以赋值表达式 b = a 为例,其结果值是赋给 b 的值(18 ),但规范定义 var 的结果值是 undefined 。如果在控制台中输入 var a = 42 会得到结果值 undefined ,而非 42 。
从技术角度来解释要更复杂一些。ES5 规范 12.2 节中的变量声明(VariableDeclaration )算法实际上有一个返回值(是一个包含所声明变量名称的字符串,很奇特吧?),但是这个值被变量语句(VariableStatement )算法屏蔽掉了(for..in 循环除外),最后返回结果为空(undefined )。
如果你用开发控制台(或者 JavaScript REPL——read/evaluate/print/loop 工具)调试过代码,应该会看到很多语句的返回值显示为 undefined ,只是你可能从未探究过其中的原因。其实控制台中显示的就是语句的结果值。
但我们在代码中是没有办法获得这个结果值的,具体解决方法比较复杂,首先得弄清楚为什么要获得语句的结果值。
先来看看其他语句的结果值。比如代码块 { .. } 的结果值是其最后一个语句 / 表达式的结果。
例如:
var b; if (true) { b = 4 + 38; }
在控制台 /REPL 中输入以上代码应该会显示 42 ,即最后一个语句 / 表达式 b = 4 + 38 的结果值。
换句话说,代码块的结果值就如同一个隐式的返回 ,即返回最后一个语句的结果值。
与此类似,CoffeeScript 中的函数也会隐式地返回最后一个语句的结果值。
但下面这样的代码无法运行:
var a, b; a = if (true) { b = 4 + 38; };
因为语法不允许我们获得语句的结果值并将其赋值给另一个变量(至少目前不行)。
那应该怎样获得语句的结果值呢?
以下代码仅为演示,切勿在实际开发中这样操作!
可以使用万恶的 eval(..) (又读作“evil”)来获得结果值:
var a, b; a = eval( "if (true) { b = 4 + 38; }" ); a; // 42
这并不是个好办法,但确实管用。
ES7 规范有一项“do 表达式”(do expression)提案,类似下面这样:
var a, b; a = do { if (true) { b = 4 + 38; } }; a; // 42
上例中,do { .. } 表达式执行一个代码块(包含一个或多个语句),并且返回其中最后一个语句的结果值,然后赋值给变量 a 。
其目的是将语句当作表达式来处理(语句中可以包含其他语句),从而不需要将语句封装为函数再调用 return 来返回值。
虽然目前语句的结果值还无关紧要,但随着 JavaScript 语言的演进,它可能会扮演越来越重要的角色。希望 do { .. } 表达式的引入能够减少对 eval(..) 这类方法的使用。
再次强调:不要使用 eval(..) 。详情请参见《你不知道的 JavaScript(上卷)》的“作用域和闭包”部分。
5.1.2 表达式的副作用
大部分表达式没有副作用。例如:
var a = 2; var b = a + 3;
表达式 a + 3 本身没有副作用(比如改变 a 的值)。它的结果值为 5 ,通过 b = a + 3 赋值给变量 b 。
最常见的有副作用(也可能没有)的表达式是函数调用:
function foo() { a = a + 1; } var a = 1; foo(); // 结果值:undefined。副作用:a的值被改变
其他一些表达式也有副作用,比如:
var a = 42; var b = a++;
a++ 首先返回变量 a 的当前值 42 (再将该值赋给 b ),然后将 a 的值加 1:
var a = 42; var b = a++; a; // 43 b; // 42
很多开发人员误以为变量 b 和 a 的值都是 43 ,这是因为没有完全理解 ++ 运算符的副作用何时产生。
递增运算符 ++ 和递减运算符 -- 都是一元运算符(参见第 4 章),它们既可以用在操作数的前面,也可以用在后面:
var a = 42; a++; // 42 a; // 43 ++a; // 44 a; // 44
++ 在前面时,如 ++a ,它的副作用(将 a 递增)产生在表达式返回结果值之前,而 a++ 的副作用则产生在之后。
++a++ 会产生 ReferenceError 错误,因为运算符需要将产生的副作用赋值给一个变量。以 ++a++ 为例,它首先执行 a++ (根据运算符优先级,如下),返回 42 ,然后执行 ++42 ,这时会产生 ReferenceError 错误,因为 ++ 无法直接在 42 这样的值上产生副作用。
常有人误以为可以用括号 ( ) 将 a++ 的副作用封装起来,例如:
var a = 42; var b = (a++); a; // 43 b; // 42
事实并非如此。( ) 本身并不是一个封装表达式,不会在表达式 a++ 产生副作用之后执行。即便可以,a++ 会首先返回 42 ,除非有表达式在 ++ 之后再次对 a 进行运算,否则还是不会得到 43 ,也就不能将 43 赋值给 b 。
但也不是没有办法,可以使用 , 语句系列逗号运算符(statement-series comma operator)将多个独立的表达式语句串联成一个语句:
var a = 42, b; b = ( a++, a ); a; // 43 b; // 43
由于运算符优先级的关系,a++, a 需要放到 ( .. ) 中。本章后面将会介绍。
a++, a 中第二个表达式 a 在 a++ 之后执行,结果为 43 ,并被赋值给 b 。
再如 delete 运算符。第 2 章讲过,delete 用来删除对象中的属性和数组中的单元。它通常以单独一个语句的形式出现:
var obj = { a: 42 }; obj.a; // 42 delete obj.a; // true obj.a; // undefined
如果操作成功,delete 返回 true ,否则返回 false 。其副作用是属性被从对象中删除(或者单元从 array 中删除)。
操作成功是指对于那些不存在或者存在且可配置(configurable,参见《你不知道的 JavaScript(上卷)》的“this 和对象原型”部分的第 3 章)的属性,delete 返回 true ,否则返回 false 或者报错。
另一个有趣的例子是 = 赋值运算符。
例如:
var a; a = 42; // 42 a; // 42
a = 42 中的 = 运算符看起来没有副作用,实际上它的结果值是 42 ,它的副作用是将 42 赋值给 a 。
组合赋值运算符,如 += 和 -= 等也是如此。例如,a = b += 2 首先执行 b += 2 (即 b = b + 2 ),然后结果再被赋值给 a 。
多个赋值语句串联时(链式赋值,chained assignment),赋值表达式(和语句)的结果值就能派上用场,比如:
var a, b, c; a = b = c = 42;
这里 c = 42 的结果值为 42 (副作用是将 c 赋值 42 ),然后 b = 42 的结果值为 42 (副作用是将 b 赋值 42 ),最后是 a = 42 (副作用是将 a 赋值 42 )。
链式赋值常常被误用,例如 var a = b = 42 ,看似和前面的例子差不多,实则不然。如果变量 b 没有在作用域中象 var b 这样声明过,则 var a = b = 42 不会对变量 b 进行声明。在严格模式中这样会产生错误,或者会无意中创建一个全局变量(参见《你不知道的 JavaScript(上卷)》的“作用域和闭包”部分)。
另一个需要注意的问题是:
function vowels(str) { var matches; if (str) { // 提取所有元音字母 matches = str.match( /[aeiou]/g ); if (matches) { return matches; } } } vowels( "Hello World" ); // ["e","o","o"]
上面的代码没问题,很多开发人员也喜欢这样做。其实我们可以利用赋值语句的副作用将两个 if 语句合二为一:
function vowels(str) { var matches; // 提取所有元音字母 if (str && (matches = str.match( /[aeiou]/g ))) { return matches; } } vowels( "Hello World" ); // ["e","o","o"]
将 matches = str.match.. 放到 ( .. ) 中是必要的,原因请参见 5.2 节。
我更偏向后者,因为它更简洁,能体现两个条件的关联性。不过这只是个人偏好,无关对错。
5.1.3 上下文规则
在 JavaScript 语法规则中,有时候同样的语法在不同的情况下会有不同的解释。这些语法规则孤立起来会很难理解。
这里我们不一一列举,只介绍一些常见情况。
1. 大括号
下面两种情况会用到大括号 { .. } (随着 JavaScript 的演进会出现更多类似的情况)。
(1) 对象常量
用大括号定义对象常量(object literal):
// 假定函数bar()已经定义 var a = { foo: bar() };
{ .. } 被赋值给 a ,因而它是一个对象常量。
a 是赋值的对象,称为“左值”(l-value)。{ .. } 是所赋的值(即本例中赋给变量 a 的值),称为“右值”(r-value)。
(2) 标签
如果将上例中的 var a = 去掉会发生什么情况呢?
// 假定函数bar()已经定义 { foo: bar() }
很多开发人员以为这里的 { .. } 只是一个孤立的对象常量,没有赋值。事实上不是这样。
{ .. } 在这里只是一个普通的代码块。JavaScript 中这种情况并不多见(在其他语言中则常见得多),但语法上是完全合法的,特别是和 let (块作用域声明)在一起时非常有用(参见《你不知道的 JavaScript(上卷)》的“作用域和闭包”部分)。
{ .. } 和 for/while 循环以及 if 条件语句中代码块的作用基本相同。
但 foo: bar() 这样奇怪的语法为什么也合法呢?
这里涉及 JavaScript 中一个不太为人知(也不建议使用)的特性,叫作“标签语句”(labeled statement)。foo 是语句 bar() 的标签(后面没有 ; ,参见 5.3 节)。标签语句具体是做什么用的呢?
如果 JavaScript 有 goto 语句,理论上我们可以使用 goto foo 跳转到 foo 处执行。goto 被公认为是一种极为糟糕的编码方式,它会让代码变得晦涩难懂(也叫作 spaghetti code),好在 JavaScript 不支持 goto 。
然而 JavaScript 通过标签跳转能够实现 goto 的部分功能。continue 和 break 语句都可以带一个标签,因此能够像 goto 那样进行跳转。例如:
// 标签为foo的循环 foo: for (var i=0; i<4; i++) { for (var j=0; j<4; j++) { // 如果j和i相等,继续外层循环 if (j == i) { // 跳转到foo的下一个循环 continue foo; } // 跳过奇数结果 if ((j * i) % 2 == 1) { // 继续内层循环(没有标签的) continue; } console.log( i, j ); } } // 1 0 // 2 0 // 2 1 // 3 0 // 3 2
contine foo 并不是指“跳转到标签 foo 所在位置继续执行”,而是“执行 foo 循环的下一轮循环”。所以这里的 foo 并非 goto 。
上例中 continue 跳过了循环 3 1 ,continue foo (带标签的循环跳转,labeled-loop jump)跳过了循环 1 1 和 2 2 。
带标签的循环跳转一个更大的用处在于,和 break 一起使用可以实现从内层循环跳转到外层循环。没有它们的话实现起来有时会非常麻烦:
// 标签为foo的循环 foo: for (var i=0; i<4; i++) { for (var j=0; j<4; j++) { if ((i * j) >= 3) { console.log( "stopping!", i, j ); break foo; } console.log( i, j ); } } // 0 0 // 0 1 // 0 2 // 0 3 // 1 0 // 1 1 // 1 2 // 停止! 1 3
break foo 不是指“跳转到标签 foo 所在位置继续执行”,而是“跳出标签 foo 所在的循环 / 代码块,继续执行后面的代码”。因此它并非传统意义上的 goto 。
上例中如果使用不带标签的 break ,就可能需要用到一两个函数调用和共享作用域的变量等,这样代码会更难懂,使用带标签的 break 可能更好一些。
标签也能用于非循环代码块,但只有 break 才可以。我们可以对带标签的代码块使用 break ___ ,但是不能对带标签的非循环代码块使用 continue ___ ,也不能对不带标签的代码块使用 break :
// 标签为bar的代码块 function foo() { bar: { console.log( "Hello" ); break bar; console.log( "never runs" ); } console.log( "World" ); } foo(); // Hello // World
带标签的循环 / 代码块十分少见,也不建议使用。例如,循环跳转也可以通过函数调用来实现。不过在某些情况下它们也能派上用场,这时请务必将注释写清楚!
JSON 被普遍认为是 JavaScript 语言的一个真子集,{"a":42} 这样的 JSON 字符串会被当作合法的 JavaScript 代码(请注意 JSON 属性名必须使用双引号!)。其实不是!如果在控制台中输入 {"a":42} 会报错。
因为标签不允许使用双引号,所以 "a" 并不是一个合法的标签,因此后面不能带 : 。
JSON 的确是 JavaScript 语法的一个子集,但是 JSON 本身并不是合法的 JavaScript 语法。
这里存在一个十分常见的误区,即如果通过 <script src=..> 标签加载 JavaScript 文件,其中只包含 JSON 数据(比如某个 API 返回的结果),那它就会被当作合法的 JavaScript 代码来解析,只不过其内容无法被程序代码访问到。JSON-P(将 JSON 数据封装为函数调用,比如 foo({"a":42}) )通过将 JSON 数据传递给函数来实现对其的访问。
{"a":42} 作为 JSON 值没有任何问题,但是在作为代码执行时会产生错误,因为它会被当作一个带有非法标签的语句块来执行。foo({"a":42}) 就没有这个问题,因为 {"a":42} 在这里是一个传递给 foo(..) 的对象常量。所以准确地说,JSON-P 能将 JSON 转换为合法的 JavaScript 语法 。
2. 代码块
还有一个坑常被提到(涉及强制类型转换,参见第 4 章):
[] + {}; // "[object Object]" {} + []; // 0
表面上看 + 运算符根据第一个操作数([] 或 {} )的不同会产生不同的结果,实则不然。
第一行代码中,{} 出现在 + 运算符表达式中,因此它被当作一个值(空对象)来处理。第 4 章讲过 [] 会被强制类型转换为 "" ,而 {} 会被强制类型转换为 "[object Object]" 。
但在第二行代码中,{} 被当作一个独立的空代码块(不执行任何操作)。代码块结尾不需要分号,所以这里不存在语法上的问题。最后 + [] 将 [] 显式强制类型转换 (参见第 4 章)为 0 。
3. 对象解构
从 ES6 开始,{ .. } 也可用于“解构赋值”(destructuring assignment,详情请参见本系列的《你不知道的 JavaScript(下卷)》的“ES6 & Beyond”部分),特别是对象的解构。例如:
function getData() { // .. return { a: 42, b: "foo" }; } var { a, b } = getData(); console.log( a, b ); // 42 "foo"
{ a , b } = .. 就是 ES6 中的解构赋值,相当于下面的代码:
var res = getData(); var a = res.a; var b = res.b;
{ a, b } 实际上是 { a: a, b: b } 的简化版本,两者均可,只不过 { a, b } 更简洁。
{ .. } 还可以用作函数命名参数(named function argument)的对象解构(object destructuring),方便隐式地用对象属性赋值:
function foo({ a, b, c }) { // 不再需要这样: // var a = obj.a, b = obj.b, c = obj.c console.log( a, b, c ); } foo( { c: [1,2,3], a: 42, b: "foo" } ); // 42 "foo" [1, 2, 3]
在不同的上下文中 { .. } 的作用不尽相同,这也是词法和语法的区别所在。掌握这些细节有助于我们了解 JavaScript 引擎解析代码的方式。
4. else if 和可选代码块
很多人误以为 JavaScript 中有 else if ,因为我们可以这样来写代码:
if (a) { // .. } else if (b) { // .. } else { // .. }
事实上 JavaScript 没有 else if ,但 if 和 else 只包含单条语句的时候可以省略代码块的 { } 。下面的代码你一定不会陌生:
if (a) doSomething( a );
很多 JavaScript 代码检查工具建议对单条语句也应该加上 { } ,如:
if (a) { doSomething( a ); }
else 也是如此,所以我们经常用到的 else if 实际上是这样的:
if (a) { // .. } else { if (b) { // .. } else { // .. } }
if (b) { .. } else { .. } 实际上是跟在 else 后面的一个单独的语句,所以带不带 { } 都可以。换句话说,else if 不符合前面介绍的编码规范,else 中是一个单独的 if 语句。
else if 极为常见,能省掉一层代码缩进,所以很受青睐。但这只是我们自己发明的用法,切勿想当然地认为这些都属于 JavaScript 语法的范畴。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论