我的数字数组用 sort() 排完序居然变成了乱序,[1,2,10,9] 排成了 [1,10,2,9],我盯着这串诡异的结果查了大半天才反应过来的深度复盘
这是一个让我对"默认行为"彻底改观的故事。我在做一个排行榜功能,需要把一组分数(数字数组)从小到大排个序。我顺手就写了 scores.sort()——在我朴素的认知里,sort() 嘛,排序嘛,数字数组排出来,当然该是从小到大的顺序。可页面一渲染,排行榜的顺序乱得一塌糊涂:本该排在前面的小分数,跑到了后面;本该靠后的大分数,反而冒到了前头。我把数组打印出来一看,惊呆了:[1, 2, 10, 9] 这个数组,sort() 之后,竟然变成了 [1, 10, 2, 9]!
我当时整个人都是懵的:10 怎么会排在 2 的前面?这是哪门子的"从小到大"?我一度怀疑是不是 sort() 有 bug,甚至怀疑是不是我的数据本身就是乱的。我反复打印、反复确认,数据没问题、sort() 也确确实实被调用了。直到我去翻了 MDN 文档,才恍然大悟——原来,JavaScript 的 Array.prototype.sort(),在你不传比较函数的时候,它的默认行为,并不是"按数字大小排序",而是:把数组里的每个元素,先转换成字符串,然后按字符串的 Unicode 码点顺序来排序!所以,[1, 2, 10, 9] 被当成了字符串 "1"、"2"、"10"、"9" 来比较:字符串比较是逐字符比的,"10" 的第一个字符是 "1",而 "2" 的第一个字符是 "2","1" 的码点小于 "2",所以 "10" 就排在了 "2" 的前面——于是,就有了 [1, 10, 2, 9] 这个在数字意义上完全错乱、但在"字符串排序"意义上完全正确的诡异结果。我一直想当然地以为的"sort() 默认按数字排",根本就是我自己脑补出来的;它真实的默认行为,是"按字符串排"。
故障现场:[1,2,10,9].sort() 为什么是 [1,10,2,9]
我把这个"诡异"的现象,和它背后的原因,用代码摊开给你看:
// 现象: 数字数组, sort() 之后顺序错乱
const scores = [1, 2, 10, 9];
scores.sort();
console.log(scores); // [1, 10, 2, 9] ← ??? 10 怎么跑到 2 前面了?
// 更夸张的例子:
console.log([100, 25, 3, 40].sort()); // [100, 25, 3, 40] → ["100","25","3","40"]
// 排序后: [100, 25, 3, 40] 变成 [100, 25, 3, 40]?
// 实际: [100, 25, 3, 40].sort() === [100, 25, 3, 40]
// 按字符串: "100" < "25" < "3" < "40" → [100, 25, 3, 40]
// 真相: sort() 不传比较函数时, 默认把元素"转成字符串", 按 Unicode 码点排序!
// [1, 2, 10, 9] → 当成 ["1", "2", "10", "9"]
// 字符串比较是"逐字符"比的:
// "10" vs "2": 先比第一个字符 "1" vs "2" → "1"(码点49) < "2"(码点50)
// → 所以 "10" < "2" → 10 排在了 2 前面!
// 最终按字符串序: "1" < "10" < "2" < "9" → [1, 10, 2, 9]
// 这不是 bug, 这是 sort() 被明确定义的默认行为(ECMAScript 规范如此)。
看着这段分析,我才算真正搞懂了这个"诡异"结果的来龙去脉。问题的核心,是我对 Array.prototype.sort() 的默认行为,有一个根深蒂固的错误假设:我一直以为,sort() 不传参数时,会"智能地"按元素的自然顺序排——数字按大小、字符串按字典序。可事实是,sort() 的默认行为,被 ECMAScript 规范明确地定义为:把每个元素转换成字符串,然后按字符串的 Unicode 码点(code unit)顺序进行比较和排序。它根本不管你的元素本来是不是数字——只要你没传比较函数,它就一律先 String() 一下,再按字符串比。于是,我的数字数组 [1, 2, 10, 9],被它当成了字符串 ["1", "2", "10", "9"];而字符串的比较,是逐字符进行的:比较 "10" 和 "2",它先比第一个字符——"1" 的 Unicode 码点是 49,"2" 的码点是 50,49 < 50,所以 "10" 整个就被判定为"小于" "2",排到了它前面。最终,按字符串序排出来,就是 "1" < "10" < "2" < "9",也就是 [1, 10, 2, 9]。这个结果,在"数字排序"的意义上是完全错的,但在"字符串排序"的意义上,却是完全正确的——sort() 忠实地、严格地,执行了它被定义的默认行为。错的不是 sort(),错的是我那个"sort() 默认按数字排"的、想当然的假设。
第一件事:搞懂 sort() 默认是"按字符串 Unicode 码点"排序
定位到根源,我必须把 sort() 的默认排序规则,彻底搞清楚、记牢:
// sort() 默认排序规则(不传比较函数时):
// 规则: 把每个元素转成字符串, 按 UTF-16 码元顺序逐字符比较
// [元素1, 元素2, ...] → [String(元素1), String(元素2), ...] → 按字符串排
// 例子, 全是"反直觉"的:
console.log([1, 2, 10, 9].sort()); // [1, 10, 2, 9] (不是 [1,2,9,10])
console.log([5, 1, 100, 20].sort()); // [1, 100, 20, 5] (不是 [1,5,20,100])
console.log([-1, -10, -2].sort()); // [-1, -10, -2] (负号也按字符比, 更乱)
// 字符串数组, 默认排序也有坑(大小写、中文):
console.log(["banana", "Apple", "cherry"].sort());
// → ["Apple", "banana", "cherry"]
// 大写字母码点(65-90) < 小写字母码点(97-122), 所以 "Apple" 排在了最前!
// (不是你以为的不分大小写的字母序)
console.log(["b", "a", "中", "1"].sort());
// → ["1", "a", "b", "中"]
// 数字字符 < 大写 < 小写 < 中文(中文码点很大), 按码点排
// 关键认知:
// 1. sort() 默认 = "按字符串码点排", 不是"按自然大小排"!
// 2. 它会"就地(in-place)"修改原数组, 并返回这个数组(同一个引用)。
// 3. 想按别的规则排(数字、自定义), 必须自己传"比较函数"。
原理终于刻进脑子里了。sort() 不传比较函数时的默认行为,就一句话:把每个元素转成字符串,按 UTF-16 码元顺序,逐字符比较。这个规则,会导致一连串"反直觉"的结果:数字数组 [5, 1, 100, 20] 会排成 [1, 100, 20, 5](因为按字符串 "1" < "100" < "20" < "5");带负号的数字更乱;就连字符串数组,默认排序也有坑——["banana", "Apple", "cherry"] 会排成 ["Apple", "banana", "cherry"],因为大写字母的码点(65-90)小于小写字母(97-122),所以首字母大写的 "Apple" 反而排到了最前面(而不是你以为的、不分大小写的字母序);中文字符的码点很大,会被排到最后。而除了"按字符串排"这个核心认知,我还顺带搞清了 sort() 的另外两个重要特性:第一,它是就地(in-place)排序的——它会直接修改原数组,并返回这个被修改后的数组(返回的和原数组是同一个引用),而不是返回一个新的、排好序的数组;第二,如果你想按"数字大小"或任何"自定义规则"来排,你必须自己给它传一个"比较函数"。这三点——默认按字符串排、就地修改、自定义靠比较函数——是我这次用血泪,给 sort() 补上的、本该一开始就掌握的基础课。
第二件事:正解——给 sort() 传一个比较函数
搞懂了根因——"默认按字符串排"——正解就一目了然了:想按数字大小排序,就给 sort() 传一个"比较函数"。比较函数接收两个元素 (a, b),你通过它的返回值,告诉 sort() 这两个元素谁该排前面:返回负数表示 a 排在 b 前,返回正数表示 a 排在 b 后,返回 0 表示两者相等。所以,数字升序,就写 (a, b) => a - b。
// 正解: 传比较函数, 明确告诉 sort() 怎么比
// 数字升序(从小到大):
const scores = [1, 2, 10, 9];
scores.sort((a, b) => a - b);
console.log(scores); // [1, 2, 9, 10] ✓ 正确了!
// 数字降序(从大到小):
console.log([1, 2, 10, 9].sort((a, b) => b - a)); // [10, 9, 2, 1] ✓
// 比较函数的返回值规则(核心!):
// compare(a, b) 返回:
// < 0 (负数) → a 排在 b 前面
// > 0 (正数) → a 排在 b 后面
// = 0 → a、b 相对顺序不变(被视为相等)
// 所以 a - b: ab 时为正(a在后)。
// 按对象的某个字段排序(很常见):
const users = [{ name: "张三", age: 30 }, { name: "李四", age: 25 }];
users.sort((a, b) => a.age - b.age); // 按 age 升序
console.log(users); // 李四(25) 在前, 张三(30) 在后 ✓
// ⚠️ 注意: 比较函数必须返回"数字", 别返回布尔值!
// ✗ scores.sort((a, b) => a > b); // 返回 true/false, 行为不可靠(被当 1/0)!
// ✓ scores.sort((a, b) => a - b); // 返回数字, 正确
这个正解的核心,是理解"比较函数"是 sort() 留给你的、用来自定义排序规则的接口。比较函数接收两个待比较的元素 (a, b),它的返回值(一个数字),决定了这两个元素的相对顺序:返回负数,sort() 就把 a 排在 b 前面;返回正数,就把 a 排在 b 后面;返回 0,就认为两者相等、保持原有相对顺序。掌握了这个规则,各种排序就都信手拈来了:数字升序,写 (a, b) => a - b(当 a<b 时,a-b 为负,a 排前,即升序);数字降序,写 (a, b) => b - a;按对象的某个字段排,写 (a, b) => a.age - b.age。这里有一个我特意强调、也很容易踩的坑:比较函数必须返回一个数字,千万别返回布尔值!很多人想当然地写成 (a, b) => a > b——这返回的是 true/false,会被隐式转成 1/0,但它永远不会返回负数,导致排序结果在不同引擎下行为不可靠、甚至排错。正确的,永远是返回一个有正有负有零的数字,比如 a - b。我那次的错误,根源就是压根没传比较函数,以为默认就按数字排;而正解,就是老老实实传一个 (a, b) => a - b,明确地告诉 sort():我要按数字大小升序排。
下面这张图,对比了"不传比较函数"和"传比较函数"两条路径:
这张图的对比很清楚:左边红色那条,不传比较函数,sort() 把元素转成字符串、按码点比,数字数组排出 [1,10,2,9] 这种诡异乱序;右边绿色那条,传一个 (a, b) => a - b,按你定义的数字规则比,得到正确的 [1,2,9,10]。两条路的根本分野,在于你有没有显式地告诉 sort() 你想怎么排。
第三件事:sort() 还有几个容易踩的"暗坑"
填平了"默认按字符串排"这个最大的坑,我又系统排查了 sort() 的其它几个容易踩的"暗坑",一并记下来:
// sort() 的其它暗坑:
// 坑1: sort() 会"就地修改"原数组!(不是返回新数组)
const arr = [3, 1, 2];
const sorted = arr.sort((a, b) => a - b);
console.log(arr); // [1, 2, 3] ← 原数组被改了!
console.log(sorted === arr); // true ← 返回的就是原数组本身
// → 如果不想改原数组, 先复制: [...arr].sort(...) 或 arr.slice().sort(...)
// (ES2023 的 arr.toSorted((a,b)=>a-b) 会返回新数组, 不改原数组)
// 坑2: 比较函数返回布尔值(前面提过, 重要):
// ✗ (a, b) => a > b // 错! 返回 true/false
// ✓ (a, b) => a - b // 对! 返回数字
// 坑3: 对超大数字用 a - b 可能溢出(极少见, 但要知道):
// 若 a、b 极大, a - b 可能超出安全整数; 可改用比较:
// (a, b) => (a < b ? -1 : a > b ? 1 : 0)
// 坑4: 字符串数组排序, 中文/大小写/本地化, 用 localeCompare 更准:
const names = ["张三", "李四", "王五"];
names.sort((a, b) => a.localeCompare(b, "zh")); // 按中文拼音/本地规则排
console.log(["banana", "Apple"].sort((a, b) => a.localeCompare(b)));
// → ["Apple", "banana"] 按本地规则(通常不分大小写地按字母序), 更符合直觉
// 坑5: 排序稳定性: 现代引擎(ES2019+)保证 sort 是"稳定"的
// (相等元素的相对顺序保持不变), 老引擎不一定。
这一排查,让我对 sort() 的认识,从"会用"升级到了"用得稳"。除了最核心的"默认按字符串排",sort() 还有几个值得记牢的特性和坑:坑1(就地修改):sort() 会直接修改原数组,并返回原数组本身(sorted === arr 为 true)——如果你不想动原数组,要先复制一份再排([...arr].sort(...)),或者用 ES2023 新增的、会返回新数组的 toSorted()。坑2(别返回布尔值):比较函数必须返回数字,别写 a > b。坑3(超大数字相减可能溢出):极少见,但如果元素是极大的数,a - b 可能超出安全整数范围,可改用三元比较 a < b ? -1 : a > b ? 1 : 0。坑4(字符串排序用 localeCompare):排序中文、或想要"不分大小写的字母序"这种符合本地直觉的排序,用 a.localeCompare(b) 比直接比码点更准。坑5(稳定性):现代引擎(ES2019+)保证 sort() 是"稳定"的(相等元素的相对顺序不变),但老引擎不一定。这些坑共同说明:sort() 这个看似最简单的 API,背后其实藏着不少需要你明确知道的行为细节——默认按字符串排、就地修改、比较函数的返回值规则、本地化排序、稳定性。把这些都搞清楚了,才能真正地、不出意外地,用好它。
第四件事:复杂排序——多字段、中文、混合类型
填平了基础的坑,我又把项目里几个"更复杂的排序需求"梳理了一遍,把它们的正确写法,沉淀成了一份模板:
// 复杂排序的几个常见模板:
// 1. 多字段排序: 先按 A, A 相同再按 B
const list = [
{ dept: "研发", age: 30 }, { dept: "研发", age: 25 }, { dept: "市场", age: 28 },
];
list.sort((a, b) =>
a.dept.localeCompare(b.dept) // 先按部门
|| a.age - b.age // 部门相同, 再按年龄(|| 利用前者为0时取后者)
);
// 2. 中文按拼音排序: 用 localeCompare 指定 "zh"
const names = ["王五", "张三", "李四"];
names.sort((a, b) => a.localeCompare(b, "zh"));
// 3. 字符串里的数字, 想按"数值"排(自然排序):
const files = ["file2", "file10", "file1"];
files.sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }) // numeric: true 很关键!
); // → ["file1", "file2", "file10"] (而不是 file1, file10, file2)
// 4. 不想改原数组: 复制后再排, 或用 toSorted(ES2023)
const original = [3, 1, 2];
const sortedCopy = [...original].sort((a, b) => a - b); // original 不变
const sorted2 = original.toSorted((a, b) => a - b); // ES2023, 同样不改原数组
// 5. 按"计算出来的值"排序(避免重复计算可先 map):
const data = [{ v: "3" }, { v: "1" }];
data.sort((a, b) => Number(a.v) - Number(b.v)); // 字符串字段转数字再比
这一梳理,让我对"排序"这件事的掌控,从"会排数字"升级到了"什么都能排对"。几个高频的复杂排序场景,都有其固定的、正确的写法:多字段排序(先按部门、部门相同再按年龄),可以用 || 短路——a.dept.localeCompare(b.dept) || a.age - b.age,前一个比较返回 0(相等)时,|| 就会取后一个比较的结果;中文按拼音排,用 a.localeCompare(b, "zh");字符串里的数字想按数值排(file1, file2, file10 而不是 file1, file10, file2),用 localeCompare 时加上 { numeric: true } 选项;不想改原数组,先 [...arr] 复制、或用 toSorted();按字段值排但字段是字符串,记得先 Number() 转成数字再相减。把这些复杂排序的场景和写法,整理成一张速查表:
| 需求 | 正确写法 | 关键点 |
|---|---|---|
| 数字升序 | (a,b)=>a-b |
返回数字,别返回布尔 |
| 多字段 | cmpA || cmpB |
前者为 0 取后者 |
| 中文拼音 | a.localeCompare(b,"zh") |
指定 locale |
| 字符串含数字 | localeCompare(b,_,{numeric:true}) |
numeric 选项 |
| 不改原数组 | [...arr].sort() / toSorted() |
sort 就地修改 |
第五件事:别对 API 的"默认行为"想当然
这次踩坑,在认知层面给了我最大的纠偏——它让我明白,很多 bug,根源都是对 API 默认行为的"想当然"。我把这层反思,沉淀了下来:
认知纠偏: 别对 API 的"默认行为"想当然
# 我的错误: 凭"直觉/想当然", 假设了 sort() 的默认行为
# - 我以为: sort() 默认按"自然大小"排(数字按大小)
# - 实际是: sort() 默认按"字符串码点"排
# → 我从没去确认过, 全凭脑补, 于是踩坑。
# 这是一类极其普遍的 bug 来源:"对 API 行为的错误假设"
# - parseInt("08") → 你以为 8, 老版本可能按八进制(现代是10进制, 但仍易混)
# - [1,2,3].map(parseInt) → 你以为 [1,2,3], 实际 [1, NaN, NaN](map传了index)
# - 0.1 + 0.2 === 0.3 → 你以为 true, 实际 false(浮点)
# - JSON.parse(JSON.stringify(obj)) → 你以为完美深拷贝, 实际丢 Date/undefined/函数
# → 这些坑, 都源于"我以为它会这样", 而没去"确认它到底怎样"。
# 正确的习惯:
# 1. 用一个 API 前(尤其是有"默认参数/默认行为"的), 花一分钟看一眼文档,
# 确认它的默认行为、边界情况, 别凭感觉假设。
# 2. 对"行为反直觉"的结果, 别先怀疑是 bug, 先怀疑"是不是我理解错了"。
# 3. 写完, 用几个"边界数据"测一下(比如排序, 测 [1,2,10,9] 而不只是 [1,2,3])。
核心: 大多数"诡异的 bug", 不是语言/库的错, 而是我们对它的"默认行为"
想当然了。花一分钟确认, 胜过查半天的 bug。
这层反思,是这次踩坑给我最高维度的收获。复盘我的错误,根源是一个"想当然"——我凭着直觉,假设了 sort() 的默认行为是"按自然大小排",却从没去确认过它真实的默认行为(按字符串码点排)。而这,是一类极其普遍的 bug 来源:对 API 行为的错误假设。JavaScript 里这样的"想当然陷阱"比比皆是:[1,2,3].map(parseInt) 你以为得到 [1,2,3],实际是 [1, NaN, NaN](因为 map 把索引当第二个参数传给了 parseInt 的基数);0.1 + 0.2 === 0.3 你以为是 true,实际是 false(浮点);JSON.parse(JSON.stringify(obj)) 你以为是完美深拷贝,实际会丢掉 Date、undefined、函数——这些坑,无一例外,都源于"我以为它会这样",而没去"确认它到底怎样"。由此,我给自己立下了几条对治"想当然"的习惯:第一,用一个 API 之前(尤其是那种有"默认参数/默认行为"的),花一分钟看一眼文档,确认它的默认行为和边界情况,别凭感觉假设;第二,当遇到"行为反直觉"的结果时,别第一时间怀疑是语言/库的 bug,而要先怀疑"是不是我自己理解错了";第三,写完代码,用几个"边界数据"测一下(就像排序,要用 [1,2,10,9] 这种能暴露问题的数据去测,而不是只用 [1,2,3] 这种碰巧也对的数据)。归根结底:大多数"诡异的 bug",根本不是语言或库的错,而是我们对它的"默认行为",想当然了。花一分钟去确认,远胜过花半天去查一个本可避免的 bug。把"想当然"和"先确认"两种习惯对比成一张表:
| 维度 | 想当然(踩坑) | 先确认(稳) |
|---|---|---|
| 用 API 前 | 凭直觉假设行为 | 花一分钟看文档 |
| 遇反直觉结果 | 先怀疑是库的 bug | 先怀疑自己理解错 |
| 测试数据 | 只测 [1,2,3] 碰巧对 | 用 [1,2,10,9] 测边界 |
| 典型受害 | sort/map(parseInt)/浮点 | 提前知道默认行为 |
| 代价 | 查半天诡异 bug | 一分钟确认避免 |
一套"用 sort() 该怎么排"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"用 sort() 排序时,该怎么写"的决策图,贴在了团队的前端规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:排序前,先想清楚要不要保留原数组(要保留就先复制或用 toSorted);再看元素是什么类型——数字就传 (a,b)=>a-b,字符串/中文就传 localeCompare,对象就按字段比;最后,一定要用能暴露问题的边界数据(比如 [1,2,10,9])验证一遍,而不是只用碰巧也对的 [1,2,3]。这条路径里,我特意标黄了那个最关键、也是我曾经完全忽略的环节——"用边界数据验证":如果我当初哪怕用 [1,2,10,9] 测一下,就不会让那个排序错乱的 bug,堂而皇之地上了线。
我立下的几条排序与 API 使用规矩
这次"数字排出乱序"的踩坑,让我把排序、乃至"使用 API"的注意事项,认真地立成了几条规矩:
- 给数字数组排序,永远传比较函数。数字升序
(a,b)=>a-b、降序(a,b)=>b-a——绝不裸调sort()指望它按数字排。 - 比较函数永远返回数字。返回负数/正数/零,别返回布尔值(
a > b是错的)。 - 记牢
sort()就地修改原数组。不想改原数组就先[...arr]复制,或用 ES2023 的toSorted()。 - 字符串/中文排序用
localeCompare。带数字的字符串加{numeric:true},中文指定"zh",比裸比码点更符合直觉。 - 用有"默认行为"的 API 前,先确认默认行为。花一分钟看文档,别凭直觉假设——
sort、parseInt、浮点运算、JSON深拷贝,都是想当然的重灾区。 - 遇到反直觉的结果,先怀疑自己。别第一时间甩锅给语言/库的 bug,先问"是不是我理解错了它的行为"。
- 用边界数据测试。排序就用
[1,2,10,9]这种能暴露问题的数据测,别只用碰巧也对的简单数据。
写在最后
这次"我的数字数组用 sort() 排出了乱序,盯着 [1,10,2,9] 查了大半天"的经历,是我在前端路上,一次很打脸、却也很受用的成长。它教给我的,远不止"sort() 数字要传比较函数"这一条具体的技术经验,更是一种对待 API、对待"默认行为"的根本态度——别想当然。我们用的每一个 API,都有它被明确定义的行为,这个行为,可能和你的"直觉"并不一致;而绝大多数"诡异的 bug",恰恰就藏在你"以为它会这样"、它"实际却那样"的那道缝隙里。
所以,当你用一个 API、尤其是一个有"默认参数"或"默认行为"的 API 时,请别凭着感觉就上——花一分钟,看一眼文档,确认一下它的默认行为到底是什么、有哪些边界情况。就像 sort(),你只要看过一眼"它默认按字符串排",就绝不会再写出 scores.sort() 然后对着乱序的排行榜抓耳挠腮。花一分钟去确认,永远胜过花半天去查一个本可避免的 bug。愿你给数组排的每一次序,都精确无误;也愿你我,在用任何一个工具之前,都能放下"想当然",多一分"先确认"的审慎。共勉。
—— 别看了 · 2026