React 中如何自定义和封装 hooks

蚊子前端博客
发布于 2023-03-03 00:10
React中内置的hooks无法满足我们的业务,我们应该如何封装一些适合的hooks呢?

我们在之前的文章 如何构建自己的 react hooks 中,也介绍过如何构建自定义的 hook。这篇文章也是我在公司内的一次分享,从定义一个简单的 hook,然后一步步引导大家,让大家了解各种 hooks 的封装。方便在后续的开发过程中,能够找到适合自己的 hooks,或者自己也可以封装几个来使用。

1. React 自带的 hooks

从 React16.8 开始,可以「函数组件+hooks」来进行开发。如我们常用的 useState(), useEffect(), useRef()等,这里我们就不展开说了。

但这些内置的 hooks,都是一些原子化的操作,稍微复杂点的需求,就写通过各种 hooks 的组合才能完成。

这里有几个注意点:

  1. hooks 只能在函数组件其他hooks中使用;普通的 js 或 ts 文件无法调用的;

  2. 自定义 Hook 更像是一种约定而不是功能。如果函数的名字以 “use” 开头并调用其他 Hook,我们就说这是一个自定义 Hook;

  3. 自定义 hook 也是 hook,只能在函数组件的顶层使用,不能在 if 或 for 循环中使用;

2. 一个简单的自定义 hook

使用原生方法绑定事件时,在卸载组件时也要解绑事件,否则在组件产生刷新时,会造成绑定多次事件(使用 React 的合成事件不用解绑)。

如下面给 window 添加 resize 事件,在组件卸载时再解除绑定。

COPYJAVASCRIPT

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,专门用来绑定和解绑事件。

COPYJAVASCRIPT

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 来实现下:

COPYJAVASCRIPT

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 中写定时器,像上面绑定事件一样,一定要注意清除定时器,否则在组件刷新时会产生多个定时器。

COPYJAVASCRIPT

const App = () => { useEffect(() => { const timer = setInterval(() => { console.log(Date.now()); }, 1000); return () => clearInterval(timer); }, []); };

一个简单的场景:验证码按钮倒计时 10s,倒计时期间禁用。

一个错误的使用方式:

COPYJAVASCRIPT

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

成功的实现方式有多种,我们来写一个相对比较好理解的一种:

COPYJAVASCRIPT

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() 的操作:

  1. 选中边框的位置,每次都需要更新到下一个图标;

  2. 延迟时间一直在变动,先加速,然后匀速,最后减速的效果;

  3. 中奖信息,从 state 中拿到奖品信息,决定最后停止的位置;

  4. 中奖后,再延迟 300ms 弹窗提示中奖的奖品;

可以看到,这个定时器是比较复杂的,而且涉及到多个 useState() 的操作。

然后我们来实现一个 useInterval 的自定义 hook,来实现定时器的操作,让调用者更加专注于业务。

COPYJAVASCRIPT

/** * 自定义的定时器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() 来实现:

COPYJAVASCRIPT

const App = () => { const [count, setCount] = useState(10); useInterval( () => { setCount(count - 1); }, count > 0 ? 1000 : null // 当count>0时正常倒计时,否则停止倒计时 ); };

这个 useInterval() 的 hook,可以在 callback 中编写任意的逻辑;而且定时器的延迟时间也可以随时调整。

4. 数据请求的 hook

我们平时在 React 中请求数据时,很多场景都会这么写:

COPYJAVASCRIPT

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,然后再稍微了解下开源组件的功能。

COPYTYPESCRIPT

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:

COPYJAVASCRIPT

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

使用:

COPYJAVASCRIPT

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。

基本用法:

COPYJAVASCRIPT

const { data, error, loading } = useRequest(getUsername);

它也有很多的用法,只是跟 swr 的用法不一样而已:

  • 手动触发:useRequest()会返回 run(),在第 2 个参数中配置上{manual: true},则 useRequest 就不会自动执行了,你可以手动执行 run(),然后才触发;

  • 生命周期:请求之前、请求成功、请求失败、请求完成等;

  • 重复上次请求:可以复用上次的参数,不用重新传参;

除此之外,还有很多其他 hook,各位按照他的规范使用即可。

ahooks中的各种hooks-蚊子的前端博客

5.2 beautiful-react-hooks

这是国外开发者维护的一个 hooks 仓库,地址:beautiful-react-hooks,目前 GitHub 上有 6.6k 的 stars。

beautiful-react-hooks中的hooks-蚊子的前端博客

我之前也给这个仓库贡献过代码:

-蚊子的前端博客

5. 总结

我们这里以不同的视角讲解了如何进行自定义的 hook,各位在后续的开发过程中,也可以根据需要,引入这些 hook 包,或者自行实现。

出个小题,请实现一个useSwitch(defaultValue)的 hook,可以传入初始值,然后返回两个参数[state, toggle]:

  • state: 表示当前的值,是 true 或 false;

  • toggle(): 调用该方法可以切换 true 和 false;注意,该方法无参数;

使用:

COPYJAVASCRIPT

const [state, toggle] = useSwitch(true); const handleClick = () => { toggle(); };
标签:
阅读(1562)

公众号:

qrcode

微信公众号:前端小茶馆