我对一个数字数组调了 sort() 排序,结果 10 竟然排在了 2 的前面,整个榜单顺序全乱,我对着 JavaScript 的 sort 默认按字符串字典序排序这个坑排查了大半天的复盘
这是一个堪称 JavaScript "新手必踩、老手偶尔也栽"的经典坑。它让我深刻体会到:有些 API 的"默认行为",和你的直觉之间,隔着一条能让人栽大跟头的鸿沟。
事情起于一个排行榜功能。我需要把一组用户的积分,从小到大(或从大到小)排好序展示。积分是数字,数组在手,我想都没想就调用了那个我以为最理所当然的方法:
// 一组积分, 我要给它排序
const scores = [1, 2, 10, 21, 3, 100, 25];
const sorted = scores.sort(); // 排序! 简单!
console.log(sorted);
// 我期望的(从小到大):
// [1, 2, 3, 10, 21, 25, 100]
// 实际打印出来的:
// [1, 10, 100, 2, 21, 25, 3] // 💥 这是什么鬼顺序?!
我盯着这个 [1, 10, 100, 2, 21, 25, 3] 看了半天,大脑宕机。这顺序毫无道理可言:1 之后竟然是 10、100,然后才是 2;3 居然被排到了最后。它既不是从小到大,也不是从大到小,看起来就像是被随机打乱了一样。可排序怎么会"随机"?我换了几组数字试,规律倒是有,但就是不对——只要数字位数不一样,顺序就乱套。线上的排行榜,因为这个,展示出来的名次完全是错的。
第一件事:看清真相——sort() 默认把元素转成字符串,按字典序比较
我带着满腹狐疑去翻了 MDN 上 Array.prototype.sort 的文档,看到那句关键的描述时,我才恍然大悟,又哭笑不得——原来 sort() 在不传参数时,默认是把每个元素转换成字符串,然后按字符串的字典序(Unicode 码点顺序)来比较的。
sort() 默认行为的真相
# MDN 原文(关键): "If compareFunction is not supplied, all non-undefined
# array elements are sorted by converting them to strings and comparing
# strings in UTF-16 code units order."
# 翻译: 如果不传比较函数, 所有元素会被【转成字符串】, 再按【字符串的
# UTF-16 码点顺序(字典序)】比较。
# 所以 [1, 2, 10, 21, 3, 100, 25].sort() 实际是在比这些【字符串】:
# "1", "2", "10", "21", "3", "100", "25"
# 字符串字典序怎么比? 像查字典/排英文单词那样, 【逐个字符】从左往右比:
# - 先比第一个字符: "1"开头的 < "2"开头的 < "3"开头的 ...
# - 第一个字符相同, 再比第二个字符
# → 所以 "1" < "10" < "100" < "2" < "21" < "25" < "3"
# (因为 "10" 的第一个字符 '1' 比 "2" 的 '2' 小, 所以 "10" 排在 "2" 前!)
# 这就是为什么:
# - 10、100 排在了 2 前面 (都以'1'开头, 字典序里'1' < '2')
# - 3 排到了最后 ('3' 这个首字符最大)
# 同样的坑也坑负数、小数:
# [10, 9, 100, 1].sort() → [1, 10, 100, 9] ('9'首字符最大排最后)
# 核心: sort() 不传比较函数时, 是按"元素转字符串后的字典序"排, 而【不是】按数值大小;
# 所以对数字数组直接 sort(), 只要位数不齐, 结果就是错的(像"10 < 2"这种)。
真相大白,我又好气又好笑。原来 sort() 不传比较函数时,根本不是按数值大小排序的,而是把每个元素转成字符串、再按字符串字典序(逐字符比 Unicode 码点,像排英文单词那样)来排。所以我那个数字数组,其实是在比 "1"、"2"、"10"、"100" 这些字符串:字典序里逐字符比第一个字符,"10" 和 "100" 的首字符是 '1',比 "2" 的首字符 '2' 小,所以 "10"、"100" 就排到了 "2" 前面;而 "3" 的首字符 '3' 最大,就被排到了最后。这就完美解释了那个"毫无道理"的顺序——它其实有严格的道理,只不过是"字符串字典序"的道理,而不是我以为的"数值大小"的道理。难怪只要数字位数不一样,顺序就乱套。
第二件事:正解——给 sort 传一个比较函数
搞懂了原理,正解就清晰了:给 sort 传一个"比较函数",明确告诉它"两个元素谁大谁小、按什么规则排"。
const scores = [1, 2, 10, 21, 3, 100, 25];
// ====== 正解一: 数字升序(从小到大) ======
const asc = [...scores].sort((a, b) => a - b);
console.log(asc); // [1, 2, 3, 10, 21, 25, 100] ✓ 正确!
// ====== 正解二: 数字降序(从大到小) ======
const desc = [...scores].sort((a, b) => b - a);
console.log(desc); // [100, 25, 21, 10, 3, 2, 1] ✓
// ====== 比较函数的规则(必须记住) ======
// sort((a, b) => ...) 的返回值含义:
// - 返回【负数】: a 排在 b 前面(a "更小")
// - 返回【正数】: a 排在 b 后面(a "更大")
// - 返回【0】: a、b 顺序不变(相等)
// 所以 a - b: a a.score - b.score); // 按 score 升序
// → [{B:2}, {A:10}, {C:100}] ✓
// ====== 字符串排序: 默认字典序通常就对, 但本地化更稳 ======
const names = ['banana', 'Apple', 'cherry'];
names.sort(); // 默认: 大写字母码点比小写小, 'Apple'会排最前
names.sort((a, b) => a.localeCompare(b)); // ✓ 更符合人类语言习惯(还能处理中文/重音)
// ====== ⚠️ sort 会【原地修改】原数组, 还返回它自己! ======
const arr = [3, 1, 2];
const r = arr.sort((a,b)=>a-b);
console.log(arr); // [1,2,3] ← 原数组被改了!
console.log(r === arr); // true ← 返回的就是原数组
// 不想改原数组: 先复制 [...arr].sort(...) 或 arr.toSorted(...)(ES2023)
// 核心: 排数字必须传比较函数 sort((a,b)=>a-b)(升序)/(b-a)(降序);
// 返回负数a在前、正数a在后; sort会原地改原数组, 别想改原数组就先[...arr]复制。
修复的核心,是"用比较函数,把'按什么排'明确告诉 sort,而不是依赖它那个反直觉的默认"。正解一/二:数字升序 sort((a,b)=>a-b)、降序 sort((a,b)=>b-a)——一行搞定。关键是记住比较函数的规则:返回负数 a 排前面(a 更小)、返回正数 a 排后面(a 更大)、返回 0 顺序不变;所以 a-b 在 a<b 时为负(a 在前)即升序,b-a 即降序。按对象字段排用 (a,b)=>a.score-b.score;字符串排序默认字典序常常够用,但 localeCompare 更符合人类语言习惯(还能处理中文/重音/大小写)。还有一个极易忽略的点:sort 会"原地修改"原数组,还把原数组返回回来——不想改原数组,就先 [...arr].sort(...) 复制,或用 ES2023 的 arr.toSorted(...)。归根结底:排数字必须传比较函数 (a,b)=>a-b/(b-a);返回负数 a 在前、正数 a 在后;sort 原地改原数组,要保留原数组就先复制。
第三件事:JavaScript 里其他"反直觉的默认/隐式行为"
排查后我把 JS 里其他类似"默认行为反直觉、隐式转换坑人"的地方也系统梳理了一遍,它们和 sort 一样,都源自"JS 太爱帮你做隐式转换/有反直觉的默认"。
JS 其他反直觉的默认/隐式行为
# 1. sort() 默认字典序(本文): 数字排序乱套。→ 传 (a,b)=>a-b。
# 2. == 的隐式类型转换: 0 == '', 0 == '0', '' == '0' 结果诡异
# [] == ![] → true (!!)。→ 一律用 === / !== (不做隐式转换)。
# 3. 浮点数精度: 0.1 + 0.2 === 0.3 → false (是0.30000000000000004)
# → 比较用 Math.abs(a-b) < Number.EPSILON; 金额用整数(分)或专门库。
# 4. parseInt 的隐式: parseInt('08')老引擎当八进制; [1,2,3].map(parseInt)
# → [1, NaN, NaN] (map传了index当第二参radix!)。→ map(Number)。
# 5. typeof null === 'object': 历史遗留 bug。→ 判 null 用 x === null。
# 6. NaN !== NaN: NaN 不等于自己。→ 判断用 Number.isNaN(x)。
# 7. 数组空位/length: arr[10]=1 会让 length 变 11(中间是空位)。
# 8. + 号的重载: 1 + '2' = '12'(字符串拼接), 1 - '2' = -1(数字)
# → + 遇到字符串就拼接, 极易因隐式转换出错。
# 共同根源: JS 为了"宽容/方便", 大量使用【隐式类型转换】和【反直觉的默认行为】;
# 它们让代码"看起来能跑", 却在边界情况给出令你意外的结果。
# 核心: JS 充满隐式转换和反直觉默认; 写JS要警惕"它是不是偷偷帮我转了类型/用了
# 我没料到的默认规则", 多用严格的 === / 显式转换 / 明确的比较函数, 少依赖隐式。
排查让我把 JS 这类反直觉行为都梳理清了。一、sort 默认字典序(本文)。二、== 的隐式类型转换([] == ![] 竟为 true,一律用 ===)。三、浮点数精度(0.1+0.2 !== 0.3)。四、parseInt 的隐式([1,2,3].map(parseInt) 得 [1,NaN,NaN],因 map 把 index 当成了 radix)。五、typeof null === 'object'。六、NaN !== NaN(用 Number.isNaN)。七、数组空位。八、+ 号的重载(1+'2'='12')。它们的共同根源是:JS 为了"宽容/方便",大量使用隐式类型转换和反直觉的默认行为;它们让代码"看起来能跑",却在边界情况给出令你意外的结果。核心是:写 JS 要警惕"它是不是偷偷帮我转了类型/用了我没料到的默认规则",多用严格的 === / 显式转换 / 明确的比较函数,少依赖隐式。下面这张图,是这次 sort 排序乱套的成因与解法:
第四件事:sort 比较函数速查表
这次踩坑后,我把常用的 sort 比较函数整理成一张表,排序时照着抄。
| 需求 | 比较函数 | 说明 |
|---|---|---|
| 数字升序 | (a,b) => a - b | 最常用, 务必记住 |
| 数字降序 | (a,b) => b - a | a、b 互换位置 |
| 按对象数字字段 | (a,b) => a.score - b.score | 取出字段相减 |
| 按对象字符串字段 | (a,b) => a.name.localeCompare(b.name) | 本地化, 支持中文 |
| 字符串升序 | (a,b) => a.localeCompare(b) | 比默认更可靠 |
| 多级排序 | (a,b) => a.x - b.x || a.y - b.y | 先按x, x相等再按y |
| 布尔/真值优先 | (a,b) => (b.vip?1:0) - (a.vip?1:0) | vip 排前面 |
这张表把常见排序需求都覆盖了。核心模式就一个:比较函数返回一个数,负数让 a 靠前、正数让 a 靠后;数字相减(a-b/b-a)、字符串用 localeCompare、多级排序用 || 串联。它给我的最大启发是:"排序"这个操作,本质上需要的不是"数据",而是"一个明确的'大小关系'定义";sort 本身只负责"按某种顺序排"的算法,而"到底什么算大、什么算小"这个语义,必须由你通过比较函数明确告诉它。这其实是一个很优雅的设计思想——"策略与机制分离":sort 提供"排序的机制"(高效的排序算法),而把"排序的策略"(比较规则)作为参数交给调用者;正是这种分离,让同一个 sort 能排数字、排字符串、排对象、排任意复杂的多级规则——你只要换一个比较函数,就换了一套排序逻辑。我最初的错,正是忽略了"我必须提供策略"这个责任,以为 sort 能自己猜出我想要数值排序;而 JS 在我没提供策略时,用了一个"万能但常常不是你要的"默认策略(转字符串比)。看清"许多通用函数都把'具体策略'通过回调交给你定",并意识到"提供正确的策略是我的责任",是用好这类高阶 API 的关键。
第五件事:稳定性与常见陷阱
关于 sort,还有几个进阶的点我也一并梳理了,免得日后再踩。
| 点 | 说明 |
|---|---|
| 比较函数必须返回数字 | 返回 true/false 是错的(被当0或1), 排序不对 |
| 返回值要"自洽" | a<b返负、a>b返正必须一致, 否则结果未定义 |
| 稳定性 | 现代引擎 sort 稳定(相等元素保持原序), 可放心多级 |
| 原地修改 | sort 改原数组; React等场景会埋坑, 先复制 |
| 大数相减溢出 | 极大数 a-b 理论可溢出, 一般无碍, 极端用比较式 |
| NaN/undefined | 含 NaN/undefined 的数组排序结果可能怪, 先过滤 |
这张表把 sort 的进阶陷阱也点清了。核心是:比较函数必须返回数字(返 true/false 是错的)、返回值规则必须自洽(a<b 返负、a>b 返正要一致),否则排序结果是"未定义"的;sort 原地修改原数组(在 React 等"不可变"场景要先复制);含 NaN/undefined 要先过滤。它给我的启发,超越了 sort 本身:当你给一个通用算法/框架传入"回调/策略"时,这个回调往往需要满足某种"契约(contract)"——比如比较函数的"返回数字、且大小关系自洽";如果你违反了这个契约(返回布尔、规则矛盾),算法不会报错,但会给出"未定义的、看似随机的"错误结果,极难排查。这让我领悟到一个重要的工程意识:用任何接受回调的 API 时,都要去搞清楚"它对我传入的回调有什么要求/契约"——参数是什么、该返回什么、有什么约束;很多诡异的 bug,不是 API 本身的错,而是我们传入的回调悄悄违反了它的契约;遵守契约,是让"你的策略"和"框架的机制"正确协作的前提。读清并遵守回调的契约——这是这个排序坑,教给我的关于"如何正确地与通用 API 协作"的一课。
第六件事:用任何 API 时,我现在的判断习惯
现在每当我调用一个不太熟、或"看起来理所当然"的 API,我都会按这张图先想清楚:
这张图的精髓,是"别想当然,先确认 API 的默认行为和副作用,不符合就显式改变它"。不清楚默认行为就查文档/写最小测试确认;默认符合需求才直接用,不符合(如 sort 默认字典序)就传参数/比较函数明确指定我要的。还要留意副作用:它会不会改原数组/原对象,会就先复制或用不可变版本。这套习惯,让我用 API 时,从"凭直觉想当然地调"变成了"先确认默认行为和副作用再用"——核心始终是:API 的默认行为常和直觉不符,排序这种要显式传规则;调用前先搞清默认是什么、有无副作用。
我立下的几条规矩
这场"sort 排序乱套"的事故,换来了我写 JavaScript 时,刻进骨子里的几条铁律:
- sort 排数字必须传比较函数。(a,b)=>a-b 升序,(b-a) 降序,绝不裸调 sort()。
- sort 默认按字符串字典序。记住这个反直觉默认,才不会再栽。
- 比较函数返回数字。负数 a 在前、正数 a 在后、0 不变;别返布尔。
- sort 原地改原数组。要保留原数组先 [...arr] 或 toSorted。
- JS 隐式转换处处是坑。用 ===、显式转换、Number.isNaN,少依赖隐式。
- 不熟的 API 先查默认行为。别凭"理所当然"的直觉调用。
- 用回调的 API 要读清契约。它要我返回什么、有什么约束。
附:一个我封装的"安全排序"小工具
这次踩坑后,我把"排序"这件事封装成了一组语义清晰的小工具,项目里排序都走它们,从此再不会有人裸调 sort() 把名次排乱:
/**
* 安全排序工具集: 语义清晰、不改原数组、专治各种"裸调sort"的坑。
* 全部返回【新数组】(用 [...arr] 复制), 不修改原数组。
*/
// 数字升序
const sortAsc = (arr) => [...arr].sort((a, b) => a - b);
// 数字降序
const sortDesc = (arr) => [...arr].sort((a, b) => b - a);
// 按对象的某个数字字段排序(key 为字段名, desc 是否降序)
const sortByNumber = (arr, key, desc = false) =>
[...arr].sort((a, b) => (desc ? b[key] - a[key] : a[key] - b[key]));
// 按对象的某个字符串字段排序(本地化, 支持中文)
const sortByString = (arr, key, desc = false) =>
[...arr].sort((a, b) => {
const r = String(a[key]).localeCompare(String(b[key]));
return desc ? -r : r;
});
// 多级排序: 传一组 {key, desc, type} 规则, 依次比较
const sortBy = (arr, rules) =>
[...arr].sort((a, b) => {
for (const { key, desc = false, type = 'number' } of rules) {
let r = type === 'string'
? String(a[key]).localeCompare(String(b[key]))
: a[key] - b[key];
if (desc) r = -r;
if (r !== 0) return r; // 这一级分出胜负就返回, 否则比下一级
}
return 0;
});
// ====== 使用 ======
sortAsc([1, 10, 2, 21, 3]); // [1,2,3,10,21] ✓
sortByNumber(users, 'score', true); // 按积分降序(排行榜!)
sortBy(players, [ // 先按段位降序, 同段位按胜场降序
{ key: 'rank', desc: true },
{ key: 'wins', desc: true },
]);
// 核心: 把排序封装成"语义化、不改原数组、内置正确比较规则"的工具;
// 调用处只表达"我要按什么排", 不再关心 a-b 的细节, 更不会裸调 sort() 踩坑。
这组小小的排序工具,是我这次踩坑后最实用的产出之一。它把我血泪换来的所有教训——"排数字要传比较函数""字符串要 localeCompare""sort 会改原数组要先复制""多级排序怎么写"——全都固化进了几个语义清晰的函数里;此后项目里任何人要排序,只需调用 sortAsc、sortByNumber(arr, 'score', true) 这样一目了然的函数,在调用处只表达"我要按什么排"的意图,而完全不必再关心 a-b 的细节,更不可能再裸调 sort() 把名次排乱。这正是我想用这个封装,留给自己也分享给你的核心思想:当一个"看似简单"的操作,实际暗藏了容易出错的细节和反直觉的默认时,最好的应对,不是寄希望于"每个人每次都记得正确地用",而是把"正确的用法"封装成一个语义化的、自带正确默认的工具,让"正确"成为最容易、最自然的选择。因为"靠纪律/记忆去避免重复踩坑"是不可靠的(人总会忘、新人总会不知道),而"用封装把坑从源头堵死、让正确的路成为唯一的路"才是可靠的;一个好的封装,不只是复用代码,更是在"固化团队踩坑换来的知识"、在"用设计防止错误再次发生"。把踩坑换来的正确姿势,沉淀成"让人不会用错"的工具——这,是我用一次排序的事故,换来的、关于如何"系统性地、而非靠运气地避免重复犯错"的、最实用的工程智慧。
补充:为什么 JavaScript 当初要这样设计 sort
排查完之后,我还多想了一步:JavaScript 为什么要把 sort 的默认设计成"按字符串排"这么反直觉的样子?理解了这背后的历史缘由,我对这个坑、乃至对 JS 这门语言,都多了一分释然和理解。
答案要回到 JavaScript 诞生的年代。JS 是一门"弱类型"语言,数组里可以混装任何类型——数字、字符串、对象、布尔,什么都能放在同一个数组里。在这种背景下,sort 面临一个难题:当它拿到一个数组、又没有任何额外信息时,它无法假设里面装的一定是数字(也可能是字符串、是混合类型)。于是设计者选择了一个"对任何类型都能给出一个确定结果"的万能默认:把所有元素都转成字符串再比较——因为任何值都能转成字符串,这样 sort 在面对任意数组时,都不会崩、总能排出一个稳定的顺序。这是一个"为了通用性/健壮性,而牺牲了对最常见场景(数字排序)的直觉友好性"的设计权衡。它不是 bug,而是那个特定历史背景和设计哲学下的、可以理解的选择——只是这个选择,让今天以"排数字"为最常见需求的我们,频频踩坑。
这个"考古"让我领悟到一个超越 sort 本身的认知:很多语言/框架里那些"看起来奇怪、反直觉"的设计,背后往往都有其特定的历史背景、约束条件或设计权衡;它们在当时的语境下,常常是合理的,只是随着语言的演进、使用场景的变迁,显得"过时"或"别扭"了。理解这一点,会让我们在面对这些"坑"时,心态从"这什么破设计"的烦躁,转向"哦,原来当初是这样考虑的"的理解——而这种理解,不仅能让我们记得更牢(知道"为什么"远比死记"是什么"牢固),还能让我们更好地预判同类设计的行为,甚至在自己做设计时,更审慎地权衡"通用性"与"对常见场景的友好性"。带着一点"理解其历史与权衡"的同理心去看待我们使用的工具——这是这个古老的 sort 坑,在技术之外,给我的一点额外收获。也正因如此,现代 JS 才补充了 toSorted 这样不改原数组的新方法,语言始终在自我修正、向更友好的方向演进。
写在最后
回头看,这场由 sort() 引发的、排行榜名次全错的事故,真正教给我的,远不止"排数字要传比较函数"这一个技巧。它让我对"如何与一个 API 打交道"这件事,有了一次认知上的重塑。我栽跟头,根源是一种危险的思维惯性:我对一个"名字听起来含义明确"的方法(sort = 排序),想当然地用我的直觉(排序当然是按大小排啊)去假设它的行为,而没有去确认它真实的、文档定义的默认行为。我以为 sort() 会读懂我的心意按数值排,可它其实有一套自己的、明确写在文档里的、却和我直觉相悖的默认规则(转字符串按字典序)。这让我领悟到一个深刻的认知:"一个 API 的名字/它看起来该做什么",和"它实际、精确地做什么",是两回事;前者是我的"主观期待",后者是它的"客观契约";而 bug,常常就诞生在这两者的缝隙里——我按我的期待去用它,它按它的契约去执行。这其实是一个普遍的工程教训:对待我们使用的工具、库、API,要带着一种"谦逊的精确"——不轻易用直觉去假设它的行为,而是去阅读它的文档、确认它的精确语义(默认值、边界、副作用、对回调的契约);尤其对那些"名字简单、看起来理所当然"的东西,更要警惕,因为正是这种"理所当然"的轻信,最容易让我们跳过确认、直接栽进它和直觉不符的细节里。用"读文档确认精确语义"代替"凭名字想当然"——这,是我用一次排行榜全错的事故,换来的、关于 JavaScript、也关于如何与一切 API 严谨协作的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次对一个数组裸调 sort() 前,先想起"它默认是按字符串排的",那我对着那个 10 排在 2 前面的诡异榜单挠头的这大半天,就值了。
—— 别看了 · 2026