
你是否注意过一个现象:初级开发者的代码里useEffect遍地都是,而资深开发者的代码中却寥寥无几。这不是偷懒,也不是高手写得少就更高级,而是因为他们对useEffect的理解完全不在一个层次。
这篇文章不聊useEffect的语法(相信你已经烂熟于心),而是从原理层面揭示:为什么你总在过度使用useEffect,而真正的高手却很少动它。
大多数开发者看到useEffect,脑子里装的是这么个概念:
"需要在组件挂载时获取数据?用useEffect" "需要在某个状态改变时更新localStorage?用useEffect" "需要监听外部库的状态变化?用useEffect"
这个思维方式有个致命问题—— 你把useEffect当成了"万能事件触发器",什么事都往里装。
其实,useEffect这个名字本身就在暗示一个真理。它不叫useAfterRender,也不叫useDoSomething,而是叫useEffect。
Effect的真实含义是:同步(Synchronization)
不是执行。不是触发。而是同步。
资深开发者把useEffect理解为:保持React组件与外部世界同步的工具。
这个"外部"包括:网络请求、DOM操作、浏览器API、第三方库的状态……凡是超出React管控范围的东西。
反过来说,如果你在用useEffect来同步React内部的状态和状态,那就走错方向了。
看看这个现实工作中的常见场景:
// 🔴 初级做法 - 充满React内部的"同步"
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// 🟢 高手做法 - 直接计算,不需要额外状态
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // 渲染时直接算
看到了吗?这才是本质差异。
初级开发者创造了一个"伪状态",还需要useEffect来保持它的同步。每次依赖变化,组件就额外渲染一次,还要处理各种潜在的bug。
高手开发者明白:衍生数据根本不需要单独的状态,在渲染阶段直接计算就行。
这样做的好处显而易见:
这看似简单的转变,却反映了对React数据流的深刻理解。
当eslint提示你"缺少依赖"时,你会怎么办?
初级开发者的两条路:
eslint-disable-next-line react-hooks/exhaustive-deps,当什么都没发生高手开发者会停下来问自己: 这个警告在告诉我什么?我的effect设计是不是有问题?
依赖数组不是形式要求,而是一份严肃的合约:
"当这些值变化时,effect必须重新执行,因为外部世界的同步关系发生了改变"
这个合约被违反时,意味着什么?意味着你的effect在基于过期的数据运行,可能导致难以追踪的bug。
看看这个现实项目中常见的坑:
// 🔴 滥用依赖数组黑名单
useEffect(() => {
// 复杂的初始化逻辑
initializeThirdPartyLib();
setupEventListeners();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 真的只想运行一次吗?还是没想清楚?
// 🔴 依赖项变化导致无限循环
useEffect(() => {
fetchData();
}, [fetchData]); // fetchData每次渲染都是新函数!
// 🟢 高手的处理方式 - 把问题解决根本
useEffect(() => {
const controller = new AbortController();
const fetch$ = fetch(`/api/data/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(err => setError(err));
return() => controller.abort(); // 正确的清理逻辑
}, [id]); // 只有真实变化的外部输入
// 或者,提取到自定义hook隐藏细节
function useFetchData(id) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
const controller = new AbortController();
fetch(`/api/data/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
return() => controller.abort();
}, [id]);
return { data, loading, error };
}
高手的核心原则是:不要与依赖数组对抗,要理解它为什么会这样。如果依赖数组看起来很奇怪,说明effect的设计有问题,需要重构。
这是区分"会用React"和"精通React"的分界线。
很多人看到这种代码就感叹"该用useEffect了":
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
return (
<div>
{loading && <div>加载中...</div>}
{error && <div>出错了:{error.message}</div>}
{user && <div>用户:{user.name}</div>}
</div>
);
}
但资深开发者看到这个模式,会直接抽象成可复用的custom hook:
// 一次抽象,永久受益
function useUserData(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
return { user, loading, error };
}
// 现在组件清爽得不行
function UserProfile({ userId }) {
const { user, loading, error } = useUserData(userId);
return (
<div>
{loading && <div>加载中...</div>}
{error && <div>出错了:{error.message}</div>}
{user && <div>用户:{user.name}</div>}
</div>
);
}
// 5个地方要用户数据?直接调用useUserData,不需要复制粘贴effect
这才叫抽象。这才是复用。
好处远不止代码简洁:
cleanup函数的确有存在的必要——清理订阅、取消定时器、中止请求。
但这里有个关键观察:如果你的代码里cleanup函数特别多,说明你的effect在做太多事情。
看这个搜索功能的常见实现:
// 🔴 什么都往一个effect里塞
useEffect(() => {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults);
}, 500);
return() => {
clearTimeout(timeoutId);
controller.abort();
};
}, [query]);
这个effect在做什么?防抖、延迟、fetch、中止……好几件事。
高手会分解它:
// 🟢 单一职责原则应用到effect
// 第一层:防抖逻辑
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return() => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
// 第二层:搜索逻辑
function useSearch(query) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
setLoading(true);
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
setResults(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
}
setLoading(false);
});
return() => controller.abort();
}, [query]);
return { results, loading, error };
}
// 第三层:在组件中组合
function SearchComponent() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
const { results, loading, error } = useSearch(debouncedQuery);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索..."
/>
{loading && <div>搜索中...</div>}
{error && <div>出错了</div>}
<ul>
{results.map(r => <li key={r.id}>{r.name}</li>)}
</ul>
</div>
);
}
关键点:每个effect只做一件事。防抖是一件事,搜索是另一件事。这样cleanup函数就清晰简单,bug也少。
这是最容易被忽视的判断力:什么时候不用useEffect。
// 错误做法
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
useEffect(() => {
setFilteredItems(items.filter(i => i.active));
}, [items]);
// 正确做法
const [items, setItems] = useState([]);
const filteredItems = items.filter(i => i.active); // 就这么简单
// 错误做法 - 把事件逻辑塞进effect
useEffect(() => {
const handleClick = () => setCount(count + 1);
button?.addEventListener('click', handleClick);
return () => button?.removeEventListener('click', handleClick);
}, [count]);
// 正确做法 - 直接用事件处理器
const handleClick = () => setCount(count + 1);
return <button onClick={handleClick}>+1</button>;
// 错误做法 - 用effect同步
useEffect(() => {
setFormData(initialData);
}, [initialData]);
// 正确做法 - 用key强制重新挂载
<Form key={initialData.id} initialData={initialData} />
// 错误做法 - 每个组件都写一遍effect
function ComponentA() {
const [data, setData] = useState(null);
useEffect(() => {
// 复杂的数据处理逻辑
processAndSetData();
}, []);
}
function ComponentB() {
const [data, setData] = useState(null);
useEffect(() => {
// 同样的逻辑复制一遍
processAndSetData();
}, []);
}
// 正确做法 - 提取custom hook
function useProcessedData() {
const [data, setData] = useState(null);
useEffect(() => {
processAndSetData();
}, []);
return data;
}
function ComponentA() {
const data = useProcessedData();
}
function ComponentB() {
const data = useProcessedData();
}
初级开发者经常被困惑:"为什么我的effect运行了两次?""为什么这个值是旧的?"
理解执行顺序就能解答一切:
这意味着什么?
理解这个执行流程,你就知道为什么不能在effect里做复杂的协调逻辑。因为effect根本不是为此设计的。
如果你看过大厂或开源库的高质量代码,会发现这个模式:
高手代码的特征:
初级代码的特征:
为什么差距这么大?不是因为高手知道更多api,而是他们想的更清楚。
如果你想从滥用useEffect的陷阱里走出来,用这个框架来检视你的代码:
问题很简单,但答案决定了一切:
"我这个useEffect在做什么?是在同步外部状态,还是在同步内部状态?"
别和eslint对抗。如果依赖数组警告出现,说明有两种可能:
这是最强大的工具。任何复杂的effect都可以被提取。好处包括:
每个effect应该专注一件事。如果发现自己在一个effect里做多件事,就是拆分的时候了。
最好的effect是你不写的。在写useEffect之前,问问:
如果这些都不行,才是useEffect的合适场景。
很多开发者陷入这样的怪圈:useEffect用得越多,代码越混乱,然后他们认为是自己没学透彻React,继续写更多useEffect来"修复"问题。
事实是反过来的。
真正的React高手之所以写少量useEffect,不是因为他们在"躲避"这个api,而是因为:
所以,想要真正提升React技能,不是学会更多useEffect的用法,而是学会何时不用它。
这是个更高维度的思维转变。理解了这一点,你的React代码质量会有质的飞跃。