运算符优先级

运算符优先级决定了运算符相对于彼此的解析方式。优先级较高的运算符成为优先级较低的运算符的操作数。

试试

优先级和结合性

考虑以下表示形式描述的表达式,其中 OP1OP2 都是运算符的占位符。

a OP1 b OP2 c

上述组合有两种可能的解释

(a OP1 b) OP2 c
a OP1 (b OP2 c)

语言决定采用哪种解释取决于 OP1OP2 的标识。

如果 OP1OP2 具有不同的优先级级别(见下表),则优先级较高的运算符先执行,结合性无关紧要。观察乘法的优先级高于加法,并先执行,即使加法在代码中先写。

js
console.log(3 + 10 * 2); // 23
console.log(3 + (10 * 2)); // 23, because parentheses here are superfluous
console.log((3 + 10) * 2); // 26, because the parentheses change the order

在相同优先级的运算符中,语言会根据结合性对它们进行分组。左结合性(从左到右)表示它被解释为 (a OP1 b) OP2 c,而右结合性(从右到左)表示它被解释为 a OP1 (b OP2 c)。赋值运算符是右结合的,所以你可以写

js
a = b = 5; // same as writing a = (b = 5);

预期结果是 ab 都被赋予值 5。这是因为赋值运算符返回被赋予的值。首先,b 被设置为 5。然后,a 也被设置为 5 — b = 5 的返回值,也就是赋值的右操作数。

另一个例子是,唯一的指数运算符具有右结合性,而其他算术运算符具有左结合性。

js
const a = 4 ** 3 ** 2; // Same as 4 ** (3 ** 2); evaluates to 262144
const b = 4 / 3 / 2; // Same as (4 / 3) / 2; evaluates to 0.6666...

运算符首先按优先级分组,然后对于具有相同优先级的相邻运算符,按结合性分组。因此,当混合使用除法和指数运算时,指数运算始终先于除法。例如,2 ** 3 / 3 ** 2 的结果为 0.8888888888888888,因为它与 (2 ** 3) / (3 ** 2) 相同。

对于前缀一元运算符,假设我们有以下模式

OP1 a OP2 b

其中 OP1 是一个前缀一元运算符,OP2 是一个二元运算符。如果 OP1 的优先级高于 OP2,那么它将被分组为 (OP1 a) OP2 b;否则,它将被分组为 OP1 (a OP2 b)

js
const a = 1;
const b = 2;
typeof a + b; // Equivalent to (typeof a) + b; result is "number2"

如果一元运算符位于第二个操作数上

a OP2 OP1 b

那么二元运算符 OP2 的优先级必须低于一元运算符 OP1,才能将其分组为 a OP2 (OP1 b)。例如,以下代码无效

js
function* foo() {
  a + yield 1;
}

因为 + 的优先级高于 yield,所以这将变成 (a + yield) 1 — 但因为 yield 是生成器函数中的保留字,所以这将是一个语法错误。幸运的是,大多数一元运算符的优先级高于二元运算符,并且不会出现这种问题。

如果我们有两个前缀一元运算符

OP1 OP2 a

那么更靠近操作数的一元运算符 OP2 的优先级必须高于 OP1,才能将其分组为 OP1 (OP2 a)。有可能得到另一种方式,最终得到 (OP1 OP2) a

js
async function* foo() {
  await yield 1;
}

因为 await 的优先级高于 yield,所以这将变成 (await yield) 1,它等待一个名为 yield 的标识符,这是一个语法错误。类似地,如果你有 new !A;,因为 ! 的优先级低于 new,所以这将变成 (new !) A,这显然是无效的。(无论如何,这段代码看起来很荒谬,因为 !A 总是产生一个布尔值,而不是一个构造函数。)

对于后缀一元运算符(即 ++--),相同的规则适用。幸运的是,这两个运算符的优先级都高于任何二元运算符,因此分组总是你期望的那样。此外,因为 ++ 的结果是,而不是引用,所以你不能像在 C 中那样将多个增量连接在一起。

js
let a = 1;
a++++; // SyntaxError: Invalid left-hand side in postfix operation.

运算符优先级将被递归处理。例如,考虑这个表达式

js
1 + 2 ** 3 * 4 / 5 >> 6

首先,我们按优先级级别从高到低对具有不同优先级的运算符进行分组。

  1. ** 运算符的优先级最高,所以它首先被分组。
  2. 查看 ** 表达式周围,它在右边有 *,在左边有 +* 的优先级更高,所以它首先被分组。*/ 具有相同的优先级,所以我们现在将它们分组在一起。
  3. 查看在 2 中分组的 *// 表达式周围,因为 + 的优先级高于 >>,所以前者被分组。
js
   (1 + ( (2 ** 3) * 4 / 5) ) >> 6
// │    │ └─ 1. ─┘        │ │
// │    └────── 2. ───────┘ │
// └────────── 3. ──────────┘

*// 组中,因为它们都是左结合的,所以左操作数将被分组。

js
   (1 + ( ( (2 ** 3) * 4 ) / 5) ) >> 6
// │    │ │ └─ 1. ─┘     │    │ │
// │    └─│─────── 2. ───│────┘ │
// └──────│───── 3. ─────│──────┘
//        └───── 4. ─────┘

请注意,运算符优先级和结合性仅影响运算符的求值顺序(隐式分组),而不是操作数的求值顺序。操作数总是从左到右进行求值。优先级较高的表达式始终先求值,然后根据运算符优先级的顺序组合其结果。

js
function echo(name, num) {
  console.log(`Evaluating the ${name} side`);
  return num;
}
// Exponentiation operator (**) is right-associative,
// but all call expressions (echo()), which have higher precedence,
// will be evaluated before ** does
console.log(echo("left", 4) ** echo("middle", 3) ** echo("right", 2));
// Evaluating the left side
// Evaluating the middle side
// Evaluating the right side
// 262144

// Exponentiation operator (**) has higher precedence than division (/),
// but evaluation always starts with the left operand
console.log(echo("left", 4) / echo("middle", 3) ** echo("right", 2));
// Evaluating the left side
// Evaluating the middle side
// Evaluating the right side
// 0.4444444444444444

如果你熟悉二叉树,把它想象成一个后序遍历

                /
       ┌────────┴────────┐
echo("left", 4)         **
                ┌────────┴────────┐
        echo("middle", 3)  echo("right", 2)

在所有运算符都被正确分组后,二元运算符将形成一棵二叉树。求值从最外层分组开始 — 也就是优先级最低的运算符(本例中为 /)。此运算符的左操作数首先求值,它可能由优先级较高的运算符组成(例如,一个调用表达式 echo("left", 4))。在左操作数求值后,右操作数将以相同的方式求值。因此,所有叶节点 — echo() 调用 — 将从左到右访问,而与连接它们的运算符优先级无关。

短路

在上一节中,我们说“优先级较高的表达式始终先求值” — 这通常是正确的,但必须用对短路的承认来修正它,在这种情况下,一个操作数可能根本不会被求值。

短路是条件求值的术语。例如,在表达式 a && (b + c) 中,如果 a假值,那么子表达式 (b + c) 甚至不会被求值,即使它被分组,因此比 && 具有更高的优先级。我们可以说逻辑 AND 运算符(&&)是“短路的”。除了逻辑 AND 之外,其他短路运算符还包括逻辑 OR(||)、空值合并(??)和可选链(?.)。

js
a || (b * c); // evaluate `a` first, then produce `a` if `a` is "truthy"
a && (b < c); // evaluate `a` first, then produce `a` if `a` is "falsy"
a ?? (b || c); // evaluate `a` first, then produce `a` if `a` is not `null` and not `undefined`
a?.b.c; // evaluate `a` first, then produce `undefined` if `a` is `null` or `undefined`

在评估短路运算符时,始终会先计算左操作数。只有当左操作数无法确定运算结果时,才会计算右操作数。

注意: 短路行为是这些运算符固有的特性。其他运算符始终会计算两个操作数,无论是否真正有用——例如,NaN * foo() 将始终调用 foo,即使结果永远不会是除了 NaN 之外的任何值。

之前的后序遍历模型仍然适用。但是,在访问短路运算符的左子树后,语言会决定是否需要计算右操作数。如果不需要(例如,因为 || 的左操作数已经是真值),则会直接返回结果,而不会访问右子树。

考虑以下情况:

js
function A() { console.log('called A'); return false; }
function B() { console.log('called B'); return false; }
function C() { console.log('called C'); return true; }

console.log(C() || B() && A());

// Logs:
// called C
// true

仅计算 C(),尽管 && 的优先级更高。这并不意味着 || 在这种情况下具有更高的优先级——正是因为 (B() && A()) 的优先级更高,才导致它被整体忽略。如果将其重新排列为:

js
console.log(A() && C() || B());
// Logs:
// called A
// called B
// false

那么 && 的短路效应只会阻止计算 C(),但由于 A() && C() 作为一个整体是 false,因此仍然会计算 B()

但是,请注意,短路不会改变最终的评估结果。它只影响操作数的计算,而不是运算符的组合方式——如果操作数的计算没有副作用(例如,记录到控制台、赋值给变量、抛出错误),短路就根本无法观察到。

这些运算符的赋值对应物(&&=||=??=)也是短路的。它们以一种不会发生赋值的方式短路。

表格

下表按从最高优先级 (18) 到最低优先级 (1) 的顺序列出了运算符。

关于该表的一些总体说明:

  1. 并非此处包含的所有语法都是严格意义上的“运算符”。例如,扩展运算符 ... 和箭头运算符 => 通常不被视为运算符。但是,我们仍然包含它们以显示它们与其他运算符/表达式相比的绑定程度。
  2. 一些运算符的某些操作数需要比由更高优先级运算符产生的表达式更窄的表达式。例如,成员访问 .(优先级 17)的右侧必须是标识符,而不是分组表达式。箭头运算符 =>(优先级 2)的左侧必须是参数列表或单个标识符,而不是随机表达式。
  3. 一些运算符的某些操作数可以接受比由更高优先级运算符产生的表达式更宽的表达式。例如,方括号表示法的方括号括起来的表达式 [ … ](优先级 17)可以是任何表达式,甚至是逗号(优先级 1)连接的表达式。这些运算符的行为就像该操作数是“自动分组”的。在这种情况下,我们将省略结合性。
优先级 结合性 单个运算符 说明
18: 分组 n/a 分组
(x)
[1]
17: 访问和调用 从左到右 成员访问
x.y
[2]
可选链
x?.y
n/a 计算成员访问
x[y]
[3]
new 带参数列表
new x(y)
[4]
函数调用
x(y)
import(x)
16: new n/a new 不带参数列表
new x
15: 后缀运算符 n/a 后缀增量
x++
[5]
后缀减量
x--
14: 前缀运算符 n/a 前缀增量
++x
[6]
前缀减量
--x
逻辑非
!x
按位非
~x
一元加
+x
一元负
-x
typeof x
void x
delete x [7]
await x
13: 幂运算 从右到左 幂运算
x ** y
[8]
12: 乘法运算符 从左到右 乘法
x * y
除法
x / y
求余
x % y
11: 加法运算符 从左到右 加法
x + y
减法
x - y
10: 按位移位 从左到右 左移
x << y
右移
x >> y
无符号右移
x >>> y
9: 关系运算符 从左到右 小于
x < y
小于或等于
x <= y
大于
x > y
大于或等于
x >= y
x in y
x instanceof y
8: 等于运算符 从左到右 相等
x == y
不相等
x != y
严格相等
x === y
严格不相等
x !== y
7: 按位与 从左到右 按位与
x & y
6: 按位异或 从左到右 按位异或
x ^ y
5: 按位或 从左到右 按位或
x | y
4: 逻辑与 从左到右 逻辑与
x && y
3: 逻辑或,空值合并 从左到右 逻辑或
x || y
空值合并运算符
x ?? y
[9]
2: 赋值和其他 从右到左 赋值
x = y
[10]
加法赋值
x += y
减法赋值
x -= y
幂运算赋值
x **= y
乘法赋值
x *= y
除法赋值
x /= y
求余赋值
x %= y
左移赋值
x <<= y
右移赋值
x >>= y
无符号右移赋值
x >>>= y
按位与赋值
x &= y
按位异或赋值
x ^= y
按位或赋值
x |= y
逻辑与赋值
x &&= y
逻辑或赋值
x ||= y
空值合并赋值
x ??= y
从右到左 条件 (三元) 运算符
x ? y : z
[11]
从右到左 箭头
x => y
[12]
n/a yield x
yield* x
扩展运算符
...x
[13]
1: 逗号 从左到右 逗号运算符
x, y

说明

  1. 操作数可以是任何表达式。
  2. “右侧”必须是标识符。
  3. “右侧”可以是任何表达式。
  4. “右侧”是逗号分隔的任何表达式列表,其优先级大于 1(即不是逗号表达式)。new 表达式的构造函数不能是可选链。
  5. 操作数必须是有效的赋值目标(标识符或属性访问)。它的优先级意味着 new Foo++(new Foo)++(语法错误),而不是 new (Foo++)(类型错误:(Foo++) 不是构造函数)。
  6. 操作数必须是有效的赋值目标(标识符或属性访问)。
  7. 操作数不能是标识符或对 私有属性 的访问。
  8. 左侧不能具有优先级 14。
  9. 操作数不能是逻辑或 || 或逻辑与 && 运算符,除非进行分组。
  10. “左侧”必须是有效的赋值目标(标识符或属性访问)。
  11. 结合性意味着 ? 后的两个表达式被隐式分组。
  12. “左侧”是单个标识符或带括号的参数列表。
  13. 仅在对象字面量、数组字面量或参数列表中有效。

组 17 和 16 的优先级可能有点模棱两可。以下是一些示例以说明。

  • 可选链始终可以替换为其各自的语法,而无需可选性(除了可选链被禁止的少数特殊情况外)。例如,任何接受 a?.b 的地方也接受 a.b,反之亦然,类似地,a?.()a() 等等也是如此。
  • 成员表达式和计算成员表达式始终可以相互替换。
  • 调用表达式和 import() 表达式始终可以相互替换。
  • 这留下了四类表达式:成员访问、带参数的 new、函数调用和不带参数的 new
    • 成员访问的“左侧”可以是:成员访问 (a.b.c)、带参数的 new (new a().b) 和函数调用 (a().b)。
    • 带参数的 new 的“左侧”可以是:成员访问 (new a.b()) 和带参数的 new (new new a()())。
    • 函数调用的“左侧”可以是:成员访问 (a.b())、带参数的 new (new a()()) 和函数调用 (a()())。
    • 不带参数的 new 的操作数可以是:成员访问 (new a.b)、带参数的 new (new new a()) 和不带参数的 new (new new a)。