運算子優先順序

運算子優先順序決定了運算子之間如何解析。優先順序更高的運算子將成為優先順序較低的運算子的運算元。

試一試

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)。