0

React 中如何自定义和封装 hooks

 1 year ago
source link: https://www.xiabingbao.com/post/react/react-hooks-rqwi9a.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
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;

  useEffect(() => {
    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-蚊子的前端博客

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

-蚊子的前端博客

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

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

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

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

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

const handleClick = () => {
  toggle();
};

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK