# 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: 会被识别为一个标签。标签通常会在循环中被用到,结合 continue 和 break 语句实现类似于 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
虽然参数 a 和 b 都有默认值,但是函数不带参数时,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 会覆盖 try 和 catch 中 return 的返回值。
如果在 try 语句块中通过 throw 抛出错误也是如此,在 continue 和 break 等控制语句中也不例外:
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 关联的代码块会被执行。直到 break、return 或者 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 为止。