一个直接调用数组 sort() 给价格排序的写法,把 10 排到了 2 前面,让整个排行榜的顺序彻底乱掉:一次 JavaScript 默认排序规则的深度复盘

价格从低到高的列表,顺序却是 1,10,100,11,2,21,3——10 排在了 2 前面。根因是 JavaScript 的 Array.prototype.sort() 不传比较函数时,默认把元素转成字符串、按 Unicode 码位(字典序)比较,而非按数值:'10' 的首字符 '1' 小于 '2',于是 10 排到 2 前面。它在个位数时碰巧正确,极易躲过简单测试。本文讲透 sort 默认比较规则,给出排数字传 (a,b)=>a-b、排对象按字段、多级用 || 串联、不改原数组用拷贝/toSorted、字符串用 localeCompare 的正解,梳理 JS 反直觉的隐式转换坑,最后落到'不要假设去验证、主动记住语言的默认坑清单'的认知。

一个直接调用数组 sort() 给价格排序的写法,把 10 排到了 2 前面,让整个排行榜的顺序彻底乱掉:一次 JavaScript 默认排序规则的深度复盘

那个 bug 是测试同学截图发我的,我看了第一眼以为他在开玩笑:一个商品价格从低到高的排序列表,顺序居然是 1, 10, 100, 11, 2, 21, 3——10 排在了 2 的前面,100 排在了 11 前面。这哪是从低到高,这简直是"乱序"。可我的代码明明写的是 prices.sort(),JavaScript 内置的排序,怎么会连数字大小都排不对?我盯着这个再眼熟不过的 sort() 看了好一会,才终于想起一个被我遗忘已久的细节,后背一凉:JavaScript 的数组 sort() 方法,在不传比较函数时,默认是把所有元素先转成字符串,再按字符串的 Unicode 码位(字典序)来比较的!所以它比较 102 时,比的是字符串 "10""2"——按字典序,"1" 这个字符排在 "2" 前面,于是 "10" 就被排到了 "2" 的前面。我以为 sort() 会"聪明地"按数字大小排,可它默认压根不看数值,只看字符串这篇就把这次"数组 sort 默认字典序排序"的坑,从头到尾复盘一遍。

故障现场:一个不带比较函数的 sort()

问题代码,是一个几乎人人都写过的"给数字数组排序":

// ✗ 出问题的代码: 直接 sort(), 不传比较函数
const prices = [1, 10, 100, 11, 2, 21, 3];
prices.sort();
console.log(prices);
// 期望: [1, 2, 3, 10, 11, 21, 100]
// 实际: [1, 10, 100, 11, 2, 21, 3]   ✗ 完全乱了!

// 为什么? sort() 不传比较函数时, 默认【把元素转成字符串, 按字典序(Unicode码位)比较】:
// 比较过程相当于比字符串:
//   "1" < "10" < "100" < "11" < "2" < "21" < "3"
//   (逐字符比: "10"和"2"比, 先比第一个字符 "1"(49) vs "2"(50), "1"小 → "10"排前面)
// → 所以 10 排在了 2 前面, 100 排在了 11 前面。

// 更坑的例子:
console.log([3, 20, 100, 9].sort());     // [100, 20, 3, 9] ✗
console.log(["b", "a", "c"].sort());     // ["a","b","c"] ✓ (字符串排序碰巧是对的)
console.log([10, 9].sort());             // [10, 9] ✗ ("10" < "9")

// 甚至负数、小数也会乱:
console.log([-1, -10, -2].sort());       // [-1, -10, -2] ✗

// 关键认知: Array.prototype.sort() 默认排序 = "按字符串字典序", 不是"按数值大小"!
//   它"恰好"能正确排字符串, 但对数字(以及任何非字符串)几乎总是错的。

第一次重新认识到 sort() 的默认行为时,我哭笑不得:"一个连数字都排不对的排序方法,居然是语言内置的默认行为?"这个坑最坑人的地方在于:它在小数据、个位数"看起来是对的"——[3, 1, 2].sort() 得到 [1, 2, 3],完全正确(因为个位数的字典序和数值序恰好一致);于是你测试时随手用几个个位数一试,"没问题",就放心上线了可一旦数据里出现了两位数及以上(10、100),字典序和数值序就分道扬镳,bug 才暴露。这种"小数据下伪装正确、大数据下原形毕露"的特性,让它极易躲过简单的测试、潜入生产下面就来拆解,sort 的默认规则到底是怎么回事。

第一件事:搞懂 sort() 的默认比较规则

我认真重读了 Array.prototype.sort 的规范,才彻底理解这个"反直觉"的默认行为。

Array.prototype.sort() 的默认行为, 到底是什么?

【核心: 不传比较函数时, sort把每个元素【转成字符串】, 按【Unicode码位(字典序)】排序】

1. sort() 的两种用法:
   - arr.sort()                  → 不传比较函数, 用【默认规则】;
   - arr.sort(compareFn)         → 传一个比较函数, 按你的规则排。

2. 默认规则(不传比较函数)是怎样的:
   - 把每个元素都【转换成字符串】(调用 String(元素));
   - 然后按字符串的【Unicode码位顺序】(俗称字典序/字母序)比较;
   - → 对数字而言, [1,10,2] 变成比较 "1","10","2", 得到 "1"<"10"<"2"。

3. 为什么字典序对数字是错的:
   - 字典序是【逐字符】从左往右比的(像查字典);
   - "10" vs "2": 先比第一个字符 '1'(码位49) vs '2'(码位50), '1'小 → "10"在前;
   - 它根本【没看数值大小】, 只看"字符串第一位谁的码小";
   - → 所以 10 < 2(在字典序里), 排序全乱。

4. 比较函数 compareFn(a, b) 的约定:
   - 返回【负数】: a 排在 b 前面;
   - 返回【正数】: a 排在 b 后面;
   - 返回【0】:   a、b 顺序不变。
   - 所以数字升序: (a, b) => a - b
     (ab时为正→a在后; 正是数值升序)

5. sort 还有两个要注意的点:
   - 它是【原地排序】(in-place): 会【改变原数组】, 同时返回原数组的引用;
   - ES2019起规范要求sort【稳定】(相等元素保持原相对顺序), 旧环境可能不稳定。

一句话: sort()默认按"元素转字符串后的字典序"排, 不是按数值; 排数字必须传比较函数 (a,b)=>a-b;
   它原地修改原数组; 默认行为对数字几乎总是错的, 只是在个位数时碰巧看着对。

这套规则,是整个坑的根。sort() 不传比较函数时,把每个元素转成字符串(String(元素)),再按字符串的 Unicode 码位(字典序)比较字典序对数字是错的:它逐字符从左往右比(像查字典),"10" vs "2" 先比第一个字符 '1'(码位 49)和 '2'(码位 50),'1' 小所以 "10" 排前面——它根本没看数值大小,只看字符串第一位谁的码小。正解靠比较函数:compareFn(a,b) 返回负数则 a 在前、正数则 a 在后、0 则不变;所以数字升序就是 (a,b)=>a-b还要注意两点:sort 是原地排序(改变原数组、返回原数组引用);ES2019 起要求稳定(相等元素保持原序),旧环境可能不稳定一句话:sort() 默认按"元素转字符串后的字典序"排、不是按数值;排数字必须传 (a,b)=>a-b;它原地修改原数组;默认行为对数字几乎总错,只在个位数时碰巧看着对。

第二件事:正解——给 sort 传比较函数,并注意它原地修改原数组

搞懂了原理,正解就清晰了:排数字一定传比较函数 (a,b)=>a-b;排对象按某字段排也传对应比较函数;不想改原数组就先拷贝;字符串本地化排序用 localeCompare

// ====== 正解一: 数字排序, 传比较函数 (a, b) => a - b ======
const prices = [1, 10, 100, 11, 2, 21, 3];
prices.sort((a, b) => a - b);        // ✓ 升序
console.log(prices);                  // [1, 2, 3, 10, 11, 21, 100] ✓
prices.sort((a, b) => b - a);        // ✓ 降序 [100, 21, 11, 10, 3, 2, 1]

// ====== 正解二: 按对象的某个字段排序 ======
const products = [
  { name: "A", price: 100 },
  { name: "B", price: 9 },
  { name: "C", price: 50 },
];
products.sort((a, b) => a.price - b.price);   // ✓ 按price升序

// 多级排序: 先按价格, 价格相同再按名字
products.sort((a, b) =>
  a.price - b.price || a.name.localeCompare(b.name)
);

// ====== 正解三: 不想改原数组? 先拷贝再排(sort会改原数组!) ======
const original = [3, 1, 2];
const sorted = [...original].sort((a, b) => a - b);  // ✓ 拷贝后排, 不动original
console.log(original);   // [3, 1, 2] 没变
// ES2023+ 还有不可变版本: original.toSorted((a,b)=>a-b) 返回新数组、不改原数组

// ====== 正解四: 字符串排序(尤其含中文/带语言习惯), 用 localeCompare ======
const names = ["张三", "李四", "王五"];
names.sort((a, b) => a.localeCompare(b, "zh"));   // ✓ 按中文习惯排序
// 纯ASCII字符串 arr.sort() 默认能对, 但涉及大小写/重音/中文时用 localeCompare 更准。

// 核心: 排数字传(a,b)=>a-b; 排对象按字段传比较函数; 多级排序用 || 串联;
//   sort原地改原数组, 不想改就先[...拷贝]或用toSorted; 字符串本地化排序用localeCompare。

修复的核心,是"永远显式告诉 sort 你想怎么比"正解一:数字排序传 (a,b)=>a-b(升序)或 b-a(降序)。正解二:按对象字段排传对应比较函数(a.price-b.price),多级排序用 || 串联(价格相同再比名字)。正解三:不想改原数组就先拷贝([...arr].sort(...))或用 ES2023 的 toSorted(返回新数组、不改原数组),因为 sort 会原地修改原数组正解四:字符串(尤其含中文/大小写/重音)用 localeCompare(纯 ASCII 默认能对,涉及语言习惯时更准)。归根结底:排数字传 (a,b)=>a-b、排对象按字段传比较函数、多级排序用 || 串联、不想改原数组就拷贝或用 toSorted、字符串本地化用 localeCompare。

第三件事:JavaScript 里其他"反直觉"的隐式转换坑

排查后我把 JS 里其他和"隐式转换/默认行为反直觉"相关的常见坑也系统梳理了一遍。

JavaScript 其他反直觉的坑

# 1. sort默认字典序(本文): 排数字要传(a,b)=>a-b。

# 2. == 的隐式转换: 0 == "" 为false但 0 == "0"为true, null == undefined为true。
#    → 一律用 === 严格相等, 避开隐式转换的迷惑。

# 3. + 的字符串拼接: 1 + "2" = "12"(数字被转字符串), 但 1 - "2" = -1。→ 注意+的二义性。

# 4. NaN: NaN === NaN 为false; typeof NaN 是 "number"。→ 用 Number.isNaN 判断。

# 5. 浮点精度: 0.1 + 0.2 !== 0.3(同其他语言)。→ 钱用整数分/库, 比较用容差。

# 6. parseInt 的坑: parseInt("08") 老引擎按八进制; [1,2,3].map(parseInt) 结果诡异。
#    → parseInt 显式传基数 parseInt(x, 10); map别直接传parseInt。

# 7. 数组空洞与length: arr[100]=1 会让length变101; map/forEach跳过空洞。

# 8. typeof null === "object": 历史遗留bug。→ 判null用 x === null。

# 共同根源: JS有一套复杂的【隐式类型转换】规则和一些【为方便而设的默认行为】;
#   它们在简单场景"贴心"、在边界场景"坑人", 且很多是历史包袱无法改动。

# 核心: 了解JS这些隐式转换/默认行为的"脾气"; 用===而非==; 排序传比较函数;
#   显式胜于隐式(显式转换、显式基数、显式比较), 别依赖那些反直觉的默认。

排查让我把 JS 的其他坑也梳理清了。一、sort 默认字典序(本文)。二、== 的隐式转换(用 === 避开)。三、+ 的字符串拼接二义性四、NaN(NaN!==NaN,用 Number.isNaN)。五、浮点精度六、parseInt 的坑(显式传基数、map 别直接传 parseInt)。七、数组空洞与 length八、typeof null === "object"它们的共同根源是:JS 有一套复杂的隐式类型转换规则和一些为方便而设的默认行为;它们在简单场景"贴心"、边界场景"坑人",且很多是历史包袱无法改动核心是:了解 JS 这些隐式转换/默认行为的"脾气";用 === 而非 ==;排序传比较函数;显式胜于隐式(显式转换、显式基数、显式比较),别依赖反直觉的默认下面这张图,是这次 sort 默认排序坑的成因与解法:

第四件事:sort 比较函数速查表

这次踩坑后,我把常见排序需求对应的比较函数整理成一张表,随用随查。

需求 比较函数 说明
数字升序 (a, b) => a - b 最常用
数字降序 (a, b) => b - a 调换 a b
按对象数字字段 (a, b) => a.age - b.age 取字段相减
按对象字符串字段 (a, b) => a.name.localeCompare(b.name) 本地化
多级排序 (a, b) => a.x - b.x || a.y - b.y || 串联
字符串本地化 (a, b) => a.localeCompare(b, "zh") 中文/重音

这张表把排序比较函数钉清了。核心是:sort灵活性全在那个比较函数里——你想按什么排、升还是降、多字段怎么排,都通过这个 (a,b)=>... 显式表达;掌握"返回负数 a 在前、正数 a 在后"这一条约定,所有排序需求都能拼出来(数字相减、字符串 localeCompare、多级用 || 串联)它给我的最大启发是:很多 API 都有一个"默认行为"和一个"可定制的钩子"——sort() 的默认行为(字典序)只覆盖最狭窄的场景,而它真正的威力在那个可传入的比较函数;真正用好一个 API,不能只会它"不传参的默认调法",而要理解它留给你定制的那个口子、并在需要时显式地用它这其实是用好工具的一条通则:"默认值是给最简单情况的兜底,不是给你具体场景的答案"——遇到默认行为不符预期时,第一反应该是"这个 API 是不是有个参数/钩子能让我定制?",而不是"它怎么这么蠢"或绕着写一堆 workaround;读文档、看清 API 提供的定制能力,往往比自己硬造轮子优雅得多掌握 sort 比较函数、善用 API 留给你的定制钩子——是这个坑带给我的、关于用好 API 的认知。

第五件事:这个坑暴露的"测试覆盖"问题

这次最值得反思的,是它为什么能溜到生产。我把"为什么测试没拦住它"分析成表。

原因 说明 改进
只测了个位数 [1,2,3]个位数字典序碰巧对 测试要含两位数/多位数
没测边界值 没测10、100、负数 覆盖跨数量级的值
没断言完整顺序 只看了"排了序"没看对不对 断言完整期望数组
用例数据太"友好" 恰好挑了不会暴露bug的数 用真实/随机/极端数据
没有回归测试 改了排序没自动验证 核心逻辑加单测

这张表道出了一个比 sort 本身更重要的教训。核心是:这个 bug 能溜到生产,根子在测试用例的数据"太友好"——我们(下意识地)用了 [1,2,3] 这种恰好不会暴露 bug 的个位数据,而没用 [2,10,1] 这种能区分"字典序"和"数值序"的数据;"测了"不等于"测到了"——用例的数据选得好不好,直接决定了测试有没有真正覆盖到风险它给我的深刻启发是:写测试用例,关键不在"测了多少条",而在"每条用例的数据,能不能区分'对的实现'和'错的实现'"——一个好的测试用例,应该是"如果实现错了,它一定会失败"的;像 [1,2,3] 这种"无论字典序还是数值序结果都一样"的数据,区分不出对错,等于没测到这个点;测试要主动地、刻意地挑选那些"能让错误暴露"的数据(边界、跨量级、易混淆的值)这让我对测试有了更深的认识:测试的价值,在于它的"证伪能力"——能不能在实现有 bug 时让你知道;追求的不是"测试通过"带来的虚假安心,而是"用足够刁钻的数据去试图攻破"的真实信心用能区分对错的刁钻数据写测试、追求测试的证伪能力——是这个 sort 坑,从测试层面给我的宝贵一课。

第六件事:我现在封装了一套排序工具

为了让团队不再裸用 sort(),我封装了一套语义清晰的排序工具,把"正确的排序"变成默认、好用的选择。

// 一套语义清晰、不改原数组的排序工具
const sortBy = {
  // 数字升序(不改原数组)
  numberAsc: (arr) => [...arr].sort((a, b) => a - b),
  numberDesc: (arr) => [...arr].sort((a, b) => b - a),
  // 按对象数字字段
  byNumberField: (arr, key) => [...arr].sort((a, b) => a[key] - b[key]),
  // 按对象字符串字段(本地化)
  byStringField: (arr, key, locale = "zh") =>
    [...arr].sort((a, b) => String(a[key]).localeCompare(String(b[key]), locale)),
  // 多级排序: 传入多个 (a,b)=>number 的比较器, 依次生效
  byMulti: (arr, ...cmps) =>
    [...arr].sort((a, b) => {
      for (const cmp of cmps) {
        const r = cmp(a, b);
        if (r !== 0) return r;
      }
      return 0;
    }),
};

// 用法: 语义清晰, 还不会误改原数组
sortBy.numberAsc([2, 10, 1]);                 // [1, 2, 10] ✓
sortBy.byNumberField(products, "price");       // 按price升序
sortBy.byMulti(products,
  (a, b) => a.price - b.price,                 // 先按价格
  (a, b) => a.name.localeCompare(b.name));     // 价格同再按名字

这套工具,把"容易踩坑的裸 sort"封装成了"语义清晰、不会出错"的方法。核心是:与其让每个人去记"sort 要传比较函数、要 a-b、会改原数组"这些容易忘的细节,不如封装成 sortBy.numberAsc() 这种意图明确、内置了正确做法、且不改原数组的工具——让"排数字"这个意图,直接对应一个"一定排得对"的方法它再次印证了我反复体会的一条工程智慧:"让正确的做法成为最容易、最自然的选择"——通过封装把"正确实践"固化下来、把"易错的细节"藏进去,远比"反复叮嘱大家小心"可靠;好的封装,不只是"少写代码",更是"把对的做法变成默认、把错的做法变得难以发生"用语义化封装固化正确的排序——是这个坑从团队协作层面教我的收尾一招。下面这张图,是我现在面对排序的决策习惯:

这张图的精髓,是"排什么传对应的比较函数,要保留原数组就先拷贝"排数字/对象/字符串/多字段都显式传对应比较函数(绝不裸 sort 排数字);要保留原数组就先拷贝或用 toSorted这套习惯,让我从"排序随手 sort()"变成了"排序先想比较函数和原数组"——核心始终是:sort 默认按字典序、会改原数组;排数字必传 (a,b)=>a-b,要保留原数组就先拷贝。

我立下的几条规矩

这场"sort 默认字典序、排行榜乱套"的事故,换来了我写 JavaScript 时,刻进骨子里的几条铁律:

  1. sort() 默认按字符串字典序排,不是数值。排数字必须传 (a,b)=>a-b。
  2. sort() 原地修改原数组。不想改就先 [...拷贝] 或用 toSorted。
  3. 排对象按字段传比较函数。数字字段相减,字符串字段 localeCompare。
  4. 多级排序用 || 串联比较器。前一级相等(返回0)才看下一级。
  5. 字符串本地化排序用 localeCompare。含中文/大小写/重音时尤其要。
  6. 测试用能区分对错的数据。排序测试要含两位数、跨量级、易混淆的值。
  7. 显式胜于隐式。别依赖语言反直觉的默认行为,显式表达意图。

附:为什么这种"语言级默认坑"值得专门记住

这次之后,我专门留意了一下:像 sort 这样"语言内置默认行为反直觉"的坑,在各语言里其实成片地存在,它们有一类共同的特征,值得专门警惕。

这类"语言级默认坑"的共同特征,值得我们专门记住、专门设防。它们的共性是:一、它是"内置的、默认的"——你不会怀疑一个语言自带的基础方法会"";二、它在简单场景"看着对"——给了你虚假的信任,让你不去深究;三、它的""其实是"设计如此"——sort 默认字典序、float 不精确、== 隐式转换,都不是 bug,而是有其历史原因的、明确的设计,只是不符合你的直觉它给我的总结是:每门语言都有一份这样的"反直觉默认行为"清单(JS 尤其长)——把你常用语言的这份"坑清单"主动地学一遍、记下来(读《You Don't Know JS》这类书、看语言的"常见陷阱"总结),是一笔回报极高的投资;因为这些坑普遍、隐蔽、且会反复出现,提前知道一个,就能少踩一个这其实是从"会用一门语言"到"真正吃透一门语言"的必经之路:不仅要知道它的特性"怎么用",还要知道它的特性"在哪里会咬人"——熟悉一门语言的"阴暗面(gotchas)",和熟悉它的"闪光点(features)"同样重要;一个真正熟练的工程师,脑子里装的不只是"这门语言能做什么",还有"这门语言会在哪些地方坑我"主动学习并记住语言的反直觉默认坑清单——是这个 sort 坑,推着我去做的一项长期功课。

写在最后

回头看,这场由"一个不带比较函数的 sort()"引发的、排行榜乱套的事故,真正教给我的,远不止"sort 排数字要传 (a,b)=>a-b"这一个技巧。它让我对"'我以为它会怎样',和'它实际怎样',之间的鸿沟,是 bug 最爱藏身的地方",有了一次刻骨的体会。我栽跟头,根源在于一个未经验证的假设:我"想当然地"以为 sort() 会"聪明地"按数值大小排序——因为这"显然"是我想要的、也"显然"是排序"该有"的样子。我把"我期望它做什么",当成了"它实际会做什么",而从没去验证过这个假设。可 sort() 的实际行为(字典序),和我的期望(数值序),是两回事;而它"恰好"在我测试用的个位数上表现一致,更是纵容了我这个错误假设——它给了我"看,果然如我所想"的虚假印证,让我更加确信了一个错误的认知这让我领悟到一个关于工程严谨性的深刻认知:程序员最危险的,不是"不知道",而是"自以为知道、却没验证"——那些"我觉得它就是这样""这还用试吗"的想当然的假设,因为从未被拿出来检验,就成了潜伏最深的隐患;尤其当现实"碰巧"在简单情况下印证了你的错误假设时,这个假设会被你更牢固地误信,直到在某个边界情况下轰然崩塌这给了我一种近乎本能的警觉:对待自己"想当然"的认知,要有一份"我真的验证过吗?"的自省——尤其是对那些"用了很多年、从没细想过"的基础 API/行为,偶尔停下来亲手 console.log 一下、查一下文档、验证一下它的真实行为,而不是凭着模糊的、可能早已错误的印象去用它;"不要假设,去验证(Don't assume, verify)"——这五个字,值得贴在每个程序员的显示器上警惕想当然的假设、对自以为知道的事去亲手验证——这,是我用一次 sort 排序乱套的事故,换来的、关于 JavaScript、也关于如何保持工程严谨的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下 arr.sort() 给数字排序时,顿一下、补上那个 (a, b) => a - b,那我对着那个乱套的排行榜排查的这段时间,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一段用 float 累加金额的 Python 代码,在几万笔订单后对账差了几分钱,让我栽进了二进制浮点精度的坑:一次用错数值类型的深度复盘

2026-6-2 15:14:36

技术教程

一次对 Go slice 做切片后 append,意外覆盖了原始 slice 里的数据,让一份订单列表凭空窜了值:一次共享底层数组的深度复盘

2026-6-2 15:25:40

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索