React 自定义 Hook 封装复用逻辑
为什么需要自定义 Hook?
在开发 React 应用时,我们经常会遇到多个组件共享同一段逻辑的情况。例如,在不同的组件中都需要监听浏览器窗口大小、获取鼠标位置,或者管理表单状态。如果只是简单地复制粘贴代码,不仅会造成代码冗余,还会让后续的维护变得异常困难。
React 提供了 自定义 Hook 这一机制,让我们能够将组件逻辑提取到可重用的函数中。自定义 Hook 本质上就是一个以 use 开头的 JavaScript 函数,它内部可以调用其他 React Hook(如 useState、useEffect 等),从而将状态逻辑与 UI 分离。
通过自定义 Hook,你可以:
- 减少重复代码,提高开发效率
- 让组件的关注点更单一,逻辑更清晰
- 在团队中共享通用逻辑,建立内部“工具箱”
自定义 Hook 基础规则
在动手编写第一个自定义 Hook 之前,你只需要牢记两条规则:
- 命名必须以
use开头:这是 React 识别 Hook 的约定,确保了 Hook 的调用顺序能够被 React 正确追踪。 - 只在最顶层调用 Hook:不要在循环、条件或嵌套函数中调用 Hook,保证每次渲染时 Hook 的调用顺序保持一致。
只要遵循这两条规则,你就可以像搭建积木一样自由地组合内置 Hook,创造出属于自己的逻辑单元。
一个最简单的例子:useToggle
让我们从一个极简的例子开始——封装切换布尔值的逻辑。这个逻辑常出现在开关、弹窗、折叠面板等场景中。
import { useState } from 'react';
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = () => {
setValue(prev => !prev);
};
return [value, toggle];
}
自定义 Hook 的返回值可以任意设计,这里我们用数组返回,方便使用者进行解构重命名:
function App() {
const [isOpen, toggleOpen] = useToggle(false);
return (
<div>
<button onClick={toggleOpen}>
{isOpen ? '关闭' : '打开'}面板
</button>
{isOpen && <div>这里是面板内容</div>}
</div>
);
}
useToggle 完全遵循了 React 的组件理念:视图只是状态的函数,而逻辑可以独立于视图存在。
实战:封装三个高频自定义 Hook
useWindowSize – 实时监听窗口尺寸
响应式布局不仅依赖 CSS,很多时候组件也需要根据窗口大小来调整行为。我们可以把 resize 事件的监听封装成一个自定义 Hook。
import { useState, useEffect } from 'react';
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
window.addEventListener('resize', handleResize);
// 在组件卸载时移除事件监听,防止内存泄漏
return () => window.removeEventListener('resize', handleResize);
}, []); // 空依赖数组确保只绑定一次
return windowSize;
}
使用起来就像使用普通状态一样简单:
function ResponsiveComponent() {
const { width, height } = useWindowSize();
return (
<div>
<p>当前窗口宽度:{width}px</p>
<p>当前窗口高度:{height}px</p>
{width < 600 ? <MobileLayout /> : <DesktopLayout />}
</div>
);
}
useLocalStorage – 状态与本地存储同步
很多应用需要将用户偏好或临时数据保存在 localStorage 中。手写读写逻辑又琐碎又容易出错,用自定义 Hook 来一劳永逸地解决问题。
import { useState } from 'react';
function useLocalStorage(key, initialValue) {
// 惰性初始化:读取现有值,如果没有则使用初始值
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// 封装 setValue,同时更新状态和 localStorage
const setValue = (value) => {
try {
// 允许 value 是一个函数,像 useState 一样
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}
现在在任何组件中持久化数据都变得轻而易举:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('app-theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
当前主题:{theme}
点击切换
</button>
);
}
useFetch – 封装数据请求逻辑
数据请求涉及加载状态、错误处理、依赖更新等常见套路。把这些逻辑抽象出来,组件就能只关心“要用什么数据”。
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 防止组件卸载后还更新状态
let cancelled = false;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (!cancelled) {
setData(json);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
fetchData();
return () => {
cancelled = true;
};
}, [url]); // 当 url 改变时重新请求
return { data, loading, error };
}
使用示例:
function UserList() {
const { data, loading, error } = useFetch('/api/users');
if (loading) return <div>加载中...</div>;
if (error) return <div>出错了:{error}</div>;
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
进阶技巧与最佳实践
1. 参数尽量灵活,返回值尽量稳定
设计自定义 Hook 时,考虑让它接受可选参数来调整行为。例如,useFetch 可以加入 options 对象来控制请求头、启用轮询等。返回值建议使用对象而不是数组,这样即便未来增加返回字段,调用方的代码也不会被破坏(数组解构依赖位置,对象解构依赖名字)。
2. 使用 useCallback 和 useMemo 避免不必要重渲染
如果你的自定义 Hook 返回函数或计算值,请用 useCallback 或 useMemo 缓存它们,防止下游组件因为引用变化而频繁重渲染。
function useToggle(initialValue) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(prev => !prev);
}, []);
return [value, toggle];
}
3. 组合已有自定义 Hook
就像搭乐高,你可以基于已有的自定义 Hook 构建更强大的 Hook。例如,我们可以结合 useLocalStorage 和 useToggle 来实现一个持久化的开关:
function useToggleWithStorage(key, initialValue) {
const [stored, setStored] = useLocalStorage(key, initialValue);
// 利用已有的 useToggle 逻辑,但替换掉原生 useState
const toggle = useCallback(() => {
setStored(prev => !prev);
}, [setStored]);
return [stored, toggle];
}
4. 保持 Hook 单一职责
一个自定义 Hook 最好只专注解决一个问题。如果一个 Hook 变得过于复杂,考虑将其拆分成几个更小的 Hook,然后在组件中组合使用它们,这样可测试性和可维护性都会大大提高。
在项目中落地自定义 Hook
当你开始在项目中使用自定义 Hook 时,建议采取以下步骤:
- 创建专门的
hooks目录,每个 Hook 一个文件,便于管理。 - 编写简单的文档注释,说明 Hook 的用途、参数和返回值。
- 为关键的 Hook 编写单元测试,使用
@testing-library/react-hooks可以轻松测试自定义 Hook。 - 在团队内分享,鼓励大家把可复用的逻辑封装成 Hook,逐渐沉淀出一套通用逻辑层。
自定义 Hook 是 React 生态中最重要的抽象工具之一。熟练掌握它,你将能够写出高度模块化、易于维护的前端代码。从今天开始,试着把你组件中的 useState、useEffect 组合提炼成你自己的 Hook 吧。