运算符优先级

运算符优先级决定了运算符之间如何解析。优先级更高的运算符将成为优先级较低的运算符的操作数。

试一试

console.log(3 + 4 * 5); // 3 + 20
// Expected output: 23

console.log(4 * 3 ** 2); // 4 * 9
// Expected output: 36

let a;
let b;

console.log((a = b = 5));
// Expected output: 5

优先级和结合性

考虑下面所示的表达式,其中 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() && B() || C());
// Logs:
// called A
// called C
// true

那么 && 的短路效应只会阻止 B() 被求值,但由于 A() && B() 整体是 falseC() 仍然会被求值。

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

这些运算符的赋值对应项(&&=||=??=)也是短路求值的。它们的短路方式是根本不发生赋值。

表格

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

关于该表的一些一般性说明

  1. 并非此处包含的所有语法在严格意义上都是“运算符”。例如,展开运算符 ... 和箭头函数 => 通常不被视为运算符。但是,我们仍然将它们包含在内,以显示它们与其他运算符/表达式的结合紧密程度。
  2. 某些运算符的某些操作数要求表达式比高优先级运算符生成的表达式更窄。例如,成员访问 .(优先级 17)的右侧必须是标识符而不是分组表达式。箭头函数 =>(优先级 2)的左侧必须是参数列表或单个标识符,而不是某些随机表达式。
  3. 有些运算符的某些操作数接受比高优先级运算符生成的表达式更广的表达式。例如,方括号表示法 [ … ](优先级 17)的方括号内表达式可以是任何表达式,甚至是逗号(优先级 1)连接的表达式。这些运算符的行为就像该操作数是“自动分组”的。在这种情况下,我们将省略结合性。
优先级 结合性 个别运算符 注意
18:分组 不适用 分组
(x)
[1]
17:访问和调用 从左到右 成员访问
x.y
[2]
可选链
x?.y
不适用 计算成员访问
x[y]
[3]
new 带参数列表
new x(y)
[4]
函数调用
x(y)
import(x)
16:new 不适用 new 不带参数列表
new x
15:后缀运算符 不适用 后缀递增
x++
[5]
后缀递减
x--
14:前缀运算符 不适用 前缀递增
++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]
不适用 yield x
yield* x
展开
...x
[13]
1:逗号 从左到右 逗号运算符
x, y

注意

  1. 操作数可以是任何表达式。
  2. “右侧”必须是标识符。
  3. “右侧”可以是任何表达式。
  4. “右侧”是逗号分隔的任何优先级 > 1 的表达式列表(即,不是逗号表达式)。new 表达式的构造函数不能是可选链。
  5. 操作数必须是有效的赋值目标(标识符或属性访问)。其优先级意味着 new Foo++(new Foo)++(语法错误),而不是 new (Foo++)(TypeError:(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)。