Wenzi

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 事件,在组件卸载时再解除绑定。

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

  1. 选中边框的位置,每次都需要更新到下一个图标;
  2. 延迟时间一直在变动,先加速,然后匀速,最后减速的效果;
  3. 中奖信息,从 state 中拿到奖品信息,决定最后停止的位置;
  4. 中奖后,再延迟 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 #

使用:

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,各位按照他的规范使用即可。

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;注意,该方法无参数;

使用:

const [state, toggle] = useSwitch(true);

const handleClick = () => {
  toggle();
};
标签:reacthooks
阅读(2753)
Simple Empty
No data