每个新的 React 项目都适用的可复用自定义钩子工具包
探索精心挑选的一组自定义 React 钩子——涵盖存储、防抖、点击事件处理以及数据获取功能——可帮助新项目避免重复的样板代码。
在现代应用程序中,React hooks 已成为管理状态、副作用以及可复用逻辑的默认方式,但仅仅了解内置 hooks 还远远不够。同样重要的是拥有自己的一套经过充分测试的小型自定义 hooks 工具包,这样就可以将其直接应用到任何新项目中,无需从头重新实现相同的功能。本文首先介绍一组值得纳入入门工具包的 hooks,随后深入讲解这些 hooks 的底层 API 实际工作原理,帮助你不仅知道该复制哪些代码,还能理解其行为背后的原因。
在创建新的 React 项目时,总有一些钩子几乎每次都会出现。有些用于解决性能问题,有些则能提升用户体验,还有些则避免在每个代码库中重复编写相同的样板逻辑。经过多次项目实践后,这些工具便成了标准的入门组件:无需从零开始解决相同的问题,只需引入相应的钩子,根据项目需求稍作调整,即可继续开发实际功能。以下是十二个符合此描述的钩子。
第一个是 useLocalStorage,它非常实用,因为几乎每个应用都需要在页面重新加载后保留某些数据,无论是主题偏好、表单输入还是其他用户设置。
import { useState } from "react";
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : initialValue;
}); const updateValue = (newValue) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
}; return [value, updateValue];
}
这种模式常用于存储深色模式偏好设置、认证令牌,或是任何需要在多次访问之间保持的配置。
接下来是useToggle,它适用于状态在true和false之间切换的常见场景。
import { useState } from "react";
function useToggle(initial = false) {
const [value, setValue] = useState(initial); const toggle = () => setValue(v => !v); return [value, toggle];
}
它非常适合用于模态框、下拉菜单、侧边栏以及类似的可切换用户界面元素。
useDebounce则常用于搜索输入框,以避免每次按键时都发送请求。
import { useState, useEffect } from "react";
function useDebounce(value, delay = 500) {
const [debounced, setDebounced] = useState(value); useEffect(() => {
const timer = setTimeout(() => {
setDebounced(value);
}, delay); return () => clearTimeout(timer);
}, [value, delay]); return debounced;
}
通过将更新操作延迟到输入暂停时,可以减少不必要的API调用。
useWindowSize能让响应式布局获取当前的视口尺寸。
import { useState, useEffect } from "react";
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
}); useEffect(() => {
const resize = () =>
setSize({
width: window.innerWidth,
height: window.innerHeight
}); window.addEventListener("resize", resize); return () => window.removeEventListener("resize", resize);
}, []); return size;
}
每当需要将某个值与上一次渲染时的值进行比较时,usePrevious 非常实用。
import { useEffect, useRef } from "react";
function usePrevious(value) {
const ref = useRef(); useEffect(() => {
ref.current = value;
}, [value]); return ref.current;
}
它特别适用于动画处理,或用于检测是否有内容发生了实际变化。
useClickOutside 能在用户点击弹出框外的任意位置时关闭这些 UI 元素,这符合用户对这类组件的直观预期。
import { useEffect } from "react";
function useClickOutside(ref, callback) {
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) {
callback();
}
} document.addEventListener("mousedown", handleClick); return () =>
document.removeEventListener("mousedown", handleClick);
}, [ref, callback]);
}
它非常适合用于下拉菜单、弹出窗口以及移动端导航菜单。
useDocumentTitle 能使浏览器标签页的标题与当前显示内容保持同步,从而便于导航和定位。
import { useEffect } from "react";
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
无需在多个组件中重复实现相同功能,只需在页面需要自定义标题的地方调用此钩子即可。
useFetch 将基本的数据获取功能封装成一个可重复使用的钩子。
import { useState, useEffect } from "react";
function useFetch(url) {
const [data, setData] = useState(null); useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData);
}, [url]); return data;
}
对于规模较大的项目,React Query或SWR这类工具通常更为合适,但这个轻量级版本已足以帮助小型应用起步。
useCopyToClipboard 用于实现如今随处可见的复制按钮功能。
function useCopyToClipboard() {
const copy = (text) => {
navigator.clipboard.writeText(text);
};
return copy;
}
它非常适合用于一键分享邀请链接、优惠码或API密钥。
useOnlineStatus 能让应用在网络连接断开或恢复时做出相应反应。
import { useState, useEffect } from "react";
function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine); useEffect(() => {
window.addEventListener("online", () => setOnline(true));
window.addEventListener("offline", () => setOnline(false));
}, []); return online;
}
虽然只是一个小功能,但它显著提升了在网络连接不稳定时的应用使用体验。
useDarkMode 负责处理现代应用用户普遍期望的深色模式切换功能。
import { useEffect } from "react";
function useDarkMode(enabled) {
useEffect(() => {
document.body.classList.toggle("dark", enabled);
}, [enabled]);
}
它通常与 useLocalStorage 配合使用,以便所选模式在不同会话之间保持一致。
最后,useTimeout 使得处理延迟操作比在组件中散布原始的 setTimeout 调用更为整洁。
import { useEffect } from "react";
function useTimeout(callback, delay) {
useEffect(() => {
const timer = setTimeout(callback, delay); return () => clearTimeout(timer);
}, [callback, delay]);
}
它适用于通知、启动画面以及任何需要在延迟后触发的操作。
在众多 React 项目中,有一个经验始终适用:无需每次都从零开始重新构建一切。保留一小批可复用的钩子能加快开发速度,让组件更易于理解,同时避免重复代码。这十二个钩子本身并不特别复杂,但合在一起能在整个项目周期中节省大量时间。随着你自己的项目不断增多,很可能会积累类似的个人钩子集合——重要的并非钩子的总数,而是它们能否解决你反复遇到的问题。今天为某个项目编写的小工具,往往会在之后的每个项目中都被频繁使用。
了解自定义钩子的功能有助于日常开发,但理解其背后的机制同样重要——即每个核心钩子解决什么问题、何时使用以及在不同渲染过程中的表现。只有具备这样的深入理解,才能让钩子的使用从简单的复制粘贴转变为真正的掌握。
在 Hooks 出现之前,函数组件无法存储状态或执行副作用,因此相关逻辑都存在于带有 state 对象和生命周期方法的类组件中:
class Counter extends React.Component {
state = {
count: 0
};
increment = () => {
this.setState({
count: this.state.count + 1
});
};
render() {
return (
<button onClick={this.increment}>
{this.state.count}
</button>
);
}
}
使用 Hooks 后,同一个组件就可以直接调用 useState 的普通函数形式:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}
函数式版本通常更易于阅读和复用。
使用 Hooks 需遵循两条规则。首先,始终在顶层调用它们——绝不能在条件语句、循环或嵌套函数内部调用。应避免以下情况:
if (isLoggedIn) {
useEffect(() => {
// ❌
}, []);
}
for (...) {
useState(0); // ❌
}
应保持 Hook 调用无条件,将条件逻辑放在其后:
function Component() {
const [count, setCount] = useState(0);
if (count > 10) {
// Normal conditional logic is fine
}
return ...;
}
这条规则的存在是因为 React 是按调用顺序而非名称来跟踪状态的。假设有两次 useState 调用:
function Component() {
const [count] = useState(0);
const [name] = useState("Salim");
return ...;
}
React 会按位置顺序排列这些调用:
Hook #1 → count
Hook #2 → name
如果第一次调用变为条件性的:
if (condition) {
useState(0);
}
useState("Salim");
那么在每次渲染时它们的顺序都可能发生变化:
Render 1:
Hook #1 → count
Hook #2 → name
Render 2:
Hook #1 → name
一旦出现这种情况,React 就无法将存储的状态与正确的调用对应起来,因此每次渲染时顺序都必须保持不变。
useState 允许组件在多次渲染之间记住某个值:
const [count, setCount] = useState(0);
它返回一个值对:
count → current state
setCount → state update function
一个简单的计数器就能体现这一模式:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
点击按钮会触发这一系列操作:
Click
↓
setCount()
↓
React schedules update
↓
Component renders again
↓
New state is calculated
↓
DOM is updated if necessary
状态并非像 let count = 0 这样的普通变量,因为后者在函数重新执行时不会保留。React会将状态存储在组件外部,并在每次渲染时提供正确的值:
Render 1
count = 0
Render 2
count = 1
Render 3
count = 2
组件会重新执行,但React会在多次执行之间保留状态。
当在一个处理函数中多次更新状态时,这一点非常重要:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
如果渲染开始时 count 的值为 0,那么这三行代码都会使用同一个状态快照,从而导致:
setCount(1)
setCount(1)
setCount(1)
而不是真正进行三次递增。解决方法是通过函数式更新来实现:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
现在React会按顺序应用每次更新,因为每个更新函数都会收到最新的状态值——这对于新状态依赖于旧状态的情况非常有用。
useEffect 可以让组件与React之外的内容保持同步:
useEffect(() => {
// synchronization logic
}, [dependencies]);
常见场景包括 API 调用、定时器、事件监听器、WebSockets、其他浏览器 API、订阅功能以及第三方库。示例:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
return <h1>{user?.name}</h1>;
}
该效果会与 userId 同步;当其发生变化时,必须重新执行该效果。
这种行为源自依赖数组。假设:
useEffect(() => {
console.log(count);
}, [count]);
React 是在概念层面比较依赖项的:
Previous dependencies
↓
New dependencies
↓
Compare
↓
Changed?
如果依赖项不同,则会再次执行该效果:
Previous: [1]
New: [2]
→ Effect needs to run
如果依赖项相同,则会跳过该效果:
Previous: [2]
New: [2]
→ Effect can be skipped
比较是使用 Object.is 进行的,而非深度相等比较。
效果函数可以返回一个清理函数,在下一次效果执行之前以及组件卸载时运行:
useEffect(() => {
const timer = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
清理功能对于以下情况非常重要:
Timers
Event listeners
Subscriptions
WebSocket connections
Abortable requests
当依赖项发生变化时,React会在执行新的效应之前清理旧的效应;组件卸载时也会进行清理。
搜索输入框就能体现这一点——每次按键都可能触发请求:
react
react hooks
react performance
由于过时的响应可能会覆盖新的响应,因此需要取消之前的请求:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
.then(res => res.json())
.then(setResults)
.catch(error => {
if (error.name !== "AbortError") {
console.error(error);
}
});
return () => {
controller.abort();
};
}, [query]);
其生命周期如下所示:
query changes
↓
cleanup previous effect
↓
abort previous request
↓
start new request
实际的搜索输入框通常会结合防抖功能使用这一机制。
一个常见的错误是对于可以直接获取的值却使用useEffect:
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
由于fullName直接来自现有值,因此完全可以跳过效应和状态的处理:
const fullName = `${firstName} ${lastName}`;
使用效应版本会增加不必要的同步操作以及额外的渲染次数。经验法则:如果能在渲染过程中计算出该值,就无需使用效应。
useRef会提供一个稳定的对象,其.current在多次渲染中保持不变,即便该值发生变化也不会触发重新渲染:
const ref = useRef(initialValue);
常见的用法是引用DOM节点,比如让输入框获得焦点:
function Input() {
const inputRef = useRef(null);
function focusInput() {
inputRef.current?.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>
Focus
</button>
</>
);
}
useRef也适用于存储那些根本不应出现在渲染结果中的可变值,例如定时器标识符:
const timerRef = useRef(null);
你可以像操作普通对象属性一样直接为其赋值:
timerRef.current = setInterval(...);
修改
ref.current
本身并不会触发重新渲染。请对比这两种思维模式:
useState
→ update → render
useRef
→ mutate .current → no render
正因如此,useRef非常适合那些需要在多次渲染间保持不变、但又不应影响屏幕上显示内容的值。
useMemo采取了不同的方法:它缓存的是计算结果而非DOM引用。
const result = useMemo(() => {
return expensiveCalculation(data);
}, [data]);
可以这样理解:useMemo = 缓存计算结果。其典型用法是在底层数据真正发生变化时才对列表进行过滤:
function ProductList({ products, search }) {
const filteredProducts = useMemo(() => {
return products.filter(product =>
product.name
.toLowerCase()
.includes(search.toLowerCase())
);
}, [products, search]);
return <ProductGrid products={filteredProducts} />;
}
如果某些无关的状态发生了变化,只要依赖项保持不变,React就可以直接返回之前计算好的值。从概念上讲,React会在每次调用该钩子时保存一份简短的记录:
Hook
├── memoized value
└── dependencies
在首次渲染时:
data = A
calculate(A)
↓
result = X
在后续渲染中,如果data仍然是A,则依赖项比较通过,React会直接复用X而无需重新计算。只有当data变为类似B的值时,React才会重新进行计算。
尽管如此,useMemo很容易被过度使用。像这样包裹代码:
const fullName = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);
几乎没什么意义——相关计算非常简单,而且记忆化本身并非免费服务;它会增加开销并带来额外的代码处理工作。只有当计算确实耗时、需要对象的稳定引用或数组的稳定引用,或者通过性能分析(或逻辑推导)证明它确实能带来帮助时,才应使用useMemo。
useCallback则是用于函数引用而非值的对应工具。
const handleClick = useCallback(() => {
doSomething();
}, []);
这一点很重要,因为普通函数会在每次渲染时都被重新创建:
function Parent() {
const handleClick = () => {
console.log("clicked");
};
return <Child onClick={handleClick} />;
}
在第一次渲染时,handleClick指向一个函数实例;在第二次渲染时则指向另一个不同的函数实例,因此尽管这两个函数的功能相同,但它们并不相等。
当子组件被包裹在 React.memo 中时,这种不等式就会成为问题:
const Child = React.memo(function Child({ onClick }) {
console.log("Child rendered");
return (
<button onClick={onClick}>
Click
</button>
);
});
而父组件的结构如下:
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("clicked");
}, []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
<Child onClick={handleClick} />
</>
);
}
当 count 发生变化时:
Parent renders
↓
handleClick reference remains stable
↓
React.memo checks Child props
↓
onClick unchanged
↓
Child can skip rendering
如果没有使用 useCallback,新创建的函数引用会被被缓存的子组件视为“新的”,从而导致它重新渲染,尽管实际上并没有任何变化。
不过,默认情况下并不应该对每个函数都使用 useCallback。在包裹处理函数之前,先考虑是否有必要通过稳定的引用带来好处——例如:
React.memo child
Dependency array
Expensive downstream computation
如果以上情况都不适用,那么让函数正常重新创建通常也是可以的。
这两个钩子的区别在于它们缓存的内容:
useMemo
→ memoizes a VALUE
useCallback
→ memoizes a FUNCTION
Conceptually:
useMemo(() => calculateValue(), deps);
useCallback(() => doSomething(), deps);
从以性能为导向的钩子函数转向结构化方案后,Context API解决了另一个问题:如何在多层嵌套组件之间传递数据。如果没有它,像当前用户这样的值可能就需要遍历中间的每一层:
App
↓
Navbar
↓
UserMenu
↓
Profile
↓
Avatar
从而导致类似以下的情况:
<App user={user} />
<Navbar user={user} />
<UserMenu user={user} />
<Profile user={user} />
<Avatar user={user} />
这种将属性转发给并不需要它们的组件的模式被称为prop drilling。
设置上下文首先需要调用创建函数:
const UserContext = createContext(null);
然后通过提供者函数来设置值:
function App() {
const user = {
name: "Salim",
role: "Developer"
};
return (
<UserContext.Provider value={user}>
<Dashboard />
</UserContext.Provider>
);
}
并在需要时读取该值:
function Profile() {
const user = useContext(UserContext);
return <h1>Hello {user.name}</h1>;
}
Profile不再需要通过每个中间组件传递user值。
一个常见的实际应用场景是主题设置:
const ThemeContext = createContext(null);
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Dashboard />
</ThemeContext.Provider>
);
}
任何组件都可以直接读取当前主题:
function Button() {
const { theme } = useContext(ThemeContext);
return (
<button className={theme}>
Submit
</button>
);
}
生成的结构大致呈树形:
App
│
└── ThemeProvider
│
└── Dashboard
│
└── Button
Button无需通过层层传递属性即可获取主题值。
需要明确的是,Context并非自动完整的状态管理解决方案。它仅能回答一个具体问题:如何让树结构深处的组件能够访问某个值?它并不提供专用库所具备的额外功能——如选择器、中间件、结构化更新等。对于更复杂的状态管理需求,可以考虑:
Redux
Zustand
Jotai
Reducer + Context
Context最好被视作一种值分发机制,而非状态管理器。
它对渲染过程也有影响。假设:
<ThemeContext.Provider value={theme}>
每当该上下文的值发生变化时,使用它的组件就可以重新渲染。上下文并不能神奇地防止重复渲染——将一个庞大且频繁变化的对象放入被广泛使用的上下文中,实际上可能会造成不必要的开销。更适合放入上下文的值是那些变化不频繁的,例如:
Theme
Locale
Authentication information
Feature flags
Application configuration
自定义 Hook 允许你将基于内置 Hook 构建的可重用逻辑封装起来。一个最简单的示例:
function useCounter() {
const [count, setCount] = useState(0);
function increment() {
setCount(c => c + 1);
}
return {
count,
increment
};
}
使用方式如下:
function Counter() {
const { count, increment } = useCounter();
return (
<button onClick={increment}>
Count: {count}
</button>
);
}
这里的真正价值在于逻辑的重用,而非标记的重用。
需要特别强调的是:自定义 Hook 共享的是逻辑,而非状态。如果两个独立的组件各自调用了同一个自定义 Hook:
const counterA = useCounter();
const counterB = useCounter();
它们并不会共享同一个 count 值。每次调用都会拥有独立的状态。从概念上来说:
Component A
└── useCounter
└── useState → State A
Component B
└── useCounter
└── useState → State B
自定义 Hook 用于封装行为逻辑,而非共享数据存储。
举个具体例子,假设某个应用需要显示用户当前是否有网络连接。无需在每个需要该功能的组件中重复实现浏览器事件监听逻辑,只需将其封装在自定义 Hook 中即可:
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(
navigator.onLine
);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}
现在,任何需要获取连接状态的组件都可以直接使用它:
function Navbar() {
const isOnline = useOnlineStatus();
return (
<span>
{isOnline ? "🟢 Online" : "🔴 Offline"}
</span>
);
}
另一个组件则可以利用同一个 Hook 来彻底改变自身的渲染方式:
function Checkout() {
const isOnline = useOnlineStatus();
if (!isOnline) {
return <p>You are offline.</p>;
}
return <PaymentForm />;
}
对于快速变化的输入值,也可采用类似的模式进行处理:
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
可像这样在搜索功能中使用它:
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (!debouncedQuery) return;
// Search API
}, [debouncedQuery]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}
这将产生如下一系列事件:
User types
↓
query changes
↓
500ms wait
↓
debouncedQuery changes
↓
API request
这些基础组件应当被组合使用,而非单独使用:
React Component
│
┌───────────────┼────────────────┐
│ │ │
useState useEffect useContext
│ │ │
UI state External systems Shared data
│
└───────────────┐
│
Custom Hook
│
┌─────────┼─────────┐
▼ ▼ ▼
useState useEffect useMemo
例如,一个自定义的 useProducts() Hook 在内部可能会依赖:
useState
+
useEffect
+
useMemo
从 useContext 中获取认证状态。重新渲染会遵循可预测的路径:
State / Props / Context change
↓
React update
↓
Render
↓
Reconciliation
↓
Commit
↓
DOM update
↓
Browser paint
每个 Hook 都承担着不同的角色,具体如下:
useState
→ provides state + schedules updates
useMemo
→ calculates/reuses values during render
useCallback
→ calculates/reuses function references during render
useContext
→ reads context during render
useEffect
→ synchronizes with external systems after commit
相关阅读
- 为 React 构建思维模型:状态同步、组件与 Hook — 了解 React 核心概念——状态同步、组件、属性、状态和 Hook——背后的原理,从而培养直觉而非死记硬背 API。
catch ()会抛出SyntaxError — 了解为何空的catch参数列表会导致JavaScript解析彻底失败,并查看两种语法正确的无参数catch块写法。