如果你在React生态系统中待了足够长的时间,你很可能见过这样的代码:几乎所有的函数都被包裹在了useCallback中,而计算出的值也被包裹在了useMemo中。
这样做的原因在于“记忆化能够提升性能”。但大多数情况下,这种做法并不会真正带来更好的性能,反而会让代码更难调试。
在这篇文章中,你将学习如何合理组织代码,以避免过度使用useCallback和useMemo。
先决条件
在阅读本教程之前,你应该已经熟悉React的钩子函数和组件。我们假设你已经了解useState、useEffect以及useRef。如果你需要复习useCallback和useMemo的相关知识,可以阅读freeCodeCamp提供的以下文章:
目录
useCallback和useMemo的作用
在讨论如何避免过度使用这些功能之前,我们先简要了解一下它们具体的作用。
`useMemo`
`useMemo`会将在函数重新渲染之间返回的值缓存起来。假设你在某个组件中维护了一个排序后的项目列表:
interface Item {
name: string;
createdAt: string;
}
function App() {
// == 其他状态 ==
const [items, setItems] = useState:- ;];
const sortedItems = [...items].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(bcreatedAt).getTime(),
);
return (
<>
{sortedItems.map((i) => (
- {i.name}
))}
>
);
}
每当`App`组件重新渲染时,React都会重新计算`sortedItems`。这意味着,只要`App`组件的状态发生任何变化,`sortedItems`就会被重新计算。
React开发者通常会使用`useMemo`来缓存这类数据。
将`sortedItems`包装在`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("__ 正在处理用户ID __", userId);
// 更新状态以停止显示加载中
};
return (
<>
>
);
}
但如果将`verifyUser`包装在`useCallback`中,只要`userId`的状态没有改变,就会继续使用同一个函数实例:
function App() {
// == 其他状态 ==
const [userId, setUserId] = useState(0);
const verifyUser = useCallback(async () => {
// 更新状态以显示加载中
console.log("__ 正在处理用户ID __", userId);
// 更新状态以停止显示加载中
}, [userId]);
return (
<>
>
);
}
记忆化带来的问题
生活中没有什么是免费的,记忆化也不例外。每当你使用`useCallback`或`useMemo`时:
-
您的应用程序会分配内存来存储缓存值以及依赖关系数组。
-
您的组件会进行比较,以判断这些依赖关系是否发生了变化。
大多数情况下,这种记忆化机制并没有实际作用。创建一个JavaScript函数的成本很低,对包含50个元素的列表进行排序也是如此。将这些操作包装在记忆化函数中,所增加的开销其实并不足以弥补其带来的好处。(不过需要注意的是,如果性能分析显示排序是整个程序的瓶颈,那么使用useMemo仍然是合理的。)
更好的做法是合理设计组件结构,从而减少不必要的重新渲染操作。
存在问题的页面
为了了解这一点的实际效果,你可以查看一个搜索页面的实现方式——在这个页面中,一个父组件负责管理整个页面的所有状态和逻辑。
如果你想跟着这个例子一起编写代码,可以克隆我为这个演示准备的简单Next.js项目:
git clone https://github.com/Olaleye-Blessing/freecodecamp-usecallback-usememo.git
# 进入该项目目录
cd freecodecamp-usecallback-usememo
# 安装所需的包
pnpm install
# 启动开发模式
pnpm dev
这个搜索页面由以下部分组成:
-
页头,用于显示页面的标题。
-
搜索框,用户可以通过它来查找产品的名称。
-
筛选按钮,点击该按钮会打开一个抽屉窗口,以便进一步进行筛选操作。
-
筛选抽屉,用户可以通过它按国家、颜色、模式和/或价格范围来筛选结果。
-
,用于显示搜索结果。

上述所有子组件都不维护任何状态或函数,它们的状态和功能都是从SearchPage组件中获取的。
我们不会详细讨论这些子组件的实现方式,因为它们仅仅负责渲染用户界面,完全不包含任何逻辑代码。
SearchPage组件的结构如下:
“`javascript
"use client";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
fetchColors,
fetchCountries,
fetchModes,
fetchProducts,
} 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 "nextnavigation";
import { FilterState, LocalSortField, 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 [localSort, setLocalSort] = useState< { field: LocalSortField; dir: "asc" | "desc" } > || null;
const searchRef = useRef
const searchTimerRef = useRef
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(filtered.maxPrice) : undefined,
sortField: filters.sortField,
sortDir: filters.sortDir,
};
const productsQuery = useQuery({
queryKey: ["products", apiFilters],
queryFn: () => fetchProducts(api Filters),
});
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
const next = new URLSearchParams(searchParams.toString());
const merged = { ...filters, ...partial };
const keyMap: Record
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
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 (
activeFilterCount={activeFilterCount}
searchRef={searchRef}
handleChange={handleQueryChange}
/>
filters={filters}
onChange={setFilters}
onReset={resetFilters}
countries={countriesQuery.data ?? []}
colors={colorsQuery.data ?? []}
modes={modesQuery.data ?? []}
activeFilterCount={activeFilterCount}
/>
{activeFilterCount > 0 && (
)}
);
}
“`
SearchPage组件负责处理渲染该页面所需的所有逻辑:
-
它会获取
products、countries、colors以及modes这些数据,并将countries、colors和modes传递给抽屉组件。 -
它还会跟踪抽屉组件的状态变化。
-
它定义了用于在本地对产品进行排序等功能所需的相关逻辑。
问题在于,只要这些状态中的任何一个发生变化,SearchPage组件中所有的相关函数都会被重新生成。例如,当在获取产品的useQuery函数(productsQuery)中,isLoading的状态从false变为true时,所有相关的函数和计算结果都会被重新计算。
首先可能会想到的解决办法是使用useCallback和useMemo来缓存这些函数和计算结果。虽然这种方法确实有效,但它会为这个页面带来不必要的性能开销。
一个更好的解决方案是将状态和相关逻辑放在它们实际被使用的位置附近。
如何将状态逻辑下移
其思路是这样的:如果只有某个组件需要使用某段状态或函数,那么就应该让这个组件自己拥有这些资源。当一个子组件管理自己的状态时,该状态的变更就不会导致父组件的重新渲染。这样一来,所有同级组件的状态和功能都会保持稳定,也就无需使用useMemo来进行缓存了。
不过,也不要将逻辑下移得过于深远,否则会导致共享功能的测试或协调变得困难。我们的目标并不是把所有的逻辑都隐藏在最深层的组件中,而是要将状态和逻辑放在对功能实现来说最为合适的层级上。
将ProductTable的逻辑移至其所属组件
观察一下产品的获取和排序过程,你会发现只有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 (
0 && (
<\/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" />
过滤器>
{activeFilterCount > 0 && (
{activeFilterCount}
)}
</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) => 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 (
<><>
{/* 背景层 */}
{/* 页头 */}
0 && (
)}
>
{/* 页脚 */}
{activeFilterCount > 0 && (
清除所有过滤器
)}
<></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) => 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(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;
// 通过按下Esc键关闭抽屉
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setDrawerOpen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
// 当抽屉打开时阻止页面滚动
useEffect(() => {
document.body.style.overflow = drawerOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [drawerOpen]);
return (
<>
{/* 背景图 */}
{/* 抽屉面板 */}
>
);
};
export default Filter;
你可以进一步优化这个设计。请注意,NarrowResultsSection是唯一一个会使用获取到的countries、colors和modes数据的组件。而在该组件内部,每一个SelectField》都会使用这些数据中的一部分。
import { FilterState } from "..interfaces";
import { PriceRangeField } from "./price-range";
import { SelectField } from "./select-field";
interface NarrowResultsSectionProps {
filters: FilterState;
onChange: (partial: Partial) => void;
countries: string[];
colors: string[];
modes: string[];
}
export function NarrowResultsSection({
filters,
onChange,
countries,
colors,
modes,
}: NarrowResultsSectionProps) {
return (
筛选结果
onChange({ country: v})}
placeholder="所有国家"
/>
onChange({ color: v})}
placeholder="所有颜色"
/>
onChange({ mode: v})}
placeholder="所有模式"
/>
);
}
你不必一开始就获取所有数据,然后将其传递给所有的SelectField》,而是可以为每个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 <>>{/*=== 渲染用户界面 ===*/</>>;
}
现在,它的实现方式变成了这样:
import { useQuery } from "@tanstack/react-query";
interface SelectFieldProps {
label: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
queryFn(): Promise>{/*=== 渲染用户界面 ===*/</>>;
}
现在,每个下拉列表都会管理自己对应的数据。当某个SelectField》的状态发生变化时,这种变化不会影响到它的兄弟元素或父元素。
将搜索逻辑移入相应的组件中
Search组件是唯一一个使用了去抖动逻辑的组件(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) => void;
}
const Search = ({ query, onChange }: SearchProps) => {
const searchRef = useRef(null);
const searchTimerRef = useRef | null>(null);
const handleClearQuery = () => {
if (searchRef.current) {
searchRef.current.value = "";
}
onChange({ query: "" });
};
const handleQueryChange = (e: ChangeEvent) => {
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 /*=== 显示用户界面 ===*/;
};
export default Search;
将筛选选项组件移入相应的组件中
FilterChips组件用于显示当前选中的筛选条件。其实,提供给该组件的hasPriceFilter和priceLabel这些数据完全可以直接放在FilterChips组件内部,而无需再通过SearchPage来传递:
interface FilterChipsProps {
filters: FilterState;
setFilters: (partial: Partial) => void;
reset Filters: () => 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 *=== 显示用户界面 ===*/>
;
}
最终的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 "nextnavigation";
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) => {
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 (
{activeFilterCount > 0 && (
)}
);
}
请注意,`setFilters`、`reset Filters`和`activeFilterCount`这些函数仍然存在于`SearchPage`组件中。这是有意为之的——因为这些值的计算结果实际上取决于URL地址,而任何从URL中获取数据的组件,在URL发生变化时都会重新渲染。因此,无论这些值是在哪里被计算的,并不会影响这一行为。
在使用这些钩子之前,请先修复你的代码
当遇到无限循环重绘的情况时,你可能会想要使用`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 (
];
const filters = {
role: "admin",
active: true,
};
return
`UserList`组件在初始化时会从服务器获取用户数据,它将`filters`和`onLoad`作为依赖项来使用。
问题在于,在`UserPage`组件中,每次重新渲染时`filters`对象都会被重新创建。虽然它的值看起来没有变化,但实际上每次重绘时都是一个新的对象引用,因此`UserList`会将其视为一个不同的值,从而触发`useEffect`效应。