JavaScript sort 排序不正確:比較函式別只回傳布林值
items.sort((a, b) => a > b) 看起來像在描述升冪,卻沒有提供完整比較結果。比較函式要區分負值、零與正值;有限數字可用 (a, b) => a - b。 同時確認你要不要修改原陣列,因為 sort() 會直接重排它。
a > b 少了「排在前面」的結果
MDN sort 文件說明 compareFn 的回傳契約:負值讓 a 排在 b 前面、正值排在後面、零表示兩者比較相等。布林值轉成數字只有 0、1,因此 a < b 時的 false 會被當成相等,沒有回傳應有的負值。
例如 a 為 1、b 為 2,a > b 回傳 false;反過來卻回傳 true。這違反比較器需要的一致性,不能依賴某個引擎某次剛好排對。換成 toSorted() 也不會修好這個比較函式。
下面是可獨立執行的基本檢查,刻意不斷言錯誤比較器在各瀏覽器會產生哪個特定順序:
import assert from 'node:assert/strict';
const badCompare = (a, b) => a > b;assert.equal(Number(badCompare(1, 2)), 0); // 應為負值,卻被視為相等assert.equal(Number(badCompare(2, 1)), 1);assert.deepEqual([10, 2, 1].sort(), [1, 10, 2]);assert.deepEqual([10, 2, 1].sort((a, b) => a - b), [1, 2, 10]);沒有 compareFn 時,數值也按轉成字串後的 UTF-16 碼元順序比較;「沒有傳比較器」與「傳了布林比較器」是兩個故障點。若欄位是名稱,應依語系需求使用字串比較;不要把數字相減套到中文標題。
先驗證數值,再決定同分怎麼排
以下函式只接受密集陣列,每筆有字串 id 與有限數字 score,依分數由高到低排序。它拒絕 NaN、Infinity、數字字串與空洞,並保留原輸入順序。
function rankByScore(rows) { if (!Array.isArray(rows)) throw new TypeError('Expected an array'); for (let i = 0; i < rows.length; i += 1) { const row = rows[i]; if (!Object.hasOwn(rows, i) || row === null || typeof row !== 'object' || typeof row.id !== 'string' || !Number.isFinite(row.score)) { throw new TypeError('Expected dense rows with id and finite score'); } } const compare = (a, b) => a.score === b.score ? 0 : (a.score > b.score ? -1 : 1); return typeof rows.toSorted === 'function' ? rows.toSorted(compare) : [...rows].sort(compare);}這裡用明確的三向比較避免相減溢位,且不在 comparator 裡改寫資料。現行 ECMAScript sort 規格要求穩定排序,因此同分時回傳零,可以保留原輸入順序。若 API 每次回傳的同分順序不同,穩定排序仍會跟著變;產品需要固定結果時,應另訂唯一且穩定的次排序 key。
比較結果為 NaN 時會被當成零,不會自動幫你把壞資料放到最後。因此先拒絕 NaN,而不是讓 a.score - b.score 帶著壞值繼續排。若 API 傳的是數字字串,請在資料邊界明確轉換及驗證,不要在每次比較時反覆猜型別。
接在函式後的驗收:
import assert from 'node:assert/strict';
const input = [ { id: 'low', score: 2 }, { id: 'first', score: 10 }, { id: 'second', score: 10 }, { id: 'negative', score: -1 },];const ranked = rankByScore(input);assert.deepEqual(ranked.map(row => row.id), ['first', 'second', 'low', 'negative']);assert.deepEqual(input.map(row => row.id), ['low', 'first', 'second', 'negative']);assert.notEqual(ranked, input);assert.equal(ranked[0], input[1]); // 新陣列仍共用相同物件assert.deepEqual(rankByScore([]), []);for (const score of [NaN, Infinity, '10', undefined]) { assert.throws(() => rankByScore([{ id: 'bad', score }]), TypeError);}assert.throws(() => rankByScore(new Array(2)), TypeError);for (let i = 1; i < ranked.length; i += 1) { assert.ok(ranked[i - 1].score >= ranked[i].score);}// 模擬沒有 toSorted 的輸入,驗證複製後 sort 的分支。const fallback = [...input];Object.defineProperty(fallback, 'toSorted', { value: undefined });assert.deepEqual(rankByScore(fallback).map(row => row.id), ranked.map(row => row.id));assert.deepEqual(fallback.map(row => row.id), input.map(row => row.id));新陣列與新物件要分開驗收
toSorted()會回傳排序後的新陣列;[...rows].sort(compare) 則先淺複製再排序。兩者都沒有深複製每筆物件,修改結果裡的 ranked[0].score,仍會改到同一個原物件。上面的 identity assertion 特別保留這個界線。
若畫面排序後其他清單也變動,先檢查是否共用原陣列並呼叫了 sort();如果是編輯其中一筆資料才連動,則要查物件共用。這與 structuredClone 的複製限制是下一層問題,不必為了排序先深複製整份資料。
本文全部 assertion 於 2026-10-21 由維護流程在 macOS、Node.js v24.13.0 執行通過,包含 fallback 分支。未比較不同瀏覽器的錯誤比較器輸出、未做效能 benchmark,也沒有驗證框架的 reactive 更新;正式 UI 仍要核對排序前後的 item ID、同分政策及原資料是否被修改。
參考資料: