`??=` 不等於 `a = a ?? b`:邏輯賦值運算子的短路與單次求值

· updated 2026-08-18·15 min read

一次 code review 上,有人問了一行 ??=:「這不就是 += 那種變形嗎?只是現在多支援 ||=??= 兩類寫法?」

前半句對——它們確實同屬 compound assignment operators 這個語法家族。後半句要修正兩個地方:邏輯賦值有三個(還有 &&=),而且它比 += 多了一層語義。更麻煩的是,網路上大量文章把 x ??= y 「化簡」成 x = x ?? y——這一步同時弄丟了短路單次求值兩個性質,而在那行被 review 的程式碼裡,弄丟短路的代價是每次呼叫都多打一次 API。

這篇把 MDN 三頁的原文、ECMA-262 的規格步驟、以及四組實際跑過的驗證放在一起,把「等價」這件事講清楚。

那一行程式碼在幹嘛

先看被 review 的那段(已去識別化、簡化欄位)。它是一個「session 內只打一次」的選項清單載入器:

ts
/**
 * 選項清單的 module-level singleton,session 內只打一次 `GET /categories`。
 * 不加 TTL:它是固定值。
 */
let categoriesPromise: Promise<string[]> | null = null

export function useCategoryOptions() {
  const options = ref<UIOption[]>([])
  const isLoading = ref(false)

  /** 首次開啟表單 modal 時呼叫;重複呼叫不會重打 API */
  async function load() {
    if (options.value.length || isLoading.value)
      return

    isLoading.value = true

    try {
      categoriesPromise ??= fetchCategoryList().then(({ status, message, data }) => {
        if (status !== 0)
          throw new Error(message)

        return data ?? []
      })
      const categories = await categoriesPromise

      options.value = categories.map(item => ({ label: item, value: item }))
    }
    catch (error) {
      // 失敗不留下壞掉的 promise,下次開啟表單可以重試
      categoriesPromise = null
      showError('取得選項失敗', formatErrorMessage(error))
    }
    finally {
      isLoading.value = false
    }
  }

  return { options, isLoading, load }
}

關鍵在於存起來的是 Promise 本身,不是 resolve 後的資料。存資料的話,第一次請求還在飛的時候第二個呼叫端會看到空值、再打一次 API;存 Promise 則是讓後來的呼叫端 await 同一個尚未 settle 的 Promise,請求只會發生一次。這個「self-managed lazy singleton」的完整重構過程我另外寫過一篇:把 Vue Composable 重構成自己管理生命週期的 Lazy-loaded Singleton

而讓 ??= 成為這裡的正確寫法的,是它的短路語義:categoriesPromise 已經有值時,右邊那整個 fetchCategoryList().then(...) 根本不會被求值

家族全貌

先把語法家族攤開。ES2021 之前只有算術/位元類,ES2021 加入三個邏輯賦值:

類別運算子短路
簡單賦值=
算術/位元複合賦值+= -= *= /= %= **= <<= >>= >>>= &= |= ^=❌ 右邊一定求值、一定寫入
邏輯賦值(ES2021)&&= ||= ??=✅ 條件不成立時右邊不求值、不寫入

所以問題的答案是:語法上同一家族,語義上多了短路這一層。

MDN 的原文

三個邏輯賦值運算子在 MDN 上是同一套措辭,值得原文照抄——因為關鍵就在那個 except 從句。

??=1

The nullish coalescing assignment (??=) operator, also known as the logical nullish assignment operator, only evaluates the right operand and assigns to the left if the left operand is nullish (null or undefined).

Nullish coalescing assignment short-circuits, meaning that x ??= y is equivalent to x ?? (x = y), except that the expression x is only evaluated once.

No assignment is performed if the left-hand side is not nullish, due to short-circuiting of the nullish coalescing operator. For example, the following does not throw an error, despite x being const:

js
const x = 1;
x ??= 2;

Neither would the following trigger the setter:

js
const x = {
  get value() {
    return 1;
  },
  set value(v) {
    console.log("Setter called");
  },
};

x.value ??= 2;

In fact, if x is not nullish, y is not evaluated at all.

&&=2

The logical AND assignment (&&=) operator only evaluates the right operand and assigns to the left if the left operand is truthy.

Logical AND assignment short-circuits, meaning that x &&= y is equivalent to x && (x = y), except that the expression x is only evaluated once.

No assignment is performed if the left-hand side is not truthy, due to short-circuiting of the logical AND operator.

||=3

The logical OR assignment (||=) operator only evaluates the right operand and assigns to the left if the left operand is falsy.

Logical OR assignment short-circuits, meaning that x ||= y is equivalent to x || (x = y), except that the expression x is only evaluated once.

No assignment is performed if the left-hand side is not falsy, due to short-circuiting of the logical OR operator.

整理成對照表:

寫法MDN 給的等價式賦值條件
x &&= yx && (x = y),且 x 只求值一次x 為 truthy
x ||= yx || (x = y),且 x 只求值一次x 為 falsy
x ??= yx ?? (x = y),且 x 只求值一次xnullundefined

網路上錯的版本,是把 x ?? (x = y) 再往下化簡成 x = x ?? y。差別就在這一步。

規格層級:ECMA-262 §13.15.2

MDN 的 except 從句在規格裡對應的是「reference 只被 evaluate 一次」。以下是 ECMA-262 Assignment Operators — Runtime Semantics: Evaluation4??= 的定義(原文照抄):

AssignmentExpression : LeftHandSideExpression ??= AssignmentExpression

  1. Let leftRef be ? Evaluation of LeftHandSideExpression.
  2. Let leftValue be ? GetValue(leftRef).
  3. If leftValue is neither undefined nor null, return leftValue.
  4. If IsAnonymousFunctionDefinition(AssignmentExpression) is true and IsIdentifierRef of LeftHandSideExpression is true, then
    1. Let lhs be the StringValue of LeftHandSideExpression.
    2. Let rightValue be ? NamedEvaluation of AssignmentExpression with argument lhs.
  5. Else,
    1. Let rightRef be ? Evaluation of AssignmentExpression.
    2. Let rightValue be ? GetValue(rightRef).
  6. Perform ? PutValue(leftRef, rightValue).
  7. Return rightValue.

&&=||= 的步驟一模一樣,只有第 3 步的判斷換成 If ToBoolean(_leftValue_) is *false*, return _leftValue_.&&=)與 If ToBoolean(_leftValue_) is *true*, return _leftValue_.||=)。

三個重點:

  1. 第 1 步只跑一次。 leftRef 是一個 Reference Record(物件 + key),整條運算式只求值一次;步驟 6 的 PutValue 重用同一個 leftRef,不會重新算一次 key。
  2. 第 3 步是 early return。 這就是短路:不只右邊不求值,連 PutValue 都不會執行。
  3. GetValue 一定會執行。 所以 getter 兩種寫法都會被呼叫,差別只在寫入

對照一般複合賦值(+= 之類)的步驟,可以看到它沒有第 3 步的 early return:

AssignmentExpression : LeftHandSideExpression AssignmentOperator AssignmentExpression

  1. Let leftRef be ? Evaluation of LeftHandSideExpression.
  2. If the AssignmentTargetType of LeftHandSideExpression is web-compat, throw a ReferenceError exception.
  3. Let leftValue be ? GetValue(leftRef).
  4. Let rightRef be ? Evaluation of AssignmentExpression.
  5. Let rightValue be ? GetValue(rightRef).
  6. …(查表決定 opText
  7. Let result be ? ApplyStringOrNumericBinaryOperator(leftValue, opText, rightValue).
  8. Perform ? PutValue(leftRef, result).
  9. Return result.

規格還有一段 note 直接點名 PutValue 那步會在 strict mode 下對唯讀屬性丟 TypeError

Additionally, it is a runtime error if the leftRef in step … is a reference to a data property with the attribute value { [[Writable]]: false }, to an accessor property with the attribute value { [[Setter]]: undefined }, or to a non-existent property of an object for which the IsExtensible predicate returns the value false. In these cases a TypeError exception is thrown.

短路讓 PutValue 整步被跳過——所以 ??= 對已有值的唯讀屬性不會丟錯,而 x = x ?? y 會。這正好是下面第 2 組驗證。

四組驗證

規格讀完,直接跑。以下是完整可執行的 ESM 檔(module 預設 strict mode,第 2 組驗證需要):

js
// 1. setter 會不會被呼叫
let writes = 0
const obj = {
  _v: 1,
  get v() { return this._v },
  set v(x) { writes++; this._v = x },
}

obj.v ??= 99
console.log('[1] ??=        setter 呼叫次數:', writes)

writes = 0
obj.v = obj.v ?? 99
console.log('[1] a = a ?? b setter 呼叫次數:', writes)

// 2. 唯讀屬性
const frozen = Object.freeze({ v: 1 })

try {
  frozen.v ??= 99
  console.log('[2] ??=        沒有 throw')
}
catch (e) { console.log('[2] ??=        throw:', e.constructor.name) }

try {
  frozen.v = frozen.v ?? 99
  console.log('[2] a = a ?? b 沒有 throw')
}
catch (e) { console.log('[2] a = a ?? b throw:', e.constructor.name) }

// 3. 左側 reference 被求值幾次
let i = 0
const arr = ['x']
arr[i++] ??= 'y'
console.log('[3] ??=        i =', i, ' arr =', arr)

i = 0
const arr2 = ['x']
arr2[i++] = arr2[i++] ?? 'y'
console.log('[3] 手寫展開    i =', i, ' arr =', arr2)

// 4. 對照組:+= 的 reference 也只求值一次
i = 0
const nums = [1]
nums[i++] += 10
console.log('[4] +=         i =', i, ' arr =', nums)

i = 0
const nums2 = [1]
nums2[i++] = nums2[i++] + 10
console.log('[4] 手寫展開    i =', i, ' arr =', nums2)

實際輸出:

text
[1] ??=        setter 呼叫次數: 0
[1] a = a ?? b setter 呼叫次數: 1
[2] ??=        沒有 throw
[2] a = a ?? b throw: TypeError
[3] ??=        i = 1  arr = [ 'x' ]
[3] 手寫展開    i = 2  arr = [ 'y' ]
[4] +=         i = 1  arr = [ 11 ]
[4] 手寫展開    i = 2  arr = [ NaN ]

逐組解讀:

  • 1 setter??= 因為短路跳過 PutValue,setter 呼叫次數是 0;a = a ?? b 一定寫入,setter 被呼叫。對有副作用的 setter(log、驗證、送 analytics)來說這是行為差異,不是效能差異。
  • 2 唯讀屬性Object.freeze??= 安然無事,a = a ?? b 在 strict mode 下丟 TypeError。跟 MDN 那句 "does not throw an error, despite x being const" 是同一件事的不同面向。
  • 3 reference 求值次數:這組最狠。arr[i++] ??= 'y' 的 key 只算一次,i 停在 1、arr[0] 保持 'x'。手寫展開版把 i 推到 2,而且結果直接錯了:LHS 的 arr2[i++] 先求值(i → 1),RHS 讀到的是 arr2[1]undefined)→ 取 'y' → 寫回 arr2[0],把 'x' 蓋掉。
  • 4 對照組nums[i++] += 10 也是 key 只算一次,得到 [11];手寫展開得到 [NaN]nums2[1]undefinedundefined + 10NaN)。

精確一點:「單次求值」不是邏輯賦值的專利

第 4 組驗證是刻意加的,因為它推翻一個容易順手建立的錯誤結論——「單次求值是 ??= 的特色」。

不是。整個 compound assignment 家族都是單次求值+= 也一樣(規格步驟裡 leftRef 同樣只 evaluate 一次)。所以 a += b 化簡成 a = a + b 在有副作用的 key 上一樣會錯,只是幾乎沒人這樣寫程式,所以不會踩到。

拆開來說,錯誤化簡 x = x ?? y 弄丟的是兩個不同層級的性質:

弄丟的性質誰有觀察條件
單次求值 reference所有 compound assignment(含 +=左側 key 有副作用(arr[i++]
短路(不求值右邊、不寫入)只有 &&= ||= ??=右邊有副作用/成本,或左側有 setter、frozen、non-writable

換句話說,真正只屬於邏輯賦值的差異是「不寫入」。這也是為什麼 MDN 三頁都用同一句 "except that the expression x is only evaluated once" 當 except 從句——短路寫在主句裡,單次求值只是附註。

為什麼誤傳活得下來

因為在最常見的情境下,兩種寫法的可觀察行為完全相同:最終值一樣,運算式的回傳值也一樣。要看出差別,得同時滿足其中一項:

  • 左邊是屬性存取,且該屬性有 setter / Proxy / frozen / non-writable
  • 右邊有副作用或成本(API 請求、console.log、遞增、大量計算)
  • 左邊的 key 本身有副作用

日常寫 code 九成碰不到;教學文章舉的例子又幾乎都是 let a = null; a ??= 5 這種單純區域變數,自然驗不出來。誤傳因此沒有被證偽的機會。

回到那一行

把文章開頭那段 singleton 的 ??= 換成錯誤化簡版:

ts
// ❌ 每次 load() 都會執行右邊 → 每次都多打一次 API
categoriesPromise = categoriesPromise ?? fetchCategoryList().then(/* … */)

// ✅ 已有值就完全不動,右邊不求值
categoriesPromise ??= fetchCategoryList().then(/* … */)

錯誤版的最終是對的——categoriesPromise 仍然是同一個 Promise,await 出來的資料也正確——所以任何斷言「資料對不對」的測試都會過。壞掉的是副作用fetchCategoryList() 每次都被呼叫,Promise 只是被丟掉。整個 singleton 的目的(省請求)失效,而且症狀是 network tab 裡多出來的請求,不是任何錯誤訊息。

這就是為什麼那句「等同於 a = a ?? b」值得挑:它不是學術細節,它會在這種 context 下產出一個沒有錯誤訊息的 bug

三個怎麼選

需求
只在「還沒設定」時給預設值,且 0 / '' / false 是合法值??=
想把所有 falsy(含 0'')都視為「沒設定」||=
只在已經有值時才覆寫(例如清洗既有值)&&=

實務上絕大多數「給預設值」的情境該用 ??=——||= 會把 0''false 這些合法值一起吃掉,是經典 bug 來源(count ||= 10 會讓 count === 0 變成 10)。&&= 相對少見,適合「有值才處理」的情境,例如 config.token &&= mask(config.token)

兩個實務註記

Vue reactive 物件state.foo ??= 1foo 已有值時不會觸發 Proxy 的 set trap;state.foo = state.foo ?? 1 會。不過 Vue 3 的 setter 內部有 hasChanged 檢查,值沒變時不會 trigger effect,所以多數情況下不會看到多餘的 re-render——真正有差的是自訂 setter、Object.defineProperty 定義的屬性,或 frozen 物件。別把它當成效能技巧,當成「語義更準確」就好。

TypeScript??=strictNullChecks 下會做 narrowing,賦值後左側型別會排除 null | undefined,所以上面那個 let categoriesPromise: Promise<string[]> | null??= 之後可以直接 await,不需要 non-null assertion。這是把 if (!x) x = y 換成 x ??= y 順便拿到的好處。

環境資訊

  • Node.js: v22.23.2(驗證用)
  • 規格:ECMA-262(ES2021 起支援邏輯賦值運算子)
  • 瀏覽器支援5:Chrome 85 / Firefox 79 / Safari 14 / Node.js 15.0.0(三個運算子相同)

本文撰寫時間:2026 年 8 月,技術版本可能隨時間更新,請以官方文件為準。

Reference

  1. Nullish coalescing assignment (??=) — MDN
  2. Logical AND assignment (&&=) — MDN
  3. Logical OR assignment (||=) — MDN
  4. ECMA-262: Assignment Operators — Runtime Semantics: Evaluation
  5. mdn/browser-compat-data — javascript/operators