返回介绍

第一部分 类型和语法

第二部分 异步和性能

5.1 语句和表达式

发布于 2023-05-24 16:38:21 字数 12508 浏览 0 评论 0 收藏 0

开发人员常常将“语句”(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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文