我在 JavaScript 里写了 const sorted = list.sort() 想拿到一个排好序的新数组、同时保留原始顺序的 list 去做别的事,结果后面所有依赖原始顺序的逻辑全乱了套,排查很久才发现 sort 根本不返回新数组、它是把原来的 list 就地排序了我那两个变量指向的压根是同一个被改过的数组的深度复盘

我有个数组 list 代表用户提交的有意义的原始顺序,需要两份东西:一份按金额排序后展示、一份保持原始顺序做第 N 条对应第 N 个录入的对应关系。我顺手写下 const sorted = list.sort((a,b)=>a.amount-b.amount),以为 sorted 是排序后的新数组、list 还是原来的样子。可后面用 list 做对应关系时全错位了,打印 list 才傻眼——它也已经被按金额排序了、原始顺序荡然无存。我搜遍代码没有任何一行给 list 重新赋值或排序,除了那句 sort;直到加了 console.log(sorted === list) 打出刺眼的 true 才彻底明白:sorted 和 list 是同一个数组,sort 不是算出一个新的排好序的数组返回,而是在原数组上就地重排元素、然后返回原数组的引用。根因是 JS 数组方法分两大类:不可变操作(map、filter、slice、concat、flat、flatMap 以及 ES2023 的 toSorted、toReversed、toSpliced、with)返回全新数组、原数组分毫不动;可变操作(sort、reverse、splice、push、pop、shift、unshift、fill、copyWithin)在原数组上就地修改、并通常把原数组本身作为返回值返回。sort 正是可变操作——它就地重排原数组再返回这个被改过的原数组,所以 const sorted = list.sort() 既没创建新数组也没保住原始 list、二者是同一个;而 const 只锁住变量指向、保证 sorted 不被重新赋值,完全拦不住所指向的数组内容被原地修改。正解是既要变换结果又要保留原数组时,绝不直接在原数组上调可变操作,而要先用展开

我在 JavaScript 里写了 const sorted = list.sort() 想拿到一个排好序的新数组、同时保留原始顺序的 list 去做别的事,结果后面所有依赖原始顺序的逻辑全乱了套,排查很久才发现 sort 根本不返回新数组、它是把原来的 list 就地排序了、我那两个变量指向的压根是同一个被改过的数组

这是一次让我把 JavaScript 里"数组的 sort"这件事,从"返回一个排好序的新数组",重新理解成"把原数组就地排序、再把它本身返回给你"的事故。我写了 const sorted = list.sort(),本意是拿到一个排好序的 sorted 去展示,同时保留原始顺序list 去做别的计算。结果后面所有依赖原始顺序的逻辑全乱了套。我排查了很久才发现:sort 根本不返回一个新数组,它是把原来的 list 就地排序了,然后把这个被改过的 list 本身返回给我;我那两个变量,指向的压根是同一个数组。这篇就把这次"以为只排了副本、其实把原始数据也排没了"的事故,从头到尾复盘一遍。

故障现场:排序之后,原始顺序的数据也不见了

我的场景很常见:有一个数组 list,代表用户提交的、有意义的原始顺序(比如按录入先后)。我需要两份东西:一份按金额排序后用于展示,一份保持原始顺序用于"第 N 条对应第 N 个录入"的对应关系。我顺手写下 const sorted = list.sort((a,b)=>a.amount-b.amount),以为 sorted 是排序后的新数组、list 还是原来的样子。

可后面用 list 做"第 N 条对应关系"时,全错位了。我打印 list,傻眼了——它也已经被按金额排序了,原始顺序荡然无存。我第一反应是"哪里手滑改了 list 吧",全局搜索却没有任何一行给 list 重新赋值或排序的代码,除了那句 list.sort(...)。我又怀疑是不是别的地方引用了它,直到我对着那句 sort 加了 console.log(sorted === list),打出一个刺眼的 true,才彻底明白——sortedlist同一个数组;sort 不是"算出一个新的排好序的数组返回",而是"在原数组上就地重排元素",然后返回原数组的引用;所以我以为的"原始顺序的 list",在我调用 sort 的那一刻,就已经被原地排序、和 sorted 变成同一个东西了。

// 我的写法: 以为 sort 返回新数组, 原 list 不变
const list = [{id:1,amount:30},{id:2,amount:10},{id:3,amount:20}];
const sorted = list.sort((a,b) => a.amount - b.amount);

// 我以为:
//   sorted = 排好序的新数组 [10,20,30]
//   list   = 还是原始顺序 [30,10,20]
// 实际:
console.log(sorted === list);   // true  ← 是同一个数组!
console.log(list);              // [{10},{20},{30}]  ← 原 list 也被排序了!

// 后面依赖"原始顺序的 list"的逻辑, 全乱了 —— 因为原始顺序根本没了

// 真相: sort 是【原地排序】(in-place / mutating),
//   它就地重排 list 的元素, 再把 list 自己返回给你赋给了 sorted。
//   const 只保证 sorted 不被重新赋值, 拦不住它指向的数组被原地修改。

问题被钉死在这个认知错位上:我把 sort 当成了一个"不可变操作"(返回新数组、不动原数组),但它其实是一个"可变操作"(原地修改原数组、并返回这个原数组)。JS 的数组方法里,有一类是"不可变"的——mapfiltersliceconcat,它们都返回一个新数组、原数组分毫不动;另一类是"可变"的——sortreversesplicepushpop,它们原地修改原数组。我习惯了前一类"取一份变换后的结果、原料还在"的心智模型,就想当然地以为 sort 也一样;可它偏偏属于后一类,我那句 const sorted = list.sort(),既没有创建新数组,也没有保住原始的 list——它把 list 就地排了序,再把 list 本身贴上 sorted 这个新标签。我以为我复印了一份去改,其实我在原件上动了刀,还以为原件好好的。

第一件事:想明白"可变操作"和"不可变操作"是两类不同的方法

把这次事故彻底想清楚,关键是理解JavaScript 的数组方法分两大类:"不可变操作"返回一个新数组、不碰原数组(map/filter/slice/concat/...);"可变操作"在原数组上就地修改、并通常返回原数组本身(sort/reverse/splice/push/pop/shift/unshift/fill)。把可变操作当不可变操作用,就会在你以为安全的原数据上,留下你没预料的改动。

这两类方法的差别,平时不显眼,因为很多时候你排完序就直接用、也不再需要原始顺序了,原地改了也没人察觉。可一旦你同时还需要原始数据(像我这样要"一份排序的、一份原序的"),"可变操作就地改了原料"这件事就会狠狠咬你一口。更隐蔽的是 const 给的虚假安全感:const sorted = ... 只保证 sorted 这个变量绑定不会被改成指向别的东西,它完全拦不住 sorted(以及和它同指一物的 list)所指向的那个数组内部被原地修改。const 锁的是"名字指向谁",不是"那个东西的内容";可变操作改的恰恰是内容。这也是为什么 const arr = []; arr.push(1) 完全合法——push 没改 arr 的指向,只改了它的内容。

// 两类方法对照: 不可变(返回新数组) vs 可变(原地改原数组)

const orig = [3, 1, 2];

// ① 不可变操作: 返回新数组, orig 不变 —— 这才是"取一份变换结果"
const mapped = orig.map(x => x * 10);   // mapped 是新数组
const filtered = orig.filter(x => x > 1);// filtered 是新数组
const sliced = orig.slice(0, 2);         // sliced 是新数组
console.log(orig);                       // [3,1,2]  ← 始终没变 ✓

// ② 可变操作: 原地改 orig, 还返回 orig 本身
const s = orig.sort();      // orig 被就地排序; s === orig
console.log(orig, s === orig);  // [1,2,3] true  ← orig 被改了!

// 想要"排序但不动原数组": 先拷贝再排, 或用 ES2023 的不可变版本
const orig2 = [3, 1, 2];
const safe1 = [...orig2].sort();     // 先浅拷贝一份再排, orig2 不动
const safe2 = orig2.toSorted();      // ES2023: 返回新数组, 原地不动(推荐)
console.log(orig2);                  // [3,1,2]  ← 保住了 ✓
// toReversed() / toSpliced() / with() 同理, 是 sort/reverse/splice/赋值的不可变版

想通这一层,我才明白自己错在哪:我没有把"这个方法到底改不改原数组"当成调用前必须搞清的一件事,而是凭着对 map/filter 的印象,默认"数组方法都返回新数组、不动原料"。sort/reverse/splice 这些恰恰是反例。它们原地修改、还把原数组返回给你,这个"返回值"极具迷惑性——它让 const sorted = list.sort() 看起来就像"拿到了一个新结果",其实只是给原数组起了个别名。要么记清哪些是可变操作、用前先拷贝(或用 toSorted 这类不可变版),要么就得承受原数据被就地改掉的后果。

第二件事:正解——要保留原数组就先拷贝,或用不可变版方法

找到根因,正解就清晰了:当你既要排序/反转/增删的结果、又要保留原数组时,别直接在原数组上调可变操作——先用展开 [...arr]arr.slice() 浅拷贝一份再操作,或者直接用 ES2023 的不可变版方法(toSorted()toReversed()toSpliced()with()),它们返回新数组、原数组分毫不动。

// 错误: 直接在原数组上 sort, 原数组被就地改掉
const sorted = list.sort((a,b) => a.amount - b.amount);  // list 也被排了 ✗

// 正解1: 先浅拷贝一份, 在拷贝上排序, 原数组不动
const sorted = [...list].sort((a,b) => a.amount - b.amount);
// 或 const sorted = list.slice().sort(...);
console.log(list);   // 原始顺序还在 ✓

// 正解2: ES2023 不可变版方法(语义最清晰, 推荐)
const sorted   = list.toSorted((a,b) => a.amount - b.amount); // 不改 list
const reversed = list.toReversed();                            // 不改 list
const removed  = list.toSpliced(1, 1);                         // 不改 list
const updated  = list.with(0, newItem);  // 改第0个元素得新数组, 不改 list

// 注意: 浅拷贝只复制一层。元素是对象时, 排序不影响原数组顺序,
//       但改动元素内部属性仍会影响原数组里的同一个对象(那是另一回事)。
//       要彻底独立到元素内部, 需要深拷贝。

// 经验法则: 把"会原地改原数组"的方法列成清单, 用前先确认
//   可变(原地改): sort, reverse, splice, push, pop, shift, unshift, fill, copyWithin
//   不可变(返回新): map, filter, slice, concat, flat, flatMap, toSorted, toReversed...

这套做法的精髓,是当原数据还要被别处使用时,就不在原物上动刀——要么复印一份在副本上改([...arr]/slice()),要么用那些承诺不改原物、只返回新结果的方法(toSorted 等)。这样"排序的结果"和"原始的数据"就成了两个真正独立的数组,各用各的、互不影响。toSorted/toReversed/toSpliced/with 是 ES2023 专门为此加的不可变版,语义一目了然:它们和 sort/reverse/splice/[i]=x 一一对应,只是不改原数组。不是不能用可变操作,而是当原数据需要被保留时,要么拷贝、要么用不可变版,别让一次排序顺手抹掉了原始顺序。

【用数组方法, 我现在认死的几条】

1. 数组方法分两类: 不可变(返回新数组) 和 可变(原地改原数组)

2. 可变操作: sort/reverse/splice/push/pop/shift/unshift/fill —— 会改原数组

3. 不可变操作: map/filter/slice/concat/flat/flatMap —— 返回新数组不动原

4. 还要用原数组时, 别直接调可变操作; 先 [...arr] / slice() 拷一份

5. 或用 ES2023 不可变版: toSorted/toReversed/toSpliced/with

6. const 只锁变量指向, 拦不住它指向的数组内容被原地修改

7. 拿到返回值别想当然是"新数组": 可变操作返回的是原数组本身

第三件事:其他"以为没动原物、其实就地改了"的同类坑

顺着"分不清可变操作和不可变操作、以为拿到的是新副本"这条线,我把同类的坑都排查了一遍,它们都源于"误以为某个操作不会改动原始对象":

第一个,reverse 也原地反转const r = arr.reverse() 和 sort 一样,把 arr 就地反转、返回它本身,原数组顺序没了。

第二个,splice 原地增删、和 slice 一字之差天差地别slice 不改原数组返回新数组,splice 原地删除/插入元素、改原数组,两者极易混淆。

第三个,对象的 Object.assign(target, ...) 会改 target。想合并对象却把第一个参数当成了不可变的,结果 target 被就地改了;要新对象用 {...a, ...b}Object.assign({}, a, b)

第四个,把数组/对象传进函数,函数内原地改了它。JS 对象按引用传递,函数里对参数做 push/排序/改属性,会影响到外面的原对象;不想影响就在函数内先拷贝。

第四件事:常用数组方法——可变 vs 不可变速查表

我把常用数组方法按"改不改原数组"归了类,用前先对一眼:

方法 类别 返回值 对原数组 不可变替代
sort 可变 原数组本身(已排序) 原地排序 toSorted()
reverse 可变 原数组本身(已反转) 原地反转 toReversed()
splice 可变 被删除元素的数组 原地增删 toSpliced()
push/pop/shift 可变 长度或被移除元素 原地增删 [...a, x] / slice
map/filter 不可变 新数组 不动 (本就不可变)
slice/concat 不可变 新数组 不动 (本就不可变)

看清这张表,用前就有谱了:看到 sort/reverse/splice/push 这类可变方法,先问"原数组还要不要"——要就先拷贝或换 toSorted 这类不可变版;map/filter/slice/concat 本就返回新数组,可以放心用。我这次踩坑,就是把可变的 sort 当成了不可变的 map 那样用,以为原 list 安然无恙。一字之差的 slice(不可变)和 splice(可变)更是重灾区,务必分清。

第五件事:我曾经对 sort/可变操作想当然的几个误区

这次事故也把我对数组可变操作的一堆"想当然"照了个底朝天:

我以为 实际上
sort 返回一个排好序的新数组 sort 原地排序原数组, 返回的是原数组本身
const sorted=list.sort() 后 list 不变 list 已被就地排序, sorted 和 list 是同一个
const 能保证那个数组不被改 const 只锁变量指向, 数组内容照样能被原地改
数组方法都像 map 一样返回新数组 sort/reverse/splice/push 都原地改原数组
slice 和 splice 差不多 slice 不改原数组返回新数组, splice 原地增删

这些误区的根子是同一个:我没把"这个操作会不会改动它作用的那个对象"当成一个需要事先确认的关键性质,而是默认"我是在取一个变换后的结果,原料应该不动"。map/filter 这个默认是对的,可对 sort/reverse/splice 就是错的——它们就地改原料、还把原料返回给你假装是新结果。分不清"取一份变换结果(不可变)"和"就地改造原物(可变)",是这一整类"原数据被悄悄改掉"事故的共同根源。

第六件事:调数组方法、排查"原数据被改"时,我现在的自检习惯

现在每当我调一个数组方法、或排查"原始数据怎么莫名其妙变了",我都会先按这张图问自己:

这张图的精髓,是"数组方法分可变和不可变;还要用原数组时,别在原数组上调 sort/splice 这类可变操作,先拷贝或用 toSorted 这类不可变版"设计就分清可变与不可变、原数据要保留就先拷贝或用不可变版方法、排查就看原数据是不是被某个可变方法就地改了、或被引用传进函数改了这套习惯,让我从"以为数组方法都返回新数组"变成了"调之前先确认它改不改原物"——核心始终是:JavaScript 的数组方法分成两大类:"不可变操作"(map、filter、slice、concat、flat、flatMap 以及 ES2023 的 toSorted、toReversed、toSpliced、with)会返回一个全新的数组、原数组分毫不动;"可变操作"(sort、reverse、splice、push、pop、shift、unshift、fill、copyWithin)则在原数组上就地修改、并且通常把原数组本身作为返回值返回;sort 正是可变操作——它就地重排原数组的元素、再返回这个被改过的原数组,所以 const sorted = list.sort() 既没有创建新数组、也没有保住原始的 list,sorted 和 list 是同一个被排序过的数组(sorted === list 为 true);而 const 只锁住变量的指向、保证 sorted 不被重新赋值,它完全拦不住所指向的那个数组的内容被原地修改;正解是当你既要变换的结果、又要保留原数组时,绝不直接在原数组上调可变操作,而要先用展开 [...arr] 或 slice() 浅拷贝一份再操作、或直接用 toSorted/toReversed/toSpliced/with 这些不可变版方法,让"变换的结果"和"原始的数据"成为两个真正独立、互不影响的数组。

我立下的几条规矩

这场"排了个序、把原始数据也排没了"的事故,换来了我用数组方法时,刻进骨子里的几条铁律:

  1. 数组方法分两类:不可变(返回新数组)和可变(原地改原数组)。
  2. 可变操作:sort/reverse/splice/push/pop/shift/unshift/fill,会改原数组。
  3. 不可变操作:map/filter/slice/concat/flat/flatMap,返回新数组不动原。
  4. 还要用原数组时,别直接调可变操作;先 [...arr]/slice() 拷一份。
  5. 或用 ES2023 不可变版:toSorted/toReversed/toSpliced/with。
  6. const 只锁变量指向,拦不住它指向的数组内容被原地修改。
  7. 可变操作的返回值是原数组本身,别误当成新数组;slice 和 splice 务必分清。

附:我现在处理"既要变换结果、又要保留原数组"的不可变小工具

这是我现在处理数组变换固定套的小工具——把这次踩坑的教训(要保留原数组就别在原物上调可变操作)固化成一套封装,让"排序排没了原始数据"那种坑再不会埋进代码:

// 统一走"不可变"风格: 凡是会改原数组的操作, 都先拷贝再做, 绝不动原物

const immutable = {
  // 排序: 永远在副本上排, 原数组不动
  sort: (arr, cmp) => [...arr].sort(cmp),
  // 反转: 同理
  reverse: (arr) => [...arr].reverse(),
  // 删除/插入: 返回新数组, 不动原
  remove: (arr, i) => arr.filter((_, idx) => idx !== i),
  // 更新某个位置: 返回新数组
  set: (arr, i, v) => arr.map((x, idx) => (idx === i ? v : x)),
  // 追加: 返回新数组(而非 push 原地改)
  append: (arr, v) => [...arr, v],
};

// 用法: 原数组 list 始终安全, 各种变换各得一份新数组
const list = [{id:1,amount:30},{id:2,amount:10},{id:3,amount:20}];
const sortedView = immutable.sort(list, (a,b) => a.amount - b.amount);
console.log(list === sortedView);   // false  ← 两个独立数组 ✓
console.log(list.map(x => x.id));   // [1,2,3] ← 原始顺序还在 ✓

// 在现代环境直接用原生不可变版更省事:
const sortedView2 = list.toSorted((a,b) => a.amount - b.amount);
// 自检断言: 任何"变换"都不该改到原数组
const before = JSON.stringify(list);
const _ = list.toSorted();          // 即便误用, toSorted 也不改原
console.assert(JSON.stringify(list) === before, "原数组被意外修改了!");

这套小工具把我这次的教训钉死在了代码里:凡是"会改原数组"的操作(排序、反转、增、删、改),都先 [...arr] 拷一份在副本上做、返回新数组,绝不在原物上动刀;现代环境就直接用 toSorted/toReversed/toSpliced/with 这些原生不可变版。再配一句"变换前后原数组的快照必须一致"的断言兜底,就彻底告别了"排个序把原始顺序也排没了"的意外。把"区分变换与修改、对要保留的原物先拷贝再操作"这个道理,沉淀成处理数组的固定风格,这是我对这次"原数据被悄悄改掉"最实在的交代——毕竟,原始数据是后面一切对应关系的地基,它就该稳稳待在原地,而不是被我顺手一句 sort 抹掉。

写在最后

回头看,这场由"sort 原地排序"引发的"原始数据被悄悄改掉"事故,真正教给我的,远不止"用 toSorted 或先拷贝"这一个技巧。它让我对"当我们对一个东西施加一个'操作'时,这个操作有两种截然不同的性质:一种是'读取并产出一个新结果、原物保持不变'(变换),另一种是'直接在原物身上做出改动'(修改);这两者在'我得到了想要的结果'这一点上看起来一样,但在'原物还在不在、还是不是原来的样子'这一点上,天差地别——而我们常常只盯着前者,忽略了后者",有了一次刻骨的体会。我栽跟头,是因为我把一个"会就地改造原物"的操作,误当成了一个"读取原物、产出新结果而不动原物"的操作——我的心智模型来自 map/filter:给它一个数组,它还我一个新数组,原数组像原料一样安静地待在那儿,我可以反复取用;我就想当然地以为所有数组方法都这样、sort 也不例外;可 sort 偏偏是"就地改造"派的——它在原物身上重排,再把原物递还给我,那个"返回值"更是给了我"这是个新结果"的错觉,让我浑然不觉原料已经被我自己动了刀这让我领悟到一个关于"变换与修改、产出新物与改造原物"的深刻认知:对任何对象施加操作前,都要分清它是"不可变操作"(基于原物算出一个新东西、原物不变,你可以安全地把原物用在别处)还是"可变操作"(就地改动原物本身,原物从此变了样);二者的危险不对称——不可变操作至多浪费点内存,而可变操作会在你"以为原物还在"的地方留下你没预料的改动,尤其当这个原物还被别处共享或后续要用时,这种"悄无声息的就地修改"会引发极难定位的连锁错误;更具迷惑性的是,可变操作往往也有返回值(返回原物本身或某个结果),这个返回值会伪装成"新产出",诱使你以为原物安然无恙;而像 const(锁定绑定)这样的保护,只防住了"名字被改指向别处",防不住"名字指向的那个东西被就地改造"——把"绑定不可变"误当成"对象不可变",又是一层叠加的错觉这给了我一种看待"一切'对共享之物施加操作'之事"时的清醒:每当我要对一个还会被别处使用的对象施加操作时,要追问"这个操作是产出一个新东西、还是就地改了原物?如果就地改了,而原物我后面还要用、或别处也在用,我是不是该先复制一份再动手"——分清"变换出新结果"和"改造原物本身",对还需保留或被共享的原物,永远先拷贝或用不可变的方式,绝不在原物上就地动刀;"区分可变操作与不可变操作、对要保留的原物先拷贝再操作",是用对 JS 数组方法、也是安全地处理一切共享数据的关键认清 sort 是原地排序、返回的是原数组本身、const 拦不住内容被改、要保留原数组就先拷贝或用 toSorted——这,是我用一次"排序排没了原始数据"的事故,换来的、关于 JavaScript、也关于如何分清变换与修改的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下 arr.sort() 时,先停一秒想想"我后面还要用原始顺序的 arr 吗?要的话是不是该写 [...arr].sort() 或 arr.toSorted()?",那我对着那句打出 truesorted === list 发懵的大半天,就值了。

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

我在 Python 类里直接写了个空列表当属性、想让每个对象都有自己独立的一份,结果给一个对象的列表追加元素、所有对象的列表里竟然都冒出了同一个元素,我对着代码看了半天都不敢相信,最后才搞懂那个列表根本不属于任何一个对象而是被全体实例共用的同一个的深度复盘

2026-6-3 7:45:46

技术教程

我的 Go 服务从一个 interface 里取值时写了 s := val.(string)、平时跑得稳稳的,某天上游传进来一个不是字符串的值整个服务当场 panic 崩溃,我盯着堆栈愣了半天才想起来 Go 的类型断言还有个带逗号 ok 的安全形式而我图省事用的那个单返回值版本断言失败是会直接炸的深度复盘

2026-6-3 7:57:34

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