ahooks useMemoizedFn:解决 useCallback 的依赖地狱

一、useCallback 的痛点

在 React 中,我们经常用 useCallback 来缓存函数,避免子组件不必要的重渲染。

javascript 复制代码
function Parent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  const handleClick = useCallback(() => {
    console.log(count);
  }, [count]);  // 依赖 count

  return <Child onClick={handleClick} />;
}

问题:每次 count 变化,handleClick 都会重新创建,Child 还是会重渲染。


二、useMemoizedFn 的解决方案

useMemoizedFn 返回的函数引用永远不变,但内部总是能访问到最新的 state 和 props。

javascript 复制代码
import { useMemoizedFn } from 'ahooks';

function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = useMemoizedFn(() => {
    console.log(count);  // 总是最新的 count
  });

  return <Child onClick={handleClick} />;
}

无论 count 如何变化,handleClick 的引用都不变,Child 不会重渲染。


三、与 useCallback 对比

javascript 复制代码
// useCallback:依赖变化时函数重新创建
const fn1 = useCallback(() => {
  doSomething(a, b, c);
}, [a, b, c]);  // 依赖地狱

// useMemoizedFn:引用永远不变
const fn2 = useMemoizedFn(() => {
  doSomething(a, b, c);  // 无需依赖数组
});
特性 useCallback useMemoizedFn
依赖数组 必须 不需要
引用稳定性 依赖变化时改变 永远不变
访问最新值 需要加入依赖 自动访问
使用复杂度

四、实战场景

场景 1:表单提交

javascript 复制代码
function Form() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);

  // ❌ useCallback:依赖太多
  const handleSubmit = useCallback(async () => {
    setLoading(true);
    await submitForm({ name, email });
    setLoading(false);
  }, [name, email]);  // 每次输入都会重新创建

  // ✅ useMemoizedFn:无需依赖
  const handleSubmit = useMemoizedFn(async () => {
    setLoading(true);
    await submitForm({ name, email });
    setLoading(false);
  });

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <button type="submit">提交</button>
    </form>
  );
}

场景 2:事件监听

javascript 复制代码
function ScrollTracker() {
  const [scrollTop, setScrollTop] = useState(0);

  // ❌ useCallback:每次 scrollTop 变化都重新绑定
  const handleScroll = useCallback(() => {
    console.log('当前位置:', scrollTop);
  }, [scrollTop]);

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [handleScroll]);  // handleScroll 变化导致重新绑定

  // ✅ useMemoizedFn:只绑定一次
  const handleScroll = useMemoizedFn(() => {
    console.log('当前位置:', scrollTop);
  });

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);  // 空依赖,只绑定一次
}

场景 3:传递给子组件

javascript 复制代码
function TodoList() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');

  // ✅ 引用稳定,子组件不会重渲染
  const handleDelete = useMemoizedFn((id) => {
    setTodos(todos.filter(t => t.id !== id));
  });

  const handleToggle = useMemoizedFn((id) => {
    setTodos(todos.map(t => 
      t.id === id ? { ...t, done: !t.done } : t
    ));
  });

  return (
    <>
      <Filter value={filter} onChange={setFilter} />
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          {...todo}
          onDelete={handleDelete}  // 引用不变
          onToggle={handleToggle}  // 引用不变
        />
      ))}
    </>
  );
}

// 子组件用 memo 包裹,只在 props 变化时重渲染
const TodoItem = memo(({ id, text, done, onDelete, onToggle }) => {
  console.log('TodoItem render:', id);
  return (
    <div>
      <input
        type="checkbox"
        checked={done}
        onChange={() => onToggle(id)}
      />
      <span>{text}</span>
      <button onClick={() => onDelete(id)}>删除</button>
    </div>
  );
});

五、原理解析

useMemoizedFn 的核心思路:用 ref 存储最新的函数,返回一个永远不变的包装函数。

javascript 复制代码
function useMemoizedFn(fn) {
  const fnRef = useRef(fn);
  
  // 每次渲染都更新 ref
  fnRef.current = fn;
  
  // 返回的函数引用永远不变
  const memoizedFn = useRef((...args) => {
    return fnRef.current(...args);
  });
  
  return memoizedFn.current;
}

关键点:

  1. fnRef 存储最新的函数
  2. memoizedFn 是包装函数,引用不变
  3. 调用时通过 fnRef.current 访问最新函数

六、注意事项

1. 不要在循环中使用

javascript 复制代码
// ❌ 错误:每次循环都创建新的 Hook
todos.map(todo => {
  const handleClick = useMemoizedFn(() => {
    deleteTodo(todo.id);
  });
  return <button onClick={handleClick}>删除</button>;
});

// ✅ 正确:在组件顶层创建
const handleDelete = useMemoizedFn((id) => {
  deleteTodo(id);
});

todos.map(todo => (
  <button onClick={() => handleDelete(todo.id)}>删除</button>
));

2. 异步函数也适用

javascript 复制代码
const fetchData = useMemoizedFn(async () => {
  const data = await api.getData();
  setData(data);
});

useEffect(() => {
  fetchData();
}, []);  // 空依赖,fetchData 引用不变

3. 配合 useEffect

javascript 复制代码
// ❌ useCallback:依赖变化导致 effect 重新执行
const fetchUser = useCallback(() => {
  return api.getUser(userId);
}, [userId]);

useEffect(() => {
  fetchUser().then(setUser);
}, [fetchUser]);  // fetchUser 变化导致重新请求

// ✅ useMemoizedFn:只在 userId 变化时请求
const fetchUser = useMemoizedFn(() => {
  return api.getUser(userId);
});

useEffect(() => {
  fetchUser().then(setUser);
}, [userId]);  // 只依赖 userId

七、性能测试

javascript 复制代码
function PerformanceTest() {
  const [count, setCount] = useState(0);
  const renderCount = useRef(0);

  // useCallback 版本
  const handleClick1 = useCallback(() => {
    console.log(count);
  }, [count]);

  // useMemoizedFn 版本
  const handleClick2 = useMemoizedFn(() => {
    console.log(count);
  });

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      
      {/* 每次 count 变化,Child1 都会重渲染 */}
      <Child1 onClick={handleClick1} />
      
      {/* Child2 永远不会重渲染 */}
      <Child2 onClick={handleClick2} />
    </>
  );
}

const Child1 = memo(({ onClick }) => {
  console.log('Child1 render');
  return <button onClick={onClick}>Click</button>;
});

const Child2 = memo(({ onClick }) => {
  console.log('Child2 render');
  return <button onClick={onClick}>Click</button>;
});

结果:

  • Child1:每次 count 变化都重渲染
  • Child2:只渲染一次

八、何时使用

适合使用 useMemoizedFn

  1. 函数需要传递给子组件
  2. 函数作为 useEffect 的依赖
  3. 函数需要绑定到 DOM 事件
  4. 函数依赖很多 state/props

不需要使用

  1. 函数不传递给子组件
  2. 函数内部没有闭包变量
  3. 性能不是瓶颈
javascript 复制代码
// 不需要:函数不传递给子组件
const handleClick = () => {
  console.log('clicked');
};

// 需要:传递给子组件
const handleClick = useMemoizedFn(() => {
  console.log('clicked');
});
return <Child onClick={handleClick} />;

九、与其他方案对比

vs useCallback

javascript 复制代码
// useCallback:需要维护依赖
const fn = useCallback(() => {
  doSomething(a, b, c);
}, [a, b, c]);

// useMemoizedFn:无需依赖
const fn = useMemoizedFn(() => {
  doSomething(a, b, c);
});

vs useEvent (React RFC)

React 团队提出的 useEvent 与 useMemoizedFn 思路类似,但还未正式发布。

javascript 复制代码
// React useEvent (未来)
const handleClick = useEvent(() => {
  console.log(count);
});

// ahooks useMemoizedFn (现在可用)
const handleClick = useMemoizedFn(() => {
  console.log(count);
});

十、最佳实践

  1. 优先使用 useMemoizedFn:在需要缓存函数时,优先考虑 useMemoizedFn

  2. 配合 memo 使用:子组件用 memo 包裹,才能体现性能优势

javascript 复制代码
const Child = memo(({ onClick }) => {
  return <button onClick={onClick}>Click</button>;
});
  1. 不要过度优化:如果组件渲染本身很快,不需要优化

  2. 统一团队规范:在团队中统一使用 useMemoizedFn 或 useCallback

相关推荐
曹牧6 小时前
BeanUtils.copyProperties‌
java
QWQ___qwq7 小时前
Java线程安全深度总结:基本类型与引用类型的本质区别
java·安全·面试
识君啊7 小时前
Java异常处理:中小厂面试通关指南
java·开发语言·面试·异常处理·exception·中小厂
月月玩代码9 小时前
Actuator,Spring Boot应用监控与管理端点!
java·spring boot·后端
唐叔在学习9 小时前
e.preventDefault()到底怎么用?
前端·javascript
阿珍爱上了阿强,在一个有星星的夜晚10 小时前
node后端页面性能监测分析
java·学习方法
Java程序之猿10 小时前
SpringBoot + camel+IBM MQ实现消息队列处理
java·spring boot·mybatis
Highcharts.js10 小时前
Highcharts React v4.2.1 正式发布:更自然的React开发体验,更清晰的数据处理
linux·运维·javascript·ubuntu·react.js·数据可视化·highcharts