阅读视图

发现新文章,点击刷新页面。

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

差点就酿成了“生产事故”

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

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

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

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

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

于是代码从:

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

变成了:

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

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

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

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

还原一下“案发现场”

原始逻辑(正常工作)

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

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

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

重构后的代码(引入 Bug)

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

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)**方法。它不会修改调用它的数组,而是返回一个新的。

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)方法。它直接修改原数组,返回的是新数组的长度(这个返回值也经常坑人)。

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,配合展开运算符

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

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

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

方案二:正确使用 concat

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

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

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

方案三:用 flatMap(推荐)

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

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 清单里增加一项检查点。


相关阅读

React 渲染两次:是 Bug 还是 Feature?聊聊严格模式的“良苦用心”

React 为啥老是渲染两次?——聊聊 Strict Mode 的那些事

看控制台的时候,有没有怀疑人生?

写 React 的时候,你有没有遇到过这种场景:

明明只写了一行 console.log,结果控制台“刷刷”给你印出来两条一模一样的。或者发送网络请求,明明只调用了一次,Network 里却躺着两个请求。

第一反应通常是:“完了,我是不是哪里写出 Bug 了?组件是不是在哪里被意外卸载又挂载了?”

别慌,大概率不是你的锅,而是 React 故意的。

罪魁祸首:Strict Mode

赶紧去你的入口文件(通常是 main.tsxindex.tsx)看一眼,是不是长这样:

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

那个 <React.StrictMode> 就是“幕后黑手”。

它的中文名叫“严格模式”。这玩意儿只在 开发环境(Development) 下生效,到了 生产环境(Production) 就会自动隐身,不会对用户产生任何影响。

为什么要搞这么个“恶作剧”?

React 团队并不是闲着没事干,非要让你的控制台变脏。这么做的核心目的是:帮你揪出不纯的函数和有副作用的代码

在 React 的设计哲学里,组件的渲染过程(Render Phase)应该是 纯粹(Pure) 的。

所谓的“纯”,就是说:

  1. 给定相同的输入(Props 和 State),必须返回相同的输出(JSX)。
  2. 不能改变作用域之外的变量,不能有副作用(Side Effects)。

如果你的组件不纯,比如你在渲染函数里偷偷修改了一个全局变量:

let count = 0;

function BadComponent() {
  count++; // ❌ 这是一个副作用!
  return <div>Count: {count}</div>;
}

在单次渲染下,你可能看不出问题。但如果 React 决定并发渲染、或者为了优化跳过某些渲染,这个全局 count 就会变得不可预测。

为了让你在开发阶段就发现这种隐患,React 采取了最简单粗暴的办法:把你的组件渲染两次

如果你的组件是纯的,渲染一次和渲染两次,对外部世界的影响应该是一样的(零影响),返回的结果也是一致的。但如果你在里面搞了小动作(比如上面的 count++),两次渲染就会导致 count 加了 2,结果就不对劲了,你立马就能发现问题。

具体哪些东西会执行两次?

在严格模式下,React 会特意重复调用以下内容:

  • 函数组件体(Function Component body)
  • useState, useMemo, useReducer 传递的初始化函数
  • 类组件的 constructor, render, shouldComponentUpdate 等生命周期

注意,这仅仅是 “调用” 两次,并不是把你的组件在 DOM 上真的挂载两次。它主要是在内存里跑两遍逻辑,看看有没有奇奇怪怪的副作用发生。

useEffect 的“挂载 -> 卸载 -> 挂载”

除了渲染过程,从 React 18 开始,Strict Mode 还加了一个更狠的检查机制,针对 useEffect

你可能会发现,组件初始化时,useEffect 里的代码也跑了两次。

严格来说,它的执行顺序是这样的:

  1. Mount(挂载) -> 执行 Effect
  2. Unmount(卸载) -> 执行 Cleanup(清除函数)
  3. Remount(挂载) -> 执行 Effect
graph LR
    A[组件挂载] --> B[执行 Effect]
    B --> C{严格模式?}
    C -- 是 --> D[模拟卸载: 执行 Cleanup]
    D --> E[再次挂载: 执行 Effect]
    C -- 否 --> F[结束]

这又是为了啥?

这是为了帮你检查 Cleanup 函数写没写对

很多时候我们写了订阅(subscribe),却忘了取消订阅(unsubscribe);写了 setInterval,却忘了 clearInterval。这种内存泄漏在单次挂载中很难发现,但在页面快速切换时就会爆雷。

通过强制来一次“挂载->卸载->挂载”的演习,React 逼着你必须把 Cleanup 逻辑写好。如果你的 Effect 写得没问题,那么“执行->清除->再执行”的结果,应该和“只执行一次”在逻辑上是闭环的。

比如一个聊天室连接:

  1. connect() (连接)
  2. disconnect() (断开)
  3. connect() (连接)

用户最终还是连接上了,中间的断开重连不应该导致程序崩溃或产生两个连接。

怎么解决?

1. 接受它,不要关掉它

最好的办法是适应它。既然 React 告诉你这里有副作用,那就去修复代码,而不是解决提出问题的人。

  • 把副作用挪到 useEffect 里去,别放在渲染函数体里。
  • 确保 useEffect 有正确的 Cleanup 函数。

2. 使用 useRef 解决数据重复请求

经常有人问:“我的请求在 useEffect 里发了两次,导致服务器存了两条数据,怎么办?”

如果你无法把后端接口改成幂等(Idempotent)的,可以使用 useRef 来标记请求状态:

import { useEffect, useRef } from 'react';

function DataFetcher() {
  const hasFetched = useRef(false);

  useEffect(() => {
    if (hasFetched.current) return; // 如果已经请求过,直接返回

    hasFetched.current = true;
    fetchData();
  }, []);

  return <div>Loading...</div>;
}

不过 React 官方更推荐使用像 React Query (TanStack Query) 或 SWR 这样的库来管理数据请求,它们内部已经处理好了这些去重逻辑。

对于Strict Mode,我的理解是:

原理层面

  • 渲染双倍:为了检测渲染逻辑是否纯粹。
  • Effect 挂载-卸载-挂载:为了检测 Effect 的清除逻辑是否正确。
  • 仅限开发环境:生产环境完全无副作用。

实用层面

  • 它是 React 自带的“代码质量检测员”。
  • 看到日志打印两次不要慌,先想想是不是 Strict Mode 的锅。
  • 千万别在渲染函数里写副作用(比如修改外部变量、直接发请求)。

使用建议

  1. 调试时:如果在排查 Bug,可以留意一下是不是因为两次渲染导致的逻辑错误。
  2. 写 Effect 时:脑子里模拟一下“连上-断开-连上”的过程,看看代码能不能扛得住。
  3. 请求处理:尽量用成熟的请求库(React Query/SWR),或者确保接口幂等。

写在最后

Strict Mode 就像一个严格的健身教练,刚开始你会觉得它很烦,总是挑你的刺,让你做重复动作。但长远来看,它能帮你练就一身“健壮”的代码体格,避免在未来复杂的并发渲染中受内伤。

下次看到控制台的双重日志,别再骂 React 了,那是它在默默守护你的代码质量。

❌