How to Avoid Overusing useCallback and useMemo in React

TL;DR · AI 摘要
过度使用useCallback和useMemo可能导致性能问题和代码复杂度增加,应仅在必要时使用。
核心要点
- useMemo和useCallback应仅用于优化性能,而非默认使用。
- 在React中,不必要的memo化可能导致组件难以调试和维护。
- 在使用useCallback和useMemo之前,应先进行性能测量。
结构提纲
按章节快速跳转。
- §引言
过度使用useCallback和useMemo可能导致代码复杂度增加和性能问题。
useMemo用于缓存函数返回值,useCallback用于缓存函数本身。
过度使用useMemo和useCallback可能导致代码难以调试和维护。
应在性能优化时使用useCallback和useMemo,而非默认使用。
- ›性能测量
在使用useCallback和useMemo之前,应先进行性能测量。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 避免过度使用useCallback和useMemo
- useMemo和useCallback的作用
- useMemo缓存函数返回值
- useCallback缓存函数本身
- 过度使用的问题
- 代码复杂度增加
- 性能问题
- 何时使用
- 性能优化时使用
- 先进行性能测量
金句 / Highlights
值得收藏与分享的关键句。
过度使用useCallback和useMemo可能导致性能问题和代码复杂度增加。
useMemo用于缓存函数返回值,useCallback用于缓存函数本身。
在使用useCallback和useMemo之前,应先进行性能测量。
如何避免在 React 中过度使用 useCallback 和 useMemo
2026年6月17日
/
Olaleye Blessing
如果你在 React 生态系统中花费了足够的时间,你可能会看到几乎所有函数都使用了 useCallback 包裹,而计算值则使用了 useMemo 包裹的代码库。
这样做的原因在于“记忆化等于更好的性能”。但大多数情况下,这并不会真正带来更好的性能,而且通常会产生更难调试的代码。
在本文中,你将学习如何构建代码结构以避免过度使用 useCallback 和 useMemo。
先决条件
在阅读本教程之前,你应该对 React hooks 和组件有一定的了解。假设你对 useState、useEffect 和 useRef 有一定的熟悉程度。如果你需要复习 useCallback 和 useMemo,可以阅读以下 freeCodeCamp 文章:
- 如何使用 useMemo 和 useCallback Hooks
- useMemo 和 useCallback Hooks 的区别
目录
- 先决条件
- useCallback 和 useMemo 的作用 useMemo useCallback
- 记忆化的问题
- 有问题的页面 如何将状态向下移动 将 ProductTable 逻辑移到其组件中 将过滤逻辑移到其组件中 将搜索逻辑移到其组件中 将过滤芯片移到其组件中 最终的 SearchPage 修复你的代码,再使用这些 Hooks
- 何时使用 useCallback 和 useMemo 在优化之前进行测量 为 React.memo 子组件稳定引用
- 结论
useCallback 和 useMemo 的作用
在讨论如何避免过度使用它们之前,我们先简要了解一下这些 hooks 的作用。
useMemo
useMemo 在重新渲染之间缓存函数的返回值。想象一下你在一个组件中有一个排序后的项目列表:
interface Item {
name: string;
createdAt: string;
}
function App() {
// == 其他状态 ==
// == 其他状态 ==
const [items, setItems] = useState<Item[]>([]);
const sortedItems = [...items].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return (
<>
<ul>
{sortedItems.map((i) => (
<li key={i.name}>{i.name}</li>
))}
</ul>
</>
);
}每次 App 组件重新渲染时,React 都会重新计算 sortedItems。这意味着只要 App 组件中的任何状态发生变化,sortedItems 都会被重新计算。
React 开发者经常使用 useMemo 来缓存这样的值。
用 useMemo 包裹它可以确保只有在 items 实际发生变化时才重新计算 sortedItems:
const sortedItems = useMemo(() => {
return [...items].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
}, [items]);useCallback
useCallback 缓存函数本身。下面的函数每次组件中的某些状态发生变化时都会被重新创建:
function App() {
// == 其他状态 ==
// == 其他状态 ==
const [userId, setUserId] = useState(0);
const verifyUser = async () => {
// 更新状态以显示加载
console.log("__ Do something with user id __", userId);
// 更新状态以移除加载
};
return (
<>
<button onClick={verifyUser}>Verify</button>
</>
);
}用 useCallback 包裹它可以确保只要 userId 没有变化,函数引用就保持不变:
function App() {
// == 其他状态 ==
// == 其他状态 ==
const [userId, setUserId] = useState(0);const verifyUser = useCallback(async () => {
// 更新状态以显示加载状态
console.log("__ 使用用户 ID 做一些事情 __", userId);
// 更新状态以移除加载状态
}, [userId]);
return (
<>
<button onClick={verifyUser}>验证</button>
</>
);使用记忆化带来的问题
生活中没有免费的东西,记忆化也不例外。每次使用 useCallback 或 useMemo 时:
- 你的应用会分配内存来存储缓存的值和依赖数组。
- 你的组件会运行一个比较,以检查依赖项是否发生了变化。
大多数情况下,这种记忆化并没有什么用。创建一个 JavaScript 函数是便宜的。对 50 个项目的列表进行排序也是便宜的。将这些封装在记忆化钩子中,反而会增加更多的开销,而不是减少。但请注意,如果性能分析显示排序是瓶颈,那么在那使用 useMemo 仍然是合理的。
更好的方法是构建你的组件,使重新渲染的频率更低。
const filters: FilterState = { query: searchParams.get("q") ?? DEFAULTS.query, country: searchParams.get("country") ?? DEFAULTS.country, color: searchParams.get("color") ?? DEFAULTS.color, mode: searchParams.get("mode") ?? DEFAULTS.mode, minPrice: searchParams.get("minPrice") ?? DEFAULTS.minPrice, maxPrice: searchParams.get("maxPrice") ?? DEFAULTS.maxPrice, sortField: (searchParams.get("sortField") as SortField) ?? DEFAULTS.sortField, sortDir: (searchParams.get("sortDir") as SortDir) ?? DEFAULTS.sortDir, };
const apiFilters = { query: filters.query, country: filters.country || undefined, color: filters.color || undefined, mode: filters.mode || undefined, minPrice: filters.minPrice ? Number(filters.minPrice) : undefined, maxPrice: filters.maxPrice ? Number(filters.maxPrice) : undefined, sortField: filters.sortField, sortDir: filters.sortDir, };
const productsQuery = useQuery({ queryKey: ["products", apiFilters], queryFn: () => fetchProducts(apiFilters), });
const countriesQuery = useQuery({ queryKey: ["countries"], queryFn: fetchCountries, staleTime: Infinity, });
const colorsQuery = useQuery({ queryKey: ["colors"], queryFn: fetchColors, staleTime: Infinity, });
const modesQuery = useQuery({ queryKey: ["modes"], queryFn: fetchModes, staleTime: Infinity, });
// 更新抽屉中的筛选条件 const setFilters = (partial: Partial<FilterState>) => { const next = new URLSearchParams(searchParams.toString()); const merged = { ...filters, ...partial };
const keyMap: Record<keyof FilterState, string> = { query: "q", country: "country", color: "color", mode: "mode", minPrice: "minPrice", maxPrice: "maxPrice", sortField: "sortField", sortDir: "sortDir", };
(Object.keys(merged) as (keyof FilterState)[]).forEach((k) => { const paramKey = keyMap[k]; const val = merged[k]; const def = DEFAULTS[k]; if (val && val !== def) { next.set(paramKey, val); } else { next.delete(paramKey); } });
router.push(\${pathname}?\${next.toString()}, { scroll: false }); };
const resetFilters = () => { router.push(pathname, { scroll: false }); };
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>) => { const val = e.target.value;
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => { setFilters({ query: val }); }, 400); };
const handleClearQuery = () => { if (searchRef.current) { searchRef.current.value = ""; }
setFilters({ query: "" }); };
const handleColumnClick = (field: LocalSortField) => { setLocalSort((prev) => { if (!prev || prev.field !== field) return { field, dir: "asc" };
if (prev.dir === "asc") return { field, dir: "desc" };
return null; }); };
const hasPriceFilter = filters.minPrice || filters.maxPrice; const priceLabel = [ filters.minPrice ? $${filters.minPrice} : null, filters.maxPrice ? $${filters.maxPrice} : null, ] .filter(Boolean) .join(" - ");
const activeFilterCount = [ filters.country, filters.color, filters.mode, filters.minPrice, filters.maxPrice, ].filter(Boolean).length;
let sortedProducts = [...(productsQuery.data || [])];
if (localSort) {
sortedProducts = [...sortedProducts].sort((a, b) => {
const aVal = a[localSort.field];
const bVal = b[localSort.field];
const cmp =
typeof aVal === "string"
? aVal.localeCompare(bVal as string)
: (aVal as number) - (bVal as number);
return localSort.dir === "desc" ? -cmp : cmp;
});
}
useEffect(() => {
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, []);
return (
<div className="min-h-screen bg-stone-50">
<Header
query={filters.query}
handleClearQuery={handleClearQuery}
onToggleFilters={() => setDrawerOpen((v) => !v)}
activeFilterCount={activeFilterCount}
searchRef={searchRef}
handleChange={handleQueryChange}
/>
<FilterDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
filters={filters}
onChange={setFilters}
onReset={resetFilters}
countries={countriesQuery.data ?? []}
colors={colorsQuery.data ?? []}
modes={modesQuery.data ?? []}
activeFilterCount={activeFilterCount}
/>
<main className="max-w-6xl mx-auto px-4 py-6">
{activeFilterCount > 0 && (
<FilterChips
filters={filters}
setFilters={setFilters}
hasPriceFilter={hasPriceFilter}
priceLabel={priceLabel}
resetFilters={resetFilters}
/>
)}
<ProductTable
products={sortedProducts}
isLoading={productsQuery.isLoading}
handleColumnClick={handleColumnClick}
localSort={localSort}
/>
</main>
</div>
);
}SearchPage 组件负责跟踪渲染页面所需的所有逻辑:
- 它获取产品、国家、颜色和模式的数据。然后将国家、颜色和模式传递给抽屉组件。
- 它跟踪抽屉的状态。
- 它定义了用于本地排序产品的函数等。
这里的问题是,任何状态的更改都会导致 SearchPage 组件中所有函数和派生值的重新创建。例如,当 products(productsQuery)的 useQuery 中的 isLoading 状态从 false 变为 true 时,我们所有的函数和派生值都会被重新创建。
首先想到的可能是使用 useCallback 和 useMemo 来缓存函数和派生值。虽然这可以工作,但会为这个页面带来不必要的性能开销。
更好的解决方案是将状态和逻辑移动到它们实际使用的地方。
如何将状态向下移动
这个想法是:如果只有一个组件需要某一块状态或函数,那么该组件应该拥有它。当子组件管理自己的状态时,状态的更改不会重新渲染父组件。这意味着所有兄弟组件的状态和函数都会保持稳定,而无需任何记忆化处理。
尽管如此,不要将逻辑移动得太远,以至于共享行为变得更难测试或协调。目标不是将每一块逻辑隐藏在尽可能深的组件中。目标是将状态和逻辑放在最低的层级,只要它们对于功能仍然有意义。
查看产品是如何获取和排序的,你会发现唯一使用这些数据的组件是 ProductsTable。这意味着我们可以将获取和排序的逻辑移到 ProductsTable 中。
当前 ProductsComponent 接收其状态和逻辑作为 props:
"use client";
interface ProductTableProps {
products: Product[];
isLoading: boolean;
handleColumnClick: (field: LocalSortField) => void;
localSort: { field: LocalSortField; dir: SortDir } | null;
}
export function ProductTable({
products,
isLoading,
handleColumnClick,
localSort,
}: ProductTableProps) {
// 使用 props 渲染 UI
}现在,ProductTable 将负责获取数据并管理其逻辑:
interface ProductTableProps {
filters: FilterState;
}
export function ProductTable({ filters }: ProductTableProps) {
const [localSort, setLocalSort] = useState<{
field: LocalSortField;
dir: "asc" | "desc";
} | null>(null);
const apiFilters = {
query: filters.query,
country: filters.country || undefined,
color: filters.color || undefined,
mode: filters.mode || undefined,
minPrice: filters.minPrice ? Number(filters.minPrice) : undefined,
maxPrice: filters.maxPrice ? Number(filters.maxPrice) : undefined,
sortField: filters.sortField,
sortDir: filters.sortDir,
};
const { data: products = [], isLoading } = useQuery({
queryKey: ["products", apiFilters],
queryFn: () => fetchProducts(apiFilters),
});
const handleColumnClick = (field: LocalSortField) => {
setLocalSort((prev) => {
if (!prev || prev.field !== field) return { field, dir: "asc" };
if (prev.dir === "asc") return { field, dir: "desc" };
return null;
});
};
let sortedProducts = products;
if (localSort) {
sortedProducts = [...products].sort((a, b) => {
const aVal = a[localSort.field];
const bVal = b[localSort.field];
const cmp =
typeof aVal === "string"
? aVal.localeCompare(bVal as string)
: (aVal as number) - (bVal as number);
return localSort.dir === "desc" ? -cmp : cmp;
});
}
return <>{/*== 使用 props 渲染 UI ==*/}</>;
}现在当 isLoading 发生变化时,SearchPage 组件将不再重新渲染。这意味着 SearchPage 组件中的派生值和其他函数不会被重新创建。这里唯一会被重新创建的值和函数是 sortedProducts 和 handleColumnClick。
SearchPage 组件现在变成这样:
"use client";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchColors, fetchCountries, fetchModes } from "./utils";
import { Header } from "./_components/header";
import { FilterDrawer } from "./_components/filter-drawer";
import { ProductTable } from "./_components/products-table";
import { FilterChips } from "./_components/filter-chips";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FilterState, SortDir, SortField } from "./interfaces";
const DEFAULTS: FilterState = {
query: "",
country: "",
color: "",
mode: "",
minPrice: "",
maxPrice: "",
sortField: "name",
sortDir: "asc",
};
export default function SearchPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [drawerOpen, setDrawerOpen] = useState(false); const searchRef = useRef<HTMLInputElement>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const filters: FilterState = {
query: searchParams.get("q") ?? DEFAULTS.query,
country: searchParams.get("country") ?? DEFAULTS.country,
color: searchParams.get("color") ?? DEFAULTS.color,
mode: searchParams.get("mode") ?? DEFAULTS.mode,
minPrice: searchParams.get("minPrice") ?? DEFAULTS.minPrice,
maxPrice: searchParams.get("maxPrice") ?? DEFAULTS.maxPrice,
sortField:
(searchParams.get("sortField") as SortField) ?? DEFAULTS.sortField,
sortDir: (searchParams.get("sortDir") as SortDir) ?? DEFAULTS.sortDir,
};
const countriesQuery = useQuery({
queryKey: ["countries"],
queryFn: fetchCountries,
staleTime: Infinity,
});
const colorsQuery = useQuery({
queryKey: ["colors"],
queryFn: fetchColors,
staleTime: Infinity,
});
const modesQuery = useQuery({
queryKey: ["modes"],
queryFn: fetchModes,
staleTime: Infinity,
});
// 更新抽屉中的筛选条件
const setFilters = (partial: Partial<FilterState>) => {
const next = new URLSearchParams(searchParams.toString());
const merged = { ...filters, ...partial };
const keyMap: Record<keyof FilterState, string> = {
query: "q",
country: "country",
color: "color",
mode: "mode",
minPrice: "minPrice",
maxPrice: "maxPrice",
sortField: "sortField",
sortDir: "sortDir",
};
(Object.keys(merged) as (keyof FilterState)[]).forEach((k) => {
const paramKey = keyMap[k];
const val = merged[k];
const def = DEFAULTS[k];
if (val && val !== def) {
next.set(paramKey, val);
} else {
next.delete(paramKey);
}
});
router.push(`\${pathname}?\${next.toString()}`, { scroll: false });
};
const resetFilters = () => {
router.push(pathname, { scroll: false });
};
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
setFilters({ query: val });
}, 400);
};
const handleClearQuery = () => {
if (searchRef.current) {
searchRef.current.value = "";
}
setFilters({ query: "" });
};
const hasPriceFilter = filters.minPrice || filters.maxPrice;
const priceLabel = [
filters.minPrice ? `$${filters.minPrice}` : null,
filters.maxPrice ? `$${filters.maxPrice}` : null,
]
.filter(Boolean)
.join(" - ");
const activeFilterCount = [
filters.country,
filters.color,
filters.mode,
filters.minPrice,
filters.maxPrice,
].filter(Boolean).length;
useEffect(() => {
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, []);
return (
<div className="min-h-screen bg-stone-50">
<Header
query={filters.query}
onChange={setFilters}
handleClearQuery={handleClearQuery}
onToggleFilters={() => setDrawerOpen((v) => !v)}
activeFilterCount={activeFilterCount}
searchRef={searchRef}
handleChange={handleQueryChange}
/><FilterDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
filters={filters}
onChange={setFilters}
onReset={resetFilters}
countries={countriesQuery.data ?? []}
colors={colorsQuery.data ?? []}
modes={modesQuery.data ?? []}
activeFilterCount={activeFilterCount}
/>
<main className="max-w-6xl mx-auto px-4 py-6">
{activeFilterCount > 0 && (
<FilterChips
filters={filters}
setFilters={setFilters}
hasPriceFilter={hasPriceFilter}
priceLabel={priceLabel}
resetFilters={resetFilters}
/>
)}
<ProductTable filters={filters} />
</main>
</div>
);
}SearchPage 组件不再负责获取和排序产品数据。
#### 将过滤逻辑移动到其组件中
我们有不同状态、数据和函数来实现这一点:
drawerOpen和setDrawerOpen用于控制过滤抽屉。
countries、colors和modes数据用于让用户选择不同选项。
activeFilterCount用于显示当前激活的过滤器数量。
目前有两个组件用于过滤。第一个是 Header 组件中的一个按钮,如下所示:
<button
onClick={onToggleFilters}
className="relative ml-auto flex items-center gap-2 px-3 py-2 text-sm text-black font-medium border border-stone-300 rounded-lg hover:bg-stone-100 transition"
>
<SlidersHorizontal className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span>
{activeFilterCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex items-center justify-center
w-5 h-5 rounded-full bg-stone-900 text-white text-xs font-bold"
>
{activeFilterCount}
</span>
)}
</button>;第二个是 FilterDrawer 组件,如下所示:
"use client";
import { useEffect, useRef } from "react";
import { X, RotateCcw } from "lucide-react";
import { FilterState } from "../interfaces";
import { SortSection } from "./filter/sort-section";
import { NarrowResultsSection } from "./filter/narrow-result-section";
interface FilterDrawerProps {
open: boolean;
onClose: () => void;
filters: FilterState;
onChange: (partial: Partial<FilterState>) => void;
onReset: () => void;
countries: string[];
colors: string[];
modes: string[];
activeFilterCount: number;
}
export function FilterDrawer({
open,
onClose,
filters,
onChange,
onReset,
countries,
colors,
modes,
activeFilterCount,
}: FilterDrawerProps) {
const drawerRef = useRef<HTMLDivElement>(null);
// 按下 Escape 键关闭抽屉
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
// 抽屉打开时禁用页面滚动
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [open]);
return (
<>
{/* 背景遮罩 */}
<div
className={`fixed inset-0 z-40 bg-black/30 transition-opacity duration-300 ${
open
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* 抽屉面板 */} <aside ref={drawerRef} className={fixed top-0 right-0 z-50 h-full w-80 bg-white shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}} aria-hidden={!open} > {/* 标题栏 */} <div className="flex items-center justify-between px-5 py-4 border-b border-stone-100"> <h2 className="font-semibold text-stone-900"> 筛选与排序 {activeFilterCount > 0 && ( <span className="ml-2 text-xs bg-stone-900 text-white px-1.5 py-0.5 rounded-full"> {activeFilterCount} </span> )} </h2> <button onClick={onClose} className="p-1.5 rounded-md hover:bg-stone-100 transition" aria-label="关闭筛选" > <X className="w-5 h-5 text-stone-600" /> </button> </div>
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-6"> <SortSection sortField={filters.sortField} sortDir={filters.sortDir} onChange={onChange} />
<hr className="border-stone-100" />
<NarrowResultsSection filters={filters} onChange={onChange} countries={countries} colors={colors} modes={modes} /> </div>
{/* 底部栏 */} {activeFilterCount > 0 && ( <div className="px-5 py-4 border-t border-stone-100"> <button onClick={() => { onReset(); onClose(); }} className="w-full flex items-center justify-center gap-2 px-4 py-2.5 border border-stone-300 rounded-lg text-sm font-medium hover:bg-stone-100 transition text-stone-700" > <RotateCcw className="w-4 h-4" /> 清除所有筛选 </button> </div> )} </aside> </>
你可以将这两个组件合并为一个 Filter 组件,该组件拥有所有这些逻辑:
import { RotateCcw, SlidersHorizontal, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { SortSection } from "./filter/sort-section"; import { NarrowResultsSection } from "./filter/narrow-result-section"; import { FilterState } from "../interfaces"; import { usePathname, useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { fetchColors, fetchCountries, fetchModes } from "../utils";
interface FilterProps { filters: FilterState; onChange: (partial: Partial<FilterState>) => void; }
const Filter = ({ filters, onChange }: FilterProps) => { const { data: countries = [] } = useQuery({ queryKey: ["countries"], queryFn: fetchCountries, staleTime: Infinity, });
const { data: colors = [] } = useQuery({ queryKey: ["colors"], queryFn: fetchColors, staleTime: Infinity, });
const { data: modes = [] } = useQuery({ queryKey: ["modes"], queryFn: fetchModes, staleTime: Infinity, });
const router = useRouter(); const pathname = usePathname(); const [drawerOpen, setDrawerOpen] = useState(false);
const drawerRef = useRef<HTMLDivElement>(null);
const onClose = () => setDrawerOpen(false); const openDrawer = () => setDrawerOpen(true); const resetFilters = () => { onClose(); router.push(pathname, { scroll: false }); };
const activeFilterCount = [ filters.country, filters.color, filters.mode, filters.minPrice, filters.maxPrice, ].filter(Boolean).length;
// Close on Escape useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") setDrawerOpen(false); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, []);
// Prevent body scroll while open useEffect(() => { document.body.style.overflow = drawerOpen ? "hidden" : ""; return () => { document.body.style.overflow = ""; }; }, [drawerOpen]);
return ( <> <button onClick={openDrawer} className="relative ml-auto flex items-center gap-2 px-3 py-2 text-sm text-black font-medium border border-stone-300 rounded-lg hover:bg-stone-100 transition" > <SlidersHorizontal className="w-4 h-4" /> <span className="hidden sm:inline">Filters</span> {activeFilterCount > 0 && ( <span className="absolute -top-1.5 -right-1.5 flex items-center justify-center w-5 h-5 rounded-full bg-stone-900 text-white text-xs font-bold" > {activeFilterCount} </span> )} </button> {/* Backdrop */} <div className={fixed inset-0 z-40 bg-black/30 transition-opacity duration-300 ${ drawerOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none" }} onClick={onClose} />
{/* Drawer panel */} <aside ref={drawerRef} className={fixed top-0 right-0 z-50 h-full w-80 bg-white shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${drawerOpen ? "translate-x-0" : "translate-x-full"}} aria-hidden={!drawerOpen} > {/* Header */} <div className="flex items-center justify-between px-5 py-4 border-b border-stone-100"> <h2 className="font-semibold text-stone-900"> Filters & Sort {activeFilterCount > 0 && ( <span className="ml-2 text-xs bg-stone-900 text-white px-1.5 py-0.5 rounded-full"> {activeFilterCount} </span> )} </h2> <button onClick={onClose} className="p-1.5 rounded-md hover:bg-stone-100 transition" aria-label="Close filters" > <X className="w-5 h-5 text-stone-600" /> </button> </div>
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-6"> <SortSection sortField={filters.sortField} sortDir={filters.sortDir} onChange={onChange} />
<hr className="border-stone-100" />
<NarrowResultsSection filters={filters} onChange={onChange} countries={countries} colors={colors} modes={modes} /> </div>
{/*页脚 */}
{activeFilterCount > 0 && (
<div className="px-5 py-4 border-t border-stone-100">
<button
onClick={resetFilters}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5
border border-stone-300 rounded-lg text-sm font-medium
hover:bg-stone-100 transition text-stone-700"
>
<RotateCcw className="w-4 h-4" />
清除所有筛选条件
</button>
</div>
)}
</aside>
</>
);
};
export default Filter;你可以更进一步。请注意,NarrowResultsSection 是唯一使用获取的国家、颜色和模式的组件。在其中,每个 SelectField 使用了这些数据的一部分。
import { FilterState } from "../../interfaces";
import { PriceRangeField } from "./price-range";
import { SelectField } from "./select-field";
interface NarrowResultsSectionProps {
filters: FilterState;
onChange: (partial: Partial<FilterState>) => void;
countries: string[];
colors: string[];
modes: string[];
}
export function NarrowResultsSection({
filters,
onChange,
countries,
colors,
modes,
}: NarrowResultsSectionProps) {
return (
<section className="flex flex-col gap-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-500">
精确筛选结果
</h3>
<SelectField
label="国家"
value={filters.country}
options={countries}
onChange={(v) => onChange({ country: v })}
placeholder="所有国家"
/>
<SelectField
label="颜色"
value={filters.color}
options={colors}
onChange={(v) => onChange({ color: v })}
placeholder="所有颜色"
/>
<SelectField
label="模式"
value={filters.mode}
options={modes}
onChange={(v) => onChange({ mode: v })}
placeholder="所有模式"
/>
<PriceRangeField
minPrice={filters.minPrice}
maxPrice={filters.maxPrice}
onChange={onChange}
/>
</section>
);
}与其在顶部获取所有数据并传递下去,你可以为每个 SelectField 提供自己的查询。
SelectField 最初看起来是这样的:
interface SelectFieldProps {
label: string;
value: string;
options: string[];
onChange: (v: string) => void;
placeholder: string;
}
export function SelectField({
label,
value,
options,
onChange,
placeholder,
}: SelectFieldProps) {
return <>{/*=== 渲染 UI ===*/}</>;
}现在,它看起来像这样:
import { useQuery } from "@tanstack/react-query";
interface SelectFieldProps {
label: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
queryFn(): Promise<string[]>;
queryKey: string;
}
export function SelectField({
label,
value,
onChange,
placeholder,
queryFn,
queryKey,
}: SelectFieldProps) {
const { data: options = [] } = useQuery({
queryKey: [queryKey],
queryFn: queryFn,
staleTime: Infinity,
});
return <>{/*=== 渲染 UI ===*/}</>;
}现在每个下拉框都管理自己的数据。一个 SelectField 内部的状态变化不会影响其兄弟组件或父组件。
#### 将搜索逻辑移动到其组件中
搜索组件是唯一使用防抖逻辑(searchTimerRef、handleQueryChange、handleClearQuery)的组件。你将把逻辑移到组件内部:
"use client";
import { Search as SearchIcon, X } from "lucide-react";
import { ChangeEvent, useEffect, useRef } from "react";
import { FilterState } from "../interfaces";
interface SearchProps {
query: string;
onChange: (partial: Partial<FilterState>) => void;
}
const Search = ({ query, onChange }: SearchProps) => {
const searchRef = useRef<HTMLInputElement>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleClearQuery = () => {
if (searchRef.current) {
searchRef.current.value = "";
}
onChange({ query: "" });
};
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
onChange({ query: val });
}, 400);
};
useEffect(() => {
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, []);
return <div>{/*=== Renders UI ===*/}</div>;
};
export default Search;#### 将过滤芯片移至其组件中
FilterChips 组件用于渲染活动过滤器的芯片。hasPriceFilter 和 priceLabel 的值可以移到组件内部,而不是 SearchPage 中:
interface FilterChipsProps {
filters: FilterState;
setFilters: (partial: Partial<FilterState>) => void;
resetFilters: () => void;
}
export function FilterChips({
filters,
setFilters,
resetFilters,
}: FilterChipsProps) {
const priceLabel = [
filters.minPrice ? `$${filters.minPrice}` : null,
filters.maxPrice ? `$${filters.maxPrice}` : null,
]
.filter(Boolean)
.join(" - ");
const hasPriceFilter = filters.minPrice || filters.maxPrice;
return <>{/*=== Renders UI ===*/}</>;
}#### 最终的 SearchPage
将所有状态和逻辑移动到需要它的组件后,SearchPage 组件如下所示:
"use client";
import { Header } from "./_components/header";
import { ProductTable } from "./_components/products-table";
import { FilterChips } from "./_components/filter-chips";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FilterState, SortDir, SortField } from "./interfaces";
const DEFAULTS: FilterState = {
query: "",
country: "",
color: "",
mode: "",
minPrice: "",
maxPrice: "",
sortField: "name",
sortDir: "asc",
};
export default function SearchPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const filters: FilterState = {
query: searchParams.get("q") ?? DEFAULTS.query,
country: searchParams.get("country") ?? DEFAULTS.country,
color: searchParams.get("color") ?? DEFAULTS.color,
mode: searchParams.get("mode") ?? DEFAULTS.mode,
minPrice: searchParams.get("minPrice") ?? DEFAULTS.minPrice,
maxPrice: searchParams.get("maxPrice") ?? DEFAULTS.maxPrice,
sortField:
(searchParams.get("sortField") as SortField) ?? DEFAULTS.sortField,
sortDir: (searchParams.get("sortDir") as SortDir) ?? DEFAULTS.sortDir,
};
const setFilters = (partial: Partial<FilterState>) => {
const next = new URLSearchParams(searchParams.toString());
const merged = { ...filters, ...partial };const keyMap: Record<keyof FilterState, string> = {
query: "q",
country: "country",
color: "color",
mode: "mode",
minPrice: "minPrice",
maxPrice: "maxPrice",
sortField: "sortField",
sortDir: "sortDir",
};
(Object.keys(merged) as (keyof FilterState)[]).forEach((k) => {
const paramKey = keyMap[k];
const val = merged[k];
const def = DEFAULTS[k];
if (val && val !== def) {
next.set(paramKey, val);
} else {
next.delete(paramKey);
}
});
router.push(`\${pathname}?\${next.toString()}`, { scroll: false });
};
const resetFilters = () => {
router.push(pathname, { scroll: false });
};
const activeFilterCount = [
filters.country,
filters.color,
filters.mode,
filters.minPrice,
filters.maxPrice,
].filter(Boolean).length;
return (
<div className="min-h-screen bg-stone-50">
<Header filters={filters} onChange={setFilters} />
<main className="max-w-6xl mx-auto px-4 py-6">
{activeFilterCount > 0 && (
<FilterChips
filters={filters}
setFilters={setFilters}
resetFilters={resetFilters}
/>
)}
<ProductTable filters={filters} />
</main>
</div>
);
}请注意,setFilters、resetFilters 和 activeFilterCount 仍然在 SearchPage 组件中。这是有意为之。这些值依赖于 URL。任何从 URL 读取的组件在 URL 变化时都会重新渲染。无论这些值是在哪里计算的,都不影响。
在使用这些 Hook 之前修复你的代码
当你遇到无限重新渲染时,可能会倾向于使用 useCallback 或 useMemo。不稳定的对象引用通常会导致无限重新渲染,尤其是当子组件的 useEffect 依赖于该对象时。理解循环发生的原因并修复根本原因总是更好的选择。
看下面这个例子:
"use client";
import { useEffect, useState } from "react";
import { fetchUsers, Filter, User } from "./utils";
interface UserListProps {
filters: Filter;
onLoad(data: User[]): void;
users: User[];
}
function UserList({ filters, onLoad, users }: UserListProps) {
console.count("__ USER LIST __");
useEffect(() => {
fetchUsers(filters).then((data) => {
onLoad(data);
});
}, [filters, onLoad]);
return (
<ul className="h-screen flex items-center justify-center flex-col">
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
const UserPage = () => {
const [userData, setUserData] = useState<User[]>([]);
const filters = {
role: "admin",
active: true,
};
return <UserList filters={filters} onLoad={setUserData} users={userData} />;
};
export default UserPage;UserList 组件在挂载时会获取用户数据。它使用 filters 和 onLoad 作为依赖项。
这里的问题在于 UserPage 组件中的 filters 对象在每次渲染时都会被重新创建。即使值看起来相同,每次重新渲染时它都是一个新的引用。UserList 每次都会认为这是一个新的值,这会触发它的 useEffect,因为它是一个依赖项。
将 filters 用 useMemo 包裹可以停止循环,但这忽略了真正的问题。useMemo 并不是用来阻止无限重新渲染的。有一些更好的解决方案可以修复这个问题:
第一个选项是使用依赖数组中的原始值而不是对象。对象是通过引用进行比较的,这就是为什么每次 useEffect 读取 filter props 时,都会看到不同的引用。而原始值是通过值进行比较的。
function UserList({ filters, onLoad, users }: UserListProps) {
console.count("__ USER LIST __");
useEffect(() => {
fetchUsers(filters).then((data) => {
onLoad(data);
});
}, [filters.active, filters.role]); // 原始值通过值进行比较,而不是引用
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}第二个选项是将对象定义在组件外部,这样它就有了一个稳定的引用。
const filters = {
role: "admin",
active: true,
};
const UserPage = () => {
const [userData, setUserData] = useState<User[]>([]);
return <UserList filters={filters} onLoad={setUserData} users={userData} />;
};第三个解决方案是如果对象是动态的,就将其存储在状态中。
const UserPage = () => {
const [userData, setUserData] = useState<User[]>([]);
const [filters, setFilters] = useState({
role: "admin",
active: true,
});
return <UserList filters={filters} onLoad={setUserData} users={userData} />;
};何时使用 useCallback 和 useMemo
本文的目标不是告诉你永远不要使用这些钩子。确实存在一些场景,这些钩子会大放异彩。
优化之前先进行测量
在进行任何优化之前,无论是使用 useCallback、useMemo,还是重构组件,你应该首先确认是否存在性能问题。对不需要优化的代码进行优化对任何人都没有好处。
React DevTools 有一个 Profiler 标签页,可以让你记录一个会话,并准确看到哪些组件正在重新渲染,频率如何,以及每次渲染需要多长时间。你应该阅读《如何使用 React 开发者工具 – 附带示例说明》。如果你更喜欢视频,可以观看 Ben 展示如何使用 React Profiler 来发现和修复性能问题。
为 React.memo 子组件稳定引用
React.memo 可以防止组件的 props 没有变化时重新渲染。但如果你传递一个函数或对象作为 prop,子组件仍然会在每次父组件渲染时重新渲染,因为函数和对象每次都会被重新创建,引用也会改变。
这时候就是使用 useCallback 或 useMemo 的正确时机:
const Child = React.memo(({ onClick }: { onClick: () => void }) => {
console.log("Child rendered");
return <button onClick={onClick}>Click me</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("clicked");
}, []);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>Increment: {count}</button>
<Child onClick={handleClick} />
</>
);
}如果没有使用 useCallback,Child 会在每次 count 改变时重新渲染,即使 handleClick 与 count 毫无关系。使用 useCallback 后,函数引用保持稳定。
通常,将 useCallback 和 React.memo 一起使用效果最佳。但如果 props 是原始值或其他稳定值,React.memo 本身也可以很有用。同样,useCallback 也可以在 React.memo 之外使用,例如在将稳定的回调传递给 effects、自定义钩子或第三方组件时。
useCallback 和 useMemo 是有用的记忆化工具,但它们并不是免费的性能提升。每次调用都会增加内存开销,并在每次渲染时进行依赖项比较。
始终以一种优化很少需要的方式构建组件。尽可能将状态和逻辑靠近使用它们的组件。在确认渲染确实存在问题之后,再结合 React.memo 使用 useCallback 和 useMemo。
前端开发人员 | 开源贡献者 | 有经验的后端开发人员
如果你读到这里,请感谢作者以表达你对他们的关心。说声谢谢
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发人员。立即开始
ADVERTISEMENT