前言
现在很多开发都开始使用React Hook,而基于业务提炼的高级自定义的React Hook可以给我们省下很多开发开发时间。因此,本文介绍以下两个目前前端开源环境中最热门的自定义 hook 库:
- react-use: 国外的最早且相对最全面的自定义 hook 库,里面的 hook 几乎覆盖基层的开发需求。
- ahooks: 阿里开源的库,前身是
umi hooks,与react-use不同的是,ahook是聚焦于做一些应对常见且复杂需求下的 hook。
接下来,我会依次介绍两个库且列出在其中的一些非常给力的 hook 的作用和用法,相信学习这些 hook 会给读者带来很大的开发效率上的提升。
react-use
useMedia
该 hook 用于调用CSS Media语法进行媒体查询,把查询结果当作state返回,如下官方例子所示:
import { useMedia } from "react-use";
const Demo = () => {
// useMedia(query: string, defaultState: boolean = false): boolean;
const isWide = useMedia("(min-width: 480px)");
return <div>Screen is wide: {isWide ? "Yes" : "No"}</div>;
};
通过该 hook 我们可以使用css media语法查询媒体设备信息。
useMedia内部是通过Window.matchMedia()实现的,有兴趣可以点击链接了解下。
useAudio
一个用于创建和管理HTML <audio>的 hook,我直接用官方示例代码进行解释:
import { useAudio } from "react-use";
const Demo = () => {
const [
// audio:为type为audio的JSX.Element
audio,
// state:为audio的状态,有以下值:
/**
{
// 缓存数据段,start和end代表缓存片段前后的时间点
// 视频音频这类数据很多情况下都是从后端断点传续过来的,即边加载边播放
// 而不是整个视频加载好后才播放的
"buffered": [
{
"start": 0,
"end": 425.952625
}
],
// 当前播放到的时间点
"time": 5.244996,
// 视频音频时间总长
"duration": 425.952625,
// 当前是否在暂停
"paused": false,
// 当前是否静音
"muted": false,
// 音量
"volume": 1,
// 因为缺少数据而暂停或延迟的状态结束,播放准备开始。
"playing": true
}
*/
state,
/**
controls是一个数据类型为AudioControls对象,内部提供了一系列控制audio的方法,如:
interface AudioControls {
// 播放
play: () => Promise<void> | void;
// 停止
pause: () => void;
// 静音
mute: () => void;
// 解除静音
unmute: () => void;
// 设置声量
volume: (volume: number) => void;
// 查询请求某个时间点的数据
seek: (time: number) => void;
}
*/
controls,
// ref为useRef创建的用于存储audio实例的对象
ref,
] = useAudio(
// 里面设置的是原生HTML audio元素的属性,
// 可通过https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/audio#%E5%B1%9E%E6%80%A7
// 查看所有可设置的属性
{
src: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3",
autoPlay: true,
}
);
return (
<div>
{audio}
{/*
上面的useAudio被调用时,形参中没设置control属性,因此创建出的audio不带control属性,因此没有播放控制栏,
而因为useAudio返回的controls中带控制的方法,因此我们可以如下面的代码中自定义播放控制栏
*/}
<pre>{JSON.stringify(state, null, 2)}</pre>
<button onClick={controls.pause}>Pause</button>
<button onClick={controls.play}>Play</button>
<br />
<button onClick={controls.mute}>Mute</button>
<button onClick={controls.unmute}>Un-mute</button>
<br />
<button onClick={() => controls.volume(0.1)}>Volume: 10%</button>
<button onClick={() => controls.volume(0.5)}>Volume: 50%</button>
<button onClick={() => controls.volume(1)}>Volume: 100%</button>
<br />
<button onClick={() => controls.seek(state.time - 5)}>-5 sec</button>
<button onClick={() => controls.seek(state.time + 5)}>+5 sec</button>
</div>
);
};
react-use针对视频<video>也提供了useVideo,有兴趣可点击查阅。
useCss
该 hook 用于根据CSS规则动态创建CSS Class,如下官方例子所示:
import { useCss } from "react-use";
const Demo = () => {
// 返回的className可直接用作元素的className
// 只有CSS规则变化时,className才会有变化
// 而且不同于内联样式(style)的是,它还可以设置媒体查询(@media)和伪选择器和伪类
const className = useCss({
color: "red",
border: "1px solid red",
"&:hover": {
color: "blue",
},
});
return <div className={className}>Hover me!</div>;
};
这样子就可以避免还要另建css文件去为组件编写简短的css样式。
useCopyToClipboard
该 hook 用于管理复制粘贴,直接看官网例子:
const Demo = () => {
const [text, setText] = React.useState("");
const [
{
// 被复制的值,如果没有则为undefined
value,
// 复制时产生的错误
error,
},
// 用于复制的方法,传入形参为要被复制的值
copyToClipboard,
] = useCopyToClipboard();
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button type="button" onClick={() => copyToClipboard(text)}>
copy text
</button>
{state.error ? (
<p>Unable to copy value: {state.error.message}</p>
) : (
state.value && <p>Copied {state.value}</p>
)}
</div>
);
};
动态效果如下所示:
从上面效果可看到,copyToClipboard在传入形参为undefined或者空字符串时,会报错,这点在使用时要注意。
useSet
该 hook 用于管理Set类型的变量,当我们在组件中对Set类型的数据进行操作时,不会改变其指向。而react在渲染前会对变量进行浅比较,如果发现一致则跳过渲染环节。这样子会让页面不能随着数据的更新而更新。而该 hook 用于处理这种情况,如下代码所示:
import React from "react";
import { useSet } from "react-use";
const Demo = () => {
const [
set,
{
// 此处返回Set操作的方法,如has, remove, toggle, reset
add,
},
] = useSet(new Set(["hello"]));
const [text, setText] = React.useState("");
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button type="button" onClick={() => add(text)}>
add
</button>
<ul>
{Array.from(set).map((item) => (
<li>{item}</li>
))}
</ul>
</div>
);
};
export default Demo;
代码效果如下所示:
除此之外,react-use还提供了类似的管理其他高级数据类型的 hook,如:
ahook
useRequest
useRequest是一个强大的异步数据管理的Hooks,他的设计能满足很多场景的需求。我们先借一个例子看一下最基本的功能:
import { useRequest } from "ahooks";
import React, { useState } from "react";
function getUsername() {
// 我们用一个公共开放接口来作做测试,该接口用于获取随机生成的用户信息
return fetch("https://randomuser.me/api/").then((res) => res.json());
}
export default () => {
// 该变量name用户存储请求得到的用户信息
const [name, setName] = useState({
first: "",
last: "",
});
// run:用于手动调用进行异步请求,
// loading:表示加载状态,在调用前后分别为true和false
// 除此之外还会返回别的一些变量,例如cancel用于取消请求
const { run, loading } = useRequest(getUsername, {
// 该变量为true表示生成手动触发的函数run,若为false则会默认执行
manual: true,
// 在run的请求成功后的回调
onSuccess: (result) => {
setName(result.results[0].name);
},
// 除了onSuccess,你还可以设置以下几个生命周期配置项,以在异步函数的不同阶段做一些处理。
// onBefore:请求之前触发
// onError:请求失败触发
// onFinally:请求完成触发
});
if (loading) {
return <div>loading...</div>;
}
return (
<>
<button onClick={run}>加载用户信息</button>
<div>
{name.first}.{name.last}
</div>
</>
);
};
动态效果如下所示:
useRequest除了上面的基础用法外,还可以设置轮询、防抖、节流、错误重试、缓存&SWR。具体可以阅读useRequest
useAntdTable
平时我们的报表页面都是以Antd ProTable这种布局的:
其中,如果拿Antd当公共组件的情况下,高级筛选栏用Form组件实现,标题栏、表格区域、分页栏都用Table组件实现。但每次在编写这种页面中,我们都需要重复弄一些繁琐但不能缺失的逻辑(本文称之为交互性代码)去处理Form组件、Table组件以及两者之间的关联,例如
Table组件方面:
- 每次都要定义的分页变量:当前页数
current和每页条数pageSize甚至还可能要每页条数pageSize等 - 要定义
loading来处理加载时呈现的动画
Table与Form的关联:
- 需要定义
query变量来存储过滤参数,以再自动重新请求(增删改后自动刷新)能当作请求参数 Form中的过滤参数变化且发出请求时,会重置当前页面current到第一页。
而用useAntdTable时我们可以省去上面这些逻辑,我们用一个场景当例子展示其便捷性:
现在要实现一个场景:根据条件查询 github 中antd的issues且放在Table中,效果如下所示:
其中高级筛选栏中的标签一栏在变动时会立即发出请求,且带重置按钮。实现这些功能会需要我们写很多熟悉的交互性代码,而用useAntdTable可以帮我们省下这些步骤,从而提高开发效率,示例代码如下所示:
import "antd/dist/antd.css";
import { Form, Table, Select, Button } from "antd";
import { useAntdTable } from "ahooks";
import { stringify } from "qs";
export default function App() {
// 生成form实例
const [form] = Form.useForm();
// 请求方法,注意传入的参数是要按照格式来
const getTableData = ({ current, pageSize }, formData) => {
const { state, labels } = formData;
const query = stringify({
state,
labels,
page: current,
per_page: pageSize,
});
return fetch(
// 使用github的方法开放接口来获取
`https://api.github.com/repos/ant-design/ant-design/issues?${query}`
)
.then((res) => res.json())
.then((res) => ({
// 该接口获取不了issues总数,因此我随便编了个,不要介意
total: 1000,
list: res,
}));
};
// 把getTableData、form放入useAntdTable
// 最终会返回tableProps 和 search 字段,分别用于管理表格和表单
// 其中,tableProps用于注入到Table组件上的Props
// 而search用于管理调用请求
const { tableProps, search } = useAntdTable(
getTableData,
// 此处还可以设置一些相关的默认参数,如:
// defaultPageSize: 默认分页数量
// refreshDeps:form的数据状态变化,会重置 current 到第一页,并重新发起请求。
// defaultParams:form的默认数据状态
{
defaultPageSize: 5,
form,
}
);
// submit用于提交表单,相当于收集表单数据和page数据后请求接口
// reset用于重置当前表单,重置后调用请求接口
const { submit, reset } = search;
return (
<div style={{ padding: 12 }}>
{/* 高级筛选栏 */}
<Form form={form} layout="inline" style={{ paddingBottom: 12 }}>
<Form.Item name="labels" label="标签">
<Select allowClear style={{ width: 150 }} onChange={submit}>
<Select.Option value="🐛 Bug">🐛 Bug</Select.Option>
<Select.Option value="help wanted">help wanted</Select.Option>
</Select>
</Form.Item>
<Form.Item name="state" label="状态">
<Select allowClear style={{ width: 150 }}>
<Select.Option value="closed">已关闭</Select.Option>
<Select.Option value="open">开放</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={submit}>
搜索
</Button>
<Button style={{ marginLeft: 8 }} onClick={reset}>
重置
</Button>
</Form.Item>
</Form>
{/* 表格区域 与 分页栏 */}
<Table rowKey="id" {...tableProps}>
<Table.Column dataIndex="title" title="标题" />
<Table.Column dataIndex="events_url" title="地址" />
<Table.Column
dataIndex="labels"
title="标签"
width="140px"
render={(value) => (
<div>
{value.map(({ name }) => (
<div>{name}</div>
))}
</div>
)}
/>
</Table>
</div>
);
}
可以看出,在结合useAntdTable写出来的页面代码会让我们少写很多交互性代码。上面的代码例子我放在code sandbox里,有兴趣可以玩玩。而关于useAntdTable的更多用法可以阅读useAntdTable。
useInViewPort
该 hook 用于判断元素是否在可使范围内,其内部是基于Intersection Observer API来实现的,其 API 格式为:
const [
// 数据类型为boolean | undefined,代表元素是否可见
inViewport,
// 数据类型为number | undefined,代表当前可见比例
ratio
] = useInViewport(
// DOM 节点或者 ref,代表要监听判断的目标
target,
options?: Options
);
interface Options{
// 与ratio对应,target 元素和 root 元素相交程度达到threshold的时候,ratio 会被更新,从而组件函数重新执行渲染
threshold: number | number[]
// 指定根(root)元素,用于检查目标的可见性。必须是目标元素的父级元素,如果未指定或者为null,则默认为浏览器视窗。
root: Element | Document | () => (Element/Document) | MutableRefObject<Element>
// 根(root)元素的外边距
rootMargin: string;
}
useInViewport在懒加载的场景下比较实用,例如我们借助该 hook 对antd的Image组件进行二次封装,让其可以实现懒加载,代码如下所示:
import { Image } from "antd";
import React from "react";
import { useInViewport } from "ahooks";
import {LoadingOutlined} from '@ant-design/icons'
type Props = Partial<ImageProps>;
const LazyImage: React.FC<Props> = (props) => {
const { src: extraSrc, ...restProps } = props;
// 随便拿"wait"当作src最初值,首次加载会失败,但不用在意
const [src, setSrc] = React.useState("wait");
const ref = React.useRef();
const [inViewport] = useInViewport(ref);
React.useEffect(() => {
// 当inViewport为真,即在可视范围内,且src还是初始值,则替换成真实的图片路径(props.src)
if (inViewport && src === "wait") {
setSrc(extraSrc);
}
}, [inViewport, src, extraSrc]);
return (
<div ref={ref}>
<Image
{...restProps}
src={src}
{/*在placeholder中设置加载时的图案*/}
placeholder={
<div style={{height:'100%',display:'flex',justifyContent:'center',alignItems:'center'}}>
<LoadingOutlined />
</div>
}
/>
</div>
);
};
export default LazyImage;
组件效果如下所示:
上面的示例代码我放在code sandbox里
useNetwork
该 hook 用于读取网络连接状态,我直接用官网的代码例子来解释:
import React from "react";
import { useNetwork } from "ahooks";
export default () => {
const networkState = useNetwork();
return (
<div>
<div>Network information: </div>
<pre>{JSON.stringify(networkState, null, 2)}</pre>
</div>
);
};
// 页面会显示如下内容
/**
Network information:
{
"online": true, // 网络是否为在线
"rtt": 50, // 当前连接下评估的往返时延
"saveData": false, // 用户代理是否设置了减少数据使用的选项
"downlink": 8.75, // 有效带宽估算(单位:兆比特/秒)
"effectiveType": "4g" // 网络连接的类型
}
**/
该 hook 是借助navigator.connection和navigator.connection.onchange来实现的。在用到websocket和webRTC的场景下非常实用。
useUrlState
一个用于管理location.search,即url上的请求参数的 hook。该hook常用于location.search要随着查询参数变化而变化的场景,如下场景:
从上面的动图可以看到,当当前页数或每页条数变化时,location.search也会随之变化。而我们手动更改location.search的当前页数为 2 后刷新时,Table中显示的也是第二页的参数,如下所示:
实现代码如下所示:
import { Table } from "antd";
import React, { useCallback } from "react";
import { useAntdTable } from "ahooks";
import "antd/dist/antd.css";
import useUrlState from "@ahooksjs/use-url-state";
export default () => {
const [page, setPage] = useUrlState({ current: 1, pageSize: 10 });
const getTableData = useCallback(
({ current, pageSize }) => {
// 发出请求之前调用setPage更改page变量的同时更改location.search
setPage({ current, pageSize });
let query = `page=${current}&size=${pageSize}`;
return fetch(`https://randomuser.me/api?results=55&${query}`)
.then((res) => res.json())
.then((res) => ({
total: res.info.results,
list: res.results,
}));
},
[setPage]
);
const { tableProps } = useAntdTable(getTableData, {
defaultParams: [
{
// defaultParams的数据类型为一个数组[pagiantion, formData],第一个元素是分页的参数
// 注意page里属性的值因为可能是从url里获取的,所以可能都是字符串,所以要用Number转化
current: Number(page.current),
pageSize: Number(page.pageSize),
},
],
});
const columns = [
{
title: "name",
dataIndex: ["name", "last"],
},
{
title: "email",
dataIndex: "email",
},
{
title: "phone",
dataIndex: "phone",
},
{
title: "gender",
dataIndex: "gender",
},
];
return <Table columns={columns} rowKey="email" {...tableProps} />;
};
实例代码地址放在code sandbox里
注意: 该 Hooks 基于 react-router 的 useLocation & useHistory & useNavigate 进行 query 管理,所以使用该 Hooks 之前,你需要保证
- 你项目正在使用 react-router 5.x 或 6.x 版本来管理路由
- 独立安装了 @ahooksjs/use-url-state
useClickAway
该 hook 用于监听目标元素外的点击事件,相当于vue中的v-clickoutside。该 hook 主要在开发组件上比较给力。我们常用的Modal,Drawer,Select都有点击组件外时会收起的机制。此时用上该 hook 会省去很多监听逻辑,例如我写的一个迷你的Drawer组件,效果如下所示:
可以看到我通过点击button打开Drawer,然后点击Drawer没反应,但点击外部的遮罩层会让Drawer收起。
其迷你Drawer组件的实现代码如下所示:
import { useClickAway } from "ahooks";
import { useCallback, useEffect, useRef } from "react";
import "./index.less";
interface Props {
visible: boolean;
onClose: () => any;
}
const Drawer: React.FC<Props> = (props) => {
const ref = useRef<HTMLDivElement>();
// 用于记录打开时动画是否已结束。需要该标志位作判断,
// 不然每次打开时,useClickAway里的回调函数会同时执行,通过props.onClose把props.visible置为false,
// 从而导致Drawer无法弹出
const transitioned = useRef(false);
useClickAway(() => {
// 当props.visible为true即打开Drawer且展现动画已结束时,点击外部元素则会收起Drawer且把transitioned置为false
if (props.visible && transitioned.current) {
props.onClose();
transitioned.current = false;
}
}, ref);
const setTransitionedTrue = useCallback(() => {
if (props.visible) {
transitioned.current = true;
}
}, [props.visible]);
return (
<div className={`drawer ${props.visible ? "drawer--opened" : ""}`}>
<div
className={`drawer__mask ${
props.visible ? "drawer__mask--opened" : ""
}`}
></div>
<div
ref={ref}
onTransitionEnd={setTransitionedTrue}
className={`drawer__content ${
props.visible ? "drawer__content--opened" : ""
}`}
>
content
</div>
</div>
);
};
export default Drawer;
示例我放在code sandbox里。
useResponsive
该 hook 用于获取响应式的信息,在做响应式页面时,我们经常通过CSS Media媒体查询来控制DOM元素的样式和位置,但部分场景下,由js来控制会更加方便,此时可以使用useResponsive来获取页面宽度信息,
该 hook 默认用bootstrap的尺寸划分来做响应式配置,如下所示:
{
'xs': 0,
'sm': 576,
'md': 768,
'lg': 992,
'xl': 1200,
}
我们用下面的代码当例子:
import React from "react";
import { useResponsive } from "ahooks";
export default function () {
// responsives是一个下面这种数据类型的变量
/**
{
'xs': boolean,
'sm': boolean,
'md': boolean,
'lg': boolean,
'xl': boolean,
}
*/
const responsive = useResponsive();
return (
<>
<p>Please change the width of the browser window to see the effect: </p>
{Object.keys(responsive).map((key) => (
<p key={key}>
{key} {responsive[key] ? "✔" : "✘"}
</p>
))}
</>
);
}
最后我们通过手动调整宽度查看页面效果,如下所示:
如果想配置自己的响应式断点,可以使用 configResponsive,示例可查看官方例子 code sandbox。
后记
喜欢的话不妨点个赞支持。