这篇文章讲述 JavaScript 表达式和用于构建各种表达式的操作符。表达式是一个可以被求值并产生一个值的 JavaScript 短语。基于简单表达式构建复杂表达式最常见的方式是使用操作符。操作符以某种方式组合其操作数的值,然后求值为一个新值。
主表达式
主表达式(primary expression),即那些独立存在,不再包含更简单表达式的表达式。常量或字面量值、某些语言关键字(null、this 等)和变量引用等都是主表达式。
当程序中出现任何独立的标识符时,JavaScript 假设它是一个变量或常量或全局对象的属性,并查询它的值。如果不存在该名字的变量,则求值不存在的变量会导致抛出 ReferenceError。
对象和数组初始化表达式
对象和数组初始化表达式也是一种表达式,其值为新创建的对象或数组。这些初始化表达式有时候也称为 对象字面量
或者 数组字面量
。但与真正的字面量不同,它们不是主表达式,因为它们包含用于指定属性或元素值的子表达式
数组初始化表达式是一个包含在方括号内的逗号分隔的表达式列表,其中的元素表达式本身也可以是数组初始化表达式,从而创建嵌套数组:
1 | > [] |
在数组字面量中,可以通过省略逗号间的值来包含未定义元素。初始化表达式中的最后一个元素表达式后面可以再跟一个逗号,而且这个逗号不会创建未定义元素。
1 | > let s = [1,,,,5] |
对象初始化表达式使用花括号包含多个子表达式,每个子表达式的格式为 属性名:属性值
,子表达式之间以逗号隔开。对象字面量也可以嵌套。
1 | let p = {Name:"jack", Age:20}; |
函数定义表达式
函数定义表达式定义 JavaScript 函数,其值为新定义的函数。函数定义表达式也是 函数字面量
。
- 函数定义表达式通常由关键字 function、位于括号中的逗号分隔的零或多个标识符(参数名),以及一个位于花括号中的 JavaScript 代码块(函数体)构成
- 函数定义表达式也可以包含函数的名字
1 | > let square = function(x) {return x * x}; |
函数也可以使用函数语句而非函数表达式来定义。在 ES6 及之后的版本中,函数表达式可以使用更简洁的 箭头函数
语法。
属性访问表达式
属性访问表达式求值为对象属性或数组元素的值。JavaScript 定义了两种访问属性的语法:
1 | expression.identifier |
expression.identifier
中,expression 指定对象,标识符指定属性名expression[expression]
中,第一个 expression 为对象或者数组,第二个 expression 指定属性名或数组元素索引
1 | > let o = {x:1, y:{z:3}} |
无论哪种属性访问表达式,位于 .
或 [
前面的表达式都会先求值。如果求值结果为 null 或 undefined,则表达式会抛出 TypeError,因为它们是 JavaScript 中不能有属性的两个值。
- 如果对象表达式后跟一个点和一个标识符,则会对以该标识符为名字的属性求值
- 如果对象表达式后跟位于方括号中的另一个表达式,则第二个表达式会被求值并转换为字符串。整个表达式的值就是名字为该字符串的属性的值
- 任何一种情况下,如果指定名字的属性不存在,则属性访问表达式的值是 undefined
expression.identifier
语法更加简单,但通过它访问的属性的名字必须是合法的标识符,而且在写代码时已经知道了这个名字。如果属性名中包含空格或标点字符,或者是一个数值(对于数组而言),则必须使用方括号语法。方括号也可以用来访问非静态属性名。
条件式属性访问
ES2020 增加了两个新的属性访问表达式:
1 | expression ?. identifier |
由于 null 和 undefined 没有属性,使用普通的属性访问表达式时会出现错误。可以使用 ?.
或者 ?.[]
来防止这种错误发生。例如对于 a?.b
- 如果 a 是 null 或者 undefined,则整个表达式的值为 undefined,不会尝试访问属性 b
- 如果 a 是其他值,则等效于
a.b
它的典型应用场景是较长的属性访问表达式链条:
1 | let a = {b: null}; |
一定要理解 ?.
以及 ?.[]
是短路操作。如果它们左侧的子表达式求值为 null 或 undefined,那么整个表达式立即求值为undefined,不会再进一步尝试访问属性。
调用表达式
调用表达式是 JavaScript 中调用(或执行)函数或方法的一种语法。调用表达式由 表示要调用函数的函数表达式
开头,函数表达式后面跟着左圆括号、逗号分隔的零或多个参数表达式的列表和右圆括号。
1 | > f(0) |
- 求值调用表达式时,首先求值函数表达式,然后求值参数表达式以产生参数值的列表
- 如果函数表达式的值不是函数,则抛出TypeError
- 然后按照函数定义时参数的顺序给参数赋值
- 之后再执行函数体
- 如果函数使用了 return 语句返回一个值,则该值就成为调用表达式的值。否则,调用表达式的值是 undefined
如果函数表达式是属性访问表达式(例如 a.sort()
中的 a.sort
),则这种调用被称为方法调用。在方法调用中,作为属性访问主体的对象或数组在执行函数体时会变成 this 关键字的值。这样就可以支持面向对象的编程范式。
条件式调用
在 ES2020 中,可以使用 ?.()
而非 ()
来调用函数。正常情况下,我们调用函数时,如果圆括号左侧的表达式是 null 或 undefined 或任何其他非函数值,都会抛出 TypeError。而使用 ?.()
调用语法,如果左侧的表达式求值 为 null 或 undefined,则整个调用表达式求值为 undefined,不会抛出异常。
与条件式属性访问表达式类似,使用 ?.()
进行函数调用也是短路操作:如果 ?.
左侧的值是 null 或 undefined,则圆括号中的任何参数表达式都不会被求值。
1 | let f = null, x = 0; |
使用 ?.()
的条件式调用表达式既适用于函数调用,也适用于方法调用。因为方法调用又涉及属性访问,所以需要理解下面调用的差异:
1 | > o.m() |
对象创建表达式
对象创建表达式创建一个新对象并调用一个函数(称为构造函数)来初始化这个新对象。对象创建表达式类似于调用表达式,只不过前面多了一个关键字 new:
1 | new Object() |
如果在对象创建表达式中不会给构造函数传参,则可以省略圆括号:
1 | new Object |
对象创建表达式的值是新创建的对象。
操作符概述
操作符在 JavaScript 中用于算术表达式、比较表达式、逻辑表达式、赋值表达式等。绝大多数操作符都是标点符号,但也有一些以关键字表示的操作符,例如 delete, typeof 等。
- JavaScript 操作符通常会按照需要转换操作数的类型
- 有些操作符的行为会因为操作数类型的不同而不同。最明显的,+ 操作符可以把数值加起来,也可以拼接字符串
- 赋值操作符及少数其他操作符(例如
++
、--
)期待操作数类型为 lval,lval 即 lvalue(左值),表示一个可以合法地出现在赋值表达式左侧的表达式。变量、对象属性和数组元素都是左值
操作符优先级控制操作符被执行的顺序。优先级高的操作符先于优先级低的操作符执行。操作符优先级可以通过圆括号显式改写。实践中,如果你完全不确定自己所用操作符的优先级,最简单的办法是使用圆括号明确求值顺序。
操作符结合性规定了相同优先级操作的执行顺序。左结合意味着操作从左到右执行,右结合意味着操作从右到左执行。
操作符的优先级和结合性规定了复杂表达式中操作的执行顺序,但它们没有规定子表达式的求值顺序。JavaScript始终严格按照从左到右的顺序对表达式求值。例如对于表达式 w = x + y * z
:
- 子表达式 w 首先被求值
- 再对 x、y、z 分别求值
- 然后再将 y 和 z 的值相乘,加到 x 上,再把结果赋值给表达式 w 表示的变量或属性
圆括号只能改变操作符的执行顺序,但不会改变从左到右的求值顺序。求值顺序只在一种情况下会造成差异,即被求值的表达式具有副效应,这会影响其他表达式的求值。
1 | > let a = [1, 2, 3] |
算术表达式
接下来介绍可以对操作数执行算术或其他数值操作的操作符。基本的算术操作符包括 +
、-
、*
、/
、%
、**
。其中 +
操作符比较特殊,需要单独讨论。另外5个基本操作符都会对自己的操作数进行求值,必要时将操作数转换为数值,然后在执行计算。无法转换为数值的操作数则转换为 NaN。
**
优先级最高,为右结合。另外-3**2
是有歧义的,因为 JavaScript 没有规定一元-
和**
的优先级,此时需要用圆括号- / 操作符用第二个操作数除第一个操作数。在JavaScript中,所有数值都是浮点数,因此所有除法操作得到的都是浮点数
- % 操作符计算第一个操作数对第二个操作数取模的结果,它返回第一个操作数被第二个操作数整除之后的余数,它返回第一个操作数被第二个操作数整除之后的余数。% 操作符也可以用于浮点数
+
可以执行字符串的拼接操作或数值的加法运算
- 如果两个操作数都是字符串,则执行字符串拼接
- 如果两个操作数都是数值,则执行数值加法运算
- 以上情况之外,都会涉及类型转换。
+
优先执行字符串拼接:只要有操作数是字符串或可以转换为字符串的对象,另一个操作数也会被转换为字符串并执行拼接操作。只有任何操作数都不是字符串或类字符串值时才会执行加法操作。严格来说,其行为如下:- 如果有一个操作数是对象,则
+
操作符首先使用对象到原始值的算法
把该操作数转换为原始值。Date 对象用toString()方法来转换(Data 类型实现了偏字符串算法),其他所有对象通过 valueOf() 转换(其他类型实现了偏数值算法)。但是由于多数对象并没有 valueOf() 方法,因此它们也会通过 toString() 方法转换 - 完成对象到原始值的转换后,如果有操作数是字符串,另一个操作数也会被转换为字符串并进行拼接
- 否则,两个操作数都被转换为数值(或NaN),计算加法
- 如果有一个操作数是对象,则
1 | > 1 + 2 |
算术一元操作符(+、-、++和–)都在必要时将自己唯一的操作数转换为数值:
++
这个操作符将其操作数转换为数值,在这个数值上加 1,然后将递增后的数值再赋值回这个变量、元素或属性。因此其操作数必须是一个 lval- ++ 操作符的本身返回值取决于它与操作数的相对位置。如果位于操作数前面,即先递增操作数,再求值为该操作数递增后的值。如果位于操作数后面,它也会递增操作数,但仍然求值为该操作数未递增的值
1 | > let i = 1, j = ++i; |
--
操作符和++
类似,只不过它将操作数的数值减 1。其他行为都是类似的
位操作符对数值的二进制表示执行低级位操作。位操作符期待整数操作数,而且将它们当成 32 位整数而非 64 位浮点值。这些操作符必要时将它们的操作数转换为数值,然后再将得到的数值强制转换为 32 位整数,即丢弃小数部分和第 32 位以外的部分。位操作符包括:&
、|
、^
、~
、<<
、>>
(有符号右移)、>>>
(零填充右移)。移位操作符的第二个操作数应该是介于 0 到 31 之间的整数。
关系表达式
关系操作符测试两个值之间的关系,并依据相应关系是否存在返回 true 或 false。
相等和不相等操作符
==
和 ===
分别用两个不同的标准来检查相等性。!=
和 !==
操作符测试的关系与 ==
和 ===
恰好相反。
===
操作符被称为严格相等操作符(或者全等操作符),它根据严格相同
的定义检查两个操作数是否完全相同==
操作符被称为相等操作符,它根据更宽松的(允许类型转换的)相同定义检查两个操作数是否相等
==
操作符是 JavaScript 早期的特性,被普遍认为是个隐患。因此实践中应该坚持使用 ===
而不使用 ==
,使用 !==
而不使用 !=
。
JavaScript 对象是按引用而不是按值比较的。对象与自己相等,但与其他任何对象都不相等。即使两个对象有同样多的属性,每个属性的名字和值也相同,它们也不相等。数组也是类似的,两个数组即使元素相同、顺序相同,它们也不相等。
1 | > {} == {} |
严格相等操作符 ===
求值其操作数,然后按下列步骤比较两个值,不做类型转换:
- 如果两个值类型不同,则不相等
- 如果两个值都是 null 或都是 undefined,则相等
- 如果两个值都是布尔值 true 或都是布尔值 false,则相等
- 如果一个或两个值是 NaN,则不相等(NaN 不等于任何值,包括自己)
- 如果两个值都是数值且值相同,则相等。如果一个值是 0 而另一个是 -0,则也相等
- 如果两个值都是字符串且相同位置包含完全相同的 16 位值,则相等,否则不相等。两个字符串有可能看起来相同,也表示同样的意思,但底层编码却使用不同的 16 位值序列,JavaScript 是不会执行 Unicode 归一化操作
- 如果两个值引用同一个对象、数组或函数,则相等。如果它们引用不同的对象,即使两个对象有完全相同的属性,也不相等
1 | > null === undefined |
相等操作符 ==
是基于类型转换的宽松相等性测试。相等操作符 == 与严格相等类似,但没那么严格。如果两个操作数的值类型不同,它会尝试做类型转换,然后再比较。
- 如果两个值类型相同,则按照前面的规则测试它们是否严格相等。如果严格相等,则相等。如果不严格相等,则不相等
- 如果两个值类型不同,== 操作符仍然可能认为它们相等。此时它会使用以下规则,基于类型转换来判定相等关系
- 如果一个值是 null,另一个值是 undefined,则相等
- 如果一个值是数值,另一个值是字符串,把字符串转换为数值,再比较转换后的数值
- 如果有一个值为 true,把它转换为 1,再比较。如果有一个值为 false,把它转换为0,再比较
- 如果一个值是对象,另一个值是数值或字符串,那么尝试把对象转换为原始值再进行比较(使用无偏好算法)
- 其他任何值的组合都不相等
1 | > null == undefined |
比较操作符
比较操作符测试操作数的相对顺序(数值或字母表顺序),包括 <
、<=
、>
和 >=
。这几个比较操作符的操作数可能是任何类型。但比较只能针对数值和字符串,因此不是数值或字符串的操作数会被转换类型。
- 如果操作数有对象,尝试将对象转换为原始值(使用偏数值算法)
- 所以对于 Date 类型,无偏好算法会将其转换为字符串,但偏数值算法则可以将其转换为数值。对于 Date 类型,字符串比较是无意义的,而数值的比较才是预期行为。因此 Date 类型使用比较运算符会得到预期结果
- 如果在完成对象到原始值的转换后两个操作数都是字符串,则使用字母表顺序比较这两个字符串。字母表顺序就是字符的 16 位值顺序
- 如果在完成对象到原始值的转换后至少有一个操作数不是字符串,则两个操作数都会被转换为数值并按照数值顺序来比较
- 只有有任意操作数(或转换后)为 NaN,都会返回 false
1 | > let d1 = new Date() |
1 | > Infinity > -Infinity |
+
操作符和比较操作符同样都会对数值和字符串操作数区别对待。+
偏向字符串,即只要有一个操作数是字符串,它就会执行拼接操作。而比较操作符偏向数值,只有两个操作数均为字符串时才按字符串处理:
1 | > 1 + 2 |
in 操作符
in 操作符期待左侧操作数是字符串、符号或可以转换为字符串的值,期待右侧操作数是对象。如果左侧的值是右侧的对象的属性名,则 in 返回 true。
1 | > let p = {x:1, y:2} |
instanceof 操作符
instanceof操作符期待左侧操作数是对象,右侧操作数是对象类的标识。这个操作符在左侧对象是右侧类的实例时求值为 true,否则求值为 false。
1 | > let d = new Date() |
所有对象都是 Object 的实例。instanceof 在确定对象是不是某个类的实例时会考虑 超类
。如果 instanceof 的左侧操作数不是对象,它会返回 false。如果右侧操作数不是对象的类,它会抛出 TypeError。
要理解 instanceof 的工作原理,必须理解 原型链
。原型链是 JavaScript 的继承机制。为了对表达式 o instanceof f
求值:
- JavaScript 会求值
f.prototype
,然后在o
的原型链上查找这个值。 - 如果找到了,则
o
是 f(或f的子类)的实例,instanceof 返回true - 如果
f.prototype
不是 o 原型链上的一个值,则 o 不是 f 的实例,instanceof 返回 false
逻辑表达式
逻辑运算符包括 &&
、||
和 !
,可以执行布尔运算。
- && 的操作数可以是非布尔值,如果两个操作数都是真值,&& 返回真值,否则返回假值
- 首先对第一个操作数即它左边的表达式求值,如果左边的值是假值,直接返回它左边的值,不再求值它右侧的表达式。
- 如果左边的值为真值,&& 操作符求值并返回它右侧的值
- && 可能会(也可能不会)对其右侧操作数求值,它具有短路求值的特性
1 | > let o = {x: 0} |
- || 执行布尔或操作,如果有一个操作数是真值,这个操作符就返回真值,否则返回假值
- 它首先会对第一个操作数,即它左侧的表达式求值,如果坐标的值是真值,直接返回它左边的值,不再求值它右侧表达式的值
- 而如果第一个操作数的值是假值,则会求值第二个操作数并返回
- || 同样具有短路求值的特性
||
的典型用法是在一系列备选项中选择第一个真值
1 | let max = maxWidth || preference.maxWidth || 500; |
- ! 用于反转其操作数的布尔值,即如果 x 是个真值,
!x
返回 false,如果 x 是个假值,!x
返回 true- 与 &&、|| 不同,! 操作符将其操作数转换为布尔值,然后再反转得到的布尔值
- 因此 ! 操作符总是返回布尔值,
!!x
就可以返回 x 对应的布尔值
赋值表达式
JavaScript 使用 = 操作符为变量或属性赋值。=
操作符期待其左侧操作数是一个左值,即变量或对象属性或数组元素,右操作数可以是任意类型的任意值。赋值表达式本身的值是其右侧的值,所以如下语句是合法的,它的作用是在同一个表达式中赋值并测试该值:
1 | if (a = b) {} |
但上述语句很可能的真实意图是 if (a == b)
,这类错误很难察觉。这正式因为赋值操作符是表达式,而不是语句(这一点 Go 就很好)。
赋值操作符具有右结合性,这意味着如果一个表达式中出现多个赋值操作符,它们会从右向左求值。
1 | a = b = c = 0; |
除了常规的 = 赋值操作符,JavaScript 还支持其他一些赋值操作符,这些操作符通过组合赋值和其他操作符提供了快捷操作,例如 +=
、-=
、*=
等等。
多数情况下,a op= b
等价于 a = a op b
。但是 a op= b
中,a 表达式只会求值一次,而 a = a op b
中 a 会求值两次。这在 a 表达式具有副效应时会有区别:
1 | data[i++] *= 2; |
求值表达式
与很多解释型语言一样,JavaScript 有能力解释 JavaScript 源代码字符串,对它们求值以产生一个值。JavaScript 是通过全局函数 eval() 来对源代码字符串求值的:
1 | > eval("3 + 2") |
eval()
可能会成为安全漏洞,为此永远不要把来自用户输入的字符串交给它执行。对于像 JavaScript 这么复杂的语言,无法对用户输入脱敏,因此无法保证在 eval() 中安全地使用。
eval()
是一个函数,它其实应该是一个操作符。语言设计者和解释器开发者一直对它加以限制,导致它越来越像操作符。
eval()
期待一个参数:
- 如果给它传入任何非字符串值,它会简单地返回这个值
- 如果传入字符串,它会尝试把这个字符串当成 JavaScript 代码来解析,解析失败会抛出 SyntaxError
- 如果解析字符串成功,它会求值代码并返回该字符串中最后一个表达式或语句的值。如果最后一个表达式或语句没有值则返回 undefined
eval()
会使用调用它的代码的变量环境,它会像本地代码一样查找变量的值、定义新变量和函数。但是如果被求值的字符串使用了 let 或 const,则声明的变量或常量会被限制在求值的局部作用域内,不会定义到调用环境中。- 如果在顶级代码中调用eval(),则它操作的一定是全局变量和全局函数。
- 传给
eval()
的代码字符串本身必须从语法上说得通:不能使用它向函数中粘贴代码片段。例如在eval("return")
就是无意义的,因为 return 只在函数中有效。传递给eval()
的字符串是作为独立的脚本运行(虽然会使用调用它的代码的变量环境)
1 | > eval(true) |
正是因为 eval()
会修改局部变量,因此它会干扰 JavaScript 的优化程序。因此解释器不会过多的优化调用 eval()
的函数。但是 eval
函数可以赋值给其他标识符,并通过其他标识符来调用。JavaScript规范中说,如 果 eval()
被以 eval
之外的其他名字调用时,它应该把字符串当成顶级全局代码来求值。被求值的代码可能定义新全局变量或全局函数,可能修改全局变量,但它不会再使用或修改调用函数的局部变量。因此也就不会妨碍局部优化。
因此,直接的 eval()
调用使用的是调用上下文的变量环境,而其他调用方式,都使用全局对象作为变量环境,因而不能读、写或定义局部变量或函数。
如下是一个例子:
1 | const geval = eval; |
严格模对 eval() 函数增加了更多限制,甚至对标识符 eval
的使用也进行了限制:
- 当我们在严格模式下调用 eval() 时,或者当被求值的代码字符串以
use strict
指令开头时,eval()
会基于一个私有变量环境进行局部求值。因此被求值的代码可以查询和设置局部变量,但不能在局部作用域中定义新变量或函数 - 严格模式让
eval()
变得更像操作符,因为eval
在严格模式下会变成保留字。此时不能再使用新值来重eval()
函数
其他操作符
条件操作符
条件操作符 ?:
是 JavaScript 唯一一个三元操作符。条件操作符的操作数可以是任意类型。第一个操作数被求值并解释为一个布尔值,如果为真,那么就求值第二个操作数并返回它的值,否则求值第三个操作数并返回它的值。
1 | greeting = "hello " + (username ? username : "there") |
可以使用 if 语句实现类似的结果,但是 ?:
操作符更简洁。
先定义
先定义(first-defined)操作符 ??
求值其先定义的操作数,如果其左操作数不是 null 或 undefined,就返回该值。否则,它会返回右操作数的值。?? 也是短路的,只有第一个操作数求值为 null 或 undefined 时才会求值第二个操作数。
如果表达式 a 没有副效应,那么 a ?? b
等价于 (a != null && a != undefined) ? a : b
。?? 也是对 ||
的一个有用的替代,适合选择先定义的操作数,而不是第一个为真值的操作数。
?? 操作符与 && 和 || 操作符类似,但优先级并不比它们更高或更低。如果表达式中混用了 ??
和它们中的任何一个,必须使用圆括号说明先执行哪个操作。
typeof 操作符
typeof 操作符求值为一个字符串,表示其操作数的类型:
1 | > typeof undefined |
注意如果操作数的值是 null
,typeof 返回 object
。如果想区分 null 和对象,必须显式测试这个特殊值。由于对于所有对象和数组,typeof 返回的都是 object
,如果要区分不同对象的类,必须使用其他方法。
delete 操作符
delete 操作符尝试删除其操作数指定的对象属性或数组元素,当删除一个属性时,这个属性就不存在了(可以通过 in 操作符检查时返回 false),而不只是将其设置为 undefined。删除某个数组元素会在数组中留下一个 坑
,并不改变数组的长度。结果数组是一个稀疏数组。
1 | > let o = {x: 10, y:20} |
delete 期待它的操作数是个左值:
- 如果操作数不是左值,delete 什么也不做,且返回 true
- 否则 delete 尝试删除指定的左值,删除成功则返回 true,
- 但是并非所有属性都是可以删除的:不可配置属性就无法删除
在严格模式下:
- delete 的操作数如果是未限定标识符,比如变量、函数或函数参数,就会导致 SyntaxError。此时,delete 操作符只能作用于属性访问表达式)
- 严格模式也会在 delete 尝试删除不可配置(即不可删除)属性时抛出 TypeError
但在严格模式之外,这两种情况都不会发生异常,delete 只是简单地返回false,表示不能删除操作数。
await 操作符
await 是 ES2017 增加的,用于让 JavaScript 中的异步编程更自然。await 期待一个 Promise 对象(表示异步计算)作为其唯一操作数,可以让代码看起来像是在等待异步计算完成(但实际上它不会阻塞主线程,不会妨碍其他异步操作进行)。
await 操作符的值是 Promise 对象的兑现值。关键在于,await 只能出现在已经通过 async 关键字声明为异步的函数中。
void 操作符
void 操作符求值自己的操作数,然后丢弃这个值并返回 undefined。由于操作数的值会被丢弃,只有在操作数有副效应时才有必要使用 void 操作符。
如下是一个例子,使用了箭头函数的简写语法,其函数体会被求值并返回表达式,这里通过 void 将返回值丢弃:
1 | let counter = 0; |
逗号操作符
逗号操作符是二元操作符,其操作数可以是任意类型。这个操作符会求值其左操作数,求值其右操作数,然后返回右操作数的值。
1 | > i = 0, j = 1, k = 2 |