标签

React Hook

React Hooks 是 React 16.8 版本引入的新特性,它允许在不编写 class 组件的情况下使用 state 和其他 React 特性。Hooks 提供了一种更简洁直观的方式来编写函数组件并复用状态逻辑。

React Hook
服务端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 基本是自我安慰**
服务端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