我们在之前的文章 如何构建自己的 react hooks 中,也介绍过如何构建自定义的 hook。这篇文章也是我在公司内的一次分享,从定义一个简单的 hook,然后一步步引导大家,让大家了解各种 hooks 的封装。方便在后续的开发过程中,能够找到适合自己的 hooks,或者自己也可以封装几个来使用。
1. React 自带的 hooks #
从 React16.8 开始,可以「函数组件+hooks」来进行开发。如我们常用的 useState(), useEffect(), useRef()等,这里我们就不展开说了。
但这些内置的 hooks,都是一些原子化的操作,稍微复杂点的需求,就写通过各种 hooks 的组合才能完成。
这里有几个注意点:
- hooks 只能在
函数组件
和其他hooks
中使用;普通的 js 或 ts 文件无法调用的; - 自定义 Hook 更像是一种约定而不是功能。如果函数的名字以 “use” 开头并调用其他 Hook,我们就说这是一个自定义 Hook;
- 自定义 hook 也是 hook,只能在函数组件的顶层使用,不能在 if 或 for 循环中使用;
2. 一个简单的自定义 hook #
使用原生方法绑定事件时,在卸载组件时也要解绑事件,否则在组件产生刷新时,会造成绑定多次事件(使用 React 的合成事件不用解绑)。
如下面给 window 添加 resize 事件,在组件卸载时再解除绑定。
const App = () => {
useEffect(() => {
// 为什么把回调单独提取出来?
const listener = () => {
const width =
document.documentElement.clientWidth || document.body.clientWidth;
const height =
document.documentElement.clientHeight || document.body.clientHeight;
console.log(width, height);
};
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
}, []);
};
若项目中经常有需要绑定原生事件的场景,每次都得手动绑定事件,然后再解绑事件。我可以自定义一个 hook,专门用来绑定和解绑事件。
const useEventListener = (
eventName: string,
handler: (ev: Event) => void,
options?: any // options是配置,可以配置绑定的元素,是否只触发一次等
) => {
const dom: Element = options?.target || window;
const handlerRef = useRef < any > null;
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
if (typeof handlerRef.current === "function") {
dom.addEventListener(eventName, handler);
return () => dom.removeEventListener(eventName, handler);
}
}, [dom, eventName, handler]);
};
一个自定义 hook,就定义好了。我们把上面的 window resize 事件用这个自定义的 hook 来实现下:
const App = () => {
useEventListener("resize", () => {
const width =
document.documentElement.clientWidth || document.body.clientWidth;
const height =
document.documentElement.clientHeight || document.body.clientHeight;
console.log(width, height);
}); // 本身就是要绑定到window上的,这里可以不传要绑定的元素
};
3. 倒计时的 hook #
在 React 中写定时器,像上面绑定事件一样,一定要注意清除定时器,否则在组件刷新时会产生多个定时器。
const App = () => {
useEffect(() => {
const timer = setInterval(() => {
console.log(Date.now());
}, 1000);
return () => clearInterval(timer);
}, []);
};
一个简单的场景:验证码按钮倒计时 10s,倒计时期间禁用。
一个错误的使用方式:
function App() {
const [count, setCount] = useState(10);
useEffect(() => {
const timer = setInterval(() => {
console.log("in setInterval", Date.now());
if (count <= 0) {
clearInterval(timer);
} else {
setCount(count - 1);
}
}, 1000);
return () => clearInterval(timer);
}, []);
return <div className="App">{count}</div>;
}
尽管由于定时器的存在,组件始终会一直重新渲染,但定时器的回调函数是挂载期间定义的,所以它的闭包永远是对挂载时 Counter 作用域的引用,故 count 永远不会超过 10。
参考:如何实现一个定时器的 hook。
成功的实现方式有多种,我们来写一个相对比较好理解的一种:
function App() {
const [count, setCount] = useState(10);
useEffect(() => {
const timer = setInterval(() => {
setCount((n) => {
if (n <= 0) {
clearInterval(timer);
return 0;
} else {
return n - 1;
}
});
}, 1000);
return () => clearInterval(timer);
}, []);
return <div className="App">{count}</div>;
}
我们是利用了 useState() 的传入 callback 的特点,可以把 count 的数据在 React 内部进行维护,规避掉闭包的问题。
但这是只有一个 useState() 时,若有多个 useState() 时,总不能用多层嵌套来实现吧?
再考虑一个比较复杂的定时器场景:九宫格的抽奖,点击中间的按钮后,选中边框绕着外层的 8 个图标开始顺时针旋转,慢慢提速直到最高速度,等接口返回结果后,再慢慢减速,最后停到中奖的位置。
这里面涉及到了多个 useState() 的操作:
- 选中边框的位置,每次都需要更新到下一个图标;
- 延迟时间一直在变动,先加速,然后匀速,最后减速的效果;
- 中奖信息,从 state 中拿到奖品信息,决定最后停止的位置;
- 中奖后,再延迟 300ms 弹窗提示中奖的奖品;
可以看到,这个定时器是比较复杂的,而且涉及到多个 useState() 的操作。
然后我们来实现一个 useInterval 的自定义 hook,来实现定时器的操作,让调用者更加专注于业务。
/**
* 自定义的定时器hook
* @param callback 回调函数
* @param delay 延迟时间,若为null则表示停止定时器
* @see https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks
*/
const useInterval = (callback: () => void, delay: number | null): void => {
// 将 callback 放在 useRef() 中,方便随时获取到最新的回调函数
const savedCallback = useRef(callback);
// 没有依赖项,每次组件刷新时,都获取到最新的callback
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
function tick() {
savedCallback.current();
}
/**
* 只有在 delay 不为 null 时才启动定时器,
* 而且这里添加了 delay 作为依赖项,每次 delay 发生变动时,
* 都会清除之前的定时器,然后启动新的定时器,方便延迟时间的调整
*/
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
};
上面的倒计时,我们用新定义的 useInterval() 来实现:
const App = () => {
const [count, setCount] = useState(10);
useInterval(
() => {
setCount(count - 1);
},
count > 0 ? 1000 : null // 当count>0时正常倒计时,否则停止倒计时
);
};
这个 useInterval() 的 hook,可以在 callback 中编写任意的逻辑;而且定时器的延迟时间也可以随时调整。
4. 数据请求的 hook #
我们平时在 React 中请求数据时,很多场景都会这么写:
const App = () => {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch("https://www.api.com")
.then((response) => response.json())
.then((res) => {
setLoading(false);
setResult(res);
})
.catch((err) => {
setLoading(false);
setError(err);
});
}, []);
};
多个页面中都有类似的场景时,每次都要写多个 useState(),设置 loading 等。这些复用的功能可以抽离出一个数据请求的 hook。
4.1 自己来实现一个请求的 hook #
我们先自己来实现一个简单的 hook,然后再稍微了解下开源组件的功能。
const useRequest = (request: () => Promise<any>) => {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<any>(null);
const aa = useCallback(async () => {
setLoading(true);
try {
const result = await request();
setLoading(false);
setResult(result);
} catch (err) {
setLoading(false);
setError(err);
}
}, [request]);
useEffect(() => {
aa();
}, [aa]);
return { loading, result, error };
};
使用封装好的 useRequest() 这个 hook:
const App = () => {
const { loading, result, error } = useRequest(() =>
fetch("https://www.api.com").then((response) => response.json())
);
console.log(loading, result, error);
return <div>{JSON.stringify(result)}</div>;
};
这里我们只是简单的封装了一下,把 loading, result 和 error 的情形封装了下,并没有考虑更多的实现。
4.2 开源 hook:swr #
- 非官方中文文档:https://swr.bootcss.com;
- GitHub:https://github.com/vercel/swr;
使用:
import useSWR from "swr";
function Profile() {
const { data, error, isLoading } = useSWR("/api/user", fetcher);
if (error) return <div>failed to load</div>;
if (isLoading) return <div>loading...</div>;
return <div>hello {data.name}!</div>;
}
该示例中,useSWR hook 接受一个字符串 key 和一个函数 fetcher。key 是数据的唯一标识符(通常是 API URL),并传递给 fetcher。fetcher 可以是任何返回数据的异步函数,你可以使用原生的 fetch 或 Axios 之类的工具。
跟我们上面实现的很像,但他的功能更多,包括但不限于:
- 请求去重:若标识一样,在同时发起同样的请求时,只会有一次网络请求;
- 自动重新请求:当你重新聚焦一个页面或在标签页之间切换时,SWR 会自动重新请求数据;
- 定期重新请求:可以设置重新请求的时间间隔;
- 更改任何 key 的数据:使用导出的
mutate(key)
,可以重新触发指定 key 的请求; - 手动触发:可以控制第 1 个参数来控制什么时候触发请求(为 null 时不触发);
比如mutate(key)
,可以在任意组件内来触发其他组件的数据更新。之前我们遇到过一个场景,简历的流转有多个阶段,每个阶段都有对应的简历数量;当我在某个组件内流转 1 个或者多个简历后,每个阶段对应的简历数量就需要更新。这里我们可以不用关心简历数量所在的组件和更新简历状态的组件,他们之间关系。只需要mutate
就可以触发。
4.3 开源 hook:react-query #
这里不做介绍了,只是告诉大家还有一个使用量比较高的库。各位可自行查阅相关文档。
5. 各种开源 hooks 合集 #
上面都是单独介绍了一些自定义 hook,或者这个 npm 包仅是参与一种功能。这部分我介绍两个多个 hooks 的合集。
5.1 ahooks #
ahooks 是阿里出的一整套 hooks 的合集,这里面也有数据请求的 hook。
基本用法:
const { data, error, loading } = useRequest(getUsername);
它也有很多的用法,只是跟 swr 的用法不一样而已:
- 手动触发:useRequest()会返回 run(),在第 2 个参数中配置上{manual: true},则 useRequest 就不会自动执行了,你可以手动执行 run(),然后才触发;
- 生命周期:请求之前、请求成功、请求失败、请求完成等;
- 重复上次请求:可以复用上次的参数,不用重新传参;
除此之外,还有很多其他 hook,各位按照他的规范使用即可。
5.2 beautiful-react-hooks #
这是国外开发者维护的一个 hooks 仓库,地址:beautiful-react-hooks,目前 GitHub 上有 6.6k 的 stars。
我之前也给这个仓库贡献过代码:
5. 总结 #
我们这里以不同的视角讲解了如何进行自定义的 hook,各位在后续的开发过程中,也可以根据需要,引入这些 hook 包,或者自行实现。
出个小题,请实现一个useSwitch(defaultValue)
的 hook,可以传入初始值,然后返回两个参数[state, toggle]:
- state: 表示当前的值,是 true 或 false;
- toggle(): 调用该方法可以切换 true 和 false;注意,该方法无参数;
使用:
const [state, toggle] = useSwitch(true);
const handleClick = () => {
toggle();
};