React中结合State与Ref使用useCallback的最佳实践疑问
问题背景
我在查阅useCallback相关资料时发现,所有文档都要求把用到的state放进依赖数组,这真的是必须的吗?如果是,那为什么不能在useCallback里直接用ref来规避依赖变化导致的函数引用更新?
根据React官方文档的说明:
使用useCallback需要传入两个参数:
- 你希望在重渲染之间缓存的函数定义。
- 包含组件内部所有在函数中用到的值的依赖列表。
从规范上我理解这个要求,但从性能角度看,这会导致依赖变化时函数引用更新,进而触发子组件不必要的重渲染,我是不是忽略了什么?
场景示例
基础场景:规范写法的性能问题
按照官方规范编写的组件:
function SomeComponent() { const [number, setNumber] = useState(0); const increment = useCallback(() => setNumber(number + 1), [number]); return <CostlyComponentToRender onClick={increment}>Increment</CostlyComponentToRender > }
这里每次number变化时,increment的引用都会更新,导致CostlyComponentToRender及其子组件重渲染。我知道这个场景可以用函数式更新setNumber(p => p + 1)来避免依赖,但这不适用于我的实际业务场景。
自定义Hook解决方案
为了规避这个问题,我写了一个useRefState自定义Hook:
export default function useRefState<T>(initialValue: T | null): { state: T | null, setState: Dispatch<SetStateAction<T>>, ref: MutableRefObject<T> } { const [state, setState] = useState<T | any>(initialValue); const ref = useRef(state); useEffect(() => { ref.current = state }, [state]); // 或者直接赋值ref.current=state?哪种开销更小? return { state, setState, ref }; }
使用方式如下:
function SomeComponent() { const { setState: set, ref } = useRefState(0); const increment = useCallback(() => set(ref.current + 1), []); return <CostlyComponentToRender onClick={increment}>Increment</CostlyComponentToRender > }
这样increment的引用永远不会变,子组件也就不需要重渲染,相比收益,这个Hook的开销可以忽略。
实际业务场景对比
规范写法版本:
父组件持有状态,子组件包含触发按钮:
const [macroType, setMacroType] = useState(MacroType.QUEUE); const [selectedMacro, setSelectedMacro] = useState(/*some obj*/); const onClickRun = useCallback(() => { switch (macroType) { case MacroType.QUEUE: prepareMacroExecution(selectedMacro); break; default: throw new Error("Unknown macro type"); } }, [macroType, selectedMacro]);
这里只要macroType或selectedMacro变化,onClickRun的引用就会更新,即便子组件用了memo,也得重渲染来更新函数引用。
使用ref的优化版本:
const {state:macroType, setState:setMacroType, ref:macroTypeRef} = useRefState(MacroType.QUEUE); const {state:selectedMacro, setState:setSelectedMacro, ref:selectedMacroRef} = useRefState(/*some obj*/); const onClickRun = useCallback(() => { const type = macroTypeRef.current; switch (type) { case MacroType.QUEUE: prepareMacroExecution(selectedMacroRef.current); break; default: throw new Error("Unknown macro type"); } }, []);
现在onClickRun的引用不会变,子组件无需重渲染,功能和之前一致。目前我没发现这个方案的问题,是不是需要权衡ref的开销和子组件重渲染的成本?
核心问题解答
1. state必须放入useCallback依赖吗?
是的,这是React的强制规范。如果不把用到的state放进依赖数组,不仅React的lint规则会报错,更会导致闭包陷阱:函数会捕获组件渲染时的state值,后续state更新后,函数里依然使用旧的state值,导致逻辑错误。
比如你忽略number依赖写useCallback(() => setNumber(number + 1), []),第一次点击会把number变成1,但之后点击都会把1+1变成2——因为函数里的number一直是初始的0,这就是闭包导致的典型问题。
2. 为什么官方不推荐直接用ref替代state依赖?
React的设计逻辑里,state是用于驱动视图更新的核心,而ref是用于访问DOM或保存不需要触发重渲染的临时值的。直接用ref读取state值,相当于绕过了React的状态更新机制,可能带来以下问题:
- 一致性问题:如果在函数里读取
ref.current时,组件还没完成重渲染(比如state刚更新,useEffect还没执行),ref.current可能还是旧值,导致逻辑错误。比如你的自定义Hook里用useEffect同步ref,在state更新后,useEffect是在组件渲染完成后执行的,如果在这期间调用函数,就会拿到旧的ref值。 - 可维护性问题:这种写法打破了React的状态管理常规,其他开发者接手时可能会困惑,增加理解成本,也容易因为误用ref导致难以排查的bug。
3. 自定义useRefState Hook的优缺点
优点:
- 确实能稳定函数引用,避免子组件不必要的重渲染,在子组件渲染成本极高的场景下,收益明显。
- 实现简单,开销很小,大部分场景下性能影响可以忽略。
潜在风险:
- 同步时机问题:用
useEffect同步ref的话,state更新后,ref的更新会滞后于组件渲染。如果在state更新后立即调用函数(比如在setState的回调里调用),会拿到旧的ref值。解决这个问题的话,可以把ref.current = state直接放在渲染阶段(不需要useEffect),因为每次组件渲染时都会执行这行代码,这样ref.current会和state保持同步,且赋值ref是渲染阶段的安全操作。 - 调试难度增加:因为函数依赖是空数组,React DevTools里无法直观看到函数依赖的状态变化,排查问题时需要额外关注ref的取值。
- 违背React设计原则:这种写法相当于用ref存储状态的副本,绕开了React的依赖追踪机制,长期来看可能会导致代码变得难以维护。
总结
你的方案在特定场景下是可行的,但需要明确权衡:
- 如果子组件的渲染成本极高,且状态变化频繁,导致函数引用频繁更新触发重渲染,那么这个方案的收益大于风险。
- 如果子组件渲染成本不高,或者状态变化不频繁,建议遵循React的规范写法,避免引入额外的复杂度和潜在bug。
内容的提问来源于stack exchange,提问作者JWittmeyer

