# YDKJS-语法

“句子”(sentence)是完整表达某个意思的一组词,由一个或多个“短语”(phrase)组成,它们之间由标点符号或连接词(and 和 or 等)连接起来。

短语可以由更小的短语组成。有些短语是不完整的,不能独立表达意思;有些短语则相对完整,并且能够独立表达某个意思。这些规则就是英语的语法。

JavaScript 的语法也是如此。语句相当于句子,表达式相当于短语,运算符则相当于标点符号和连接词。

var a = 3 * 6 // 声明语句:声明了变量,赋值表达式:对变量进行了赋值
b // 表达式语句

# 语句的结果值

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

获得结果值最直接的方法是在浏览器开发控制台中输入语句,默认情况下控制台会显示所执行的最后一条语句的结果值。

以赋值表达式 b = a 为例,其结果值是赋给 b 的值(18),但规范定义 var 的结果值是 undefined。如果在控制台中输入 var a = 42 会得到结果值 undefined,而非 42。

但我们在代码中通常没有办法获得这个结果值的:

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

// 这样是行不通的
var a, b;
a = if (true) {
 b = 4 + 38;
};

// 这样可行,但是强烈不推荐
var a, b
a = eval('if (true) { b = 4 + 38; }')
a // 42

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

# 表达式的副作用

最常见的有副作用(也可能没有)的表达式是函数调用:

function foo() {
  a = a + 1
}
var a = 1
foo() // 结果值:undefined。副作用:a的值被改变

递增运算符 ++ 和递减运算符 -- 都是一元运算符,它们既可以用在操作数的前面,也可以用在后面。结合赋值操作,前置时会先赋值再运算:

var a = 42
var b = a++

a // 43
b // 42

也就是说 ++ 在前面时,如 ++a,它的副作用(将 a 递增)产生在表达式返回结果值之前,而 a++ 的副作用则产生在之后。

如果不想使用前置递增或递减运算,也可以使用语句系列逗号运算符(statement-series comma operator)将多个独立的表达式语句串联成一个语句:

var a = 42,
  b
b = (a++, a)

a // 43
b // 43

另外,delete 运算符的副作用就是属性被从对象中删除,更有趣的是赋值运算符:

var a, b, c
a = b = c = 42 // 注意它和 var a = b = 42,后者如果 b 未声明非严格模式会自动声明一个全局变量,严格模式则会报错

# 上下文规则

在 JavaScript 语法规则中,有时候同样的语法在不同的情况下会有不同的解释。

譬如,大括号通常会在我们声明一个变量的时候遇到:

// 假定函数bar()已经定义
var a = {
  foo: bar(),
}

如果将上例中的 var a = 去掉会发生什么情况呢?

{
  foo: bar()
}

在这里大括号只是一个普通的代码块,更重要的是其中的 foo: bar() 是合法的, foo: 会被识别为一个标签。标签通常会在循环中被用到,结合 continuebreak 语句实现类似于 goto 的语法。

标签也能用于非循环代码块,但只有 break 才可以。我们可以对带标签的代码块使用 break ___,但是不能对带标签的非循环代码块使用 continue ___,也不能对不带标签的代码块使用 break

// 标签为 bar 的代码块
function foo() {
  bar: {
    console.log('Hello')
    break bar
    console.log('never runs')
  }
  console.log('World')
}
foo()
// Hello
// World

还有一个坑常被提到:

console.log([] + {}) // "[object Object]"
console.log({} + []) // 0

第一行代码中,{} 出现在 + 运算符表达式中,因此它被当作一个值(空对象)来处理。[] 会被强制类型转换为 "",而 {} 会被强制类型转换为 "[object Object]"

但在第二行代码中,{} 被当作一个独立的空代码块(不执行任何操作)。代码块结尾不需要分号,所以这里不存在语法上的问题。最后 + [][] 显式强制类型转换为 0。

另外还有一个很深的误会是认为 JavaScript 中有 else if,因为我们经常会写出下面这样的代码:

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

事实上 JavaScript 没有 else if,但 if 和 else 只包含单条语句的时候可以省略代码块的,所以上面的代码实际上实际上是这样的:

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

从 ES6 开始,{ .. } 也可用于“解构赋值”(destructuring assignment)。

# 运算符优先级

运算符的优先级决定了表达式中运算执行的先后顺序,优先级高的运算符最先被执行。

逗号运算符对它的每个操作数求值(从左到右),并返回最后一个操作数的值。在 JavaScript 中,它的优先级最低。

let x = 1

x = (x++, x)
console.log(x) // 2
x = (2, 3)
console.log(x) // 3

&&|| 来说,如果从左边的操作数能够得出结果,就可以忽略右边的操作数。我们将这种现象称为“短路”(即执行最短路径)。和条件运算符一起使用时,它们都会优先被计算。

关联性决定了拥有相同优先级的运算符的执行顺序。左关联(左到右)相当于把左边的子表达式加上小括号 (a OP b) OP c,右关联(右到左)相当于 a OP (b OP c)

常见的条件运算符和赋值运算符都是右关联的。

如果运算符优先级/关联规则能够令代码更为简洁,就使用运算符优先级/关联规则;而如果 ( )(圆括号,在 JavaScript 中拥有最高优先级) 有助于提高代码可读性,就使用 ( )

# 自动分号

有时 JavaScript 会自动为代码行补上缺失的分号,即自动分号插入(Automatic Semicolon Insertion,ASI)。

function foo(a) {
  if (!a) return
  a *= 2
  // ..
}

由于 ASI 会在 return 后面自动加上 ;,所以这里 return 语句并不包括第二行的 a \*= 2

return 语句的跨度可以是多行,但是其后必须有换行符以外的代码:

/*

function foo(a) {
 return (
  a * 2 + 3 / 12
 );
}

*/

建议在所有需要的地方加上分号,将对 ASI 的依赖降到最低。

# 错误

JavaScript 不仅有各种类型的运行时错误(TypeError、ReferenceError、SyntaxError 等),它的语法中也定义了一些编译时错误,这些在编译阶段发现的代码错误叫作“早期错误”。

比如在严格模式中,使用同名的函数参数、对象中包含同名的属性、非法的正则表达式都会产生早起错误。

这些错误在代码执行之前是无法用 try..catch 来捕获的,相反,它们还会导致解析/编译失败。

ES6 规范定义了一个新概念,叫作 TDZ(Temporal Dead Zone,暂时性死区:指的是由于代码中的变量还没有初始化而不能被引用的情况)。最直观的例子是 ES6 规范中的 let 块作用域:

{
  a = 2 // ReferenceError!
  let a
}

有意思的是,对未声明变量使用 typeof 不会产生错误,但在 TDZ 中却会报错:

{
  typeof a // undefined
  typeof b // ReferenceError! (TDZ)
  let b
}

# 函数参数

另一个 TDZ 违规的例子是 ES6 中的参数默认值:

var b = 3
function foo(a = 42, b = a + b + 5) {
  // ..
}

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

对 ES6 中的参数默认值而言,参数被省略或被赋值为 undefined 效果都一样,都是取该参数的默认值。然而某些情况下,它们之间还是有区别的:

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)以得到相同的值。相反,不传递参数就不会建立关联。但是,在严格模式中并没有建立关联这一说。

所以,在开发中不要依赖这种关联机制。需遵守一个原则,即不要同时访问命名参数和其对应的 arguments 数组单元。

# try..finally

finally 中的代码总是会在 try 之后执行,如果有 catch 的话则在 catch 之后执行。也可以将 finally 中的代码看作一个回调函数,即无论出现什么情况最后一定会被调用。

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

虽然在 try 语句块中执行 return 了,但 finally 中的代码依然会执行。而且,finally 中的 return 会覆盖 trycatchreturn 的返回值。

如果在 try 语句块中通过 throw 抛出错误也是如此,在 continuebreak 等控制语句中也不例外:

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

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

事实上,还可以将 finally 和带标签的 break 混合使用:

function foo() {
  bar: {
    try {
      return 42
    } finally {
      // 跳出标签为 bar 的代码块
      break bar
    }
  }
  console.log('Crazy')
  return 'Hello'
}
console.log(foo())
// Crazy
// Hello

如你所见,但切勿这样操作。利用 finally 加带标签的 break 来跳过 return 只会让代码变得晦涩难懂。

# switch

使用 switch 语句首先需要设置表达式 n(通常是一个变量)。随后表达式的值会与结构中的每个 case 的值做比较。如果存在匹配,则与该 case 关联的代码块会被执行。直到 breakreturn 或者 switch 代码块结束。

switch (n) {
  case 1:
    // 执行代码块 1
    break
  case 2:
    // 执行代码块 2
    break
  default:
  // 与 case 1 和 case 2 不同时执行的代码
}

其中的比较方式将会采用严格相等。如果需要通过强制类型转换来进行相等比较,这时就需要做一些特殊处理:

switch (true) {
  case n == 1:
    // 执行代码块 1
    break
  case n == 2:
    // 执行代码块 2
    break
  default:
  // 与 case 1 和 case 2 不同时执行的代码
}

最后,default 是可选的,break 相关规则对 default 仍然适用:

var a = 10
switch (a) {
  case 1:
  case 2:
  // 永远执行不到这里
  default:
    console.log('default')
  case 3:
    console.log('3')
    break
  case 4:
    console.log('4')
}
// default
// 3

这里首先遍历并找到所有匹配的 case,如果没有匹配则执行 default 中的代码。因为其中没有 break,所以继续执行已经遍历过的 case 3 代码块,直到 break 为止。

# 参考