各位前端界的“代码艺术家”们,大家好!
今天我们不聊框架,不聊库,也不聊什么“下一代的React”。今天我们来聊聊一个稍微有点“枯燥”,但能让你保住发际线、让你在周五下午不用加班重写代码的神器——自定义 Hooks。
我知道你们在想什么:“不就是封装个函数吗?我会啊!function foo() { return 'bar' } 这种我都会。”
停!别急着打字。如果你觉得自定义 Hooks 只是“换个名字的函数”,那你离“资深架构师”还有十万八千里的距离。今天,我要带你们潜入 React Hooks 的深海,看看如何把那些乱成一团的“意大利面式代码”变成整洁、优雅、可复用的“艺术品”。
准备好了吗?咱们开讲。
第一讲:为什么要跟组件过不去?
想象一下,你正在开发一个电商网站。有一天,产品经理拍着桌子说:“老板说了,首页要加个‘最近浏览’功能!”
你当时就裂开了。为什么?因为你要去写 localStorage,要去写 useEffect 去同步状态,还要写一堆逻辑判断用户是不是登录了,还要处理数据过期……
于是,你写了一个 useRecentView Hook,把逻辑全塞进去,然后在 ProductCard 里调用它。
过了三个月,产品经理又来了:“老板说,‘收藏夹’也要加个‘最近浏览’的功能!”
你的手颤抖着打开了编辑器。你会怎么做?复制粘贴 useRecentView?还是把逻辑再写一遍?
这就是重复。重复是编程界的万恶之源,是内存泄漏的温床,是导致你掉头发的元凶。
自定义 Hooks 的核心使命,就是消除重复。它就像是一个“代码搅拌机”,你把原材料(业务逻辑)扔进去,吐出来的就是整齐划一的代码块。
但是!重点来了!
很多初学者用 Hook,就像是把家里的洗衣机和冰箱都塞进了同一个厨房里。他们直接把组件的 state 扔给 Hook,或者在 Hook 里直接去 setState。这就破坏了封装性。
今天我们的目标只有一个:如何写出一个既干干净净、又能搞定复杂逻辑,还不会把组件搞成一团浆糊的 Hook。
第二讲:从“小打小闹”到“状态封装”
我们先从一个最简单的例子开始。假设我们需要一个计数器。
1. 糟糕的封装(反模式)
很多初学者会这么写:
// ❌ 反模式:直接操作组件的 State
function BadCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return (
<div>
<button onClick={increment}>加</button>
<span>{count}</span>
<button onClick={decrement}>减</button>
</div>
);
}
这看起来没啥问题,对吧?但这就像是你把所有衣服都堆在沙发上一团糟。如果你想把这个计数器用到“购物车”里,或者“点赞”里,你就得把 setCount 的逻辑复制过去。
2. 优雅的封装(正确姿势)
我们要提取逻辑,但要把状态藏起来。
// ✅ 好的封装:状态私有化
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
// 甚至可以加个“步长”控制
const incrementBy = (amount) => setCount(c => c + amount);
return { count, increment, decrement, reset, incrementBy };
}
// 使用
function GoodCounter() {
const { count, increment, decrement } = useCounter(0);
return (
<div>
<button onClick={increment}>加</button>
<span>{count}</span>
<button onClick={decrement}>减</button>
</div>
);
}
看懂了吗? 这里发生了什么?
组件 GoodCounter 完全不知道 count 是怎么存的,它是 useState 还是 useReducer,甚至是个全局变量,它都不在乎。它只关心一个契约:给我一个数字,我给你提供加减的方法。
这就是封装性。这就是可复用性。
第三讲:持久化状态 —— useLocalStorage 的艺术
接下来,我们进入实战。假设我们要做一个表单,用户输入的内容需要保存到浏览器里,刷新不丢失。
1. 没有封装的痛苦
你会怎么写?
function LoginForm() {
const [value, setValue] = useState(() => {
return localStorage.getItem('username') || '';
});
useEffect(() => {
localStorage.setItem('username', value);
}, [value]);
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
这段代码有三个坏处:
- 重复:如果另一个组件也需要存名字,你得再写一遍。
- 耦合:
useEffect直接依赖value,每次输入都触发localStorage,性能很差(虽然浏览器会优化,但逻辑上很乱)。 - 初始化逻辑复杂:还要在
useState里写那个奇怪的() => ...。
2. 完美的 useLocalStorage
让我们把它封装起来。
function useLocalStorage(key, initialValue) {
// 1. 初始化状态:从 LocalStorage 读,如果没有就用默认值
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === "undefined") {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
// 2. 封装更新函数:写 LocalStorage,同时更新 State
const setValue = (value) => {
try {
// 允许用户传函数(和 useState 一样)
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// 使用
function BetterLoginForm() {
const [name, setName] = useLocalStorage('username', '');
return (
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}
深度解析:
你看,组件 BetterLoginForm 现在干净得像张白纸。它不需要知道 JSON.stringify 是干嘛的,也不需要知道 localStorage 怎么用。它只需要调用 setName,数据就自动存好了。
这就是内部状态的封装性。外部组件不需要关心“持久化”这个副作用是如何实现的。
第四讲:数据获取 —— useFetch 的进阶之路
这是业务逻辑最重的地方。我们要从后端拿数据。
1. 基础版:能用就行
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch(url);
const json = await res.json();
setData(json);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
这很好,很标准。但是,业务逻辑往往不止这些。
2. 进阶版:带参数、去抖、重试
假设我们有一个搜索框。用户每输入一个字,我们就发一次请求。这会导致大量的网络请求。
我们需要一个去抖 的逻辑。
function useSearchUsers(query) {
const [state, setState] = useState({
data: null,
loading: false,
error: null,
});
useEffect(() => {
// 如果没有输入,直接返回空,不发请求
if (!query) {
setState({ data: null, loading: false, error: null });
return;
}
let timeoutId;
setState(prev => ({ ...prev, loading: true, error: null }));
// 去抖逻辑:500ms 后再执行
timeoutId = setTimeout(async () => {
try {
const res = await fetch(`https://api.github.com/search/users?q=${query}`);
const json = await res.json();
setState({ data: json, loading: false, error: null });
} catch (err) {
setState({ data: null, loading: false, error: err });
}
}, 500);
// 清理函数:如果用户在 500ms 内又输入了,清除之前的定时器
return () => clearTimeout(timeoutId);
}, [query]);
return state;
}
这里有什么玄机?
注意那个 return () => clearTimeout(timeoutId);。这是 Hooks 的生命线!
当组件卸载,或者 query 变化导致 useEffect 重新执行时,之前的定时器必须被销毁。否则,如果你快速输入 “a”, “ab”, “abc”,会有 3 个请求在排队。这就是内存泄漏的温床。
封装性体现在哪里?
组件 SearchComponent 只需要管好输入框,把 query 传给 useSearchUsers。至于怎么去抖,怎么处理错误,怎么控制 loading,全在 Hook 里搞定。组件不需要写 useEffect,不需要写 setTimeout,不需要写 AbortController(为了取消请求)。
第五讲:表单逻辑 —— useForm 的复杂封装
这是业务开发中最常见的场景。表单验证、提交、重置。
1. 简单的表单处理
如果不封装,你的组件会长这样:
function BadForm() {
const [formData, setFormData] = useState({ name: '', email: '' });
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const validate = () => {
const newErrors = {};
if (!formData.name) newErrors.name = "名字不能为空";
if (!formData.email.includes('@')) newErrors.email = "邮箱格式不对";
return newErrors;
};
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setIsSubmitting(true);
// 发送 API...
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
{/* ... */}
</form>
);
}
看,光是表单逻辑就占了组件一大半。而且 validate 函数可能很复杂,包含各种正则、异步校验。
2. 完美的 useForm Hook
让我们把这一切封装起来。为了演示,我们使用一个简单的策略模式来处理验证。
function useForm(initialValues, validate, onSubmit) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setValues(prev => ({ ...prev, [name]: value }));
// 可选:输入时实时清除错误
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
const handleSubmit = (e) => {
e.preventDefault();
const validationErrors = validate(values);
setErrors(validationErrors);
if (Object.keys(validationErrors).length === 0) {
setIsSubmitting(true);
onSubmit(values, {
resetForm: () => setValues(initialValues),
setErrors: (newErrors) => setErrors(newErrors),
}).finally(() => setIsSubmitting(false));
}
};
return {
values,
errors,
handleChange,
handleSubmit,
isSubmitting,
};
}
// 使用
const validateUserForm = (values) => {
const errors = {};
if (!values.username) errors.username = 'Required';
if (!values.email) errors.email = 'Required';
return errors;
};
const onSubmitUser = (values, helpers) => {
console.log('Submitting:', values);
// 模拟 API
return new Promise(resolve => setTimeout(resolve, 1000));
};
function GoodForm() {
const {
values,
errors,
handleChange,
handleSubmit,
isSubmitting
} = useForm(
{ username: '', email: '' },
validateUserForm,
onSubmitUser
);
return (
<form onSubmit={handleSubmit}>
<input name="username" value={values.username} onChange={handleChange} />
{errors.username && <span>{errors.username}</span>}
<input name="email" value={values.email} onChange={handleChange} />
{errors.email && <span>{errors.email}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交'}
</button>
</form>
);
}
这个 Hook 强在哪里?
- 关注点分离:组件只负责 UI 渲染,
useForm负责逻辑。 - 可扩展性:你可以在
useForm里加防抖、加自动聚焦、加字段级校验。 - 契约明确:
onSubmit接收一个helpers对象,允许你在提交成功后重置表单。
第六讲:保持封装性的“黄金法则”
好了,讲了这么多例子,我必须总结一下那些让你代码“不优雅”的坑。作为资深专家,我必须把这些“禁忌”写下来,贴在你的显示器上。
规则 1:不要把组件的 State 扔进 Hook
这是新手最容易犯的错误。
❌ 错误示范:
function BadHook(componentState) {
useEffect(() => {
// 直接依赖组件的 state
console.log(componentState.value);
}, [componentState.value]);
}
为什么错?因为这样 Hook 就和组件紧紧绑定了。如果你把这个 Hook 移到另一个组件,它就会失效。
✅ 正确做法:通过参数传入。
function GoodHook(value) {
useEffect(() => {
console.log(value);
}, [value]);
}
规则 2:不要在 Hook 里直接 setState
这叫“越权管理”。Hook 应该返回一个函数,让组件去调用,而不是直接去动组件的内部。
❌ 错误示范:
function BadHook() {
// 危险!这会破坏 React 的状态更新队列
// 而且如果组件重新渲染,这个副作用可能会被触发两次
const [count, setCount] = useState(0);
useEffect(() => {
setCount(1);
}, []);
return count;
}
✅ 正确做法:暴露控制权。
function GoodHook() {
const [count, setCount] = useState(0);
// Hook 不动,组件动
const increment = useCallback(() => setCount(c => c + 1), []);
return { count, increment };
}
规则 3:清理副作用
还记得我们在 useSearchUsers 里写的 return () => clearTimeout(...) 吗?这是必须的。
如果你在 Hook 里开了定时器、订阅了事件、或者请求了数据,一定要在组件卸载或者依赖变化时清理它们。
如果不清理,你的组件卸载了,数据还在后台跑,内存在泄漏,用户还在被请求骚扰。这叫“僵尸代码”。
规则 4:依赖数组要诚实
useEffect 的第二个参数是 []。这是懒人的借口,也是 Bug 的温床。
如果你在 Hook 里使用了外部变量(比如 props 或 state),你必须把它放在依赖数组里。如果你不想让它变化,就用 useCallback 包裹它。
// 如果不写依赖,这个 effect 只会跑一次。
// 如果里面的逻辑依赖了 url,那这就是 Bug。
useEffect(() => {
fetchData(url);
}, []); // 危险!
// 正确写法
useEffect(() => {
fetchData(url);
}, [url]); // 诚实一点
第七讲:进阶封装 —— 泛型与上下文
当我们封装的东西越来越多,我们可能会遇到类型问题,或者跨组件通信问题。
1. 泛型 Hook:让类型安全
假设我们要封装一个通用的 useFetch,它可以获取任何类型的数据。
// TypeScript 版本的泛型 Hook
function useFetch<T>(url: string) {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: string | null;
}>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(data => setState({ data, loading: false, error: null }))
.catch(err => setState({ data: null, loading: false, error: err.message }));
}, [url]);
return state;
}
// 使用
interface User {
id: number;
name: string;
}
function UserProfile() {
const { data, loading } = useFetch<User>('https://api.example.com/user');
if (loading) return <div>Loading...</div>;
return <div>{data?.name}</div>;
}
通过泛型 T,我们在编译时就能发现数据类型错误。这就是封装的“硬核”之处。
2. 上下文封装:把状态提升变成“魔法”
有时候,你需要把状态在多层组件间传递。这很痛苦。但你可以封装一个 Context Hook。
// 1. 创建 Context
const UserContext = createContext();
// 2. 封装 Provider 和 Hook
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const login = (userData) => setUser(userData);
const logout = () => setUser(null);
return (
<UserContext.Provider value={{ user, login, logout }}>
{children}
</UserContext.Provider>
);
}
// 3. 调用 Hook
function useUser() {
const context = useContext(UserContext);
if (!context) throw new Error("useUser must be used within UserProvider");
return context;
}
// 使用
function Navbar() {
const { user, logout } = useUser();
return <button onClick={logout}>Logout</button>;
}
你看,现在任何组件只要调用 useUser(),就能拿到用户状态和方法。组件不需要知道数据是存在 App 组件里,还是存在 Context 里。这就是完全的封装。
第八讲:性能优化 —— 别让 Hook 变成累赘
封装得好不代表性能就好。如果你的 Hook 太重,会导致整个组件疯狂重渲染。
1. 使用 useMemo 和 useCallback
在 Hook 内部,如果你计算了一个很大的数组或者对象,记得用 useMemo。
function useExpensiveData(data) {
// 即使 data 没变,如果这里每次都重新计算,性能会爆炸
const processedData = useMemo(() => {
return data.map(item => item * 2); // 假设这是个很重的操作
}, [data]);
return processedData;
}
2. 避免在渲染期间创建函数
这是新手最容易犯的性能错误。
❌ 糟糕的 Hook:
function BadHook() {
const handleClick = () => console.log('clicked');
// 每次渲染都会创建一个新的函数引用,导致子组件重渲染
return <button onClick={handleClick}>Click</button>;
}
✅ 优秀的 Hook:
function GoodHook() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // 空依赖,函数永远不变
return <button onClick={handleClick}>Click</button>;
}
第九讲:终极奥义 —— 逻辑复用的边界
最后,我们要聊聊什么时候不要封装。
不要为了封装而封装。如果你只是想把一段代码稍微缩短一点,就写个 Hook,那反而会增加代码的理解成本。
什么时候应该封装?
- 逻辑重复:同样的代码在 3 个地方出现了。
- 副作用集中:一堆
useEffect搞得你眼花缭乱。 - 状态管理复杂:你需要同时管理多个相互关联的状态。
什么时候不要封装?
- 组件太小:比如一个只有 10 行代码的 Button,没必要封装。
- 逻辑太具体:这个逻辑只在这个组件里有用,换了地方就不行了。
- 为了炫技:写一个 200 行的 Hook 试图解决所有问题。
结语(真正的结尾)
好了,朋友们,我们聊了这么多。
React 自定义 Hooks 真的只是“函数”吗?不,它是思维的转变。
它强迫你思考:“这一堆逻辑,到底是为了解决什么问题?” 是为了状态同步?是为了数据获取?还是为了表单验证?
一旦你抓住了这个问题的本质,你就能把它抽离出来,封装成一个完美的 Hook。这个 Hook 就像是一个黑盒子。你把输入(参数)扔进去,它把输出(返回值)吐出来。至于它内部是怎么工作的,它怎么存储状态,怎么处理副作用,你完全不需要知道。
这种封装性,不仅让代码变得整洁,更让你的心智模型变得清晰。
当你下次再看到那个写着 500 行、逻辑混杂的 UserProfile 组件时,不要害怕,也不要愤怒。拿出你的 Hook,像手术刀一样,把那些“坏肉”切掉,换成一个个干干净净的、可复用的逻辑模块。
记住,写代码不是在砌砖头,而是在搭乐高。每一个 Hook 都是一个积木块。把积木搭好,你的应用大厦才能屹立不倒。
现在,去重构你的代码吧!让那些 useEffect 和 setState 见鬼去吧!
谢谢大家!