React useCallback 实战指南:利用回调函数记忆化消除子组件多余重渲染(refine 博客演示项目)
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
本篇技术文章围绕 React 的useCallback()Hook 展开:讲解它如何保证回调函数在父组件多次重渲染之间维持引用一致性(referential integrity),并结合 refine 仓库中自带的博客演示应用 examples/blog-react-memoization-memo,通过deletePost回调从父组件传入子组件的完整案例,演示如何观察、定位并用useCallback消除多余重渲染,最后给出useCallback与useMemo的对比及其与useEffect、useContext、自定义 Hook 的组合用法。读完之后,你可以掌握“回调引用不等”这一 React 重渲染根因的判断方法,并能在自己的组件树中正确落地回调记忆化。
一、什么是 ReactuseCallback
useCallback()是一个记忆化(memoize)函数定义的 Hook:它接受一个函数作为参数,对函数进行记忆化,并返回记忆化后的函数。该函数随后可以被传递给子组件并在其中调用。
用useCallback对函数定义做记忆化,可以避免因“函数对象身份(identity)变化”而引发的多余重渲染,从而优化组件性能。其工作机制是:缓存函数对象本身——每当父组件重渲染时,传给子组件的是同一个缓存的回调对象,而不是每次重新创建的副本。
为什么需要useCallback
在 React 中,组件内部定义的回调函数(如事件处理器)会在组件的每一次重渲染时创建一个新的函数对象。这一行为破坏了传递给子组件(并在子组件中调用)的回调的引用相等性。
举个典型场景:一个回调作为 prop 从父组件传给子组件。默认情况下,父组件每次渲染,子组件都会收到一份新的回调副本。仅仅因为接收到的函数对象身份发生了变化,子组件就会多进行一次重渲染。这些“无用”的重渲染会累积为应用的性能开销。因此,React 中的回调函数应该被记忆化,使它们在父组件的重渲染之间维持引用一致性。
useCallback()正是通过存储函数对象本身来保证这一点:父组件每次重渲染时传给子组件的都是同一个回调对象,从而阻止子组件因函数身份变化而产生的重渲染。
二、演示项目结构:基于 refine 仓库中的博客示例应用
本文使用的演示应用是 React Memoization 系列的第三个应用,聚焦“博客文章列表 + 用户文章列表”场景。该示例项目随 refine 仓库提供,位于 examples/blog-react-memoization-memo,可运行、可对照源码逐步复现本文的全部结论。
组件层级与数据流如下:
<App />(App.jsx):持有signedIn状态,导航栏中的Sign Out按钮点击后切换该状态,并作为 prop 传给<Blog />;<Blog />(Blog.jsx):渲染最新文章区<LatestPost />和用户文章区<UserPostsIndex signedIn={signedIn} />,并用React.memo包裹导出;<UserPostsIndex />(UserPostsIndex.jsx):挂载时通过fetchUserPosts()拉取用户文章存入userPosts状态,并定义deletePost回调;<UserPostsList />(UserPostsList.jsx):接收userPosts数组与deletePost回调,为每篇文章渲染一个“Delete”按钮;<UserPost />(UserPost.jsx):纯展示组件,把文章标题渲染为锚点链接。
其中fetchUserPosts直接读取本地静态数据(userPosts.json,含 10 条文章记录),见 fetchUserPosts.js:
import userPosts from "./../data/userPosts.json"; const fetchUserPosts = () => userPosts; export default fetchUserPosts;从源码结构看,这个示例把“重渲染观测点”做成了可开关的:UserPostsIndex.jsx与UserPostsList.jsx中都预留了被注释掉的console.log(例如// console.log('Rendering UserPostsList component');),而UserPostsIndex.jsx中同时保留了普通函数版与useCallback版两种deletePost定义(后者以注释形式存在于第 9、16 行附近)。这为“先观察问题、再开启记忆化对比”的实验流程提供了便利。
核心组件源码解析
<UserPostsIndex />的关键实现如下(对应 UserPostsIndex.jsx):
import React, { useCallback, useEffect, useState } from "react"; import fetchUserPosts from "../fetch/fetchUserPosts"; import UserPostsList from "./UserPostsList"; const UserPostsIndex = ({ signedIn }) => { const [userPosts, setUserPosts] = useState([]); const deletePost = (e) => { const { postId } = e.currentTarget.dataset; const remainingPosts = userPosts.filter( (post) => post.id !== Number.parseInt(postId), ); setUserPosts(remainingPosts); }; useEffect(() => { const posts = fetchUserPosts(); setUserPosts(posts); }, []); return ( <div className="box my-1 p-2"> <div className="m-1 py-1"> <h2 className="heading-md">Your Posts</h2> <p className="m-1 p-1">{signedIn ? `Signed in` : `Signed out `}</p> {userPosts && ( <div className="px-1"> {<UserPostsList userPosts={userPosts} deletePost={deletePost} />} </div> )} </div> </div> ); }; export default React.memo(UserPostsIndex);可以看到:<UserPostsIndex />从<Blog />接收signedInprop,挂载后拉取并保存用户文章;在 JSX 中把userPosts与deletePost一起传给<UserPostsList />。注意组件导出时使用了React.memo包裹——这是系列前两部分(memo与useMemo)已经落地的优化。
<UserPostsList />(对应 UserPostsList.jsx):
import React from "react"; import UserPost from "./UserPost"; const UserPostsList = ({ userPosts, deletePost }) => { console.log("Rendering UserPostsList component"); return ( <div className="px-1"> {userPosts.map((post) => ( <div key={post.id} className="box my-1 flex-row"> <UserPost post={post} /> <button className="btn btn-danger" >import React from "react"; const UserPost = ({ post }) => { // console.log('Rendering UserPost component') return ( <div className="flex-row-left my-1"> <a href={`#${post.title}`} className=""> <h4 id={post.title} className="font-sm px-2 font-bold"> {post.title} </h4> </a> </div> ); }; export default UserPost;三、观察“回调引用不等”导致的多余重渲染
可以预见,deletePost是造成<UserPostsList />重渲染的嫌疑对象。为了观察它,先在<UserPostsIndex />中加入一行日志:
console.log("Rendering UserPostsIndex component");再在<UserPostsList />中加入:
console.log("Rendering UserPostsList component");此时打开浏览器控制台(Ubuntu 下Ctrl + Shift + J,Mac 下Command + Option + J),可以看到两个组件的初始渲染日志。
接着点击导航栏中的Sign Out按钮,控制台会显示由<UserPostsIndex />、<UserPostsList />和<UserPost />引发的成批渲染日志。
从 App.jsx 的源码可以印证这一现象的成因:signedIn是一个useState状态,handleClick每次点击都会setSignedIn(!signedIn),状态变化使<Blog />重渲染并把新的signedIn传给<UserPostsIndex />——因此<UserPostsIndex />的重渲染是合理的。
但<UserPostsList />的重渲染初看并不合理:我们期望它只在userPosts变化时重渲染,而userPosts并不随signedIn变化;且该组件已经用React.memo()记忆化,本应阻止其因祖先状态变化而重渲染。
问题恰恰出在deletePost上:由于引用不等(referential inequality),<UserPostsIndex />每次重渲染都会在运行时生成一个全新的deletePost函数对象。这个对象身份的变化触发了<UserPostsList />的重渲染——整条链路是:signedIn变化 →<UserPostsIndex />重渲染 → 创建并传入新版deletePost→<UserPostsList />重渲染。React.memo的浅比较认为 prop 变了,因此放行重渲染。
四、用useCallback记忆化事件回调
对deletePost做记忆化后,<UserPostsIndex />的每次重渲染都会交付同一个函数对象。改动如下:
import React, { useCallback, useEffect, useState } from "react"; const UserPostsIndex = ({ signedIn }) => { // highlight-next-line const deletePost = useCallback(e => { const { postId } = e.currentTarget.dataset; const remainingPosts = userPosts.filter(post => post.id !== parseInt(postId)); setUserPosts(remainingPosts); }, [userPosts]); // ... }; export default React.memo(UserPostsIndex);现在多次点击Sign Out按钮,控制台会显示:<UserPostsIndex />仍在重渲染,但<UserPostsList />与<UserPost />不再重渲染。
原因在于useCallback()会缓存deletePost并在每次渲染时产生同一份副本,从而保持其引用完整性,阻止<UserPostsList />的多余重渲染,实现组件层面的性能优化。
依赖数组的作用
注意useCallback第二个参数中传入了依赖项userPosts。把userPosts作为依赖,意味着我们希望userPosts的变化触发函数记忆体的更新:每当userPosts的值改变,<UserPostsIndex />中都会创建一个新的deletePost函数对象,<UserPostsList />随之重渲染一次。这正是期望行为——列表内容变了,子组件确实需要重新拿到基于新列表过滤逻辑的回调;而在userPosts不变的渲染中,函数引用保持稳定,多余重渲染被挡住。
五、useCallback 与 useMemo 的对比
不少开发者会混淆useCallback与useMemo的适用场景。理解两者的区别是优化 React 应用的关键,下面给出简明对比。
useCallback
useCallback记忆化的是一个函数定义,使其不会在每次渲染时被重新创建(除非依赖变化)。当你想把某个回调传给一个用React.memo包裹的子组件、避免其多余重渲染时,它非常有用:
let handleClick = useCallback(() => { // Function logic here }, [dependencies]);上例中,只要依赖不变,handleClick在每次渲染中都会保持同一引用。
useMemo
useMemo记忆化的是函数调用的结果,缓存其返回值。适合不想在每次渲染时重复执行的昂贵计算:
const computedValue = useMemo(() => { return expensiveCalculation(data); }, [data]);这里computedValue只在data变化时才会重新求值。
关键差异
| 维度 | useCallback | useMemo |
|---|---|---|
| 返回值 | 记忆化后的函数 | 记忆化后的值 |
| 主要用途 | 避免函数被不必要地重新创建 | 避免在不必要时重新计算值 |
何时使用 useCallback
- 阻止子组件重渲染:把
useCallback包裹的函数作为 prop 传给被React.memo优化的子组件,确保该函数引用不会被不必要地重新创建; - 事件处理器:对于每次渲染都会新建的函数(如事件处理器),用
useCallback保持其引用稳定。
何时使用 useMemo
- 昂贵计算:存在执行重量级计算的函数,希望它不必每次渲染都运行;
- 派生数据:基于 props 或 state 计算出的结果,除非依赖变化否则不应重算。
一个类比
可以把useCallback理解为记忆化“函数本身”,而useMemo记忆化的是“函数的结果”。
实践示例:过滤列表场景
使用 useMemo:
const filteredItems = useMemo(() => { return items.filter((item) => item.active); }, [items]); return <ItemList items={filteredItems} />;这里filteredItems只在items变化时重新求值。
使用 useCallback:
const getFilteredItems = useCallback(() => { return items.filter((item) => item.active); }, [items]); return <ItemList getItems={getFilteredItems} />;这种情况下,只要items未变,getFilteredItems的函数引用就不会改变。
六、何时使用、何时不应使用 useCallback
useCallback的设计意图是通过维持引用一致性来记忆化回调函数,不应与用于缓存函数值的useMemo混淆。它最常用于在多次重渲染之间记忆化函数。
其他适用场景
useCallback()还常用于:
- 防抖(debouncing)中缓存回调;
- 防止
useEffect()中事件被不必要地触发(见下文与useEffect的组合示例)。
不应使用 useCallback 的情况
- 不要用
useCallback去记忆化“函数的值”(那是useMemo的职责); - 避免过度使用:
useCallback本身也有成本(额外的缓存管理与依赖比较),它会增加组件自身的开销; - 建议仅在“函数引用相等性确实重要”的场景(如传给
React.memo子组件的回调、进入useEffect依赖数组的函数)中使用。
七、useCallback 与其他 Hook 的组合用法
了解useCallback与其他 React Hook 的协作方式,有助于编写性能更好、效率更高的组件。
useCallback + useEffect
使用useEffect时,其中的函数同样需要纳入依赖数组。如果该函数每次渲染都变化,useEffect就会被不必要地反复执行。此时用useCallback包裹它:
import React, { useState, useEffect, useCallback } from "react"; function SearchComponent() { const [query, setQuery] = useState(""); const fetchResults = useCallback(() => { // Fetch data based on query }, [query]); useEffect(() => { fetchResults(); }, [fetchResults]); return <input value={query} onChange={(e) => setQuery(e.target.value)} />; }上例中fetchResults被useCallback记忆化,只在query变化时才会改变,因此useEffect不会每次渲染都执行。
useCallback + useMemo
useCallback记忆化函数,useMemo记忆化值。有时两者配合使用可以进一步优化组件:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]); const handleClick = useCallback(() => { doSomethingWith(memoizedValue); }, [memoizedValue]);先用useMemo记忆化昂贵计算,再取该值并用useCallback记忆化使用它的函数。
useCallback + useContext
在 Context 中,如果传入的函数引用发生变化,可能引起消费端组件重渲染。用useCallback可以阻止这类不必要的更新:
const MyContext = React.createContext(); function MyProvider({ children }) { const [state, setState] = useState(0); const increment = useCallback(() => { setState((prev) => prev + 1); }, []); return ( <MyContext.Provider value={{ state, increment }}> {children} </MyContext.Provider> ); } function ChildComponent() { const { state, increment } = useContext(MyContext); // ChildComponent won't re-render unless state or increment changes }通过记忆化increment,确保上下文值不会在不必要时发生变化,从而优化消费该 Context 的组件。
useCallback + 自定义 Hooks
编写自定义 Hook 时,可以用useCallback记忆化该 Hook 返回的函数,使消费组件获得稳定的函数引用:
function useWindowWidth() { const [width, setWidth] = useState(window.innerWidth); const handleResize = useCallback(() => { setWidth(window.innerWidth); }, []); useEffect(() => { window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, [handleResize]); return width; }在这个自定义 Hook 中,handleResize被useCallback记忆化,事件监听器不会在每次渲染后重新注册。
八、在 refine 仓库中运行该示例
如需动手复现本文的观察实验,可以在 refine 仓库的示例目录中进行:
- 进入 examples/blog-react-memoization-memo 目录;
- 按 README 说明安装依赖并启动(原 README 使用 yarn:
yarn install后yarn start,也可以按等价包管理器执行); - 浏览器打开本地地址后,在控制台观察各组件的渲染日志,然后点击
Sign Out按钮触发连锁重渲染; - 在 UserPostsIndex.jsx 中取消注释第 8~16 行的
useCallback版本定义、注释掉普通函数版本(文件中已保留两种实现的切换位),重新观察控制台日志,确认<UserPostsList />不再重渲染。
适用前提与限制:从 package.json 可见,该示例基于 React 19 与 react-scripts(通过 craco 启动),要求 Node>=20。本文结论(引用不等导致重渲染、useCallback维持引用稳定)与 React 版本无关,属于 React 组件模型的一般规律。
九、小结
本文以 refine 仓库中的博客演示应用为载体,完整演示了如何利用 ReactuseCallbackHook 防止因回调函数引用不等引发的多余重渲染:
- 先通过浏览器控制台观察到一个以 prop 形式传入子组件的回调,因父组件每次渲染都创建新函数对象,触发了本被
React.memo保护着的<UserPostsList />的无用重渲染; - 再用
useCallback()对该回调记忆化(依赖数组传userPosts),使其在父组件重渲染之间始终交付同一个函数对象,从而阻断多余重渲染; - 最后系统对比了
useCallback与useMemo的分工边界,并给出与useEffect、useMemo、useContext及自定义 Hook 组合的实战模式。
核心原则只有一条:当“函数引用相等性”对组件树的行为有实质影响时(传给React.memo子组件的回调、进入 effect 依赖数组的函数、Context 值中的方法),才引入useCallback;否则不必为记忆化而记忆化。
(本文为 React Memoization 系列的第三篇,前两部分分别是 React Memo 指南 与 React useMemo Hook 指南。)
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考