前端项目中那些值得借鉴的最佳实践与避坑指南
又踩坑了,React 里 useEffect 依赖项没写对,数据总对不上
上周改一个用户资料页,页面加载后要从接口拉取用户信息,然后根据用户角色动态设置页面标题。结果每次刷新页面,标题要么是空的,要么是上一个用户的——明显是状态没同步。我一开始以为是接口慢,加了 loading 状态,但问题依旧。折腾了半天才发现,是 useEffect 的依赖项写错了。
最开始的“想当然”写法
我最初的代码大概是这样的:
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // 这里只写了空依赖!
useEffect(() => {
if (user) {
document.title = ${user.name} - 个人中心;
}
}, [user]); // 看似合理?
return /* ... */;
}
看起来没问题吧?第一个 effect 只在组件挂载时跑一次,第二个 effect 监听 user 变化更新标题。但问题就出在第一个 effect 的依赖项是空数组——它根本不会响应 userId 的变化!
比如,用户从 A 页面跳转到 B 页面,userId 从 1001 变成 1002,但第一个 effect 不会重新执行,user 还是 1001 的数据,导致标题错乱。这在 SPA 路由切换时特别容易中招。
试了三种方案,前两种都翻车了
发现问题后,我第一反应是:把 userId 加进第一个 effect 的依赖里不就完了?
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // 改成这样
确实能解决问题,但马上又遇到新坑:如果 fetchUser 是个异步操作,而用户快速切换多个用户(比如点列表里的不同头像),旧的请求可能还没完成,新的请求又发出去了。结果就是,先发的慢请求后回来,覆盖了后发的快请求的结果——显示错人!
于是我赶紧加了个 abort controller 来取消旧请求:
useEffect(() => {
const controller = new AbortController();
fetchUser(userId, { signal: controller.signal })
.then(setUser)
.catch(e => {
if (e.name !== 'AbortError') console.error(e);
});
return () => controller.abort();
}, [userId]);
逻辑上没问题,但代码变得啰嗦,而且每次都要手动处理取消,项目里几十个类似的地方,复制粘贴容易漏。更烦的是,有些老接口不支持 AbortSignal,还得额外封装一层兼容逻辑。
后来我又想到用 useRef 存当前的 userId,在回调里判断是否匹配再 setState。但这样不仅绕,还容易出错,setState 本身也有竞态风险。
核心代码就这几行:用 useAsyncFn 或自定义 hook 封装
最后我决定不自己造轮子了,直接用社区成熟的方案。其实 React 官方文档里就提过,对于这种“依赖变化触发异步操作”的场景,最好的方式是把整个逻辑封装成一个自定义 hook,或者用现成的工具库。
我选了最轻量的方式:自己写个简单的 useFetch,核心就几行,但能自动处理依赖变化和请求取消:
import { useState, useEffect, useRef } from 'react';
function useFetch(fetchFn, deps) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const latestRef = useRef(0);
useEffect(() => {
const currentId = ++latestRef.current;
setLoading(true);
fetchFn()
.then(result => {
if (currentId === latestRef.current) {
setData(result);
setLoading(false);
}
})
.catch(error => {
if (currentId === latestRef.current) {
setLoading(false);
console.error('Fetch failed:', error);
}
});
return () => {
// 组件卸载时自动忽略后续结果
};
}, deps);
return { data, loading };
}
用法也简单:
function UserProfile({ userId }) {
const { data: user, loading } = useFetch(
() => fetch(https://jztheme.com/api/users/${userId}).then(r => r.json()),
[userId] // 关键:依赖 userId
);
useEffect(() => {
if (user) {
document.title = ${user.name} - 个人中心;
}
}, [user]);
if (loading) return <div>加载中...</div>;
return /* ... */;
}
这里用 latestRef 计数器来判断是否是最新请求的结果,避免旧数据覆盖新数据。虽然不是用 AbortController,但效果一样,而且兼容所有环境。亲测有效,连 IE11 都能跑(虽然现在没人用了)。
踩坑提醒:这三点一定注意
- 依赖项别偷懒写空数组:只要 effect 里用了外部变量(props、state、函数等),就必须加进依赖数组。ESLint 的
react-hooks/exhaustive-deps规则一定要开,别关! - 异步操作必须处理竞态:网络请求天然有延迟,多个请求交错时,不处理就会显示错数据。要么用 AbortController,要么用计数器/时间戳判断,别指望“一般不会那么快切换”。
- 别在 effect 里直接写复杂逻辑:像上面那样,把数据获取逻辑抽成自定义 hook,不仅复用方便,还能集中处理 loading、error、竞态等问题,主组件干净多了。
其实 React 官方文档里专门有一节讲“如何处理竞态条件”,但很多人(包括我)第一次写的时候都会忽略。直到线上 bug 报上来才意识到问题严重性。
这个方案不是最优的,但最简单
说实话,现在主流方案是用 React Query 或 SWR 这类数据获取库,它们内置了 stale-while-revalidate、自动重试、竞态处理等能力,比手写 hook 强太多。但如果你的项目还没引入这些库,或者只是临时改个小功能,上面那个 useFetch 已经够用,而且不到 20 行代码,理解成本低。
改完之后,标题终于能正确随用户切换了。不过有个小瑕疵:如果用户切换极快,loading 状态可能会闪一下,但不影响功能,暂时没管。毕竟优先级不高,先上线再说。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有更简洁的竞态处理方式?或者你用 React Query 怎么配置的?一起讨论下。

暂无评论