懂王系列(八)之彻底搞懂JavaScript语句

356 阅读12分钟

作为一名前端小白,不知道大家是否遇到和我一样的问题。看了一道面试题的解析,当时觉得会了,可是过两天以后再看又不会了;盲目追求各种新技术,感觉什么都会点,但是一上手就不行了... 痛定思痛后,我终于认识到了问题所在,开始专注于基本功的修炼。近半年来通读了(其实是囫囵吞枣)《JavaScript高级程序设计》、《你不知道的JavaScript上、中、下》等书籍,本系列文章是我读书过程中对知识点的一些总结。喜欢的同学记得帮我点个赞😁。

懂王系列(一)之彻底搞懂JavaScript函数执行机制
懂王系列(二)之彻底搞懂JavaScript作用域
懂王系列(三)之彻底搞懂JavaScript对象
懂王系列(四)之彻底搞懂JavaScript类
懂王系列(五)之彻底搞懂JavaScript原型
懂王系列(六)之彻底搞懂JavaScript中的this
懂王系列(七)之彻底搞懂JavaScript数据类型
懂王系列(八)之彻底搞懂JavaScript语句
懂王系列(九)之彻底搞定JavaScript类型转换

1. 语句和表达式

执行特定任务的一组单词、数字和运算符被称为语句

a = b * 2;

字符 a 和 b 称为变量,2本身就是一个值,称为字面值,= 和 * 是运算符

一个表达式是对一个变量或值的引用,或者是一组值和变量与运算符的组合,a = b * 2; 这个语句中有四个表达式

• 2 是一个字面值表达式。
• b 是一个变量表达式,表示获取它的当前值。
b * 2 是一个算术表达式,表示进行乘法运算。
a = b * 2 是一个赋值表达式,意思是将表达式 b * 2 的结果赋值给变量 a

JavaScript 中表达式可以返回一个结果值。例如:

var a = 3 * 6;
var b = a;
b;

这里,3 * 6 是一个表达式(结果为 18)。第二行的 a 也是一个表达式,第三行的 b 也是。表达式 ab 的结果值都是 18。 这三行代码都是包含表达式的语句。var a = 3 * 6var b = a 称为“声明语句”(declaration statement),因为它们声明了变量(还可以为其赋值)。a = 3 * 6b = a(不带 var)叫作“赋值表达式”。

第三行代码中只有一个表达式 b,同时它也是一个语句(虽然没有太大意义)。这样的情况通常叫作“表达式语句”(expression statement)。

1.1 语句的结果值

语句都有一个结果值(statement completion value),undefined 也算

如果在控制台中输入 var a = 42 会得到结果值 undefined,而非 42

var b;
if (true) {
	b = 4 + 38;
}

在控制台 /REPL 中输入以上代码应该会显示 42,即最后一个语句 / 表达式 b = 4 + 38 的结值。

换句话说,代码块的结果值就如同一个隐式的返回,即返回最后一个语句的结果值。

但下面这样的代码无法运行:

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

1.2 表达式的副作用

常有人误以为可以用括号 ()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 中第二个表达式 aa++ 之后执行,结果为 43,并被赋值给 b

delete 用来删除对象中的属性和数组中的单元。它通常以单独一个语句的形式出现:

var obj = {
	a: 42
};
obj.a; // 42
delete obj.a; // true
obj.a; // undefined

如果操作成功,delete 返回 true,否则返回 false。其副作用是属性被从对象中删除(或者单元从 array 中删除)。

var a;
a = 42; // 42
a; // 42

a = 42 中的 = 运算符看起来没有副作用,实际上它的结果值是 42,它的副作用是将 42 赋值给 a

var a, b, c;
a = b = c = 42;

这里 c = 42 的结果值为 42(副作用是将 c 赋值 42),然后 b = 42 的结果值为 42(副作用是将 b 赋值 42),最后是 a = 42(副作用是将 a 赋值 42)。

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"]

1.3 上下文规则

1.3.1 大括号

{
	foo: bar()
}

很多人以为这里的 { .. } 只是一个孤立的对象常量,没有赋值。事实上不是这样。{ .. } 在这里只是一个普通的代码块。foo 是语句 bar() 的标签

// 标签为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

1.3.2 代码块

[] + {}; // "[object Object]"
{} + []; // 0

第一行代码中,{} 出现在 + 运算符表达式中,因此它被当作一个值(空对象)来处理。 [] 会被强制类型转换为 "",而 {} 会被强制类型转换为 [object Object]
但在第二行代码中,{} 被当作一个独立的空代码块(不执行任何操作)。代码块结尾不需要分号,所以这里不存在语法上的问题。最后 + [][] 显式强制类型转换为 0

1.3.2 else if

事实上js没有else if,我们经常用到的 else if 实际上是这样的:

if (a) { 
 // ..
} 
else {
  if (b) { 
  	// ..
  } 
  else {
  	// .. 
  }
}

if (b) { .. } else { .. } 实际上是跟在 else 后面的一个单独的语句,所以带不带 { } 都可以。换句话说,else if 不符合前面介绍的编码规范,else 中是一个单独的 if 语句。

2. 运算符优先级

&& 运算符先于 || 执行

2.1 短路

a && b 为例,如果 a 是一个假值,足以决定 && 的结果,就没有必要再判断 b 的值。同样对于 a || b,如果 a 是一个真值,也足以决定 || 的结果,也就没有必要再判断 b 的值。

2.2 更强的绑定

a && b || c ? c || b ? a : c && b : a

(a && b || c) ? (c || b) ? a : (c && b) : a

因为 && 运算符的优先级高于 ||,而 || 的优先级又高于 ? :

2.3 关联

关联和执行顺序不是一回事。

但它为什么又和执行顺序相关呢?原因是表达式可能会产生副作用,比如函数调用:

var a = foo() && bar();

这里 foo() 首先执行,它的返回结果决定了 bar() 是否执行。所以如果 bar()foo() 之前执行,整个结果会完全不同。

这里遵循从左到右的顺序(JavaScript 的默认执行顺序),与 && 的关联无关。因为上例中只有一个 && 运算符,所以不涉及组合和关联。

a && b && c 这样的表达式就涉及组合(隐式),这意味着 a && bb && c 会先执行。

从技术角度来说,因为 && 运算符是左关联(|| 也是),所以 a && b && c 会被处理为 (a && b) && c。不过右关联 a && (b && c) 的结果也一样。

如果 && 是右关联的话会被处理为 a && (b && c)。但这并不意味着 c 会在 b 之前执行。右关联不是指从右往左执行,而是指从右往左组合。任何时候,不论是组合还是关联,严格的执行顺序都应该是从左到右,a,b,然后 c。

? :(即三元运算符或者条件运算符):

a ? b : c ? d : e;

答案是 a ? b : (c ? d : e)。和 && 以及 || 运算符不同,右关联在这里会影响返回结果,因为 (a ? b : c) ? d : e 对有些值(并非所有值)的处理方式会有所不同。

举个例子:

true ? false : true ? true : true; // false
true ? false : (true ? true : true); // false
(true ? false : true) ? true : true; // true

另一个右关联(组合)的例子是 = 运算符。本章前面介绍过一个串联赋值的例子:

var a, b, c;
a = b = c = 42;

它首先执行 c = 42,然后是 b = ..,最后是 a = ..。因为是右关联,所以它实际上是这样来处理的:a = (b = (c = 42))

2.3 错误

在编译阶段发现的代码错误叫作“早期错误”(early error)。语法错误是早期错误的一种(如 a = ,)。另外,语法正确但不符合语法规则的情况也存在。 这些错误在代码执行之前是无法用 try..catch 来捕获的,相反,它们还会导致解析 / 编译失败。

2.4 函数参数

ES6 规范定义了一个新概念,叫作 TDZ(Temporal Dead Zone,暂时性死区)。

TDZ 指的是由于代码中的变量还没有初始化而不能被引用的情况。

对此,最直观的例子是 ES6 规范中的 let 块作用域:

{
 a = 2; // ReferenceError!
 let a; 
}

a = 2 试图在 let a 初始化 a 之前使用该变量(其作用域在 { .. } 内),这里就是 a 的TDZ,会产生错误。 有意思的是,对未声明变量使用 typeof 不会产生错误,但在 TDZ 中却会报错:

{
 typeof a; // undefined
 typeof b; // ReferenceError! (TDZ)
 let b;
}
var b = 3;
function foo( a = 42, b = a + b + 5 ) {
 // ..
}

b = a + b + 5 在参数 b(= 右边的 b,而不是函数外的那个)的 TDZ 中访问 b,所以会出错。而访问 a 却没有问题,因为此时刚好跨出了参数 a 的 TDZ。

function foo( a = 42, b = a + 1 ) {
  console.log(
    arguments.length, a, b,
    arguments[0], arguments[1]
  );
}
foo(); // 0 42 43 undefined undefined
foo( 10 ); // 1 10 11 10 undefined
foo( 10, undefined ); // 2 10 11 10 undefined
foo( 10, null ); // 2 10 null 10 null

虽然参数 ab 都有默认值,但是函数不带参数时,arguments 数组为空。
相反,如果向函数传递 undefined 值,则 arguments 数组中会出现一个值为 undefined 的单元,而不是默认值

ES6 参数默认值会导致 arguments 数组和相对应的命名参数之间出现偏差,ES5 也会出现这种情况:

function foo(a) {
 a = 42;
 console.log(arguments[0]);
}
foo(2); // 42 (linked)
foo(); // undefined (not linked)

向函数传递参数时,arguments 数组中的对应单元会和命名参数建立关联(linkage)以得到相同的值。相反,不传递参数就不会建立关联。但是,在严格模式中并没有建立关联这一说:

function foo(a) {
  "use strict";
  a = 42;
  console.log(arguments[0]);
}
foo(2); // 2 (not linked)
foo(); // undefined (not linked)

2.5 try...finally

function foo() {
  try {
  	return 42;
  } 
  finally {
  	console.log( "Hello" );
  }
  console.log( "never runs" );
}
console.log( foo() );
// Hello
// 42

这里 return 42 先执行,并将 foo() 函数的返回值设置为 42。然后 try 执行完毕,接着执行 finally。最后 foo() 函数执行完毕,console.log(..) 显示返回值。

try 中的 throw 也是如此:

function foo() {
  try {
  	throw 42; 
  }
  finally {
  	console.log( "Hello" );
  }
  console.log( "never runs" );
}
console.log( foo() );
// Hello
// Uncaught Exception: 42

如果 finally 中抛出异常(无论是有意还是无意),函数就会在此处终止。如果此前 try 中已经有 return 设置了返回值,则该值会被丢弃:

function foo() {
  try {
  	return 42;
  } 
  finally {
  	throw "Oops!";
  }
  console.log( "never runs" );
}
console.log( foo() );
// Uncaught Exception: Oops!

continuebreak 等控制语句也是如此:

for (var i=0; i<10; i++) {
  try {
  	continue; 
  }
  finally {
  	console.log( i );
  }
}
// 0 1 2 3 4 5 6 7 8 9

continue 在每次循环之后,会在 i++ 执行之前执行 console.log(i),所以结果是 0..9 而非1..10

finally 中的 return 会覆盖 trycatchreturn 的返回值

function foo() {
  try {
  	return 42;
  } 
  finally {
  	// 没有返回语句,所以没有覆盖
  } 
}
function bar() {
  try {
  	return 42;
  }
  finally {
  	// 覆盖前面的 return 42
  	return; 
  }
}
function baz() {
  try {
  	return 42;
  } 
  finally {
    // 覆盖前面的 return 42
    return "Hello";
  }
}
foo(); // 42
bar(); // undefined
baz(); // Hello

通常来说,在函数中省略 return 的结果和 return; 及 return undefined; 是一样的,但是 在 finally 中省略 return 则会返回前面的 return 设定的返回值。

2.6 switch

switch (a) {
  case 2:
  	// 执行一些代码
  	break;
  case 42:
    // 执行另外一些代码
    break;
  default:
  	// 执行缺省代码
}

acase 表达式的匹配算法与 === 相同。通常 case 语句中的 switch 都是简单值

var a = "hello world";
var b = 10;
switch (true) {
  case (a || b == 10):
    // 永远执行不到这里
    break;
  default:
  	console.log( "Oops" );
}
// Oops

因为 (a || b == 10) 的结果是 "hello world" 而非 true,所以严格相等比较不成立。此时可以通过强制表达式返回 true 或 false,如 case !!(a || b == 10):