Code Review 惊魂:同事的“优雅”重构,差点让管理员全部掉线

差点就酿成了"生产事故"

昨天下午,组里的实习生小李提交了一个 PR,说是把权限管理模块的代码"优化"了一下。

我扫了一眼 Diff,绿油油的一片,看起来确实清爽了不少。代码风格从原本的命令式变成了函数式,逻辑似乎也没啥大问题。手指悬在 "Approve" 按钮上,正准备点下去,突然看到一行对数组的处理,心里咯噔了一下。

赶紧把代码拉到本地跑了一遍------果然,原本正常的权限列表,重构后直接变成了空数组!

如果这行代码真的上线了,明天所有的管理员都会因为没有权限而被拦在后台门外,那我们组这个季度的绩效怕是要集体泡汤。

小李改动的地方很简单,他觉得原来的 push 写法太繁琐,想用 concat 让他变得"优雅"一点。

于是代码从:

javascript 复制代码
// 老代码         
defaultRoles.forEach(role => {
  userPermissions.push(role);
});

变成了:

javascript 复制代码
// 新代码:
userPermissions.concat(defaultRoles);

我把他叫到工位上,指着这行代码问他:"你觉得这两段代码等价吗?"

他一脸茫然:"不都是把数组拼起来吗?concat 不是更符合函数式编程习惯吗?"

看来,是时候聊聊 JS 数组方法里那些容易让人"阴沟里翻船"的返回值陷阱了。

还原一下"案发现场"

原始逻辑(正常工作)

我们的业务逻辑大概是这样的:从后端拉取用户的角色,然后把对应的权限塞到一个数组里。

javascript 复制代码
// 伪代码:将嵌套的权限数组扁平化
// 以前是用 reduce + push 写的,虽然丑点,但能用
export const flatPermissions = allPermissions.reduce((acc, role) => {
    // ... 省略中间处理逻辑
    // 把处理好的权限 push 进累加器
    acc.push(...processedPermissions); 
    return acc;
}, []);

这段代码跑了一年多,稳如老狗。

重构后的代码(引入 Bug)

小李为了追求代码整洁,在重构时把 push 换成了 concat

javascript 复制代码
export const flatPermissions = allPermissions.reduce((acc, role) => {
    // ... 省略中间处理逻辑
    
    // 他以为这行代码会把新权限加到 acc 里
    acc.concat(processedPermissions); 
    
    return acc;
}, []);

看起来逻辑没变,对吧?

但在 JS 引擎眼里,这行代码的意思是:

  1. 拿出 acc 数组。
  2. 创建一个新数组 ,内容是 acc + processedPermissions
  3. 把这个新数组扔掉(因为没有接收返回值)。
  4. 返回原本的、没有任何变化的 acc

结果就是:flatPermissions 永远是个空数组。

到底哪里出了问题?

我给小李画了张图,解释这俩方法的本质区别。

concat vs push:本质的区别

这俩方法的区别,不仅仅是写法不同,而是设计哲学完全不同。

1. concat:我只产生新东西,不碰旧东西

concat 是**非变异(Non-mutating)**方法。它不会修改调用它的数组,而是返回一个新的。

javascript 复制代码
const arr1 = [1, 2];
const arr2 = [3, 4];

// 错误用法:以为 arr1 变了
arr1.concat(arr2); 
console.log(arr1); // [1, 2] -> 根本没动!

// 正确用法:必须接收返回值
const result = arr1.concat(arr2);
console.log(result); // [1, 2, 3, 4]

2. push:我就改旧东西

push变异(Mutating)方法。它直接修改原数组,返回的是新数组的长度(这个返回值也经常坑人)。

javascript 复制代码
const arr1 = [1, 2];
const arr2 = [3, 4];

// push 修改了 arr1
const length = arr1.push(...arr2); 

console.log(arr1); // [1, 2, 3, 4] -> 变了!
console.log(length); // 4 -> 返回的是长度

内存和引用对比

为了加深印象,咱们看个图:

graph TD subgraph Push操作 A[原数组 arr1] -->|push| A A -.->|变大了| A end subgraph Concat操作 B[原数组 arr1] -->|concat| C[新数组 result] B -.->|保持原样| B end
  • Push: 在原数组内存地址上扩容。
  • Concat: 申请新内存,复制旧数据,复制新数据,返回新地址。

怎么修?这有三招

方案一:继续用 push,配合展开运算符

这是性能最好的改法,虽然看起来稍微没那么"函数式"。

javascript 复制代码
export const flatPermissions = allPermissions.reduce((acc, role) => {
    // 使用展开运算符 ... 把新数组打散塞进去
    acc.push(...role.permissions);
    return acc;
}, []);

优点 :性能好,内存抖动少。 缺点 :修改了 acc(但在 reduce 内部通常是可以接受的)。

方案二:正确使用 concat

如果你坚持要用 concat,记得把返回值接住。

javascript 复制代码
export const flatPermissions = allPermissions.reduce((acc, role) => {
    // 返回新的数组给下一次迭代
    return acc.concat(role.permissions);
}, []);

优点 :纯函数式,不修改原数组。 缺点:在循环中频繁创建新数组,可能带来额外的内存开销。

方案三:用 flatMap(推荐)

既然是"映射"+"扁平化",JS 早就给我们准备好了专用 API。

javascript 复制代码
export const flatPermissions = allPermissions.flatMap(role => role.permissions);

优点 :代码最简洁,语义最清晰,专门干这事的。 缺点:老旧浏览器(如 IE)不支持,需要 Polyfill。

避坑指南:还有哪些方法是"只读"的?

concat 只是冰山一角。JS 的数组方法里,修改原数组返回新数组的方法经常让人晕头转向。

我整理了一份清单,建议背下来,或者贴在电脑屏幕旁边:

⚠️ 会修改原数组(Mutating)

  • push() / pop()
  • unshift() / shift()
  • splice() (这个最容易混淆!)
  • sort()
  • reverse()
  • fill()

✅ 只返回新数组(Non-Mutating)

  • concat()
  • slice() (注意是 slice 不是 splice)
  • map() / filter()
  • reduce()
  • toSorted() / toReversed() / toSpliced() (ES2023 新出的"安全版"方法)

写在最后

那天下午,我让小李把代码改了回去,并顺便给他科普了一波数组方法的副作用。

Code Review 的意义其实就在这里:不仅是找 Bug,更是团队技术栈的校准和经验的传承。

对于每一个开发者来说,写代码不能光图"看着顺眼"。在把 push 换成 concat 之前,先问自己两个问题:

  1. 我接收返回值了吗?
  2. 这代码是在循环里吗?

希望这次"险些发生"的生产事故,能给你的 Code Review 清单里增加一项检查点。


相关阅读

相关推荐
_柳青杨35 分钟前
一文吃透 Node.js 事件循环:从原理到 Node 20+ 重大变更
javascript·后端
JieE21211 小时前
LeetCode 101. 对称二叉树|JS 递归 + 迭代双解法,彻底搞懂镜像判断
javascript·算法
冬奇Lab13 小时前
AI Workflow 定义的四次演进:从 Markdown 到 JS 脚本,再到分布式多 Agent
javascript·人工智能·agent
一颗烂土豆19 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
kyriewen21 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
weedsfly1 天前
迭代器、生成器与异步迭代——让数据“按需流动”的艺术
前端·javascript
假如让我当三天老蒯1 天前
前端跨域解决方案(学习用)
前端·javascript·面试
铁皮饭盒1 天前
Bun 哪比 Node.js 快?
javascript·后端
JieE2121 天前
LeetCode 56. 合并区间|超清晰 JS 图解思路,面试高频区间题
javascript·算法·面试
candyTong2 天前
RTK 技术原理:一次典型会话里,80% 上下文是怎么省下来的
javascript·后端·架构