JavaScript 语法
JavaScript 引擎在执行代码时,会先进行词法解析,生成 token,然后经过语法解析,生成 AST,然后通过语义分析直接解释执行代码或者经过代码生成器,进行转译或者编译(生成字节码)。
ECMA-262 通过叫做 ECMAScript 的“伪语言”为我们描述了 JavaScript 的基本语法规则。
扩展:Ecma 是有一个国际标准化组织,ECMA-262 是一个标准,这个标准定义了一种脚本语言规范叫 ECMAScript,JavaScript 是 ECMAScript 的一种实现。
JavaScript 语法的基本规则
自动插入分号规则(automatic semicolon insertion)
行尾使用分号的风格来自于 Java ,也来自于 C 语言和 C++,这一设计最初是为了降低编译器的工作负担。
两个前提:
以换行为基础
解析器会尽量将新行并入当前行,当且仅当符合 ASI 规则时才会将新行视为独立的语句。
三条 ASI 规则:
有换行符,且下一个 token(新行的第一个 token)并入当前行是不符合语法的,就自动插入分号
有换行符,且语法中规定此处不能有换行符(no LineTerminator here 规则),那么就自动插入分号。
在
continuereturnbreakyield等后自动插入分号++、--后缀表达式作为新行的开始,在行首自动插入分号
代码块的最后一个语句后会自动插入分号,或者说代码块的最后一个语句的下一个 token 是
},不符合语法的,所以要自动插入分号
例一:
例子中:第一行的结尾处有换行符,后面的 void 关键字接在 1 之后是不合法的,命中了第一条规则,因此会在 void 前插入分号。
例二:
第二行的 a 之后有换行符,后面有跟了 ++ 运算符,如果没有换行符,a++是合法的语法,但是如下的 JavaScript 标准定义中,可以看到有[no LineTerminator here]的字样,这是一个语法定义中的规则,不允许在此处换行。于是,命中了第二条规则,这里的 a 后面要插入一个分号。所以上面代码最终的结果是:b 和 c 都变成了 2,a 还是 1。
例三:
在上面的例子中, return 和 1 被注释分隔开了,根据 JavaScript 自动插入分号的规则,带换行符的注释也被认为是有换行符,恰好 return 也有 [no LineTerminator here] 规则的要求,所以 return 后面会自动插入分号,f 执行的返回值是 undefined。
不写分号需要注意的情况
以下情况不会自动插入分号,因为在编译器看来都是合法的语法。
以括号开头的语句
这段代码有两个立即执行的函数表达式(IIFE),原本的意图是形成两个 IIFE ,但是第三行结束的位置,JavaScript 引擎会认为函数返回的可能是个函数,那么,在后面再跟括号形成函数调用就是合理的,因此这里不会自动插入分号,出现语法报错。所以这是一些鼓励不写分号的编码风格会要求大家写 IIFE 时必须在行首加分号的原因。
以数组开头的语句
这段代码的本意是一个变量 a 赋值,然后对一个数组执行 forEach,但是因为没有自动插入分号,被理解为下标运算符和逗号运算符,这个例子甚至不会报错!
以正则表达式开头的语句
这段代码的本意是声明三个变量,然后测试一个字符串中是否含有字符 a,但是因为没有自动插入分号,正则的第一个斜杠被理解成了除号,后面就成了一系列的除法运算,这段代码同样没有报错,b 的结果是 0.5。
以 Template 开头的语句
以 Template 开头的语句比较少见,但是跟正则配合时,仍然不是不可能出现:
这段代码本意是声明函数 f,然后赋值给 g,再测试字符串 Template 中是否含有字母 a,但是因为没有自动插入分号,函数 f 被认为跟 Template 一体的,进而被莫名其妙地执行了一次。这段代码不报错,打印为空,g 的值为 null。
总结
要不要加分号?哪些场景必须加分号?
( [ + - / 反引号 前要注意加引号。
JavaScript 语言提供了相对可用的分号自动补全规则,几年前,由于构建工具有一些问题,导致不加分号可能会出问题,所以各种各样的书基本上都会推荐加分号,现在来说,真正会导致上下行解析出问题的 token 有 6 个:括号,方括号,正则开头的斜杠,加号,减号,反引号,一般实际代码中不会出现用正则,加号,减号,反引号作为行首的情况,所以一行开头是括号或者方括号的时候加上分号就可以了,其他时候全部不需要。
不过如果分号写习惯了,也没必要花力气改习惯,如果习惯了不加,只要注意上面说到的情况,不写 bug 就好。
另外:空语句必须加分号
no LineTerminator here 规则
no LineTerminator here 规则表示它所在的结构中的这一位置不能插入换行符。
自动插入分号规则的第二条:有换行符,且语法中规定此处不能有换行符,那么就自动插入分号。这条规则跟 no LineTerminator here 强相关。
no LineTerminator here 规则:
带标签的 continue 语句,不能在 continue 后插入换行
带标签的 break 语句,不能在 break 后插入换行
return 后不能插入换行
后自增,后自减运算符前不能插入换行
throw 和 Exception 之间不能插入换行
凡是 async 关键字,后面都不能插入换行
箭头函数的箭头前,也不能插入换行
yield 之后,不能插入换行
例一:带标签的 continue 语句,不能在 continue 后插入换行
例二:带标签的 break 语句,不能在 break 后插入换行
例三:return 后不能插入换行
例四:后自增、后自减运算前不能插入换行
例五:throw 和 Exception 之间不能插入换行
例六:凡是 async 关键字,后面都不能插入换行
例七:箭头函数的箭头前不能插入换行
例八:yield 之后不能插入换行
实际上,no LineTerminator here 规则的存在,多数情况是为了保证自动插入分号行为是符合预期的,不过,JavaScript 设计的最初,遗漏了一些重要的情况,所以有一些之前说的不符合预期的情况出现。
JavaScript 程序执行体:脚本、模块、函数体
从 ES6 开始,JavaScript 有两种源文件,脚本和模块。脚本可以由浏览器或者 node 环境引入执行的,而模块只能由 JavaScript 代码用 import 引入执行。在语法上,模块和脚本之间的区别仅仅在于是否包含 import 和 export。
从概念上我们可以认为脚本是具有主动性的 JavaScript 代码段,是控制宿主完成一定任务的代码;而模块是被动性的 JavaScript 代码段,是等待被调用的库。
脚本中可以包含语句。模块中可以包含三种内容:import 声明、export 声明和语句。
现代浏览器可以支持用 script 标签引入模块或者脚本,如果要引入模块,必须给 script 标签添加 type="module"。如果引入脚本,不需要 type。如果 script 标签不加 type="module",浏览器默认我们加载的文件时脚本而非模块,如果我们在脚本中使用 export 就会报错。
JavaScript 引擎除了执行脚本和模块外,还可以执行函数。执行函数的行为通常是在 JavaScript 代码执行时,注册宿主环境的某些事件触发的,而执行的过程,就是执行函数体(函数的花括号中间的部分)。跟脚本和模块比起来,函数体中的语句列表中多了 return 语句。
四种函数体
普通函数体,例如:
异步函数体,例如:
生成器函数体,例如:
异步生成器函数体,例如:
预处理机制
JavaScript 执行前,会对脚本、模块、函数体和块级作用域中的语句进行预处理。预处理过程将会提前处理 var 语句、函数声明、class 声明、const 和 let 语句,以确定其中变量的意义。
词法环境又称作用域,可以分类:
全局环境,包括脚本和模块
函数环境
块环境
var 声明
var 声明永远作用于全局(脚本、模块)和函数体这个级别,在预处理阶段,不关心赋值的部分,只是在当前作用域声明这个变量。
上面的代码声明了脚本级别的 a,又声明了 foo 函数体级别的 a,虽然函数体级别的 var 是在 console.log 语句之后,但是预处理过程在执行之前,所以有函数体级别的变量 a ,就不会去访问外层作用域中的变量 a 了,不过函数体级别的变量 a 此时还没有赋值,所以是 undefined。
这段代码跟上一段代码相比唯一的区别是 var a = 2 外面包了一个 if,我们知道 if(false) 中的代码永远不会被执行,但是不影响预处理,var 的作用能够穿透一切语句结构,它只认脚本、模块和函数体三种语法结构。所以这里的结果跟上一段代码完全一样,会得到 undefined。
注:当使用关键字 var 时,该变量是在距离最近的函数内部或全局词法环境中定义的(注意:忽略块级作用域),这种行为有些怪异,因此 ES6 提供了两个新的声明变量的关键字:let 和 const。let 和 const 直接在最近的词法环境中定义变量(可以是在块级作用域内:例如循环中、函数内或全局环境内),我们可以使用 let 和 const 定义块级别、函数级别、全局级别的变量。
function 声明
在全局(脚本、模块、和函数体级别),function 声明表现跟 var 类似,不同之处在于,function 声明在预处理阶段不只是在作用域中声明了,还进行了赋值。
这段代码声明了函数 foo,在声明之前,我们用 console.log 打印函数 foo,可以发现打印出来的已经是函数 foo 的值了。
function 声明出现在 if 等语句中(块级作用域)的情况比较复杂,它仍然作用于脚本、模块和函数体级别,在预处理阶段,仍然会产生变量,但是不再被提前赋值。
注:出现在 if 等语句中的 function,在 if 创建的作用域中仍然会被提前,并且产生赋值的效果。
class 声明
class 声明在全局的行为跟 function 和 var 都不一样。
在 class 声明之前使用 class 名,会抛出错误。很像是 class 没有预处理,但是实际上并非如此,class 声明也会被预处理,它会在作用域中创建变量,并要求在声明之前访问它时抛出错误。
上面的代码试图在 class 声明前打印变量 c,跟没有预处理一样,报错了。
我们把实例代码修改一下,class 放入函数体中,在外层作用域中有变量 c,然后试图在 class 之前打印 c,执行后发现仍然抛出了错误,如果去掉 class 声明,则会正常打印出 1,说明出现在后面的 class 声明影响了前面语句的结果。
注 1:class 声明作用不会穿透 if 等语句结构,所以只有写在全局环境才会有声明作用。
注 2:class 设计比 function 和 var 更符合直觉,而且在遇到一些比较奇怪的用法时,倾向抛出错误,按照现代语言设计的评价标准,及早抛错是好事,它能够帮助我们尽量在开发阶段就发现代码可能存在的问题。
注 3:const 和 let 跟 class 的机制相同。
总结整个预处理的过程
详细来说,JavaScript 引擎执行 JavaScript 代码的过程分两个阶段进行。一旦创建了新的词法环境,就会执行第一阶段。在第一阶段,没有执行代码,但是 JavaScript 引擎会访问并注册在当前词法环境中所声明的变量和函数(预处理)。
具体的预处理过程如下:
如果是创建一个函数环境,那么创建形参及函数参数的默认值。如果是非函数环境,将跳过此步骤。
如果是创建全局或函数环境,就扫描当前代码进行函数声明(不会扫描其他函数的函数体),但是会忽略函数表达式或箭头函数。对于所找到的函数声明,将创建函数,并绑定到当前环境与函数名相同的标识符(变量)上。若该标识符已经存在,那么该标识符的值将被重写。如果是块级作用域,将跳过此步骤。
扫描当前代码进行变量声明。在全局或函数环境中,查找所有当前函数以及其他函数之外通过 var 声明的变量,并查找所有通过 let 或 const 定义的变量。在块级环境中,仅查找当前块中通过 let 或 const 定义的变量。对于所查找到的变量,若该标识符不存在,进行注册并将其初始化为 undefined。若该标识符已经存在,将保留原来的值。
在其他地方可能会成为“变量提升”,但是通过上面描述可以看出,变量和函数声明并没有实际发生移动。只是代码执行之前,现在词法环境中进行了注册。
指令序言机制
脚本和模块都支持一种特别的语法,叫做指令序言(Directive Prologs)。
指令序言最早是为了 use strict 设计的,它规定了一种给 JavaScript 代码添加元信息的方式。
虽然"use strict"是 JavaScript 标准中规定的唯一一种指令序言,但是设计指令序言的目的是:留给 JavaScript 引擎的实现者一些统一的表达方式,在静态扫描时指定 JavaScript 代码的一些特性。
例如,假设我们要设计一种声明本文件不需要进行 lint 检查的指令,我们可以这样设计:
JavaScript 的指令序言是只有一个字符串直接量(单引号和双引号没有差别)的表达式语句,它只能出现在脚本、模块和函数体的最前面。
例如下面的例子中,"use strict"没有出现在脚本的最前面,所以不是指令序言
严格模式
严格模式是在 ES5 中引入的特性,语法是 "use strict"; ,它可以改变 JavaScript 引擎的默认行为并执行更加严格的语法检查,一些在普通模式下的静默错误会在严格模式下抛出异常。在严格模式下部分语言特性会被改变,甚至完全禁用一些不安全的语言特性。
JavaScript 操作符
ECMA-262 描述了一组用于操作数据类型的操作符,包括算术操作符(如加号和减号)、位操作符、关系操作符 和相等操作符。JavaScript 操作符特别之处在于,他们能够适用于很多值,例如字符串、数字、布尔值,甚至 对象。不过在应用于对象时,相应的操作符通常都会调用对象的 valueOf()和(或)toString()方法,以便 取得可以操作的值。
一元操作符
只能操作一个值的操作符叫做一元操作符。
1. 递增和递减操作符
前置型
执行前置递增和递减操作时,变量的值都是在语句被求值以前改变的。(在计算机科学领域,这种情况通常被称为副效应)
后置型
后置递增和递减的操作是在包含它们的语句被求值之后才执行的。
所有这四个操作符对任何值都适用,也就是它们不仅适用于整数,还可以用于字符串、布尔值、浮点数值和对象, 在应用于不同的值时,递增和递减操作符遵循的规则类似于先执行 Number,把其他值转换成数字,然后再按照数字 进行递增和递减操作。
2. 一元加减操作符
一元加操作符以一个加号(+)表示,放在数值之前,对数值不会产生任何影响。不过在对非数值应用一元加操作 符时,该操作符会像 Number()转型函数一样对这个值进行转换。
一元减操作符主要用于标识负数,例如将 1 转换为-1。在将一元减操作符应用于数值时,该值会变成负数。 而当应用于非数值时,一元减操作符遵循与一元加操作符相同的规则,最后再将得到的数值转换为负数。
3. 其他的一元运算符
逻辑非:!
按位非: ~
typeof
void
delete
await
位操作符
位操作符用于在最基本的层次上,即按内存中表示数值的位来操作数值。
JavaScript 中的所有数值都以 IEEE75464 位格式存储。当对数值应用位操作时, 位操作符并不直接操作 64 位的值。 而是先将 64 位的值转换成 32 位的数值,然后执行操作,最后再将结果转换回 64 位。对于开发人员 来说,由于 64 位存储格式是透明的,因此整个过程就像是操作 32 位数值一样,就跟在其他语言中以类似 方式执行二进制操作一样。但这个转换过程也导致了一个严重的副效应,即在对特殊的 NaN 和 Infinity 值应用 位操作时,这两个值都会被当成 0 来处理。
如果对非数值应用位操作符,会先用 Number()函数将该值转换为一个数值(自动完成),然后再应用位操作, 得到的结果将是一个数值。
对于有符号的整数,32 位中的前 31 位用于表示整数的值,第 32 位用于表示数值的符号:0 表示整数,1 表示负数。 这个表示符号的位叫做符号位,符号位的值决定了其他位数值的格式。
正数
正数以纯二进制格式存储,数值 18 的二进制表示是 00000000000000000000000000010010,没有用到的位以 0 填充,所以这个值只有 5 个有效位,更简洁的 10010,这 5 位本身就决定了实际的值。
负数
负数同样是以二进制码存储,但使用的格式是二进制补码。计算一个数值的二进制补码,需要经过下列 3 个步骤:
(1)求这个数值绝对值的二进制码(例如,要求-18 的二进制补码,先求 18 的二进制码); (2)求二进制反码,即将 0 替换为 1,将 1 替换为 0; (3)得到的二进制的反码加 1。
JavaScript 会尽力向我们隐藏所有这些信息。换句话说,在以二进制字符串形式输出一个负数时,我们看到的 只是这个负数绝对值的二进制码前加了一个负号。
按位非(NOT)
按位非操作符由一个波浪线(~)表示,执行按位非的结果就是返回数值的反码。
这里,对 25 执行按位非操作,结果得到了-26。验证了按位非操作的本质:操作数的负值减 1。
按位与(AND)
按位与操作符由一个和号字符(&)表示,它有两个操作符数。从本质上讲,按位与操作就是将两个值的每一位 对齐,对相同位置上的两个数执行 AND 操作,简而言之,按位与操作只在两个数值的对应位都是 1 时才返回 1,任何 一位是 0,结果都是 0。
底层操作:
按位或(OR)
按位或操作符由一个竖线符号(|)表示,同样也有两个操作数。按位或操作在有一个位是 1 的情况下就返回 1, 而只有在两个位都是 0 的情况下才返回 0。
底层操作:
按位异或(XOR)
按位异或操作符由一个插入符号(^)表示,也有两个操作数。这个操作在两个数值对应位上只有一个 1 时才返回 1, 如果对应的两位都是 1 或都是 0,则返回 0。
底层操作:
左移
左移操作符由两个小于号(<<)表示,这个操作符会将数值的所有位向左移动指定的位数。
注意 1:在向左移位后,原数值的右侧多出了 5 个空位。左移操作会以 0 来填充这些空位,以便得到的结果是一个 完整的 32 位二进制数。
注意 2:左移不会影响操作数的符号位。换句话说,如果将-2 向左移动 5 位,结果将是-64,而非 64。
有符号的右移
有符号的右移操作符由两个大于号(>>)表示,这个操作符会将数值向右移动,但保留符号位(即正负号标记)。 有符号的右移操作与左移操作恰好相反。
注意:在移位过程中,原数值中也会出现空位。只不过这次的空位出现在原数值的左侧,符号位的右侧,此时 JavaScript 会用符号位的值来填充所有空位,以便得到一个完整的值。
无符号右移
无符号右移操作符由 3 个大于号(>>>)表示,这个操作符会将数值的所有 32 位都向右移动。
对正数来说,无符号右移的结果与有符号右移相同。
对负数来说,情况就不一样了。首先,无符号右移是以 0 来补充空位的。而不是像有符号右移那样以符号位的值 来填充空位。所以,对正数的无符号右移与有符号右移结果相同,但对负数的结果就不一样了。其次,无符号右移 操作符会把负数的二进制码当成正数的二进制码。而且,由于负数以其绝对值的二进制补码形式表示,因此就会 导致无符号右移后的结果非常大。
或者也可以这样理解,因为-64 的二进制码是 11111111111111111111111111000000 而且无符号右移 操作会把这个二进制码当成正数的二进制码,换算成十进制就是 4294967232。如果把这个值右移 5 位,结果 就变成了 00000111111111111111111111111110,即十进制的 134217726。
位操作符的实用
1. 判断奇偶
2. 取整
3. 交换值
4. RGB 值和 16 进制颜色值转换
布尔操作符
逻辑非(!)
逻辑非操作符是由一个叹号(!)表示,无论这个值是什么数据类型,这个操作符都会返回一个布尔值。逻辑 非操作符首先会将它的操作数转换为一个布尔值,然后在对其求反。
逻辑与(&&)
逻辑与操作符由两个和号(&&)表示,有两个操作数。逻辑与操作可以应用于任何类型的操作数,而不仅仅是 布尔值。在有一个操作数不是布尔值的情况下,逻辑与操作就不一定返回布尔值(可以能返回对象,字符串, 数字,null,undefined 等)。
逻辑与操作属于短路操作,即如果第一个操作数能够决定结果,那么就不会在对第二个操作数求值。对于逻辑 与操作而言,如果第一个操作数是 false,则无论第二个操作数是什么值,结果都不再可能是 true 了。
逻辑或(||)
逻辑或操作符由两个竖线符号(||)表示,有两个操作数。如果有一个操作数不是布尔值,逻辑或也不一定 返回布尔值。
逻辑或操作符也是短路操作符。也就是说,如果第一个操作数的求值结果为 true,就不会对第二个操作数求值了。
乘性操作符
JavaScript 定义了 3 个乘性操作符:乘法、除法和求模。这些操作符在操作数为非数值的情况下会执行自动的 类型转换。如果参与乘性计算的某个操作数不是数值,后台会先用 Number()转型函数将其转换为数值。
乘法(*)
乘法操作符由一个星号(*)表示,用于计算两个数值的积。
特殊情况:
如果乘积超过了 JavaScript 数值的表示范围,则返回 Infinity 或-Infinity。
如果有一个操作数是 NaN,则结果是 NaN。
如果是
Infinity * 0,则结果是 NaN。如果是 Infinity 与非 0 数值相乘,则结果是 Infinity 或-Infinity,取决于有符号操作数的符号。
如果是
Infinity * Infinity,结果是 Infinity。
除法(/)
除法操作符由一个斜线符号(/)表示,执行第二个操作数除第一个操作数的计算。
特殊情况:
如果商超过了 JavaScript 数值的表示范围,则返回 Infinity 或-Infinity。
如果有一个操作数是 NaN,则结果是 NaN。
如果是
Infinity/Infinity,则结果是 NaN。如果
0/0,则结果是 NaN。如果是非零的有限数被零除,则结果是 Infinity 或-Infinity,取决于有符号操作数的符号。
如果是 Infinity 被任何非零数值除,则结果是 Infinity 或-Infinity,取决于有符号操作数的符号。
求模(%)
求模(余数)操作符由一个百分号(%)表示。
特殊情况:
如果被除数是无穷大值而除数是有限大的数值,则结果是 NaN。
如果被除数是有限大的数值而除数是零,则结果是 NaN。
如果是
Infinity % Infinity,则结果是 NaN。如果被除数是有限大的数值而除数是无穷大值,则结果是被除数。
如果被除数是零,则结果是零。
加性操作符
加法(+)
特殊情况:
如果两个操作符都是数值:
如果有一个操作数是 NaN,则结果就是 NaN。
如果是
Infinity + Infinity,则结果是 Infinity如果是
-Infinity + -Infinity,则结果是 -Infinity如果是
Infinity + -Infinity,则结果是 NaN如果是
+0 + +0,则结果是 +0如果是
-0 + -0,则结果是 -0如果是
+0 + -0,则结果是 +0
如果有一个操作数是字符串
如果两个操作数都是字符串,就是字符串拼接
如果只有一个操作数是字符串,则将另一个操作数转换为字符串(如果对象、数值或布尔值,则调用它们的 toString()方法取得相应的字符串值。对于 undefined 和 null,则分别调用 String()函数并取得 字符串"undefined"和"null"),然后再将两个字符串拼接起来。
如果有一个操作数是数值
另一个操作数是对象,会调用对象的 toString()方法,然后应用前面所说的字符串规则。
另一个操作数是布尔值、null 和 undefined,会使用 Number 转型函数,然后应用前面所说的数值规则
如果两个操作数都是布尔值、null 和 undefined,都会使用 Number 转型函数,然后应用前面所说的数值规则
如果两个操作数都是对象,都会调用 toString()方法,然后进行字符串拼接。
但是注意:有个特殊情况是{}开头时,会被当做块进行调用,并不是个空对象
减法(-)
特殊情况:
如果两个操作符都是数值:
如果有一个操作数是 NaN,则结果是 NaN。
如果是
Infinity - Infinity,则结果是 NaN如果是
-Infinity - -Infinity,则结果是 NaN如果是
Infinity - -Infinity,则结果是 Infinity如果是
-Infinity - Infinity,则结果是 -Infinity如果是
+0 - +0,则结果是 +0如果是
-0 - +0,则结果是 -0如果是
-0 - -0,则结果是 +0如果是
+0 - -0,则结果是 +0
如果有一个操作数是字符串、布尔值、null 或 undefined,则先在后台调用 Number()函数将其转换为数值, 然后再根据前面的规则执行减法运算。如果转换的结果是 NaN,则减法的结果就是 NaN。
如果有一个操作数是对象,则调用对象的 valueOf()方法以取得表示该对象的数值。如果得到的值是 NaN, 则减法的结果就是 NaN。如果对象没有 valueOf()方法,则调用其 toString()方法并将得到的字符串转换为数值。
但是注意:对象中有一个类型数组,如果有一个操作数是数组,也会调用 Number()函数将其转为数值
关系运算符
小于(<)、大于(>)、小于等于(<=)、大于等于(>=)这几个关系运算符用于对两个值进行比较,都返回一个布尔值。
比较规则:
如果两个操作数都是数值,则执行数值比较。
如果两个操作数都是字符串,则比较两个字符串对应的字符编码值。
如果一个操作数是数值,则将另一个操作数转换为一个数值,然后执行数值比较。
如果一个操作数是对象,则调用这个对象的 valueOf()方法,用得到的结果按照前面的规则执行比较。如果 对象没有 valueOf()方法,则调用 toString()方法,并用得到的结果根据前面的规则执行比较。
如果一个操作数是布尔值,则先将其转换为数值,然后再执行比较。
任何操作数与 NaN 进行关系比较,记过都是 false。
相等操作符
在比较字符串、数值和布尔值的相等性时,问题比较简单。但在涉及到对象的比较时,问题就变得复杂了。最早 的 ECMAScript 中的相等和不等操作符会在执行比较之前,先将对象转换成相似的类型。后来,有人提出了这种 转换到底是否合理的质疑。最后,ECMAScript 的解决方案就是提供两组操作符:相等和不相等—— 先转换再比较,全等和不全等——仅比较而不转换。
相等和不相等
比较的规则:
如果有一个操作数是布尔值,则在比较相等性之前先将其转换为数值。
如果一个操作数是字符串,另一个操作数是数值,在比较相等性之前先将字符串转换成数值。
如果一个操作数是对象,另一个操作数不是,则调用对象的 valueOf()方法,用得到的基本类型值按照前面的规则进行比较
null 和 undefined 是相等的。
比较相等性之前,不能讲 null 和 undefined 转换成其他任何值。
如果有一个操作数是 NaN,则相等操作符会返回 false,而不相等操作符会返回 true。重要提示:即使 两个操作数都是 NaN,相等操作符也会返回 false,因为按照规则,NaN 不等于 NaN。
如果两个操作数都是对象,则比较它们是不是同一个对象(比较引用)。
全等和不全等
除了在比较之前不转换操作数之外,全等和不全等操作符与相等和不相等操作符没有什么区别。
由于相等和不相等操作符存在类型转换问题,而为了保持代码中数据类型的完整性,推荐使用全等和不全等操作符。
条件操作符(即三元运算符)
赋值操作符
如果在等于号(=)前面再添加乘性操作符、加性操作符、或位操作符,就可以完成复合赋值操作。
乘/赋值(*=)
除/赋值(/=)
模/赋值(%=)
加/赋值(+=)
减/赋值(-=)
左移/赋值(<<=)
有符号右移/赋值(>>=)
无符号右移/赋值(>>>=)
设计这些操作符的主要目的就是简化赋值操作。使用它们不会带来任何性能的提升。
逗号操作符
使用逗号操作符可以在一条语句中执行多个操作。逗号操作符多用于声明多个变量;但除此之外,逗号操作符 还可以用于赋值。在用于赋值时,逗号操作符总会返回表达式中的最后一项。
JavaScript 语句
普通语句
语句块
语句块就是一对大括号,语句块的意义和好处在于:让我们可以把多行语句视为同一行语句,这样,if、for 等语句定义起来就比较简单了。
注:ES6 开始,语句块会产生作用域,const 和 let 会受到块级作用域的影响。
空语句
空语句就是一个独立的分号,它的存在仅仅是从语言设计完备性的角度考虑,允许插入多个分号而不抛出错误。
表达式语句
if 语句
if 语句是条件语句,作用是在满足条件时执行它的内容语句,这个语句可以是一个语句块,这样就可以实现有条件地执行多个语句了。
if 语句还有 else 结构,用于不满足条件时执行。
switch 语句
switch 语句继承自 Java,Java 中的 switch 语句继承自 C 和 C++,原本 switch 语句是跳转的 变形,所以我们如果要用它来实现分支,必须加上 break。
用一个例子说明 switch 原本的设计是类似 goto 的思维,当 num 为 1 时输出 1 2 3,当 num 为 2 时输出 2 3,当 num 为 3 时输出 3。
如果要把上面的代码改成分支型,需要在每个 case 后加上 break。
虽然 JavaScript 中的 switch 语句借鉴自其他语言,但这个语句也有自己的特色。首先,可以在 switch 语句中 使用任何数据类型,无论是字符串、布尔值、对象等都没有问题。其次,每个 case 的值不一定是常量,可以是 变量,甚至是表达式。
在 C 时代,switch 生成的汇编代码性能是略优于 if else 的,但是对 JavaScript 来说,则无本质区别。
扩展:switch 语句在比较值时使用的是全等操作符,因此不会发生类型转换。
循环语句
for 循环
for 语句是一种前测试循环语句,它具有在执行循环之前初始化变量和定义循环后要执行的代码的能力。
这里为了配合新语法,加入了允许 let 和 const,实际上,const 在这里是非常奇葩的东西, 因为这里声明和初始化变量,按惯例是用于控制循环的,但是它如果是 const 就没法改了。关于这一点, 可能是从保持 let 和 const 一致性的角度考虑的吧。
扩展:可以用 for 构造出一个无限循环。
for in 循环
for in 循环枚举对象的属性,这里体现了属性的 enumerable 特性。
对象 o 的属性中 a 和 b 是可枚举属性,c 是不可枚举属性,for in 循环枚举它的属性,只能输出 a 和 b。
扩展 1:ECMAScript 对象的属性没有顺序。因此,通过 for in 循环输出的属性名的顺序是不可预测的。具体来讲 所有属性都会被返回一次,但返回的先后次序可能会因浏览器而异。
扩展 2:在 ES5 之前,如果要迭代的对象的变量值为 null 或 undefined(即 in 后是 null 或 undefined), for in 语句会抛出错误。ES5 更正了这一行为,对这种情况不再抛出错误,而只是不执行循环体。
for of 循环
for of 循环背后的机制是 iterator 机制,我们可以给任何对象添加 iterator,使它可以用于 for of 语句。
这段代码展示了如何为一个对象添加 iterator。但是,在实际操作中,我们一般不需要这样定义 iterator,可以使用 generator function 生成 iterator。
for await of 循环
JavaScript 为异步生成器函数配备了异步的 for of。
while 循环
while 语句属于前测试循环语句。
do while 循环
do while 语句是一种后测试循环语句,即只有在循环体中的代码执行之后,才会测试出口条件。换句话说:do while 循环无论如何至少会执行一次
return 语句
return 语句用于函数中,它终止函数的执行,并且指定函数的返回值。
break 语句
break 语句用于跳出循环语句或者 switch 语句,属于控制型语句,特别的一点是,它有带标签(label)的用法, 可以控制自己被外层的哪个语句结构消费,这可以跳出复杂的语句结构。
continue 语句
continue 语句用于结束本次循环并继续循环,属于控制型语句,跟 break 一样,它也有带标签的用法。
with 语句
with 语句的作用是将代码的作用域设置到一个特定的对象中。
with 语句是个非常巧妙的设计,它会把对象的属性在它内部的作用域内变成变量,但这也会把 JavaScript 的变量引用关系变得不可分析,所以一般都认为这种语句都属于糟粕(严格模式下不允许使用 with 语句,会抛出 语法错误)。但是历史无法改写,现在已经无法去除 with 了,了解基本用法即可。
try 语句和 throw 语句
try 语句和 throw 语句用于处理异常。它们是配合使用的,在大型应用中,异常机制非常重要。
throw 一般用于抛出异常,但是单纯从语言的角度,我们可以抛出任何值,也不一定是异常逻辑,但是为了 保证语义清晰,不建议用 throw 表达任何非异常逻辑。
try 语句用于捕获异常,用 throw 抛出异常,可以在 try 语句的结构中被处理掉:try 部分用于标识捕获异常的 代码段,catch 部分则用于捕获异常后做一些处理,而 finally 则是用于执行后做一些必须执行的清理工作。
catch 结构会创建一个局部的作用域,并且把一个变量写入其中,需要注意,在这个作用域,不能再声明变 量 e 了,否则会报错。
finally 语句一般用于释放资源,它一定会被执行,即使在 try 中出现了 return,finally 中的语句也一定 要被执行。
debugger 语句
debugger 语句的作用是:通知调试器在此端点。在没有调试器挂载时,它不产生任何效果。
声明语句
声明型语句跟普通语句最大的区别就是:声明型语句响应预处理过程,普通语句只有执行过程。
var 语句
var 声明语句是古典的 JavaScript 中声明变量的方式。而现在,在绝大多数情况下,let 和 const 都是更好的选择。
如果我们仍然想要使用 var,我的个人建议是,把它当做一种“保障变量是局部”的逻辑,遵循以下三条规则:
声明同时必定初始化;
尽可能在离使用的位置近处声明;
不要在意重复声明。
上例中,两次声明了变量 x,完成了两端逻辑,这两个 x 意义上可能不一定相关,这样,不论我们把代码复制粘贴 到哪里,都不会出错。
当然,更好的办法是使用 let 改造,用代码块限制了第一个 x 的作用域,这样就更难发生变量命名冲突引起的错误了。
let 语句和 const 语句
具体的看块级作用域那篇文章,这里着重说一点,let 和 const 声明虽然看上去是执行到了才会生效,但是实际上, 他们还是会被预处理。如果当前作用域内有声明,就无法访问到外部的变量。
上面的代码中,在 if 的作用域中,变量 a 声明之前,我们访问了变量 a,这时会抛出一个错误,这说明 const 声明 仍然是有预处理机制的。在执行到 const 语句前,我们的 JavaScript 引擎就已经知道后面的代码将会声明变量 a, 从而不允许我们访问外层作用域中的 a。
class 声明
class 最基本的用法只需要 class 关键字、名称和一对大括号。它的声明特征跟 const 和 let 类似,都是作用于 块级作用域,预处理阶段会屏蔽外部变量。
class 内部,可以使用 constructor 关键字来定义构造函数。还能定义 getter/setter 和方法。以目前的兼容性, class 中的属性只能写在构造函数中,并且需要注意,class 默认内部的函数定义都是 strict 模式的。
函数声明
普通函数声明
async 函数声明
async 函数是可以暂停执行,等待异步操作的函数,它的底层是 Promise 机制。
generator 函数声明
生成器函数可以理解为返回一个序列的函数,它的底层是 iterator 机制。
async generator 函数声明
表达式语句
之前所说的语句作用基本上都是产生各种结构,来控制表达式语句执行,或者改变表达式语句的意义。事实上,真正 能干活的就只有表达式语句。
表达式语句实际上就是一个表达式,它是由运算符连接变量或者直接量构成的。
PrimaryExpression 主要表达式
Primary Expression 是表达式的原子项,它是表达式的最小单位,它所涉及的语法结构也是优先级最高的。
Primary Expression 包含了各种“直接量”(literal)。
直接量就是直接用某种语法写出来的具有特定类型的值。在运行时有各种值,比如数字 123,字符串 Hello World,通俗来讲 ,直接量就是在代码中把他们直接写出来的语法。
如下代码展示了各种直接量:
需要注意,在语法层面,function、{和 class 开头的表达式语句与声明语句有语法冲突,所以,我们要想 使用这样的表达式,必须加上括号来回避语法冲突。
Primary Expression 还可以是 this 或变量,在语法上,把变量称作“标识符引用”。
任何表达式加上圆括号,都被认为是 Primary Expression。这个机制使得圆括号成为改变运算优先顺序的手段。
MemberExpression 成员表达式
Member Expression 是由 Primary Expression 构成的更复杂的表达式,Member Expression 通常是用于访问对象成员的。
它有几种形式:
前两种用法很好理解,就是用标识符的属性访问和用字符串的属性访问。而 new.target 是个新加入的语法, 用于判断函数是否是被 new 调用,super 则是构造函数中,用于访问父类的属性的语法。
从语法结构需要,在 JavaScript 标准中还有两种 Member Expression,这两种被放入 Member Expression, 仅仅意味着它们跟属性运算属于同一优先级,没有任何语义上的关联。
带函数的模板,这个带函数名的模板表示:把模板的各个部分算好后传递给一个函数。
带参数列表的 new 运算符,注意,不带参数列表的 new 运算优先级更低,不属于 Member Expression.
NewExpression New 表达式
New Expression 是由 Member Expression 加上 new 构成的。这里的 New Expression 特指没有参数列表的表达式。
注:不加 new 也可以构成 New Expression,JavaScript 中默认独立的高优先级表达式都可以构成低优先级表达式。
看一个稍微复杂的例子:
直观上看,它可能有两种意思:
实际上,它等价于第一种。我们可以用以下代码来验证:
这段代码最后得到了下面的结果:
这说明,1 被当做调用 Cls 时的参数传入了。即等价于 new (new Cls(1))。
CallExpression 函数调用表达式
除了 New Expression,Call Expression 也是由 Member Expression 构成的。它的基本形式是 Member Expression 后 加一个括号里的参数列表,或者我们可以用上 super 关键字代替 Member Expression。
例如:
上面复杂的变体形态,跟 Member Expression 几乎是一一对应的。我们可以理解为,Member Expression 中 的某一子结构具有函数调用,那么整个表达式就成为了一个 Call Expression。而 Call Expression 就失去了 比 New Expression 优先级高的特性。
LeftHandSideExpression 左值表达式
New Expression 和 Call Expression 统称为 LeftHandSideExpression,左值表达式。
直观的讲,左值表达式就是可以放到等号左边的表达式。JavaScript 语法则是下面这样:
这样的用法其实是符合语法的,只是,原生的 JavaScript 函数,返回的值都不能被赋值。因此多数时候,我们看到的赋值 将会是 Call Expression 的其他形式。
另外,根据 JavaScript 运行时的设计,不排除某些宿主会提供返回引用类型的函数,这时候,赋值就是有效的了。
左值表达式最经典的用法是用于构成赋值表达式,但是在 JavaScript 标准中,会发现它出现在各种场合,凡是 需要“可以被修改的变量”的位置,都能见到的它的身影。
RightHandSideExpression 右值表达式
在一些通用的计算机语言设计理论中,能够出现在赋值表达式右边的叫做右值表达式(RightHandSideExpression), 而在 JavaScript 标准中,规定了在等号右边表达式叫做条件表达式(ConditionalExpression),所以在 JavaScript 标准中,从未出现过右值表达式的字样。
JavaScript 标准也规定了左值表达式同时都是条件表达式(也就是右值表达式),此外,左值表达式也可以通过跟 一定的运算符组合,逐级构成更复杂的结构,直到成为右值表达式。
这块的知识可以用运算符和语法两个角度来分析,对运算符来说的“优先级”,如果从我们语法的角度来看,那就是“表达式结构”。 讲“乘法运算符的优先级高于加法”,从语法的角度看就是“乘法表达式和加法运算符构成加法表达式”。
右值表达式可以理解为以左值表达式为最小单位开始构成的,然后左值表达式一步步构成更为复杂的语法结构。 看一下下面的几种表达式。
更新表达式 UpdateExpression
左值表达式搭配 ++ -- 运算符,可以形成更新表达式。更新表达式会改变一个左值表达式的值。分为前后 自增,前后自减四种。
注:在 ES2018 中,跟早期版本有所不同,前后自增自减运算被放到了同一优先级。
一元运算表达式 UnaryExpression
更新表达式搭配一元运算符,可以形成一元运算表达式。
乘方表达式 ExponentiationExpression
乘方表达式是由更新表达式构成的。使用 ** 符号。
例子中,-2 这样的一元运算表达式,是不可以放入乘方表达式的,如果需要表达类似的逻辑,必须加括号。
注意:**运算是右结合的,这跟其它正常的运算符(也就是左结合运算符)都不一样。
乘法表达式 MultiplicativeExpression
乘方表达式可以构成乘法表达式,用乘号或者除号、取余符号连接就可以了。
乘法表达式由三种运算符,他们分别表示乘、除和取余。它们的优先级是一样的,所以统一放在乘法运算表达式中。
加法表达式 AdditiveExpression
加法表达式是由乘法表达式用加号或者减号连接构成的。
加法表达式由加号和减号两种运算符:
注意:加号还能表示字符串连接。
移位表达式 ShiftExpression
移位表达式由加法表达式构成,移位是一种位运算,分成三种:
移位运算符把操作数看做二进制表示的整数,然后移动特定位数,所以左移 n 位相当于乘以 2 的 n 次方,右移 n 位 相当于除以 2 取整数 n 次。
普通移位会保持正负数。无符号移位会把减号视为符号位 1,同时参与移位:
这个会得到 2147483647,也就是 2 的 32 次方,跟负数的二进制表示法相关。
关系表达式 RelationalExpression
移位表达式可以构成关系表达式,这里的关系表达式就是大于、小于、大于等于、小于等于等运算符号连接,统称为关系运算。
注意:这里的 <= 和 >= 关系运算,完全是针对数字的,所以 <= 并不等价于 < 或 ==。
相等表达式 EqualityExpression
在语法上,相等表达式是由关系表达式用相等比较运算符(如 == )连接构成的。
关系表达式的优先级高于相等表达式,所以我们可以像下面这段代码一样使用,而不需要加括号。
相等表达式由四种运算符和关系表达式构成:
相等表达式包含了有一个 JavaScript 中著名的设计失误,那就是 == 的行为。
虽然标准中写的 == 十分复杂,但是归根结底,类型不同的变量比较时 == 运算只有三条规则:
undefined 与 null 相等;
字符串和 boolean 都转为数字再比较;
对象转换成 primitive 类型再比较。对象如果转换成了 primitive 类型跟等号另一边类型恰好相同, 则不需要转换成数字。
建议仅在确认 == 发生在 Number 和 String 类型之间时使用。例如:
位运算表达式
位运算表达式含有三种:
按位与表达式 BitwiseANDExpression
按位与表达式由按位与运算符(&)连接相等表达式构成,按位与表达式把操作数视为二进制整数,然后把 两个操作数按位做与运算。
按位异或表达式 BitwiseXORExpression
按位异或表达式由按位异或运算符(^)连接按位与表达式构成,按位异或表达式把操作数视为二进制整数,然后 把两个操作数按位做异或运算。异或两位相同时得 0,两位不同时得 1。
按位或表达式 BitwiseORExpression
按位或表达式由按位或运算符(|)连接按位异或表达式构成,按位或表达式把操作数视为二进制整数,然后把两个 操作数按位做或运算。
逻辑与表达式和逻辑或表达式
逻辑与表达式由按位或表达式经过逻辑与运算符连接构成,逻辑或表达式则由逻辑与表达式经过逻辑或运算符 连接构成。
逻辑表达式具有短路特征:
这里的 foo 将不会被执行,这种中断后面表达式执行的特性就叫做短路。
条件表达式 ConditionalExpression
条件表达式由逻辑或表达式和条件运算符构成,条件运算符又称三目运算符,它有三个部分,由两个运算符 ? 和 : 配合使用。
AssignmentExpression 赋值表达式
Assignment Expression 赋值表达式由多种形态。
最基本的使用等号赋值
这里需要理解一个稍微复杂的概念是,等号是可以嵌套的:
结合一些运算符的赋值表达式
能这样用的运算符有下面几种:
Expression 表达式
赋值表达式可以构成 Expression 表达式的一部分。在 JavaScript 中,表达式就是用逗号运算符连接的赋值表达式。
在 JavaScript 中,比赋值运算符优先级更低的就是逗号运算符了。我们可以把逗号理解为一种小型的分号。
逗号分隔的表达式会顺次执行,就像不同的表达式语句一样。“整个表达式的结果”就是“最后一个逗号后的表达式结果”, 上面的例子表达式的结果就是 null。
参考文档
Last updated