author: 大布布将军
前言:午夜惊魂 Network 面板
假设前端同学==A
继上周改完 Context 的 Bug 后,A这周又遇到了新的灵异事件。
场景很经典:A写了一个简单的详情页,页面加载时请求一次 API 获取数据。代码看起来人畜无害,清纯可爱。
但是,当A打开 Chrome 的 Network 面板时,A揉了揉眼睛。同一个 API 接口,在几毫秒内被调用了两次。
A第一反应是鼠标坏了,双击了? 第二反应是后端 Socket 没握手好? 第三反应:React,是不是你小子又在搞我?
今天我们就来聊聊这个让无数前端在深夜破防的 useEffect,以及它是如何一步步变成代码里的"逻辑黑洞"的。
第一案:双重请求的"灵异"事件
先看代码,这应该是全天下 React 开发者写得最顺手的一段:
tsx
useEffect(() => {
console.log('组件挂载了,开始请求数据...');
fetchUserProfile(id).then(data => setUser(data));
}, [id]);
现象: 控制台打印了两行"组件挂载了...",Network 里飞出去两个请求。
真相(React 18 Strict Mode): 如果你的项目是用 create-react-app 或者 vite 建的,并且套了一层 <React.StrictMode>,在 开发环境(Development) 下,React 会故意搞你心态。
它会执行:Mount(挂载) -> Unmount(卸载) -> Mount(再次挂载) 。
React 官方的解释是:
"我们要帮你测试,你的 Effect 里的清理函数(Cleanup Function)是不是写对了。如果你的组件卸载时不清理垃圾,我就把它重新挂载一遍,让你看看会不会出 Bug。"
怎么解决?
- 鸵鸟战术(不推荐) :去
index.js把<StrictMode>删了。世界清静了,但你就失去了一个帮你检查内存泄漏的保镖。 - AbortController(正道的光) : 在
useEffect里写上清理逻辑,取消上一次请求。
useEffect(()
const controller = new AbortController();
fetchUserProfile(id, { signal: controller.signal })
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') {
// 处理真正的错误
}
});
return () => {
// 组件卸载(或者由于 Strict Mode 导致的假卸载)时,取消请求
controller.abort();
};
}, [id]);
老司机心里话: 其实绝大多数时候,在开发环境请求两次根本无所谓。只要你的后端接口是幂等的(查询通常都是),除了看着心烦,上线后(生产环境)它是不会跑两次的。
第二案:useEffect 是怎么变成"逻辑黑洞"的?
比双重请求更可怕的,是把 useEffect 当作逻辑胶水来用。
也就是传说中的"连锁反应(Chain Reaction)"
假设有一个表单:用户选了"国家",自动重置"省份";选了"省份",自动重置"城市"。
❌ 菜鸟写法(Effect 连锁):
//
useEffect(() => {
setProvince('');
}, [country]);
// 监听 province,变了就改 city
useEffect(() => {
setCity('');
}, [province]);
// 监听 city,变了就去请求区号...
useEffect(() => {
if(city) fetchAreaCode(city);
}, [city]);
这有什么问题? 这简直就是玩弹珠台!
- 用户改了
country-> Render -> 触发 Effect 1。 - Effect 1 修改
province-> Render -> 触发 Effect 2。 - Effect 2 修改
city-> Render -> ...
React 必须跑好几轮渲染才能把状态稳住。代码一旦复杂起来,这种依赖链条就像纠缠在一起的耳机线,根本理不清数据流向。
✅ 高手写法(在事件中处理):
永远记住一句话:如果是用户操作引起的改变,就在事件处理函数里写,别在 Effect 里写。
const
setCountry(newCountry);
// 直接在这里重置,不要等 Effect
setProvince('');
setCity('');
};
这就叫Batch Update(批处理) 。一次点击,改好所有状态,React 只需一次 Render 就能把正确的 UI 画出来。清晰、高效、无副作用。
第三案:由数据推导数据的"脱裤子放屁"
还有一个重灾区:冗余状态。
❌ 错误示范:
const
const [lastName, setLastName] = useState('Doe');
const [fullName, setFullName] = useState('');
// 兄弟,你这是在用 React 算 1+1=2 吗?
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
这里 fullName 根本不需要是 State!因为它可以直接由 firstName 和 lastName 计算出来。 你这么写,又是多一次 Render。
✅ 正确示范:
const
const [lastName, setLastName] = useState('Doe');
// 在渲染过程中直接计算
// 只要组件重渲染,fullName 自动就是最新的
const fullName = `${firstName} ${lastName}`;
如果计算很昂贵(比如要遍历几千个数组),那就用 useMemo 包一下,但千万别用 useEffect 去 setState。
总结:该把 useEffect 关进笼子了
React 官方文档现在都在极力劝退 useEffect。
为什么? 因为 useEffect 的本意是: "将组件与外部系统(Network, DOM, Subscription)同步" 。 它不是 用来监听数据变化的,也不是用来做逻辑流转的。
当你准备写下 useEffect 之前,请先问自己三个问题:
- 这是为了和后端/外部同步吗? 是 -> 写吧。
- 这是用户交互(点击/输入)导致的吗? 是 -> 写在 Event Handler 里。
- 这是可以用现有的 Props/State 算出来的吗? 是 -> 直接算,或者用
useMemo。
把 useEffect 用在刀刃上,你的代码会少一半 Bug,你的发际线会后退得慢一点。
好了,A要去把那个在 useEffect 里写 setInterval 还没有清除定时器的实习生代码改了,不然浏览器内存要炸了。
下期预告 :你以为
useRef只是用来获取 DOM 节点的吗?错了,它是 React 函数式组件里唯一的"逃生舱"和"时光胶囊"。下一篇,带你解锁useRef的骚操作。