1. 类组件时代的三重折磨:为什么Hooks会被逼出来
先交代一下背景。如果你入行就用Hooks,可能很难体会当年class组件到底有多别扭。我从React 15时代开始写业务,经历过HOC嵌套、render props、生命周期里塞满逻辑的日子,所以16.8正式推Hooks时,我基本上是第一时间切换的。
类组件时代第一个让人崩溃的问题是状态逻辑复用。当时想抽出一段公共逻辑(比如订阅窗口尺寸、请求用户信息),主流方案是HOC(高阶组件)或者render props。HOC的典型写法长这样:
// 高阶组件:withWindowSize function withWindowSize(WrappedComponent) { return class extends React.Component { state = { width: window.innerWidth }; componentDidMount() { window.addEventListener('resize', this.handleResize); } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); } handleResize = () => { this.setState({ width: window.innerWidth }); }; render() { return <WrappedComponent width={this.state.width} {...this.props} />; } }; }用倒是能用,但一段逻辑被拆成了state、componentDidMount、componentWillUnmount、handleResize四个地方,你永远不知道改一个逻辑要动几个文件。更可怕的是当多个HOC叠加时:
export default withRouter(withAuth(withWindowSize(MyComponent)));这行代码的背后是层层嵌套的Provider和高阶包装,排查数据从哪来都得翻半天源码。社区里管这叫"包装地狱",真不是夸张。
第二个痛点是this。class组件里到处都是this.state、this.props、this.setState,一不小心忘记绑定事件处理函数,this就是undefined。新手最容易在这里被折磨,即使老手偶尔也会因为箭头函数和普通函数的混用而出错。这个心智负担和业务逻辑本身毫无关系,纯粹是语言和组件模型叠加出来的额外成本。
第三个痛点更隐蔽:副作用逻辑被生命周期强行切碎。一个常见的场景——某个数据变化时需要先取消旧请求再发起新请求,同时还要更新页面标题。在class组件里,你需要在componentDidMount里写初始化逻辑,在componentDidUpdate里比对prevProps决定是否重新执行,在componentWillUnmount里做清理。同一件事的逻辑散落在三个方法里,每次改动都要跳来跳去。
Hooks的出现本质上不是"少写几个字",而是换了一种组织逻辑的单位:从"按生命周期切分"变成"按关注点聚合"。useEffect把一个完整副作用的订阅、更新、清理放在同一个函数里,useState把状态声明和更新方式放在一起,逻辑的归属感一下子清晰了很多。这也是我在这篇文章里想带大家系统性过一遍的核心视角——不要只记API怎么用,要理解每个Hook设计出来到底为了解决什么问题。
下面我按照日常开发中实际用到的频率,把整个Hooks全家桶逐个过一遍。
2. 状态管理双子星:useState与useReducer的选型思路
2.1 useState的三种写法与更新语义
useState是大家最熟悉的一个,但很多细节仍然容易翻车。最基本的用法:
const [count, setCount] = useState(0);这里有几个值得注意的点。
第一是初始化只会在首次渲染时执行。如果你写的是useState(computeInitialValue()),那么这个computeInitialValue在每次渲染时都会被调用,虽然返回值只在第一次被使用——白白浪费性能。正确做法是传入函数:
// 惰性初始化:这个函数只会在组件首次渲染时执行一次 const [state, setState] = useState(() => { const saved = localStorage.getItem('myKey'); return saved ? JSON.parse(saved) : defaultValue; });尤其当初始化逻辑涉及复杂计算或者读取外部存储时,一定要用函数式写法。
第二是更新时优先使用函数式写法。当新状态依赖旧状态时:
setCount(prev => prev + 1);为什么不直接写setCount(count + 1)?因为在异步场景或者连续多次更新的场景下,count拿到的可能是旧值。函数式写法接收的是"最新的上一次状态",配合React的批处理机制才能保证结果正确。
第三是setState传入相同值时,React会跳过这次渲染。这个比较实用,比如你重复set同一个对象引用,组件不会白白重渲染。
2.2 React 18的自动批处理:为什么setState有时不像你想象的执行
React对状态更新的批处理机制经历了一个演进。class组件时代,在事件处理函数里的多次setState会被合并成一次渲染,但在setTimeout、Promise回调、原生事件监听器里,每次setState都会触发一次渲染。React 18之后改了策略——所有场景默认自动批处理。
这意味着什么?举个实际例子:
function handleClick() { setCount(1); setFlag(true); setLoading(false); // React 18+ 会把上面三次更新合并成一次渲染 }这个机制的改变让状态更新行为更可预期了,但也带来了一个经典陷阱:如果你在异步回调里依赖某个刚set完的状态,直接读变量是拿不到的:
function handleSubmit() { setLoading(true); fetchData().then(() => { // 这里的loading仍然是旧值! console.log(loading); }); }这是闭包特性加异步更新共同导致的问题,不是Bug。要获取最新状态,要么用useEffect监听,要么用useRef保存最新值,后面第6节会展开讲。
2.3 useReducer:当状态逻辑开始变得复杂时
useReducer适合的状态模型是:多个状态字段互相依赖、更新逻辑可以归类为有限几种动作。比如表单、购物车、复杂交互流程。
一个典型的购物车逻辑:
const initialState = { items: [], total: 0 }; function cartReducer(state, action) { switch (action.type) { case 'ADD_ITEM': { const items = [...state.items, action.payload]; const total = items.reduce((sum, item) => sum + item.price, 0); return { items, total }; } case 'REMOVE_ITEM': { const items = state.items.filter(item => item.id !== action.payload.id); const total = items.reduce((sum, item) => sum + item.price, 0); return { items, total }; } default: return state; } } const [cart, dispatch] = useReducer(cartReducer, initialState);选用useReducer的判断标准我一般看两条:
- 更新逻辑是否涉及多个字段联动的计算(比如加购物车的同时要算总数)。
- 更新操作的类型是否有限且可枚举(ADD、REMOVE、UPDATE这些)。
如果只是count + 1这种简单场景,用reducer纯属杀鸡用牛刀。如果状态之间没有联动关系、每个字段独立更新,用多个useState反而更清晰。还有一个团队协作层面的好处:reducer是纯函数,容易单测,逻辑集中,Code Review的时候一眼能看出某类操作会不会产生非法状态。
3. 副作用编排实战:useEffect与useLayoutEffect的边界
3.1 useEffect的执行时机:渲染后、浏览器绘制后
useEffect默认是在浏览器完成绘制之后异步执行的。React先把组件渲染到DOM上,交给浏览器绘制,然后才执行effect。这个顺序很重要,因为它保证了effect不会阻塞用户看到首屏内容。
完整的执行环节可以这样理解:
触发更新 -> 组件函数执行 -> React计算出新的虚拟DOM -> 提交到真实DOM(commit) -> 浏览器绘制(paint) -> 执行useEffect回调这也解释了为什么在useEffect里做DOM测量拿到的尺寸是"新渲染之后"的尺寸。在class组件时代,很多人在componentDidMount里测量DOM,两者的时机类似,但Hooks的写法更直观。
3.2 清理函数:被大多数人忽视的必修课
useEffect的返回值如果是函数,React会在下次effect执行前或组件卸载时调用它。这个清理机制用来取消订阅、清除定时器、撤销请求:
useEffect(() => { const timer = setInterval(() => { // 轮询逻辑 }, 3000); return () => { clearInterval(timer); }; }, []);很多人觉得加不加清理无所谓,因为开发模式下组件只挂载一次。但生产环境里组件可能会因为路由切换、条件渲染而被卸载,不清理定时器就是内存泄漏;不取消请求就会在组件卸载后收到setState警告(React 18之后不再警告,但内存里的闭包还在)——更麻烦的是可能产生竞态:旧请求晚返回结果覆盖了新请求的结果。
3.3 依赖数组与闭包陷阱:从"按生命周期想"到"按值想"
依赖数组是useEffect最难的坎,核心问题是闭包捕获了渲染那一刻的值。
经典例子:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { // 这里的count永远是初次渲染时的0! setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); }这段代码的结果是count永远停在1。因为effect只执行了一次,闭包里的count是首次渲染的0。修复方式有两种:一是把依赖加进数组:
useEffect(() => { const id = setInterval(() => { setCount(prev => prev + 1); }, 1000); return () => clearInterval(id); }, []);只要用函数式更新,不依赖外部变量,依赖数组就可以留空。二是用useRef持有最新值(后面细讲)。
理解依赖数组的关键是换一种思维方式:不要在头脑里模拟"这个effect什么时候该重新执行",而是要问"effect内部用到了哪些外部值,这些值变了之后,effect所在的世界是否需要重新同步一遍"。这就是React官方和社区反复强调的"依赖外部值就声明它"原则。
3.4 useLayoutEffect:少数需要同步操作的场景
useLayoutEffect与useEffect的区别只有一个关键点:它是同步执行的,发生在浏览器绘制之前。也就是说,在useLayoutEffect里修改DOM,用户不会看到"先渲染旧样式再闪成新样式"的闪烁。
典型场景是测量DOM并调整布局,比如根据某个元素的宽度动态决定工具提示框的悬浮方向:
useLayoutEffect(() => { const rect = ref.current.getBoundingClientRect(); // 根据rect信息调整样式 setPosition(computePosition(rect)); }, [deps]);如果这段逻辑写在useEffect里,用户可能先看到错误位置的提示框闪一下再跳到正确位置。用useLayoutEffect则能在绘制前完成调整。
日常建议是:能用useEffect就用useEffect,因为它不阻塞绘制。只有出现布局闪烁问题时再换成useLayoutEffect。另外注意服务端渲染时useLayoutEffect会警告,需要时得做条件兼容,不过现在大多数项目已经用客户端渲染或者框架处理了这个问题。
3.5 StrictMode下effect重复执行:开发模式的"体检报告"
React 18的StrictMode在开发环境下会故意让effect挂载-清理-再挂载一次。我第一次遇到时也以为是Bug,查了很久才发现这是刻意为之:帮你暴露漏写清理函数的问题。如果你写的effect没有正确清理,StrictMode的双执行会放大异常,比如重复订阅事件导致副作用叠加。所以遇到strict模式下的双执行,别急着骂框架,先检查自己的清理逻辑是不是不完整。
4. useContext与跨组件状态:从props drilling到迷你store
4.1 什么场景真的需要Context
useContext解决的问题是跨多层组件的状态传递。假设有个主题色,要从根组件传到第五层子组件,中间三层都用不到它。只靠props传递的话,每一层都得写theme={this.props.theme},这个"透传"代码不产生任何业务价值。Context允许你把值直接"广播"给任意深度的消费者。
但Context不是万能的。它适合的是低频变化的全局数据:主题、当前登录用户、语言环境、权限标记。不适合高频变化的数据(比如实时鼠标位置),因为每次value变化,所有消费者都会重渲染。React团队也建议Context与状态管理库配合使用,而不是把一切都塞进Context。
4.2 Provider的value引用稳定性:一个常见的重渲染陷阱
看这段代码:
function App() { const [theme, setTheme] = useState('light'); return ( <ThemeContext.Provider value={{ theme, setTheme }}> <Main /> </ThemeContext.Provider> ); }问题在于:每次App重渲染,value={{ theme, setTheme }}都会创建一个新的对象引用。即使theme没变,所有消费ThemeContext的子组件也会因为引用变化而重渲染。解决方式是用useMemo缓存value:
const value = useMemo(() => ({ theme, setTheme }), [theme]);这是个很容易被忽视的性能细节。很多团队遇到"随便点一下页面就大面积重渲染"的问题,排查到最后经常发现是Context value写成了内联对象。
4.3 拆Context的粒度:别把一堆数据塞进一个Provider
实际开发中我喜欢把Context按业务域拆开。比如一个后台管理系统,登录用户信息、权限点、偏好设置是三个互相独立的域,就拆成三个Context。如果塞进一个Provider,任何一部分变了,所有消费者都得跟着重渲染。
拆分的另一个好处是职责清晰:组件只订阅自己关心的数据。读者可以理解为粒度越细,互相拖累的机会越少。
4.4 useReducer + useContext:一套轻量级状态方案
这两个Hook组合起来可以做一个去中心化的迷你状态中心,不打任何状态管理库,也能满足不少业务场景:
const CountContext = createContext(null); function CountProvider({ children }) { const [state, dispatch] = useReducer(countReducer, initialState); const value = useMemo(() => [state, dispatch], [state]); return ( <CountContext.Provider value={value}> {children} </CountContext.Provider> ); } function useCount() { const context = useContext(CountContext); if (!context) { throw new Error('useCount必须在CountProvider内部使用'); } return context; }注意我在useCount里加了空值判断和报错提示,这是个小技巧:当组件被错误地放置到Provider外面时,报错信息比"undefined is not a function"友好得多。团队协作时这类防御性代码能节省大量排查时间。
这里需要明确:这种方案并不等同于redux这样的全局状态管理。它适合中小型应用,或者大型应用里某个子模块的状态隔离。一旦出现跨模块复杂联动、需要时间旅行调试、中间件处理异步副作用,还是得引入成熟的状态管理库。
5. useMemo、useCallback与memo:性能优化的正确打开方式
5.1 三兄弟的分工:先弄明白各自管什么
很多初学者分不清useMemo、useCallback和React.memo,我先把分工表列出来:
| 工具 | 作用对象 | 核心目的 | 返回结果 |
|---|---|---|---|
| useMemo | 任意计算结果 | 缓存计算结果,避免重复计算 | 计算值本身 |
| useCallback | 函数 | 缓存函数引用,保证依赖它的人不受影响 | 函数本身 |
| React.memo | 组件 | 比较props的引用,跳过不必要的子组件重渲染 | 缓存后组件 |
一句话记忆:useMemo缓存"值",useCallback缓存"函数引用",React.memo缓存"组件渲染结果"。
5.2 引用变化如何引发子组件被迫重渲染
React组件的默认行为是:父组件重渲染,子组件函数也会重新执行,无论子组件的props有没有变化。这在大多数场景下没问题,因为React的协调计算足够快。但有两个例外:渲染成本较高的子组件、以及依赖引用相等性的场景。
最常见的连锁反应是这样的:
function Parent() { const [count, setCount] = useState(0); // 普通函数,每次Parent渲染都会得到一个新引用 const handleClick = () => setCount(c => c + 1); // 普通对象,每次Parent渲染都是新引用 const config = { retry: 3, timeout: 5000 }; return <ExpensiveChild onClick={handleClick} config={config} />; }就算给ExpensiveChild包上React.memo,props里的函数和对象每次都是新引用,memo的浅比较永远不相等,缓存形同虚设。正确做法是在父组件里把引用稳定下来:
const handleClick = useCallback(() => setCount(c => c + 1), []); const config = useMemo(() => ({ retry: 3, timeout: 5000 }), []);useCallback的本质就是"让这个函数在依赖不变时保持同一个引用"。注意它本身也是useMemo的语法糖——useCallback(fn, deps)等价于useMemo(() => fn, deps)。
再往深一层说,当自定义Hook返回函数或者对象时,如果这些返回值的引用每次调用都变,外部使用方一旦用了useEffect依赖它,就会陷入"函数引用变了 -> effect重跑 -> 触发状态更新 -> 再次渲染 -> 函数引用又变"的循环。所以任何从自定义Hook返回的函数,我都建议用useCallback包一层。
5.3 性能优化的滥用:什么时候不该用它们
我的态度可能跟很多教程不一样:不要给所有东西都套上useMemo和useCallback。
理由有三点。
第一,它们本身有成本。每次渲染都要比较依赖数组是否变化,这本身是一次遍历。对于简单的计算逻辑,缓存开销可能大于直接计算的代价。
第二,心智负担。依赖数组写错,可能导致缓存失效或者缓存过期,引入非常难排查的隐性Bug。
第三,代码可读性变差。满屏的useMemo、useCallback会让核心业务逻辑淹没在性能优化代码里。
我实际使用的判断标准是:
- 只有渲染成本确实高的组件才值得用
React.memo,判断依据是DevTools里看渲染耗时。 - 只有引用稳定性影响下游行为时才用
useCallback,比如作为useEffect依赖、传给memo子组件。 useMemo只用于真正复杂的计算,比如数组排序、数据聚合、正则处理大文本;简单的表达式直接算。
React官方文档有句话老外常引用:"Your first instinct should be not to use useMemo. Your second instinct should be to consider whether it's worth it."(第一反应是别用,第二反应是认真考虑值不值得用。大意如此。)我很认同。
6. useRef的两种身份与useImperativeHandle
6.1 第一种身份:指向DOM元素的"遥控器"
useRef最基础的用法是拿到DOM节点:
function AutoFocusInput() { const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); return <input ref={inputRef} />; }在函数组件里,ref对象不会因为重渲染而重新创建,.current属性保存的是同一个引用。这个特性让useRef成为"跨渲染期保存变量"的天然容器。
6.2 第二种身份:保存可变值而不触发重渲染
这是useRef在日常开发中更值钱的能力。useState存的值更新会触发重渲染,但有的时候你只是需要一个"随时能取、随时能改、改完不用重渲染"的值。
经典的例子是保存定时器ID:
function useTimer() { const timerRef = useRef(null); const start = () => { timerRef.current = setInterval(() => { // 定时逻辑 }, 1000); }; const stop = () => { clearInterval(timerRef.current); }; useEffect(() => () => clearInterval(timerRef.current), []); return { start, stop }; }比定时器更微妙的场景是"在异步回调里读取最新状态"。前面说过,闭包会捕获旧值,用ref可以绕过去:
function Chat() { const [messages, setMessages] = useState([]); const messagesRef = useRef(messages); // 每次渲染后同步最新的messages到ref useEffect(() => { messagesRef.current = messages; }, [messages]); // 在setTimeout回调里读取的一定是最新messages const delayedCheck = () => { setTimeout(() => { console.log(messagesRef.current.length); }, 3000); }; }这个"用ref保存最新值"的模式再抽象一步,就是社区常见的useLatest自定义Hook:
function useLatest(value) { const ref = useRef(value); useEffect(() => { ref.current = value; }, [value]); return ref; }一个值得注意的细节:修改ref.current不会触发渲染,也不会被并发渲染打断。它是同步生效的,这在处理某些紧急数据传递时比useState更可靠。但反过来,不要依赖ref触发UI更新,UI更新必须走useState。
6.3 useImperativeHandle:向父组件暴露受控的方法
forwardRef配合useImperativeHandle用于父组件主动调用子组件的某些方法。典型场景:父组件需要触发子组件的滚动、聚焦、表单校验。
const Child = forwardRef((props, ref) => { const inputRef = useRef(null); useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => { inputRef.current.value = ''; }, validate: () => { // 返回校验结果 return inputRef.current.value.length > 0; } })); return <input ref={inputRef} defaultValue={props.defaultValue} />; });这样父组件就可以通过childRef.current.focus()来调用子组件方法,而不必直接操作子组件内部的DOM。
这里必须提一个经验:useImperativeHandle本质上是"命令式代码"——它绕开了React的数据流。能不用就尽量不用,优先考虑把状态提升到父组件、通过props驱动子组件表现。但有些场景确实绕不开,比如通用的富文本编辑器、复杂交互的图表组件,这种情况下合理地暴露方法面比强行遵守"声明式"原则要好维护得多。
7. 自定义Hook:让逻辑复用的单位变成函数
7.1 自定义Hook只是一个约定,不是魔法
很多教程把自定义Hook说得高大上,其实它的本质非常朴素:一个以use开头的普通函数,函数内部可以调用其他Hook。
为什么必须以use开头?这是给编译器(尤其是eslint的hooks规则)看的信号,方便静态检查Hook规则是否被遵守。如果你不遵守命名约定,规则就无法识别函数内部是否调用了Hook,也就无法帮你排查违规调用。
自定义Hook的价值是前面所有Hook的集大成者:它把"状态、副作用、缓存、上下文、ref"打包成一个可复用的单元。举个例子,订阅窗口尺寸的逻辑,在class时代需要HOC,现在只需:
function useWindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); useEffect(() => { const handleResize = () => { setSize({ width: window.innerWidth, height: window.innerHeight }); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); return size; }所有想用窗口尺寸的组件都只需要const size = useWindowSize();这一行。
7.2 实战:写一个防抖useDebounce
防抖是前端的高频需求——输入框联想搜索。直接给代码:
function useDebounce(value, delay = 300) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; }用法示范:
function SearchInput() { const [keyword, setKeyword] = useState(''); const debouncedKeyword = useDebounce(keyword, 300); // 只有防抖后的值变化才触发搜索 useEffect(() => { if (debouncedKeyword.length >= 2) { fetchSearchResults(debouncedKeyword); } }, [debouncedKeyword]); return <input value={keyword} onChange={e => setKeyword(e.target.value)} />; }这个Hook的精髓在于useEffect的清理函数:每次value变化,先清掉上一个定时器,再重新计时。这就是防抖的底层实现逻辑——用清理函数"取消上一次的计划"。理解了这一点,你就掌握了useEffect清理函数在真实业务中的最高频用法。
7.3 实战:写一个请求Hook useRequest
网络请求几乎是每个应用都逃不掉的需求。一个最小可用的请求Hook:
function useRequest(requestFn, deps = []) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); setError(null); requestFn() .then(response => { if (!cancelled) { setData(response); } }) .catch(e => { if (!cancelled) { setError(e); } }) .finally(() => { if (!cancelled) { setLoading(false); } }); return () => { cancelled = true; }; }, deps); return { data, loading, error }; }这里我特意加了cancelled标志位,用来解决前面提过的竞态问题:请求还没返回时组件卸载或依赖变了,旧请求的settle就不应该再影响状态。这是手写请求逻辑时最容易遗漏的关键点。
7.4 组合优于继承:把Hook组装成更大的Hook
自定义Hook最大的优势是可以组合。比如做一个搜索场景:
function useDeferredSearch(requestFn, delay = 300) { const [keyword, setKeyword] = useState(''); const debouncedKeyword = useDebounce(keyword, delay); const { data, loading, error } = useRequest( () => requestFn(debouncedKeyword), [debouncedKeyword] ); return { keyword, setKeyword, data, loading, error }; }调用方一行代码就能拿到完整的搜索能力。这种"小Hook组合成大Hook"的模式,比class时代的功能复用干净太多了——依赖关系是显式的,输入输出是明确的,测试时也可以单独验证每个小Hook。
8. 规则、坑位与调试:Hooks日常开发避坑清单
8.1 为什么Hooks不能放在条件语句、循环和嵌套函数里
React底层是用链表来记录每个组件上的Hook的。每次渲染时,React按照你调用Hook的顺序依次取用对应的状态。如果你在一次渲染中调用了3个Hook,下次渲染却因为某个条件只调用了2个,React就不知道第二个Hook对应的是哪个状态了,轻则状态错乱,重则直接报错。
理解了这个机制,就明白为什么官方规定"Hooks必须在组件顶层调用"。这不是为了限制你,而是为了保障调用顺序跨渲染稳定。我在实际开发中遇到过同事在if (isReady)里调用Hook导致状态串位的问题,排查了很久才定位到——这种问题特别隐蔽,因为报错信息可能只是一个诡异的"状态不更新"。
如果确实有条件地使用某些逻辑,怎么办?把条件判断放进Hook内部:
// 错误:条件调用Hook if (enabled) { useEffect(() => { /* ... */ }, []); } // 正确:Hook内部自己处理 useEffect(() => { if (!enabled) return; // ... }, [enabled]);8.2 exhaustive-deps警告:修复还是忽略
eslint的exhaustive-deps规则会检查useEffect依赖数组的完整性。很多人的第一反应是"警告而已,忽略它",但这往往就是Bug的温床。
我的经验是:大多数情况应该按照警告补全依赖,但也要区分"真依赖"和"引用依赖"。
比如这个场景:
const [user, setUser] = useState(null); useEffect(() => { fetchUser().then(setUser); }, []); // fetchUser来自哪里?如果fetchUser是从外部import的稳定函数,不需要进依赖数组。如果它是组件内部定义的函数,每次渲染都是新引用,放进依赖会导致effect无限执行。这时应该用useCallback把函数引用稳定下来,或者把函数定义移出去。
真正需要"强行忽略"的场景少之又少,比如"只在挂载时执行一次"且明确知道不需要响应某些值变化。即便如此,我也建议在忽略处写注释说明原因,而不是直接关掉规则。团队协作里,一行注释能避免下一个人踩坑后回来骂你。
8.3 我踩过的三个真实案例
第一个案例是定时器的闭包陷阱。我之前写过一个带倒计时功能的弹窗组件,倒计时数字用useState存储,setInterval里直接读count并减一。结果数字永远不更新。当时排查了很久,最后意识到是闭包捕获了初始值。后来统一改成函数式更新或者用ref持有最新值,问题才彻底解决。现在我看到任何setInterval和useState的组合,第一反应就是检查读取方式。
第二个案例是Object/Array依赖导致的Effect死循环。有一段代码想"当筛选条件变化时重新请求数据",于是把filters对象放进了依赖数组。但filters在每次渲染时都由上层重新创建,引用永远不同,effect每次渲染都触发请求,页面疯狂刷新。解决办法是拆解依赖:只依赖filters.id和filters.keyword这种原始值,而不是整个对象。
第三个案例是组件卸载后仍尝试setState。某个详情页请求还没返回,用户就切走了。请求返回后执行setState,React 18虽然不再警告,但这个更新没有任何意义,还可能导致内存层面的资源无法释放。后来我在所有异步流程里都加了取消标志位(就是上一节useRequest里的cancelled模式),这个问题才从根上消失。
8.4 排查Hooks问题的有效姿势
日常调试我依赖三个工具:
第一是React DevTools的Profiler。当怀疑性能问题时,录制一次交互,看每个组件渲染耗时和渲染原因,是解决"为什么这个组件重渲染了"的最快路径。
第二是设计良好的日志。给自定义Hook内部加条件日志,比如useRequest里在状态切换时打印[useRequest] loading: true,定位问题会有奇效。但注意上线前移除,别污染生产日志。
第三是状态设计复盘。如果发现某个组件频繁出现诡异的渲染行为,我一般先反问自己:这个状态是不是放错了层级?这个状态该不该放useState?很多时候Hooks的问题不是Hooks本身的问题,而是把不该共享的状态共享了,把不该提升的状态提升了。
把这些规则和坑位过一遍之后,回头看Hooks的全家桶,其实核心就一条主线:用更朴素、更可组合的方式组织状态与逻辑。这一套下来,日常开发中的绝大多数场景都能覆盖,剩下的边角问题,基本都能靠官方文档和上面这些排查思路解决。