`??=` 不等於 `a = a ?? b`:邏輯賦值運算子的短路與單次求值
一次 code review 上,有人問了一行 ??=:「這不就是 += 那種變形嗎?只是現在多支援 ||= 和 ??= 兩類寫法?」
前半句對——它們確實同屬 compound assignment operators 這個語法家族。後半句要修正兩個地方:邏輯賦值有三個(還有 &&=),而且它比 += 多了一層語義。更麻煩的是,網路上大量文章把 x ??= y 「化簡」成 x = x ?? y——這一步同時弄丟了短路和單次求值兩個性質,而在那行被 review 的程式碼裡,弄丟短路的代價是每次呼叫都多打一次 API。
這篇把 MDN 三頁的原文、ECMA-262 的規格步驟、以及四組實際跑過的驗證放在一起,把「等價」這件事講清楚。
那一行程式碼在幹嘛
先看被 review 的那段(已去識別化、簡化欄位)。它是一個「session 內只打一次」的選項清單載入器:
/**
* 選項清單的 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 (nullorundefined).Nullish coalescing assignment short-circuits, meaning that
x ??= yis equivalent tox ?? (x = y), except that the expressionxis 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
xbeingconst:jsconst x = 1; x ??= 2;Neither would the following trigger the setter:
jsconst x = { get value() { return 1; }, set value(v) { console.log("Setter called"); }, }; x.value ??= 2;In fact, if
xis not nullish,yis 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 &&= yis equivalent tox && (x = y), except that the expressionxis 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 ||= yis equivalent tox || (x = y), except that the expressionxis 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 &&= y | x && (x = y),且 x 只求值一次 | x 為 truthy |
x ||= y | x || (x = y),且 x 只求值一次 | x 為 falsy |
x ??= y | x ?? (x = y),且 x 只求值一次 | x 為 null 或 undefined |
網路上錯的版本,是把 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
- Let leftRef be ? Evaluation of LeftHandSideExpression.
- Let leftValue be ? GetValue(leftRef).
- If leftValue is neither undefined nor null, return leftValue.
- If IsAnonymousFunctionDefinition(AssignmentExpression) is true and IsIdentifierRef of LeftHandSideExpression is true, then
- Let lhs be the StringValue of LeftHandSideExpression.
- Let rightValue be ? NamedEvaluation of AssignmentExpression with argument lhs.
- Else,
- Let rightRef be ? Evaluation of AssignmentExpression.
- Let rightValue be ? GetValue(rightRef).
- Perform ? PutValue(leftRef, rightValue).
- Return rightValue.
&&= 和 ||= 的步驟一模一樣,只有第 3 步的判斷換成 If ToBoolean(_leftValue_) is *false*, return _leftValue_.(&&=)與 If ToBoolean(_leftValue_) is *true*, return _leftValue_.(||=)。
三個重點:
- 第 1 步只跑一次。
leftRef是一個 Reference Record(物件 + key),整條運算式只求值一次;步驟 6 的PutValue重用同一個leftRef,不會重新算一次 key。 - 第 3 步是 early return。 這就是短路:不只右邊不求值,連
PutValue都不會執行。 GetValue一定會執行。 所以 getter 兩種寫法都會被呼叫,差別只在寫入。
對照一般複合賦值(+= 之類)的步驟,可以看到它沒有第 3 步的 early return:
AssignmentExpression : LeftHandSideExpression AssignmentOperator AssignmentExpression
- Let leftRef be ? Evaluation of LeftHandSideExpression.
- If the AssignmentTargetType of LeftHandSideExpression is
web-compat, throw a ReferenceError exception.- Let leftValue be ? GetValue(leftRef).
- Let rightRef be ? Evaluation of AssignmentExpression.
- Let rightValue be ? GetValue(rightRef).
- …(查表決定 opText)
- Let result be ? ApplyStringOrNumericBinaryOperator(leftValue, opText, rightValue).
- Perform ? PutValue(leftRef, result).
- 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 組驗證需要):
// 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)
實際輸出:
[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, despitexbeingconst" 是同一件事的不同面向。 - 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]是undefined,undefined + 10→NaN)。
精確一點:「單次求值」不是邏輯賦值的專利
第 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 的 ??= 換成錯誤化簡版:
// ❌ 每次 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 ??= 1 在 foo 已有值時不會觸發 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 月,技術版本可能隨時間更新,請以官方文件為準。