语句和表达式
开发人员常常将“语句”和“表达式”混为一谈。
JavaScript中表达式可以返回一个结果值:
var a = 3 * 6;
var b = a;
b;
这里,3*6是一个表达式(结果为18)。第二行的a也是一个表达式,第三行的b也是。表达式a和b的结果值都是18。
这三行代码都是包含表达式的语句。var a = 3 * 6 和var b = a称为“声明语句”,因为它们声明了变量(还可以为其赋值)。a = 3 * 6 和b = a(不带var)叫作“赋值表达式”。
第三行代码中只有一个表达式b,同时它也是一个语句(虽然没有太大意义)。这样的情况通常叫做“表达式语句”。
语句的结果值
很多人不知道,语句都有一个结果值(undefined也算)。
获得结果值最直接的方法是在浏览器开发控制台中输入语句,默认情况下控制台会显示所执行的最后一条语句的结果值。
规范定义var的结果值是undefined,如果在控制台中输入var a=42会得到结果值undefined,而非42。
但我们在代码中是没有办法获得这个结果值的,具体解决办法比较复杂,首先得弄清楚为什么要获得语句的结果值。
先来看看其他语句的结果值:
var b;
if (true) {
b = 4 + 38;
}
在控制台中输入以上代码应该会显示42。换句话说,代码块的结果值就如同一个隐式的返回,即返回最后一个语句的结果值。
但下面这样的代码无法运行:
var a, b;
a = if (true) {
b = 4 + 38;
};
因为语法不允许我们获得语句的结果值并将其赋值给另一个变量(至少目前不行)。
那应该怎样获得语句的结果值呢?
可以使用万恶的eval(..)来获得结果值:
var a, b;
a = eval( "if (true) { b = 4 + 38; }" );
a; // 42
这并不是个好办法,但确实管用。
ES7规范有一项“do表达式”提案,类似下面这样:
var a, b;
a = do {
if (true) {
b = 4 + 38;
}
};
a; // 42
其目的是将语句当做表达式来处理(语句中可以包含其他语句),从而不需要将语句封装为函数再调用return来返回值。
表达式的副作用
最常见的有副作用(也可能没有)的表达式是函数调用:
function foo() {
a = a + 1;
}
var a = 1;
foo(); // 结果值:undefined。副作用:a的值被改变
var a = 42;
var b = a++;
a; // 43
b; // 42
++在前面时,如++a,它的副作用(将a递增)产生在表达式返回结果值之前,而a++的副作用则产生在之后。
++a++会产生ReferenceError错误,因为运算符需要将产生的副作用赋值给一个变量。以++a++为例,它首先执行a++(根据运算符优先级),返回42,然后执行++42,这时会产生ReferenceError错误,因为++无法直接在42这样的值上产生副作用。
可以使用,语句系列逗号运算符将多个独立的表达式语句串联成一个语句:
var a = 42, b;
b = ( a++, a );
a; // 43
b; // 43
a++,a中第二个表达式a在a++之后执行,结果为43,并被赋值给b。
再如delete运算符:
var obj = {
a: 42
};
obj.a; // 42
delete obj.a; // true
obj.a; // undefined
如果操作成功,delete返回true,否则返回false。其副作用是属性被从对象中删除(或者单元从array中删除)。
上下文规则
标签
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
continue foo并不是指“跳转到标签foo所在位置继续执行”,而是“执行foo循环的下一轮循环”。
带标签的循环跳转一个更大的用处在于,和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。
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数据传递给函数来实现对其的访问。
代码块
[] + {}; // "[object Object]"
{} + []; // 0
表面上看+运算符根据第一个操作数([]或{})的不同会产生不同的结果,实则不然。
第一行代码中,{}出现在+运算符表达式中,因此它被当做一个值(空对象)来处理。[]会被强制类型转换为"",而{}会被强制类型转换为“[object Object]”。
但在第二行代码中,{}被当做一个独立的空代码块(不执行任何操作)。代码块结尾不需要分号,所以这里不存在语法上的问题。最后+[]将[]显示强制类型转换为0。
对象解构
从ES6开始,{..}也可用于“解构赋值”,特别是对象的解构:
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;
{..}还可以用作函数命名参数的对象解构,方便隐式地用对象属性赋值:
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]
在不同的上下文中{..}的作用不尽相同,这也是词法和语法的区别所在。
~else if和可选代码块~
很多人误以为JavaScript中有else if,因为我们可以这样来写代码:
if (a) {
// ..
}
else if (b) {
// ..
}
else {
// ..
}
事实上JavaScript没有else if,但if和else只包含单条语句的时候可以省略代码块的{}。if(b){..}else{..}实际上是跟在else后面的一个单独的语句,所以带不带{}都可以。换句话说,else if不符合前面介绍的编程规范,else中时一个单独的if语句。
运算符优先级
var a = 42, b;
b = ( a++, a );
a; // 43
b; // 43
如果去掉()会出现什么情况?
var a = 42, b;
b = a++, a;
a; // 43
b; // 42
原因是,运算符的优先级比=低。所以b=a++,a其实可以理解为(b=a++),a。前面说过a++有后续副作用,所以b的值是++对a做递增之前的值42。
这只是一个简单的例子。请务必记住,用,来连接一系列语句的时候,它的优先级最低,其他操作数的优先级都比它高。
短路
对&&和||来说,如果从左边的操作数能够得出结果,就可以忽略右边的操作数。我们将这种现象称为“短路”(即执行最短路径)。
“短路”很方便,也很常用:
function doSomething(opts) {
if (opts && opts.cool) {
// ..
}
}
opts&&opts.cool中的opts条件判断如同一道安全保护,因为如果opts未赋值(或者不是一个对象),表达式opts.cool会出错。通过使用短路特性,opts条件未通过时opts.cool就不会执行,也就不会产生错误!
||运算符也一样:
function doSomething(opts) {
if (opts.cache || primeCache()) {
// ..
}
}
这里首先判断opts.cache是否存在,如果是则无需调用primeCache()函数,这样可以避免执行不必要的代码。
更强的绑定
a && b || c ? c || b ? a : c && b : a
执行顺序是这样的:
(a && b || c) ? (c || b) ? a : (c && b) : a
因为&&运算符的优先级高于||,而||的优先级又高于?:。
关联
&&和||运算符先于?:执行,那么如果多个相同优先级的运算符同时出现,又该如何处理呢?它们的执行顺序是从左到右还是从右到左?
一般说来,运算符的关联不是从左到右就是从右到左,这取决于组合是从左开始还是从右开始。
请注意:关联和执行顺序不是一回事。
但它为什么又和执行顺序相关呢?原因是表达式可能会产生副作用,比如函数调用:
var a = foo() && bar();
这里foo()首先执行,它的返回结果决定了bar()是否执行。所以如果bar()在foo()之前执行,这个结果会完全不同。
这里遵循从左到右的顺序(JavaScript的默认执行顺序),与&&的关联无关。因为上例中只有一个&&运算符,所以不涉及组合和关联。
而a&&b&&c
这样的表达式就涉及组合(隐式),这意味着a&&b或b&&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 ? c || b ? a : c && b : a;
组合顺序:
((a && b) || c) ? ((c || b) ? a : (c && b)) : a
错误
JavaScript不仅有各种类型的运行时错误(TypeError、ReferenceError、SyntaxError等),它的语法中也定义了一些编译时错误。
在编译阶段发现的代码错误叫做“早期错误”。语法错误是早起错误的一种(如a=,)。另外,语法正确但不符合语法规则的情况也存在。
正则表达式常量中的语法。这里JavaScript语法没有问题,但非法的正则表达式也会产生早期错误:
var a = /+foo/; // 错误!
语法规定赋值对象必须是一个标识符,因此下面的42会报错:
var a;
42 = a; // 错误!
ES5规范的严格模式定义了很多早期错误。比如在严格模式中,函数的参数不能重名:
function foo(a,b,a) { } // 没问题
function bar(a,b,a) { "use strict"; } // 错误!
再如,对象常量不能包含多个同名属性:
(function () {
"use strict";
var a = {
b: 42,
b: 43
}; // 错误!
})();
从语义角度来说,这些错误并非词法错误,而是语法错误,因为它们在词法上是正确的。只不过由于没有GrammarError类型,一些浏览器选择用SyntaxError来代替。
提前使用变量
ES6规范定义了一个新概念,叫做TDZ(暂时性死区)。
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;
}
函数参数
另一个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(a, b);
}
foo(); // 42 43
foo(undefined); // 42 43
foo(5); // 5 6
foo(void 0, 7); // 42 7
foo(null); // null 1
对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
try..finally
finally中的代码总是会在try之后执行,如果有catch的话则在catch之后执行。也可以将finally中的代码看做一个回调函数,即无论出现什么情况最后一定会被调用。
如果try中有return语句会出现什么情况呢?return会返回一个值,那么调用该函数并得到返回值的代码是在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!
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
continue在每次循环之后,会在i++执行之后执行console.log(i),所以结果是0..9而非1..10。
ES6中新加入了yield,可以将其视为return的中间版本。然而与return不同的是,yield在generator重新开始时才结束,这意味着try{..yield..}并未结束,因此finally不会在yield之后立即执行。
finally中的return会覆盖try和catch中return的返回值:
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设定的返回值。
事实上,还可以将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
有时可能会需要通过强制类型转换来进行相等比较(即==),这时就需要做一些特殊处理:
var a = "42";
switch (true) {
case a == 10:
console.log("10 or '10'");
break;
case a == 42;
console.log("42 or '42'");
break;
default:
// 永远执行不到这里
}
// 42 or '42'
除简单值以外,case中还可以出现各种表达式,它会将表达式的结果值和true进行比较。因为a==42的结果为true,所以条件成立。
尽管可以使用==,但switch中true和true之间仍然是严格相等比较。即如果case表达式的结果为真值,但不是严格意义上的true,则条件不成立。所以,在这里使用||和&&等逻辑运算符就很容易掉进坑里:
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)
。
网友评论