盘点那些让开发效率翻倍的React Hook

4,729 阅读9分钟

前言

现在很多开发都开始使用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>
  );
};

动态效果如下所示:

useClipboard.gif

从上面效果可看到,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;

代码效果如下所示:

useSet.gif

除此之外,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-basic.gif

useRequest除了上面的基础用法外,还可以设置轮询、防抖、节流、错误重试、缓存&SWR。具体可以阅读useRequest

useAntdTable

平时我们的报表页面都是以Antd ProTable这种布局的:

image

其中,如果拿Antd当公共组件的情况下,高级筛选栏Form组件实现,标题栏表格区域分页栏都用Table组件实现。但每次在编写这种页面中,我们都需要重复弄一些繁琐但不能缺失的逻辑(本文称之为交互性代码)去处理Form组件、Table组件以及两者之间的关联,例如

Table组件方面:

  1. 每次都要定义的分页变量:当前页数current和每页条数pageSize甚至还可能要每页条数pageSize
  2. 要定义loading来处理加载时呈现的动画

TableForm的关联:

  1. 需要定义query变量来存储过滤参数,以再自动重新请求(增删改后自动刷新)能当作请求参数
  2. Form中的过滤参数变化且发出请求时,会重置当前页面current 到第一页。

而用useAntdTable时我们可以省去上面这些逻辑,我们用一个场景当例子展示其便捷性:

现在要实现一个场景:根据条件查询 github 中antdissues且放在Table中,效果如下所示:

useAntdTable-github-issues.gif

其中高级筛选栏中的标签一栏在变动时会立即发出请求,且带重置按钮。实现这些功能会需要我们写很多熟悉的交互性代码,而用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 对antdImage组件进行二次封装,让其可以实现懒加载,代码如下所示:

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;

组件效果如下所示:

useInViewport.gif

上面的示例代码我放在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.connectionnavigator.connection.onchange来实现的。在用到websocketwebRTC的场景下非常实用。

useUrlState

一个用于管理location.search,即url上的请求参数的 hook。该hook常用于location.search要随着查询参数变化而变化的场景,如下场景:

useUrlState.gif

从上面的动图可以看到,当当前页数每页条数变化时,location.search也会随之变化。而我们手动更改location.search当前页数为 2 后刷新时,Table中显示的也是第二页的参数,如下所示:

useUrlState-changeUrl.gif

实现代码如下所示:

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 之前,你需要保证

  1. 你项目正在使用 react-router 5.x 或 6.x 版本来管理路由
  2. 独立安装了 @ahooksjs/use-url-state

useClickAway

该 hook 用于监听目标元素外的点击事件,相当于vue中的v-clickoutside。该 hook 主要在开发组件上比较给力。我们常用的Modal,Drawer,Select都有点击组件外时会收起的机制。此时用上该 hook 会省去很多监听逻辑,例如我写的一个迷你的Drawer组件,效果如下所示:

useClickAway.gif

可以看到我通过点击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>
      ))}
    </>
  );
}

最后我们通过手动调整宽度查看页面效果,如下所示:

useResponsive.gif

如果想配置自己的响应式断点,可以使用 configResponsive,示例可查看官方例子 code sandbox

后记

喜欢的话不妨点个赞支持。