学会这些自定义hooks,让你摸鱼时间再翻一倍🐟🐟

·  阅读 10230
学会这些自定义hooks,让你摸鱼时间再翻一倍🐟🐟

前言

拿破仑曾说过,不想当将军的士兵不是好士兵,不会摸鱼的员工不是好员工 [手动狗头]。

本文将从各种实用场景出发,讲解不同场景下使用自定义hooks的最佳实践,手牵手教你封装自己的hooks工具库,高效摸鱼🐟,拒绝低效🙅。

文章中涉及到的代码都放到了Github中:Demo

下面让我们开始吧🏃。

自定义hooks环节

hooks规则不是本文的重点,不做过多的赘述,详情见官网 Building Your Own Hooks

按使用场景的频率进行排序,封装常用的hooks如下:

  • useMount
  • useUnMount
  • useUpdateEffect
  • useFirstMount
  • useDebounceState
  • useDebounceEffect
  • useThrottleState
  • useThrottleEffect
  • useDeepCompareEffect
  • useSetState
  • useLatest
  • useCountdown

如果有一些hooks你经常用,但是我没有列出来,请在评论区告诉我,你提需求,我来写😉。

useMount

在不少场景中,我们仅仅只需要在组件初次渲染时执行某些逻辑,比如项目中关于配置的请求,一般情况下我们会这么做:

  useEffect(() => {
    //做一些事情
  }, []);
复制代码

这么做最大的缺点在于语义不够清晰,即使在deps中我们传入的是一个空数组。

那我们是不是可以封住一个hook: useMount,让它只在组件初次渲染期间执行,用来明确语义,提高代码可读性

期望用法:

const UseMountExample = () => {
  const [num, setNum] = useState(0);

  useMount(() => {
    console.log("useMount");
  });

  return (
    <div>
      num:{num}
      <button onClick={() => setNum(num + 1)}>add</button>
    </div>
  );
};
复制代码

useMount具体实现(一行代码轻松搞定,是不是很简单):

import { EffectCallback, useEffect } from "react";

export const useMount = (callback: EffectCallback) => {
  useEffect(callback, []);
};
复制代码

效果:我们可以看到,当组件重新渲染时,useMount中也不会执行。

echeu-q6z8g.gif

useUnMount

同样的,在一些场景下,我们仅仅只需要在组件卸载时触发一些逻辑。例如,清除定时器或重置一些状态时,通常我们会这么做:

  useEffect(() => {
    return () => {
      //执行组件销毁时的逻辑
    };
  }, []);
复制代码

它的缺点同样很明显,我们足足用了4行代码来表达组件卸载时的生命周期,而且语义也不清晰,为了提高代码可读性,我们需要封装hook: useUnMount,用来明确语义。

期望用法:

//Child.tsx
const Child = () => {
  const [num, setNum] = useState(0);
  
  useUnMount(() => console.log(num, "num")); //在组件销毁时打印出num值
  
  return (
    <div>
      num:{num}
      <button onClick={() => setNum(num + 1)}>add</button>
    </div>
  );
};

//Demo.tsx
const UseUnmountExample = () => {
  const [showFlag, setShowFlag] = useState(true); //模拟Child组件销毁,对Child组件进行显示/隐藏

  return (
    <div>
      {showFlag && <Child />}
      <button onClick={() => setShowFlag(false)}>销毁child</button>
    </div>
  );
};
复制代码

useUnMount具体实现(同样一行代码就能搞定):

import { useEffect } from "react";

export const useUnMount = (fn: () => any): void => {
  useEffect(() => () => fn(), []);
};
复制代码

效果:

uli3w-frzau.gif 很明显,最后Child组件销毁时输出的num并不是我们想要的,这个其实是因为useEffect中闭包机制导致的,这样实现始终都是执行的第一次渲染时传入的函数,为了拿到实时的状态,这里需要借助 useRef 来实现。

修改useUnMount实现:

import { useEffect, useRef } from "react";

export const useUnMount = (fn: () => any): void => {
  const fnRef = useRef(fn);
  fnRef.current = fn; //拿到实时的fn

  useEffect(() => () => fnRef.current(), []);
};
复制代码

再来测试一下:

ydyrt-tt93d.gif 这样就解决了闭包导致的问题,好啦,继续下一个👇。

useUpdateEffect

在部分场景下,我们想要忽略首次执行,只需在依赖项发生变化时去执行某些逻辑。通常的做法是定义一个isFirstMount的变量来判断,像这样:

  const isFirstMountRef = useRef(false); //用来判断是否是初次渲染

  useEffect(() => {
    isFirstMountRef.current = true;
  }, []);

  useEffect(() => {
    if (isFirstMountRef.current) {
      return;
    }

    //执行二次渲染时的逻辑
    xxx
  }, [deps]);
复制代码

同样的,这段代码看起来很不优雅,我们用了一大段代码来制造一个只在依赖更新时才运行的环境。 这个时候我们需要封装一个hook: useUpdateEffect,它需要忽略首次渲染,只有当依赖发生变化时才会执行,用法应与useEffect相同。

期望用法:

const UseUpdateEffectExample = () => {
  const [num, setNum] = useState(0);

  useUpdateEffect(() => {
    console.log(num, "num更新时的值");

    return () => {
      console.log("销毁之前num:", num);
    };
  }, [num]);

  return (
    <div>
      {num}
      <button onClick={() => setNum(num + 1)}>add</button>
    </div>
  );
};
复制代码

useUpdateEffect具体实现:

import { useEffect } from "react";
import { useFirstMount } from "./useFirstMount";

export const useUpdateEffect: typeof useEffect = (effect, deps) => {
  const isFirstMount = useFirstMount(); //判断是否是初次渲染

  useEffect(() => {
    if (!isFirstMount) {
      return effect(); //二次渲染才执行
    }
  }, deps);
};
复制代码

效果:可以看到,首次渲染没有执行,只有当重新渲染时才会打印。

hho5a-qxilf.gif 在实现过程中,我们封装了另外一个hook: useFirstMount,它用来判断是否为首次渲染,实现起来比较简单,我们直接开干。

useFirstMount

用来判读是否是首次渲染:

import { useRef } from "react";

export function useFirstMount(): boolean {
  const isFirst = useRef(true);

  //如果是初次渲染
  if (isFirst.current) {
    isFirst.current = false;

    return true; 
  }

  return isFirst.current;
}
复制代码

useDebounceState

场景:现有如下表格,表格数据的获取依赖两个参数:

  • 搜索框中搜索参数:keyword
  • 表格底部的分页器参数:page/pageSize

image.png

为了防止api接口频繁请求,之前我们大概率会这么做:给输入框的onChange事件和分页器的onPageChange事件都加上防抖函数。但是经验教训告诉我,当表单的筛选条件过多或后续加更多的条件时,很容易遗漏掉部分条件的防抖。

这个时候希望封装一个hook:useDebounceState,它能够对state进行防抖,我们通过这个防抖之后的值再去请求api,这样就能达到一样的效果,并且当后续表格中加更多条件时,我们只需要在state中处理即可。

期望用法:

const [state, setState] = useState({
  keyword: "",
  page: 1,
  pageSize: 10,
}); //定义表格api的params
const debounceParams = useDebounceState(state, 1000); //拿到防抖state

useEffect(() => {
  //请求api
}, [debounceParams]);
复制代码

useDebounceState具体实现:

function useDebounceState<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
  useEffect(
    () => {
      // 在delay时间后更新debouncedValue
      const handler = setTimeout(() => {
        setDebouncedValue(value);
      }, delay);
  
     return () => clearTimeout(handle); //当传入的value变化时,清除之前的定时器
    },
    [value, delay] 
  );
  return debouncedValue;
}
复制代码

效果: wp9ru-3elh2.gif

useDebounceEffect

上面场景我们通过useDebounceState返回了一个debounceParams,以此来达到了防抖的目的。但是它并不是该场景的最优解,最重要的是产生一个额外的变量debounceParams,那我们能不能再优化一下?变成这样:

const [state, setState] = useState({
  keyword: "",
  page: 1,
  pageSize: 10,
}); //定义表格api的params

 useDebounceEffect(
    () => {
      //api请求
    },
    [state],
    1000
  );
复制代码

useEffect => useDebounceEffect,这么做能够达到相同的效果,并且可以让代码看起来更加简洁优雅。

useDebounceEffect具体实现:

import { useState,DependencyList,EffectCallback,useEffect,useRef } from "react";
import { useUnMount } from "./useUnMount";
import { useUpdateEffect } from "./useUpdateEffect";

export const useDebounceEffect = (
  effect: EffectCallback,
  deps: DependencyList,
  delay = 1000
) => {
  const timeoufRef = useRef<ReturnType<typeof setTimeout>>();
  const [refreshFlag, setRefreshFlag] = useState(true); //用于更新effect

  useEffect(() => {
    timeoufRef.current = setTimeout(() => {
      setRefreshFlag(!refreshFlag);
    }, delay);

    return () => timeoufRef.current && clearTimeout(timeoufRef.current);
  }, [...deps, delay]);

  //只有当依赖refreshFlag变化时,才执行传入的effect
  useUpdateEffect(effect, [refreshFlag]);

  //当页面销毁时,及时清除定时器
  useUnMount(
    () => () => timeoufRef.current && clearTimeout(timeoufRef.current)
  );
};
复制代码

核心原理其实与useDebounceState相同,唯一不同的是这里需要通过一个标志refreshFlag去控制effect的执行。至于为什么要这样,不这样做会有什么坑?这里先留个思考题给大家思考,到时候评论区里看答案😜。

useThrottleState

useThrottleState是节流函数的hooks版本,主要用在下拉加载、上拉刷新等场景,用法与我们前面写的useDebounceState相同,也是返回一个节流函数处理过的值throttleValue。

useThrottleState具体实现:

import { useRef, useState } from "react";
import { useUnMount } from "./useUnMount";
import { useUpdateEffect } from "./useUpdateEffect";

export const useThrottleState = <T>(initialState: T, delay = 5000) => {
  const [state, setState] = useState<T>(initialState);
  const timeout = useRef<ReturnType<typeof setTimeout>>();
  const nextValue = useRef(null) as any;
  const hasNextValue = useRef(false);

  useUpdateEffect(() => {
    if (timeout.current) {
      nextValue.current = initialState;
      hasNextValue.current = true;
    } else {
      setState(initialState);
      const timeoutCallback = () => {
        if (hasNextValue.current) {
          setState(nextValue.current);
          hasNextValue.current = false;
        }
        timeout.current = undefined;
      };
      timeout.current = setTimeout(timeoutCallback, delay);
    }
  }, [initialState]);

  useUnMount(() => {
    timeout.current && clearTimeout(timeout.current);
  });

  return state;
};
复制代码

useThrottleEffect

useThrottleEffect为 useEffect 增加节流的能力,有些场景下使用useThrottleEffect会比使用useThrottleState代码更加简洁。

useThrottleEffect具体实现:

import { useEffect, useRef, useState } from "react";
import { useUnMount } from "./useUnMount";

export const useThrottleEffect = <T, U extends any[]>(
  fn: (...args: U) => T,
  args: U,
  delay = 200
) => {
  const [state, setState] = useState<T | null>(null);
  const timeout = useRef<ReturnType<typeof setTimeout>>();
  const nextArgs = useRef<U>();

  useEffect(() => {
    if (timeout.current) {
      //如果有正在进行中的
      nextArgs.current = args;
    } else {
      setState(fn(...args));
      const timeoutCallback = () => {
        if (nextArgs.current) {
          setState(fn(...nextArgs.current));
          nextArgs.current = undefined;
        }
        timeout.current = undefined;
      };
      timeout.current = setTimeout(timeoutCallback, delay);
    }
  }, args);

  useUnMount(() => {
    timeout.current && clearTimeout(timeout.current);
  });

  return state;
};
复制代码

useDeepCompareEffect

由于useEffect中的浅比较机制,导致了一些很让人头疼的问题。比如我们在最开始的例子中:

image.png

分页器这块,我们可能会这么去编码:

  const [pagination, setPagination] = useState({
    page: 1,
    page_size: 10,
  });
  
  //当触发分页器时执行
  const onPageChange = (page, page_size) => {
    setPagination({
      page,
      page_size,
    });
  };

  //当分页器触发时重新请求接口:
  useEffect(() => {
    //请求数据
  }, [pagination]);
复制代码

这就导致了一个问题,当用户选择了相同的page或page_size时,依然会触发useEffect,这样会导致重新请求一遍相同的数据,这是我们不希望看到的。

这个时候就需要封装一个 hook: useDeepCompareEffect来解决这个问题,我们可以对deps进行深度比较,只有当深比较后前后不一致才会触发渲染,用法与useEffect相同。

期望用法:

const UseDeepCompareEffectDemo = () => {
  const [obj, setObj] = useState({ a: "1" });

  useDeepCompareEffect(() => {
    console.log("渲染");
  }, [obj]);

  return (
    <div>
      UseDeepCompareEffectDemo:
      <button onClick={() => setObj({ a: "2" })}>setObj</button>
    </div>
  );
};
复制代码

useDeepCompareEffect具体实现:

import isEqual from 'lodash/isEqual';
import { useEffect, useRef } from 'react';
import type { DependencyList, EffectCallback } from 'react';

const depsEqual = (aDeps: DependencyList, bDeps: DependencyList = []) => {
  return isEqual(aDeps, bDeps);
};

const useDeepCompareEffect = (effect: EffectCallback, deps: DependencyList) => {
  const ref = useRef<DependencyList>();
  const signalRef = useRef<number>(0);

  if (!depsEqual(deps, ref.current)) {
    ref.current = deps;
    signalRef.current += 1;
  }

  useEffect(effect, [signalRef.current]);
};

export default useDeepCompareEffect;
复制代码

效果:

wilyr-1azcq.gif

useSetState

依旧拿之前的表格来举例子,在上面触发分页器的事件中,我们是这么做的:

   const [state, setState] = useState({
    keyword: "",
    page: 1,
    pageSize: 10,
  }); //定义表格api的params

  //分页器事件
  const onPageChange = (page, page_size) => {
    setState({
      ...state,
      page,
      page_size,
    });
  };
复制代码

那有没有办法让我们拥有与class组件中的setState一样的能力呢?比如,我只需要修改state中的page属性时,直接这么做就可以,其他参数不受影响:

   setPagination({ page });
复制代码

自定义 hook:useSetState 就可以帮助我们解决这个问题,具体实现:

import { useState } from "react";

export const useSetState = <T extends object>(initialState: T | (() => T)) => {
  const [state, setState] = useState<T>(initialState);

  const set = (value: Partial<T> | ((preState: T) => Partial<T>)): void => {
    setState({
      ...state,
      ...(value instanceof Function ? value(state) : value),
    });
  };

  return [state, set] as const;
};
复制代码

useLatest

在实现useUnMount的过程中,我们提到过useEffect的闭包问题,我们的解决方式是通过useRef去更新状态,为了解决这一类的问题,我们可以封装成 hook:useLatest 来保证我们能够始终拿到最新的值。

useLatest具体实现:

import { useRef } from "react";

export const useLatest = <T>(value: T): { current: T } => {
  const ref = useRef(value);
  ref.current = value;
  return ref;
};
复制代码

用法:

 const latestStateRef = useLatest(state); //拿到最新的state
复制代码

useCountdown

最后一个,大家再坚持一下就完啦😄!

倒计时的场景,相信在不少业务中大家都遇到过,简简单单封装成一个hook,提高摸鱼效率。

期望用法:

const UseCountDownDemo = () => {
  const [timestamp, { days, hours, minutes, seconds }] = useCountdown({
    targetDate: "2022-12-31 24:00:00",
  });
  return (
    <div>
      UseCountDownDemo
      <br />
      倒计时:{days}-{hours}-{minutes}-{seconds}
    </div>
  );
};
复制代码

useCountdown具体代码实现:

import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react";
import { useLatestState } from "./useLatestState";

export type TDate = Date | number | string | undefined;

export type Options = {
  targetDate?: TDate;
  interval?: number;
  onEnd?: () => void;
};

export interface FormattedRes {
  days: number;
  hours: number;
  minutes: number;
  seconds: number;
  milliseconds: number;
}

const calcLeft = (t?: TDate) => {
  if (!t) {
    return 0;
  }
  // https://stackoverflow.com/questions/4310953/invalid-date-in-safari
  const left = dayjs(t).valueOf() - new Date().getTime();
  if (left < 0) {
    return 0;
  }
  return left;
};

const parseMs = (milliseconds: number): FormattedRes => {
  return {
    days: Math.floor(milliseconds / 86400000),
    hours: Math.floor(milliseconds / 3600000) % 24,
    minutes: Math.floor(milliseconds / 60000) % 60,
    seconds: Math.floor(milliseconds / 1000) % 60,
    milliseconds: Math.floor(milliseconds) % 1000,
  };
};

export const useCountdown = (options?: Options) => {
  const { targetDate, interval = 1000, onEnd } = options || {};

  const [timeLeft, setTimeLeft] = useState(() => calcLeft(targetDate));

  const onEndRef = useLatestState(onEnd);

  useEffect(() => {
    if (!targetDate) {
      // for stop
      setTimeLeft(0);
      return;
    }

    // 立即执行一次
    setTimeLeft(calcLeft(targetDate));

    const timer = setInterval(() => {
      const targetLeft = calcLeft(targetDate);
      setTimeLeft(targetLeft);
      if (targetLeft === 0) {
        clearInterval(timer);
        onEndRef.current?.();
      }
    }, interval);

    return () => clearInterval(timer);
  }, [targetDate, interval]);

  const formattedRes = useMemo(() => {
    return parseMs(timeLeft);
  }, [timeLeft]);

  return [timeLeft, formattedRes] as const;
};
复制代码

效果:

mpa6m-wf2mk.gif

总结

其实这些自定义hooks也并不需要我们手写,github中就有不少优秀的hooks库:ahooksreact-useuseHooks 等,其中 react-use 在github上最受欢迎,star数达到了29k,大家直接安装使用即可。

笔者也只是读了一部分库的源码,发现一些hooks其实是能够帮助我们大大提高效率的,在此进行分享。如果能够帮助到你,不妨帮忙点个赞♥️♥️♥️。

推荐阅读

  1. 从零到亿系统性的建立前端构建知识体系✨
  2. 我是如何带领团队从零到一建立前端规范的?🎉🎉🎉
  3. 【Webpack Plugin】写了个插件跟喜欢的女生表白,结果......😭😭😭
  4. 前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用🔥
  5. 浅析前端异常及降级处理
  6. 前端重新部署后,领导跟我说页面崩溃了...
  7. 前端场景下的搜索框,你真的理解了吗?
  8. 手把手教你实现React数据持久化机制
  9. 面试官:你确定多窗口之间sessionStorage不能共享状态吗???🤔
分类:
前端
收藏成功!
已添加到「」, 点击更改