freeCodeCamp.org

How to Avoid Overusing useCallback and useMemo in React

8.5内容质量
How to Avoid Overusing useCallback and useMemo in React

TL;DR · AI 摘要

过度使用useCallback和useMemo可能导致性能问题和代码复杂度增加,应仅在必要时使用。

核心要点

  • useMemo和useCallback应仅用于优化性能,而非默认使用。
  • 在React中,不必要的memo化可能导致组件难以调试和维护。
  • 在使用useCallback和useMemo之前,应先进行性能测量。

结构提纲

按章节快速跳转。

  1. 过度使用useCallbackuseMemo可能导致代码复杂度增加和性能问题。

  2. useMemo用于缓存函数返回值,useCallback用于缓存函数本身。

  3. 过度使用useMemo和useCallback可能导致代码难以调试和维护。

  4. 应在性能优化时使用useCallback和useMemo,而非默认使用。

  5. 在使用useCallback和useMemo之前,应先进行性能测量。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 避免过度使用useCallback和useMemo
    • useMemo和useCallback的作用
      • useMemo缓存函数返回值
      • useCallback缓存函数本身
    • 过度使用的问题
      • 代码复杂度增加
      • 性能问题
    • 何时使用
      • 性能优化时使用
      • 先进行性能测量

金句 / Highlights

值得收藏与分享的关键句。

#React#前端#性能优化#useCallback#useMemo
打开原文

如何避免在 React 中过度使用 useCallback 和 useMemo

2026年6月17日

/

#React

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 在重新渲染之间缓存函数的返回值。想象一下你在一个组件中有一个排序后的项目列表:

code
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:

code
const sortedItems = useMemo(() => {
  return [...items].sort(
    (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
  );
}, [items]);

useCallback

useCallback 缓存函数本身。下面的函数每次组件中的某些状态发生变化时都会被重新创建:

code
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 没有变化,函数引用就保持不变:

code
function App() {
  // == 其他状态 ==
  // == 其他状态 ==
  const [userId, setUserId] = useState(0);
tsx
const verifyUser = useCallback(async () => {
  // 更新状态以显示加载状态
  console.log("__ 使用用户 ID 做一些事情 __", userId);
  // 更新状态以移除加载状态
}, [userId]);

return (
  <>
    <button onClick={verifyUser}>验证</button>
  </>
);

使用记忆化带来的问题

生活中没有免费的东西,记忆化也不例外。每次使用 useCallbackuseMemo 时:

  • 你的应用会分配内存来存储缓存的值和依赖数组。
  • 你的组件会运行一个比较,以检查依赖项是否发生了变化。

大多数情况下,这种记忆化并没有什么用。创建一个 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;

tsx
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:

code
"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 将负责获取数据并管理其逻辑:

code
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 组件中的派生值和其他函数不会被重新创建。这里唯一会被重新创建的值和函数是 sortedProductshandleColumnClick

SearchPage 组件现在变成这样:

code
"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);
tsx
  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}
      />
tsx
<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 组件不再负责获取和排序产品数据。

#### 将过滤逻辑移动到其组件中

我们有不同状态、数据和函数来实现这一点:

  • drawerOpensetDrawerOpen 用于控制过滤抽屉。
  • countriescolorsmodes 数据用于让用户选择不同选项。
  • activeFilterCount 用于显示当前激活的过滤器数量。

目前有两个组件用于过滤。第一个是 Header 组件中的一个按钮,如下所示:

tsx
<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 组件,如下所示:

tsx
"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> </>

code

你可以将这两个组件合并为一个 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);

code

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 &amp; 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>

code

{/*页脚 */}
        {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 使用了这些数据的一部分。

code
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 最初看起来是这样的:

code
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 ===*/}</>;
}

现在,它看起来像这样:

code
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)的组件。你将把逻辑移到组件内部:

code
"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 中:

code
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 组件如下所示:

code
"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 };
tsx
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>
);
}

请注意,setFiltersresetFiltersactiveFilterCount 仍然在 SearchPage 组件中。这是有意为之。这些值依赖于 URL。任何从 URL 读取的组件在 URL 变化时都会重新渲染。无论这些值是在哪里计算的,都不影响。

在使用这些 Hook 之前修复你的代码

当你遇到无限重新渲染时,可能会倾向于使用 useCallbackuseMemo。不稳定的对象引用通常会导致无限重新渲染,尤其是当子组件的 useEffect 依赖于该对象时。理解循环发生的原因并修复根本原因总是更好的选择。

看下面这个例子:

tsx
"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 组件在挂载时会获取用户数据。它使用 filtersonLoad 作为依赖项。

这里的问题在于 UserPage 组件中的 filters 对象在每次渲染时都会被重新创建。即使值看起来相同,每次重新渲染时它都是一个新的引用。UserList 每次都会认为这是一个新的值,这会触发它的 useEffect,因为它是一个依赖项。

filtersuseMemo 包裹可以停止循环,但这忽略了真正的问题。useMemo 并不是用来阻止无限重新渲染的。有一些更好的解决方案可以修复这个问题:

第一个选项是使用依赖数组中的原始值而不是对象。对象是通过引用进行比较的,这就是为什么每次 useEffect 读取 filter props 时,都会看到不同的引用。而原始值是通过值进行比较的。

code
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>
  );
}

第二个选项是将对象定义在组件外部,这样它就有了一个稳定的引用。

code
const filters = {
  role: "admin",
  active: true,
};

const UserPage = () => {
  const [userData, setUserData] = useState<User[]>([]);

  return <UserList filters={filters} onLoad={setUserData} users={userData} />;
};

第三个解决方案是如果对象是动态的,就将其存储在状态中。

code
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 的正确时机:

code
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