服务端5月28日 02:51
useCallback 和 useMemo 有什么区别?什么场景下使用?> 面试官问:"useCallback 和 useMemo 有什么区别?什么场景下使用?"——这道题几乎出现在每一场 React 岗位的面试中。答案其实不复杂,但很多人答完区别就卡在使用场景上,要么说"都用上总没错",要么完全不知道什么时候该用。
## 核心区别:一句话记住
**useCallback 缓存函数引用,useMemo 缓存计算结果。**
```jsx
// 这两行等价
const fn = useCallback(() => doSomething(a), [a]);
const fn = useMemo(() => () => doSomething(a), [a]);
```
`useCallback(fn, deps)` 本质上就是 `useMemo(() => fn, deps)` 的语法糖。记住这个等式,很多困惑会自动消散。
| 特征 | useCallback | useMemo |
|------|-------------|---------|
| 返回值 | 函数本身 | 函数的执行结果 |
| 缓存对象 | 函数引用 | 任意值(对象、数组、基本类型) |
| 典型场景 | 传给子组件的回调 | 昂贵计算 / 保持引用稳定 |
| 一句话 | "别重新创建这个函数" | "别重新算这个值" |
## 为什么需要它们:React 重渲染机制
React 函数组件每次渲染都会**重新执行整个函数体**。你在组件里写的每一行代码——定义变量、创建函数、计算表达式——每次渲染都重新跑一遍:
```jsx
function MyComponent({ items }) {
const handleClick = () => console.log("clicked"); // 每次渲染都创建新函数
const filtered = items.filter(item => item.active); // 每次渲染都重新过滤
return <Child onClick={handleClick} data={filtered} />;
}
```
如果 `items` 有 10000 条,而组件每秒渲染 60 次,你就在每秒过滤 60 万次。更麻烦的是,`handleClick` 每次都是新的引用——如果 `Child` 用了 `React.memo`,它期待的"不变引用"就白费了。
useCallback 和 useMemo 的作用就是告诉 React:"如果依赖没变,把上次的结果还给我。"
## useCallback:保持函数引用稳定
### 配合 React.memo 阻止子组件无效渲染
这是 useCallback 最核心的使用场景:
```jsx
function Parent({ items }) {
// ❌ 每次渲染都创建新函数,Child 的 React.memo 白费了
const handleClick = () => { console.log("clicked"); };
// ✅ 函数引用稳定,Child 不会因它而重渲染
const handleClick = useCallback(() => { console.log("clicked"); }, []);
return <Child onClick={handleClick} items={items} />;
}
const Child = React.memo(({ onClick, items }) => {
console.log("Child render");
return <button onClick={onClick}>Click</button>;
});
```
**关键点**:useCallback 单独用效果有限,必须**配合 React.memo** 才能阻止子组件重渲染。如果子组件没有 `React.memo` 包裹,父组件渲染它就会渲染——useCallback 改变不了这一点。
### 作为 useEffect 的稳定依赖
```jsx
function UserProfile({ userId }) {
// ❌ fetchUser 每次都是新引用,useEffect 每次都会执行
const fetchUser = () => { api.getUser(userId); };
// ✅ 只在 userId 变化时重新创建
const fetchUser = useCallback(() => { api.getUser(userId); }, [userId]);
useEffect(() => { fetchUser(); }, [fetchUser]);
}
```
不过这里有个陷阱:用 `useCallback` 缓存函数再放进 `useEffect` 依赖,实际上你是**间接依赖了 callback 的真实依赖**。对于上面的例子,直接在 `useEffect` 里写请求逻辑、直接依赖 `userId` 更直接。这个模式主要用在**自定义 Hook** 中,函数需要暴露给外部使用:
```jsx
function useUserData(userId) {
const fetchUser = useCallback(() => {
return api.getUser(userId);
}, [userId]);
return { fetchUser }; // 调用方拿到的引用是稳定的
}
```
### 自定义 Hook 中的 useCallback
这是面试中容易忽略的场景。当你写一个自定义 Hook 返回方法时,如果不加 useCallback,消费方每次拿到的都是新函数,它的 useEffect 会被反复触发:
```jsx
function useTable(pagination) {
const refresh = useCallback(() => {
fetchData(pagination);
}, [pagination]);
const reset = useCallback(() => {
setFilters({});
setPagination({ page: 1 });
}, []);
return { refresh, reset }; // 消费方可以安全地放入依赖数组
}
```
## useMemo:缓存计算结果
### 避免重复的数组操作
```jsx
function ProductList({ products, filter }) {
// ❌ 每次渲染都重新过滤
const filteredProducts = products.filter(p => p.name.includes(filter));
// ✅ 只在 products 或 filter 变化时重新计算
const filteredProducts = useMemo(() =>
products.filter(p => p.name.includes(filter)),
[products, filter]
);
return <ul>{filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
```
### 复杂计算(排序、聚合、数据转换)
```jsx
function DataTable({ data }) {
const sortedData = useMemo(() => {
return [...data].sort((a, b) => a.score - b.score);
}, [data]);
const chartData = useMemo(() => {
return data.reduce((acc, item) => {
const key = item.category;
if (!acc[key]) acc[key] = { total: 0, count: 0 };
acc[key].total += item.value;
acc[key].count += 1;
return acc;
}, {});
}, [data]);
return <Chart data={chartData} />;
}
```
### 保持对象和数组的引用稳定
这个用法很容易被忽略,但在性能优化中很重要:
```jsx
function Parent({ items }) {
// ❌ 每次渲染创建新对象,子组件 React.memo 失效
const style = { color: "red" };
const config = { threshold: 0.5, rootMargin: "0px" };
// ✅ 对象引用稳定
const style = useMemo(() => ({ color: "red" }), []);
const config = useMemo(() => ({ threshold: 0.5, rootMargin: "0px" }), []);
return <Child style={style} observerConfig={config} />;
}
```
useCallback 缓存函数,useMemo 缓存值——这是一个重要的互补关系。当你需要传一个稳定引用的对象或数组给子组件时,用 useMemo。
## useMemo 和 useRef 的区别
面试中经常追加这个问题。两者都能"记住"上一次的值,但机制完全不同:
| 特征 | useMemo | useRef |
|------|---------|--------|
| 触发重渲染 | 依赖变化时返回新值,组件正常重渲染 | 修改 .current 不触发重渲染 |
| 用途 | 缓存计算结果 | 持久化可变值(DOM 引用、前一次渲染的值等) |
| 依赖追踪 | 有依赖数组,自动更新 | 无依赖,手动管理 |
```jsx
// useMemo:依赖变了才重算,结果参与渲染
const sorted = useMemo(() => data.sort(), [data]);
// useRef:记住上一次的值,但不触发重渲染
const prevCount = useRef(count);
useEffect(() => {
prevCount.current = count;
}, [count]);
```
如果你只是想跨渲染记住一个值但不影响渲染输出,用 useRef;如果你需要基于依赖缓存计算结果参与渲染,用 useMemo。
## 组合使用:性能优化三层架构
在一个复杂的列表组件中,三个 Hook 经常协同工作:
```jsx
function SearchResults({ query, data }) {
// 第一层:缓存数据计算结果
const results = useMemo(() => {
return data.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
);
}, [data, query]);
// 第二层:缓存事件处理函数
const handleItemClick = useCallback((id) => {
console.log("Selected:", id);
}, []);
// 第三层:缓存传递给子组件的 props 对象
const listProps = useMemo(() => ({
items: results,
onItemClick: handleItemClick
}), [results, handleItemClick]);
return <ResultList {...listProps} />;
}
```
这种"数据 → 函数 → props 对象"的三层缓存,是 React 性能优化的标准范式。但记住,这是在**已经发现性能问题**之后的优化手段,不是写代码时的默认操作。
## 用 DevTools 验证优化效果
说了这么多"什么时候该用",那怎么判断你写的 useCallback / useMemo 真的有用?靠猜是不行的,用 React DevTools Profiler:
1. 打开 Chrome DevTools → Profiler 标签
2. 点击录制按钮,操作你的组件
3. 停止录制,查看火焰图
4. 找到不必要的重渲染(灰色条表示"没变但重渲染了")
5. 针对性地加 useCallback / useMemo,再录一次对比
如果你加了缓存但 Profiler 没有变化,说明这个缓存是多余的——移除它。优化从来不是越多越好。
## 三个最常见的坑
### 坑一:过度使用——简单运算不需要缓存
```jsx
// ❌ 两个数相加也要 useMemo?缓存的成本比计算还大
const total = useMemo(() => a + b, [a, b]);
// ❌ 简单的字符串拼接也要 useCallback?
const label = useCallback(() => `${firstName} ${lastName}`, [firstName, lastName]);
// ✅ 直接写
const total = a + b;
const label = `${firstName} ${lastName}`;
```
经验法则:计算操作耗时 < 1ms 的,不需要 useMemo。只有循环遍历大数组、递归、复杂对象转换才值得。useCallback 同理——如果子组件没被 React.memo 包裹,你加不加 useCallback 效果一样。
### 坑二:闭包陷阱——遗漏依赖
```jsx
// ❌ multiplier 在闭包里但不在依赖数组
const multiplier = 2;
const result = useMemo(() => value * multiplier, [value]);
// ✅ 所有引用的外部变量都要声明
const result = useMemo(() => value * multiplier, [value, multiplier]);
```
务必开启 `eslint-plugin-react-hooks` 的 `exhaustive-deps` 规则,让 ESLint 帮你检查。闭包陷阱不是"偶尔遇到"的问题,是"迟早遇到"的问题。
### 坑三:useCallback 配合了没 memo 的子组件
```jsx
// ❌ 子组件没有 React.memo,useCallback 基本白用
function Parent() {
const handleClick = useCallback(() => {}, []);
return <PlainChild onClick={handleClick} />;
}
```
没有 `React.memo` 的子组件,父组件渲染它就会渲染——useCallback 改变不了这一点。这是一个非常常见的"写了等于没写"的场景。
## React 19 Compiler 会取代它们吗?
React 19 引入了 React Compiler(实验性),可以自动为代码插入等效的 `useMemo` 和 `useCallback`。如果你的项目已经启用了 Compiler,手动写这些 Hook 的需求会大幅减少。
但目前绝大多数项目(React 16-18)仍然需要手动优化。而且即使有了 Compiler,理解 useCallback 和 useMemo 的原理,能帮你在遇到性能问题时快速定位根因——Compiler 不是万能的,它也会犯错,这时候你需要知道底层的运作方式来判断是 Compiler 的 bug 还是你代码的问题。
## 面试速答模板
面试中被问到这道题,建议这样组织答案:
**区别**:useCallback 缓存函数引用,useMemo 缓存计算结果。`useCallback(fn, deps)` 等价于 `useMemo(() => fn, deps)`,是它的语法糖。
**场景**:useCallback 主要配合 React.memo 阻止子组件无效渲染,或作为自定义 Hook 的稳定返回值;useMemo 用于昂贵计算和保持对象/数组引用稳定。
**注意点**:两者都不是"越多越好"。没有 React.memo 的子组件加 useCallback 无效;简单计算加 useMemo 反而更慢;务必开启 exhaustive-deps 规则避免闭包陷阱。
**追问准备**:React 19 Compiler 可以自动处理大部分缓存需求,但理解原理对定位问题仍然必要。useRef 也能"记住"值但不触发重渲染,和 useMemo 的触发机制不同。
## 总结
| 你需要 | 用什么 | 关键搭档 |
|--------|--------|----------|
| 缓存**函数**给子组件 | useCallback | React.memo |
| 缓存**计算结果** | useMemo | — |
| 缓存**对象/数组**引用 | useMemo | React.memo |
| 防止 **useEffect 不必要触发** | useCallback / useMemo | 依赖数组 |
记住三点:
1. **先写对,再优化**。等 React DevTools Profiler 告诉你哪里慢了再动手,不要预先给所有东西加缓存
2. **useCallback 是 useMemo 的特例**,缓存的是函数引用,不是计算结果
3. **没有 React.memo 的子组件,useCallback 基本是自我安慰**标签
React Hook
React Hooks 是 React 16.8 版本引入的新特性,它允许在不编写 class 组件的情况下使用 state 和其他 React 特性。Hooks 提供了一种更简洁直观的方式来编写函数组件并复用状态逻辑。

服务端5月27日 19:54
如何在 Jest 中测试 React Hooks?renderHook 和 act 怎么用?测试 React Hooks 的核心工具是 `renderHook` 和 `act`。React 18 之后,`renderHook` 已从废弃的 `@testing-library/react-hooks` 迁移到 `@testing-library/react`,用法也有变化。
## 核心思路
- **renderHook**:在测试环境中渲染 Hook,返回 `result`(当前返回值)、`rerender`(重新渲染)、`unmount`(卸载)
- **act**:包裹所有会导致状态更新的操作,确保 React 完成渲染后再执行断言
- **waitFor**:处理异步状态更新,替代旧版的 `waitForNextUpdate`
## 安装依赖
```bash
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
```
> 注意:`@testing-library/react-hooks` 已废弃,React 18+ 请统一使用 `@testing-library/react`。
## 测试 useState
```javascript
import { renderHook, act } from '@testing-library/react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return { count, increment, decrement };
}
test('useCounter 初始值和更新', () => {
const { result } = renderHook(() => useCounter(0));
// 验证初始状态
expect(result.current.count).toBe(0);
// 用 act 包裹状态更新
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
```
**关键点**:任何触发 `setState` 的调用都必须包裹在 `act()` 中,否则 React 会发出警告,断言也可能基于未更新的状态。
## 测试 useEffect
```javascript
import { renderHook, act } from '@testing-library/react';
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
return () => { document.title = 'default'; };
}, [title]);
}
test('useEffect 设置和清理', () => {
const { result, unmount, rerender } = renderHook(
({ title }) => useDocumentTitle(title),
{ initialProps: { title: 'Hello' } }
);
expect(document.title).toBe('Hello');
// 依赖变化时 effect 重新执行
rerender({ title: 'World' });
expect(document.title).toBe('World');
// 卸载时执行清理函数
unmount();
expect(document.title).toBe('default');
});
```
**关键点**:用 `rerender` 测试依赖变化,用 `unmount` 测试清理逻辑。
## 测试 useContext
```javascript
import { renderHook } from '@testing-library/react';
const ThemeContext = createContext('light');
function useTheme() {
return useContext(ThemeContext);
}
test('useContext 读取 Provider 值', () => {
const wrapper = ({ children }) => (
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current).toBe('dark');
});
```
**关键点**:Hook 依赖 Context 时,通过 `wrapper` 选项注入 Provider,`renderHook` 会自动用 wrapper 包裹组件树。
## 测试异步 Hook
```javascript
import { renderHook, waitFor, act } from '@testing-library/react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => res.json())
.then(json => { if (!cancelled) { setData(json); setLoading(false); } })
.catch(err => { if (!cancelled) { setError(err); setLoading(false); } });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
test('useFetch 异步请求', async () => {
// 用 jest.fn mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve({ name: 'test' }) })
);
const { result } = renderHook(() => useFetch('/api/data'));
// 初始状态
expect(result.current.loading).toBe(true);
// 等待异步完成
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: 'test' });
expect(result.current.error).toBeNull();
});
```
**关键点**:
- 用 `waitFor` 等待异步更新,不要在 `act` 里 `await waitFor`(那是反模式)
- 异步 Hook 需要处理竞态:组件卸载后不应再 `setState`,用 `cancelled` 标志位或 `AbortController`
## 测试自定义 Hook
```javascript
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
test('useDebounce 防抖', () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'hello' } }
);
expect(result.current).toBe('hello');
// 快速更新值,防抖未到期
rerender({ value: 'world' });
expect(result.current).toBe('hello'); // 还是旧值
// 快进 500ms
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('world');
jest.useRealTimers();
});
```
**关键点**:涉及定时器的 Hook,用 `jest.useFakeTimers()` + `act(() => jest.advanceTimersByTime(ms))` 精确控制时间。
## 常见报错排查
### "not wrapped in act()" 警告
**原因**:状态更新发生在 `act()` 之外(如异步回调、定时器未用 fake timers)。
**解决**:
- 异步操作用 `waitFor` 或 `await act(async () => ...)`
- 定时器用 `jest.useFakeTimers()` 并在 `act` 中推进时间
- 确保所有 `setState` 调用都在 `act` 内
### "Can't perform a React state update on an unmounted component"
**原因**:异步操作完成后组件已卸载,仍然调用了 `setState`。
**解决**:在 `useEffect` 清理函数中取消异步操作(`cancelled` 标志位 / `AbortController`)。
## 最佳实践
1. **用 `@testing-library/react` 的 `renderHook`**,不要再用废弃的 `@testing-library/react-hooks`
2. **所有状态更新包裹 `act`**,同步用 `act(fn)`,异步用 `await act(async fn)` 或 `waitFor`
3. **测试行为不测实现**:关注 Hook 的输入输出,不关注内部状态变量名
4. **测试边界**:初始值、空值、错误状态、并发场景
5. **用 `rerender` 测试依赖变化**,用 `unmount` 测试清理逻辑
6. **Mock 外部依赖**(API、定时器、DOM API),不 Mock React 内置 Hook