如何写好一个hook
如何写好一个hook
先从一个最常用最简单的useState开始:
[count, setCount] = useState(0);
setCount(1);useState hook接收一个初始值,返回一个数组,包含一个值和修改这个值的方法(接收一个新值)。此时想要实现这个功能,只需在hook中返回一个全局值以及修改它的函数即可:
let value: any;
function useState<T>(initValue: T): [T, (v: T) => void] {
value = value ?? initValue;
function setState(newValue: T) {
value = newValue;
render();
}
return [value, setState];
}但是有个问题,如果在同一个组件中使用多个useState时:
[count0, setCount0] = useState(0);
[count1, setCount1] = useState(1);它们都会调用useState这个钩子函数,但是这个函数只管着一个全局变量value,这就导致一个组件只能有一个useState。解决方法也很简单,让钩子维护一个数组,数组的每一位都是一个value,而且每当重新调用useState时要自动挪到下一个位置,同时每个state内部要保存自己的下标,这样调用setState时才知道到底该改谁:
let currentStates: any[] = [];
// 记录当前state的下标
let lastIndex = 0;
function useState<T>(initValue: T): [T, (v: T) => void] {
// 自动挪到下一位存
const currentIndex = lastIndex++;
currentStates[currentIndex] = currentStates[currentIndex] ?? initValue;
function setState(newValue: T) {
currentStates[currentIndex] = newValue;
render(); // 重渲染,react单向绑定,只改值不会触发页面变化
}
// 通过闭包保证 setState 时状态更改是正确的。
return [currentStates[lastIndex], setState];
}由于状态使用列表严格按顺序维护的,使用时也必须有严格的先后关系,这也是为什么用hook的时候不能使用if、for嵌套,那会破坏顺序。
此时setState在闭包内部,它会保存唯一指向自己的currentIndex。
从上面的例子可以看出,hook其实只有一个作用,就是在内部维护一个或多个对象,接收初始值,返回一系列值和方法来读取或操作这些对象。
比如useState hook,内部维护了一个值,接收这个值的初始值,返回值本身和修改它的方法;useRef hook维护DOM引用,返回这个引用本身;如果自定义一个倒计时hook,可以在内部维护一个倒计时器,返回目前的倒计时时间,以及操作这个定时器的方法,如开始、暂停、重置等,用的时候可以直接:
[timeRemain, start, pause, reset] = useBackCount(60);综上,最基本的hook只要做到以下三点:
- 维护内部对象;
- 把内部对象挂在全局列表(如果设计成整个app只准用一次,也可以不用列表而是做特殊处理);
- 返回和对象有关的值和方法。
一般需要自己实现的hook都如上述所说,但也有一类特殊的hook,它们不返回东西,而是根据环境的变化伺机而动地执行某些逻辑,比如useEffect:
useEffect(() => {
console.log(`value changed to: ${value}`);
return 'Component destroyed.';
}, [value]);它接收两个参数,一个回调函数,和一个列表。当列表里的任何一个值发生变化时,就执行回调。可以理解为一种监听器。有如下两种特殊情况:
- 当第二个参数是空数组时,就只有在组件渲染时执行一次。如果定义了清理函数
return () => {...},只在卸载时执行一次。 - 不给第二个参数(连空表都没有),则每次重新渲染都重新执行
由于这些特性它可以用来模拟生命周期。
要实现这样的hook,首先要解决在依赖变化时,执行回调函数。这个变化,是本次 render 和上次 render 时的依赖比较。也就是说只要检查目前的值和上次保存的值一不一样,不一样就执行:
const lastDepsBox: any[] = [];
let index = 0;
function useEffect(callback: any, deps?: any[]) {
const lastDeps = lastDepsBox[index];
const changed =
!lastDeps
|| !deps
|| deps.some((dep, index) => dep !== lastDeps[index]);
if(changed) {
lastDepsBox[index] = deps;
callback();
}
index++;
}这是不带清除函数的情况,真实的useEffect的清除函数在下一次更新时调用:

按这个顺序添加清除函数的实现:
const lastDepsBox: any[] = [];
const cleanupFunctions: ((() => void) | undefined)[] = []; // 存储清理函数
let index = 0;
function useEffect(callback: () => void | (() => void), deps?: any[]) {
const currentIndex = index;
const lastDeps = lastDepsBox[currentIndex];
let changed = true;
if(deps === undefined) {
changed = true;
} else if(deps.length === 0) {
changed = lastDeps === undefined;
} else {
changed =
!lastDeps
|| deps.some((dep, i) => dep !== lastDeps[i]);
}
if(changed) {
if(cleanupFunctions[currentIndex]) {
cleanupFunctions[currentIndex]();
cleanupFunctions[currentIndex] = undefined;
}
// callback返回一个函数,作为清理函数
const cleanup = callback();
if(typeof cleanup === 'function') {
cleanupFunctions[currentIndex] = cleanup;
}
lastDepsBox[currentIndex] = deps ? [...deps] : deps;
}
index++;
}了解了hook的概念和职责,我将用一个例子来说明一个合格的custom hook应该怎么写。
需求描述:设计一个功能完整、易于使用的验证器 Hook,它不仅能处理异步验证,还能管理验证状态,同时做防抖处理,防止验证请求发送过于频繁。
看到需求后首先确定参数。在设计hook时要注意功能单一、解耦,真正的验证逻辑不会在hook里执行,而是传入一个验证函数,这个验证函数内部再向后端发送验证请求,是异步的。除此以外还会有一些别的参数,直接设为一个options对象,后面随用随加:
/**
* useValidator Hook
* @param {Function} validator - 验证函数,可以是同步或异步的
* @param {Object} options - 配置选项
* @returns {Object} 验证状态和方法
*/
const useValidator = (validator, options = {}) => {}要实现这个功能,至少需要以下状态:
// 是否正在验证中(异步时用)
const [isValidating, setIsValidating] = useState(false);
// 报错列表
const [errors, setErrors] = useState([]);
// 最后一次验证通过的值
const [lastValidValue, setLastValidValue] = useState(null);
// 总验证次数
const [validationCount, setValidationCount] = useState(0);所有用户的配置都写在options里,包含防抖时间、值变化时是否自动验证、是否收集所有错误:
const {
debounce = 300, // 防抖延迟:减少频繁验证,提升性能
validateOnChange = true, // 自动验证:值变化时是否自动触发验证
multipleErrors = false, // 多错误模式:是否收集所有错误而不只是第一个
} = options;另外,对于中止控制和防抖用到的定时器,不能在每次渲染都充值,所以用Ref引用保存:
const timeoutRef = useRef(null);
const abortControllerRef = useRef(null);然后写核心的验证方法。主要分6个步骤:
1.取消未完成的验证 -> 2.更新状态 -> 3.执行传入的验证函数 -> 4.处理结果 -> 5.处理错误 -> 6.清除状态const validate = useCallback(async (value, customValidator = validator) => {如果之前有未完成的异步验证,立即取消它。否则可能会和新的验证任务争抢状态。具体的判断方式是查看目前是否有残留的中止控制器:
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 清除后创建新的中止控制器,用于控制本次验证的生命周期
abortControllerRef.current = new AbortController();第二步更新状态,向外界表明验证已经开始,并在计数器上+1:
setIsValidating(true); // 标记验证开始
setValidationCount(prev => prev + 1); // 增加验证计数器执行验证,验证函数里传一个动态信号参数,使其可以随时接收中止信号来结束验证:
try {
// 传递中止信号,允许验证函数在需要时响应取消请求
const result = await customValidator(value, {
signal: abortControllerRef.current.signal
});等到它返回结果,分情况接收,如果成功,清除错误并更新最后一次验证值,返回结果对象(同步状态)。如果失败,根据错误的个数决定怎么返回。
如果在最外层失败(连失败的结果都没有),就直接报验证过程错误(要注意排除自己放弃的情况,手动中止不算错):
if (result.isValid) {
// 验证成功的情况
setErrors([]); // 清空所有错误
setLastValidValue(value); // 更新最后有效值
return {
isValid: true,
errors: [],
value
};
} else {
// 验证失败的情况
// 根据 multipleErrors 配置决定错误信息的格式
const newErrors = multipleErrors && Array.isArray(result.errors)
? result.errors // 多错误模式:保持数组格式
: [result.errors || '验证失败']; // 单错误模式:转换为数组
setErrors(newErrors); // 更新错误状态
return {
isValid: false,
errors: newErrors,
value
};
}
} catch (error) {
// ---------- 5. 错误处理 ----------
// 检查是否是主动取消的错误(AbortError),这种错误不需要处理
if (error.name !== 'AbortError') {
// 真实的验证错误:提取错误信息并更新状态
const errorMessage = error.message || '验证过程发生错误';
setErrors([errorMessage]);
return {
isValid: false,
errors: [errorMessage],
value
};
}
// 如果是取消请求导致的错误,返回 null 表示验证被中断
return null;最后清掉中断控制器,把正在验证状态设为false:
finally {
// ---------- 6. 清理工作 ----------
// 只有当验证没有被取消时,才更新验证状态
if (!abortControllerRef.current?.signal.aborted) {
setIsValidating(false);
}
}
}, [validator, multipleErrors]); // 依赖项:当 validator 或 multipleErrors 变化时重新创建函数用户配置中可以手动选择是否使用防抖,所以要单独写一个防抖版本的验证:
const debouncedValidate = useCallback((value) => {
// 清除之前设置的防抖定时器,确保只有最后一次调用会真正执行
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// 返回 Promise,让调用者可以等待验证结果
return new Promise((resolve) => {
// 设置新的防抖定时器
timeoutRef.current = setTimeout(async () => {
// 防抖延迟后执行实际验证
const result = await validate(value);
resolve(result); // 将验证结果传递给 Promise
}, debounce);
});
}, [validate, debounce]); // 依赖项:validate 函数和防抖配置它返回一个Promise对象。
在hook的最后,把所有该返回的都返回:
return {
// ---------- 状态值(供组件读取)----------
isValidating, // 是否正在验证:用于显示加载状态
errors, // 所有错误信息:用于显示错误详情
hasErrors: errors.length > 0, // 是否有错误:用于条件渲染
lastValidValue, // 最后有效值:可用于回滚或比较
validationCount, // 验证次数:用于调试和分析
// ---------- 操作方法(供组件调用)----------
// 主要的验证方法:根据配置决定是否防抖
validate: debounce > 0 ? debouncedValidate : validate,
manualValidate, // 手动立即验证
reset, // 重置状态
// ---------- 便捷属性(计算属性)----------
firstError: errors[0] || null, // 第一个错误:常用场景的便捷访问
};其中手动验证和reset函数的逻辑比较简单,实现在此处略过。
在组件中,可以这样使用:
const usernameValidator = useValidator(validateUsername, {
debounce: 500, // 用户输入后等待 500ms 再验证
validateOnChange: true, // 输入变化时自动验证
multipleErrors: false // 只显示第一个错误
});
// 使用核心验证器验证value
usernameValidator.validate(value);