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));同分要跨次載入保持順序,加入唯一 ID
穩定排序保留的是「這次輸入」的同分順序。若 API 第一回傳回 b, a,第二回傳回 a, b,只按 score 排序仍會跳動。前面的 rankByScore() 刻意維持原順序;若產品要求固定排名,可另訂「分數遞減、同分依唯一 ID 遞增」的契約。
這裡限定 ID 是非空 ASCII 英數字、底線或連字號,使用字串 </> 的碼元順序,並拒絕重複 ID。這是技術識別碼排序,不是中文名稱的語系排序,也不會把 item-10 當成自然數字排在 item-2 之後。兩種需求要使用不同政策。
以下範例獨立執行,不會改變前面 helper 的同分行為:
import assert from 'node:assert/strict';
function rankWithStableId(rows) { if (!Array.isArray(rows)) throw new TypeError('Expected an array'); const ids = new Set(); 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' || !/^[A-Za-z0-9_-]+(?![\s\S])/.test(row.id) || !Number.isFinite(row.score) || ids.has(row.id)) { throw new TypeError('Expected dense rows with unique ASCII id and finite score'); } ids.add(row.id); } return [...rows].sort((a, b) => { if (a.score !== b.score) return a.score > b.score ? -1 : 1; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; });}
const a = { id: 'a', score: 10 };const b = { id: 'b', score: 10 };const c = { id: 'c', score: 2 };for (const input of [[b, c, a], [a, b, c], [c, a, b]]) { const before = [...input]; assert.deepEqual(rankWithStableId(input).map(row => row.id), ['a', 'b', 'c']); assert.deepEqual(input, before); assert.notEqual(rankWithStableId(input), input);}assert.throws(() => rankWithStableId([a, { ...a }]), TypeError);assert.throws(() => rankWithStableId([{ id: 'a\n', score: 1 }]), TypeError);assert.throws(() => rankWithStableId([{ id: 'a', score: NaN }]), TypeError);assert.throws(() => rankWithStableId(new Array(1)), TypeError);assert.deepEqual(rankWithStableId([]), []);console.log('同分固定 ID、重複值與原陣列檢查通過');同一份資料集合即使換輸入順序,現在也得到相同 ID 順序;若資料會跨頁載入,後端分頁也需要採相同排序契約,前端只重排目前這一頁無法修正全域排名。範例限一般資料物件,不處理會在讀取時改變數值的 getter 或 Proxy。
此獨立範例及原有排序範例由維護流程於 2026-10-29 在 macOS、Node.js v24.13.0 執行通過;沒有測真實 API 分頁、資料庫 collation、瀏覽器 UI 或效能。
名稱含數字時,用 Collator,但別假設只回傳 1 或 -1
技術 ID 的碼元排序不能直接代替使用者看到的名稱排序。例如 item-10 與 item-2,若希望名稱中的數字依數值比較,可建立一次 Intl.Collator('zh-TW', { numeric: true }),將其 compare 交給排序。這不會解析商品價格或版本號,也不是自然語言排序政策的通用答案。
MDN Collator 文件提醒比較結果只保證正負與零,不能要求剛好等於 -1 或 1。同樣不要包成 collator.compare(a, b) > 0,那會回到本文開頭的布林比較器問題。
下面是獨立範例,固定語系與選項,並以唯一 ASCII ID 處理名稱比較相等的情況;輸入的唯一 ID、密集陣列與字串名稱,須由資料邊界預先驗證:
import assert from 'node:assert/strict';
assert.ok(Intl.Collator.supportedLocalesOf(['zh-TW']).length === 1);const collator = new Intl.Collator('zh-TW', { usage: 'sort', numeric: true, sensitivity: 'variant',});const compare = (a, b) => { const nameOrder = collator.compare(a.name, b.name); if (nameOrder !== 0) return nameOrder; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;};assert.ok(collator.compare('item-2', 'item-10') < 0);assert.ok(collator.compare('item-10', 'item-2') > 0);const a = { id: 'a', name: 'item-2' };const b = { id: 'b', name: 'item-2' };const c = { id: 'c', name: 'item-10' };for (const input of [[c, b, a], [b, a, c], [a, c, b]]) { const before = [...input]; const result = [...input].sort(compare); assert.deepEqual(result.map(row => row.id), ['a', 'b', 'c']); assert.deepEqual(input, before); assert.equal(result[0], a);}console.log('Collator 數字名稱、同名次排序與原陣列檢查通過');2026-11-03 維護流程在 macOS、Node.js v24.13.0 執行此範例及原有排序檢查通過。範例只斷言數字名稱與同名 ID,未斷言完整中文字庫的固定排列。不同 runtime 的語系資料版本可能影響結果;需要跨環境一致、可供分頁的順序時,應由負責全域排序的後端固定 collation 與次排序規則,不能把瀏覽器排序結果直接視為資料庫順序。
新陣列與新物件要分開驗收
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、同分政策及原資料是否被修改。
參考資料:
MDN:Array.prototype.toSorted()