深入探究 React Hooks 你一直在写却从未写对的最佳实践
你打开编辑器,新建一个组件,熟练地敲下 useState、useEffect,写完业务逻辑,跑起来没报错,提交代码,完事。
这套流程你可能每天重复十几次。但你有没有想过,为什么你的组件越写越臃肿?嗯......也不完全是,有想过,为什么你的组件越写越臃肿。为什么一个简单的列表页,useEffect 的依赖数组能写到七八个?为什么你明明只改了一个输入框的值,整个页面却在疯狂 re-render?
React Hooks 从 2019 年发布到现在,已经七年了。
一、你写的 useEffect,有一半可能不该存在
这不是危言耸听。打开你手头任意一个 React 项目,搜索 useEffect,数一下有多少个是在做"根据 state A 计算 state B"这件事。比如这种代码:
tsx
// 经典反模式:用 useEffect 同步派生状态
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
const [keyword, setKeyword] = useState('');
useEffect(() => {
setFilteredItems(items.filter(item => item.name.includes(keyword)));
}, [items, keyword]);
看起来没毛病对吧?items 或 keyword 变了,就重新过滤一遍。逻辑上完全正确。
但问题在于,这段代码会让你的组件渲染两次 。第一次是 items 或 keyword 变化触发的渲染,第二次是 useEffect 里调用 setFilteredItems 又触发了一次渲染。用户看不出来,但你的组件树在默默承受双倍的压力。
正确的做法简单到让人觉得不值一提:
tsx
// 直接在渲染过程中计算
const [items, setItems] = useState([]);
const [keyword, setKeyword] = useState('');
const filteredItems = items.filter(item => item.name.includes(keyword));
没了。不需要 useEffect,不需要额外的 useState。等等,其实useEffect,不需要额外的 useState。filteredItems 就是一个从已有 state 派生出来的值,每次渲染时重新计算就行。
"但是过滤操作开销很大怎么办?"------这时候才轮到 useMemo 出场:
tsx
// 计算量大时用 useMemo 缓存
const filteredItems = useMemo(
() => items.filter(item => item.name.includes(keyword)),
[items, keyword]
);
React 官方文档里有一句话说得很直白:"You might not need an effect." 这句话应该贴在每个 React 开发者的显示器上。
1.1 那什么时候该用 useEffect?
一条简单的判断标准:只有当你需要跟 React 渲染系统之外的东西打交道时,才需要 useEffect。
这些"外部的东西"包括:浏览器 DOM API、定时器、网络请求、第三方库实例、WebSocket 连接等。
tsx
// 正当的 useEffect 用途:与外部系统同步
useEffect(() => {
const map = new MapboxGL.Map({ container: mapRef.current });
return () => map.remove();
}, []);
// 正当的 useEffect 用途:订阅浏览器事件
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
如果你发现 useEffect 里做的事情是"state A 变了,所以设置 state B",十有八九可以去掉它。
1.2 从 useEffect 链条中脱身
比双重渲染更可怕的是 useEffect 链条------A 变了触发 effect 设置 B,B 变了又触发另一个 effect 设置 C。代码一多,你完全搞不清一个状态变化会引起什么连锁反应。
tsx
// useEffect 瀑布流,调试噩梦
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
useEffect(() => {
setGreeting(`Hello, ${fullName}`);
}, [fullName]);
useEffect(() => {
document.title = greeting;
}, [greeting]);
重构的思路很清晰------把能在渲染时计算的全部提出来,只保留真正需要副作用的部分:
tsx
// 派生值直接算,只保留真正的副作用
const fullName = `${firstName} ${lastName}`;
const greeting = `Hello, ${fullName}`;
useEffect(() => {
document.title = greeting;
}, [greeting]);
三、自定义 Hook 的边界感
3.1 一个好的自定义 Hook 长什么样
先看一个我在几乎每个项目里都会写的 Hook:
tsx
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
用起来极其自然:
tsx
function SearchBox() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 300);
useEffect(() => {
if (debouncedKeyword) {
fetchSearchResults(debouncedKeyword);
}
}, [debouncedKeyword]);
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />;
}
这个 Hook 好在哪?它满足三个条件:独立的关注点 (只做防抖)、可复用 (任何需要防抖的值都能用)、接口简洁(入参和返回值一目了然)。
3.2 过度抽象的信号
再看一个反面案例:
tsx
// 过度抽象:把一个组件的全部逻辑塞进 Hook
function useUserProfile(userId: string) {
const [user, setUser] = useState(null);
const [editing, setEditing] = useState(false);
const [formData, setFormData] = useState({});
const [saving, setSaving] = useState(false);
const [avatarFile, setAvatarFile] = useState(null);
const [showCropModal, setShowCropModal] = useState(false);
useEffect(() => { /* 加载用户数据 */ }, [userId]);
const handleSave = async () => { /* 保存逻辑 */ };
const handleAvatarChange = (file: File) => { /* 头像处理 */ };
const handleCrop = (area: CropArea) => { /* 裁剪逻辑 */ };
return {
user, editing, formData, saving, avatarFile, showCropModal,
setEditing, setFormData, handleSave, handleAvatarChange,
handleCrop, setShowCropModal,
};
}
这个 Hook 返回了十二个东西。它不是"逻辑复用",它只是"把代码从组件搬到了另一个文件"。组件变短了,但复杂度一点没少,反而多了一层间接性------你读组件的时候还得跳到 Hook 里才能理解逻辑。判断标准很简单:如果这个 Hook 只被一个组件使用,而且返回值超过 4 个,大概率是过度抽象了。
3.3 更合理的拆分方式
与其做一个"大而全"的 Hook,不如按关注点拆分成几个小 Hook:
tsx
// 按关注点拆分
function useUser(userId: string) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
return { user, loading };
}
function useImageCrop() {
const [file, setFile] = useState<File | null>(null);
const [showModal, setShowModal] = useState(false);
const [croppedUrl, setCroppedUrl] = useState<string | null>(null);
const startCrop = (f: File) => { setFile(f); setShowModal(true); };
const confirmCrop = (area: CropArea) => {
setCroppedUrl(cropImage(file!, area));
setShowModal(false);
};
return { showModal, file, croppedUrl, startCrop, confirmCrop };
}
每个 Hook 职责单一,useUser 可以在任何需要获取用户信息的地方复用,useImageCrop 可以在任何需要图片裁剪的场景复用。
四、实战场景:那些年你一定踩过的坑
4.1 闭包陷阱:useEffect 里拿到的永远是旧值
这可能是 Hooks 里最让人抓狂的问题了。来看一个计数器:
tsx
// 经典闭包陷阱
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log('current count:', count); // 永远是 0
setCount(count + 1); // 永远设置成 1
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
useEffect 的依赖数组是 [],意味着回调函数只在首次渲染时创建一次。继续。这个回调"记住"了创建时的 count 值------也就是 0。之后无论组件渲染多少次,定时器里的 count 永远是 0。
解决方案有两种,看场景选择:
tsx
// 方案一:使用函数式更新,不依赖外部变量
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
// 方案二:用 useRef 持有最新值(适合需要读取但不需要触发渲染的情况)
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setInterval(() => {
console.log('current count:', countRef.current); // 永远是最新值
}, 1000);
return () => clearInterval(id);
}, []);
setCount(prev => prev + 1) 的函数式更新不依赖闭包里的 count,而是拿到 React 内部最新的 state 计算一下。
4.2 请求竞态:快速切换 Tab 时数据错乱
后台管理系统里很常见的场景:Tab 切换加载不同分类的数据。用户快速点击 Tab A -> Tab B -> Tab C,三个请求几乎同时发出(这个说法其实不太严谨)。如果 Tab A 的请求最后才返回,页面上显示的就是 Tab A 的数据,但用户当前停在 Tab C 上。
tsx
// 没有处理竞态,可能展示错误数据
useEffect(() => {
setLoading(true);
fetchData(activeTab).then(data => {
setData(data);
setLoading(false);
});
}, [activeTab]);
解法是利用 useEffect 的清理函数:
tsx
// 用 cleanup 标记过期请求
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchData(activeTab).then(data => {
if (!cancelled) {
setData(data);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [activeTab]);
当 activeTab 变化时,React 先执行上一次 effect 的清理函数(把 cancelled 设为 true),再执行新的 effect。上一次请求即使返回了,也会因为 cancelled 为 true 而被忽略。
如果你的项目使用 AbortController,可以做得更彻底------直接取消请求本身:
tsx
// 用 AbortController 真正取消请求
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetchData(activeTab, { signal: controller.signal })
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [activeTab]);
4.3 useCallback 和 useMemo 的滥用问题
"为了性能优化,所有函数都包一层 useCallback,所有计算都包一层 useMemo"------这是另一种常见的误区。
tsx
// 无意义的 useCallback
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <button onClick={handleClick}>Click me</button>;
useCallback 本身有开销------它需要在每次渲染时比较依赖数组。如果你包裹的函数传给的是一个普通的 HTML 元素(<button>、<div>),这层缓存完全没有意义,因为原生 DOM 元素不会因为 props 引用变化而跳过渲染。
useCallback 只在两种场景下有意义:
tsx
// 场景一:传给用 React.memo 包裹的子组件
const MemoizedList = React.memo(({ onItemClick }: Props) => {
// 渲染很重的列表
});
function Parent() {
const handleItemClick = useCallback((id: string) => {
navigate(`/items/${id}`);
}, [navigate]);
return <MemoizedList onItemClick={handleItemClick} />;
}
// 场景二:作为其他 Hook 的依赖
function useSearch(fetchFn: () => Promise<Data[]>) {
useEffect(() => {
fetchFn().then(setData);
}, [fetchFn]); // fetchFn 引用不稳定会导致无限请求
}
useMemo 同理。对一个 20 条数据的数组做 .filter() 用不着 useMemo,JavaScript 引擎处理这种量级的数据快得你根本感知不到。只有当你处理成百上千条数据的复杂计算,或者需要保持引用稳定时,useMemo 才值得引入。
六、避坑清单:8 个高频错误
6.1 依赖数组相关
不要对依赖数组撒谎。 ESLint 的 exhaustive-deps 规则提示你缺少依赖时,不要随手加一个 // eslint-disable-next-line。那个警告在保护你。
tsx
// 压制 lint 警告,埋下定时炸弹
useEffect(() => {
fetchData(userId, filters);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]); // filters 变了不会重新请求
6.3 在循环/条件语句中调用 Hooks
这是 React 的铁律------Hooks 必须在组件顶层调用 ,不能放在 if、for、或嵌套函数里。React 靠调用顺序来识别每个 Hook,顺序一乱,state 就全串了。
tsx
// 条件调用 Hook,React 会直接报错
if (isLoggedIn) {
const [profile, setProfile] = useState(null);
}
// Hook 无条件调用,在内部处理条件逻辑
const [profile, setProfile] = useState(null);
useEffect(() => {
if (isLoggedIn) {
fetchProfile().then(setProfile);
}
}, [isLoggedIn]);
6.4 用 useState 存放不需要触发渲染的值
定时器 ID、上一次请求的参数、DOM 测量值------这些数据变化时你并不想让组件重新渲染。useRef 才是正确的工具:
tsx
// 用 useState 存定时器 ID,每次清除/设置都多一次渲染
const [timerId, setTimerId] = useState<number | null>(null);
// 用 useRef,修改不触发渲染
const timerRef = useRef<number | null>(null);
6.5 useEffect 缺少清理函数
6.6 在 useEffect 里直接用 async
tsx
// useEffect 不接受 async 函数
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
// 在内部定义 async 函数
useEffect(() => {
async function loadData() {
const data = await fetchData();
setData(data);
}
loadData();
}, []);
useEffect 期望返回值是一个清理函数或 undefined,而 async 函数返回的是 Promise------类型对不上。
6.7 状态初始化的性能陷阱
tsx
// 每次渲染都执行 expensiveComputation
const [data, setData] = useState(expensiveComputation());
// 传入函数,只在首次渲染时执行
const [data, setData] = useState(() => expensiveComputation());
区别就是一对括号。
6.8 Context 导致的不必要渲染
useContext 没有选择器机制------只要 Context 的值变了,所有消费这个 Context 的组件都会重新渲染,哪怕它们只用了其中一个字段。
tsx
// 一个巨大的 Context,任何字段变化都会触发全量渲染
const AppContext = createContext({
user: null,
theme: 'light',
locale: 'zh-CN',
notifications: [],
});
// 按变化频率拆分成多个 Context
const UserContext = createContext(null);
const ThemeContext = createContext('light');
const NotificationContext = createContext([]);
变化频率不同的数据放在同一个 Context 里,是性能问题的常见根源。notifications 每隔几秒更新一次,但 theme 可能整个会话都不会变------把它们拆开,theme 的消费者就不会被 notifications 的更新波及。