不会写hook? 看看ahook怎么处理

1,672 阅读8分钟

背景

最近想多找一找封装React hook的感觉。 要了解hook能够, 适合去处理什么事情, 怎么去处理事情。

嘿, 这里不妨直接看业界大佬开源的ahooks处理,提供了什么hook以及该hook是如何实现的。

常用基础hook

在开发中其实也经常用到的一些hook, 包括ahook的很多hook源码中都涉及到了。 这一节主要理解比较常见, 基础的hook的源码

useMemoizedFn

useMemoizedFn的作用是持久化function。 保证函数地址永远不会变化

ahook的输出函数规范中: ahooks 所有的输出函数,地址都是不会变化的。

那么如何实现呢, 首先我们想到的应该就是useCallback去做一层缓存。但是单纯使用useCallback能力是有限的, 当useCallback的依赖项更新的时候, 地址也会发生变化。故可以分成以下两种情况:

  • 返回的函数无需关注后续其他状态的更新,也就是说没有其他依赖项, 那么直接使用useCallback
  • 返回的函数需要关注其他状态的更新, 那么使用useMemoizedFn

至于前者能不能使用后者的方法呢, 当然是可以的。 只不过又多了消耗, 没有这个必要

在实现上要实现传入的函数可变, 传出的函数地址不变, 那么就至少需要两个变量。

  • 一个来控制“变”: 始终接受传入的新的函数, 使执行的时候能够执行新的函数的内容。源码中使用的是fnRef
  • 一个来控制“不变”: 只有初始化的时候才赋值,此后不变, 使其能够始终返回不变的函数地址。源码中使用的是memoizedFn
  • memoizedFn.current指向的函数内部再去调用fnRef.current指向的函数
function useMemoizedFn<T extends noop>(fn: T) {

  const fnRef = useRef<T>(fn);
  fnRef.current = useMemo(() => fn, [fn]);

  const memoizedFn = useRef<PickFunction<T>>();
  if (!memoizedFn.current) {
    memoizedFn.current = function (this, ...args) {
      return fnRef.current.apply(this, args);
    };
  }
  return memoizedFn.current as T;
}

useUpdate

React组件的更新源于状态的变化。 当我们想要强制要求组件更新的时候, 此时就可以封装一个hook利用无意义的状态强制更新。

useUpdate 会返回一个函数,调用该函数会强制组件重新渲染。

它的源码很简单, 就是利用了当组件的state产生变化的时候, 组件会重新渲染的原理。 该hook返回的函数的利用useCallback进行了缓存。 当用户调用该函数的时候, state发生变化, 组件重新渲染。

const useUpdate = () => {
  const [, setState] = useState({});

  return useCallback(() => setState({}), []);
};

useLatest

返回当前最新值的 Hook,可以避免闭包问题。

何为闭包问题呢,我们经常能看到的例子。当我们count发生更改的时候, 是不影响setInterval中访问的count的。 因为它始终访问的是初次渲染时外部的count

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      console.log("setInterval:", count);
    }, 1000);
  }, []);

  return (
    <div>
      count: {count}
      <br />
      <button onClick={() => setCount((val) => val + 1)}>增加 1</button>
    </div>
  );
}

有两种解决方法, 第一种就是将count加入依赖项, 当count产生变化的时候对应的effect函数也重新生成, 此时访问的就是最新的count。 第二种就可以借助useRef返回的对象引用不变的基础上, 改变current指向数值。 这个也就是useLastest的源码内容

function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;

  return ref;
}

相关代码修改为如下即可

function App() {
  const [count, setCount] = useState(0);
+  const value = useLatest(count); 
  useEffect(() => {
    const interval = setInterval(() => {
+      console.log("setInterval:", value.current);
   }, 1000);
    return () => clearInterval(interval);
  }, []);

  return (
    <div>
      count: {count}
      <br />
      <button onClick={() => setCount((val) => val + 1)}>增加 1</button>
    </div>
  );
}

State相关源码

此处介绍跟state相关的hook源码。 在我看来state相关的hook主要分为两类。

  • 一类为扩展状态变化的多样性。 比如useSetState, useToggleuseBoolean, useDebounce, useThrottleuseMapuseSet
  • 一类是与其他数据类型结合的状态管理,提供获取/更新等操作。 比如useCookieState, useLocalStorageState, useSessionStorageStateuseUrlState

useSetState

普通的useState在处理普通数据类型的时候比较方便, 但是遇上object类型就相对麻烦了毕竟值是全覆盖的。我们一般是通过setValue{...preValue, ...newState}来处理。 诶, 这个冗余的步骤直接封装到hook不就可以了。

useSetState管理 object 类型 stateHooks,用法与class组件的 this.setState 基本一致。

也就是说他返回的方法要有自动进行浅合并的功能, 且若传入回调函数时可以获取到最新的state值。那么就是将useStateset函数进行扩展即可。 注意这里使用了useCallback包一层防止函数地址变更引起不必要的重新渲染

const useSetState = <S extends Record<string, any>>(
  initialState: S | (() => S),
): [S, SetState<S>] => {
  const [state, setState] = useState<S>(initialState);  

  const setMergeState = useCallback((patch) => {
    setState((prevState) => {
      // 若为函数则传入prevState调用函数拿到newState, 否则则为传入的newState
      const newState = isFunction(patch) ? patch(prevState) : patch;  
      // 然后进行合并
      return newState ? { ...prevState, ...newState } : prevState;
    });
  }, []);

  return [state, setMergeState];
};

useToggle

用于在两个状态值间切换的Hook。 那么可以是boolean值的直接切换, 也可以是传入的两个状态值的切换。

const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue?: boolean);
const [state, { toggle, set, setLeft, setRight }] = useToggle<T>(defaultValue: T);
const [state, { toggle, set, setLeft, setRight }] = useToggle<T, U>(defaultValue: T, reverseValue: U);

可以看到传入的defaultValue默认为false, 当reverseValue不传入的话则默认取反去处理。 操作方法对象用useMemo包了一层, 也就是说, 当传入的defaultValuereverseValue变化的时候, 该hook是不理会的,始终操作初始传入的值。

function useToggle<D, R>(defaultValue: D = false as unknown as D, reverseValue?: R) {
  const [state, setState] = useState<D | R>(defaultValue);
  const actions = useMemo(() => {
    const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R;
    const toggle = () => setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue));
    const set = (value: D | R) => setState(value);
    const setLeft = () => setState(defaultValue);
    const setRight = () => setState(reverseValueOrigin);
    return {
      toggle,
      set,
      setLeft,
      setRight,
    };
  }, []);
  return [state, actions];
}

useBoolean hook其实就是基于useToggle再进行了一层封装

useCookieState

hook可以将状态存储在 Cookie 。 也就是说可以直接通过该hook去操作cookie

实现上无非就是在初始化state和更新state的两个过程, 都进行扩展

  • 初始化的时候根据cookiekeycookie拿取值或options默认值初始化state
  • 更新的时候根据传入的值顺便去更新cookie中对应的cookieKey的值
function useCookieState(cookieKey: string, options: Options = {}) {
  const [state, setState] = useState<State>(() => {
    // 通过cookieKey拿到cookie值
    const cookieValue = Cookies.get(cookieKey); 
    // 成功拿到了的话则为state的初始值
    if (isString(cookieValue)) return cookieValue;
    // 没有的话则看options是否有传入的默认值, 可以是值也可以是方法
    if (isFunction(options.defaultValue)) {
      return options.defaultValue();
    }
    return options.defaultValue;
  });
  // 注意这里又使用了useMemoizedFn, 因为传入的options是支持更新的
  const updateState = useMemoizedFn(
    (
      newValue: State | ((prevState: State) => State),
      newOptions: Cookies.CookieAttributes = {},
    ) => {
      // 获取到新传入的state值并且更新状态
      const { defaultValue, ...restOptions } = { ...options, ...newOptions };
      const value = isFunction(newValue) ? newValue(state) : newValue;
      setState(value);
      // 再根据state值去操作cookie
      if (value === undefined) {
        Cookies.remove(cookieKey);
      } else {
        Cookies.set(cookieKey, value, restOptions);
      }
    },
  );

  return [state, updateState] as const;
}

useLocalStorageState, useSessionStorageState都是差不多的思路,useUrlState则相对再麻烦一点, 它涉及React-routerqs, 且它有独立的package.json是提供以独立打包的

usePrevious

hook的作用是保存上一次的状态。那么内部就两个指针, curRef指向当前的stateprevRef指向上一次的state。 当变更产生时, 指针变化即可

function usePrevious<T>(
  state: T,
  shouldUpdate: ShouldUpdateFunc<T> = defaultShouldUpdate,
): T | undefined {
  const prevRef = useRef<T>();
  const curRef = useRef<T>();

  if (shouldUpdate(curRef.current, state)) {
    prevRef.current = curRef.current;
    curRef.current = state;
  }

  return prevRef.current;
}

Effect相关的源码

useUpdateEffect

useUpdateEffect 用法等同于 useEffect,但是会忽略首次执行,只在依赖更新时执行。

实现上重点关注首次执行即可, 其实就是需要一个flag。 首次执行时变更状态且不执行erffect。 此后放行。 该flag使用useRef去处理即可

export default createUpdateEffect(useEffect);

export const createUpdateEffect: (hook: EffectHookType) => EffectHookType =
  (hook) => (effect, deps) => {
    const isMounted = useRef(false); // flag
    // 销毁的时候, 重置状态
    hook(() => {
      return () => {
        isMounted.current = false;
      };
    }, []);

    hook(() => {
      if (!isMounted.current) {  // 首次执行的时候, 重置状态, 不处理effect
        isMounted.current = true;
      } else {
        return effect(); // 否则调用
      }
    }, deps);
  };

useUpdateLayoutEffect同理

useAsyncEffect

React中的useEffect是不支持异步函数的, 当你直接在useEffect使用async...await...的时候, 会直接抛出如下错误。 报错中也提供了建议的写法。 image.png

诶那么如果我就是觉得这样写不够优雅, 就想按照正常的effect去写呢。 此时就可以抽取相关逻辑为hook。 源码逻辑上和建议写法一样, 就是多了Generator函数的处理。 注意通过 useAsyncEffect 实现的写法没有 useEffect 返回函数中执行清除副作用的功能。原因可见 DefinitelyTyped/issues

function useAsyncEffect(
  effect: () => AsyncGenerator<void, void, void> | Promise<void>,
  deps?: DependencyList,
) {
  useEffect(() => {
    const e = effect();
    let cancelled = false;
    async function execute() {
      if (isAsyncGenerator(e)) { // 这里处理Generator函数
        while (true) {
          const result = await e.next();
          if (result.done || cancelled) {
            break;
          }
        }
      } else {
        await e; // 不是Generator函数的话就await
      }
    }
    execute();
    return () => {
      cancelled = true;
    };
  }, deps);
}

useDebounceFn

用来处理防抖函数的 Hook。关于防抖的处理使用的是loadsh提供的方法。

useDebounceuseDebounceEffect都是基于该hook实现的。 useDebounceuseState进行结合, useDebounceEffectuseEffect进行结合

import debounce from 'lodash/debounce';
function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) {
  // 拿到最新的fn
  const fnRef = useLatest(fn);
  const wait = options?.wait ?? 1000;
  const debounced = useMemo(
    () =>
      debounce(
        (...args: Parameters<T>): ReturnType<T> => {
          return fnRef.current(...args);
        },
        wait,
        options,
      ),
    [],
  );
 // 组件销毁时,取消防抖函数调用。防止造成内存泄漏
  useUnmount(() => {
    debounced.cancel();
  });

  return {
    run: debounced,
    cancel: debounced.cancel,
    flush: debounced.flush,
  };
}

useLockFn

useLockFn用于给一个异步函数增加竞态锁,防止并发执行。

要加锁的话其实就是需要有一个参数来表明状态。 当异步函数完成之前,触发该函数都直接返回不执行。 等异步函数完成之后或抛错时, 再重置状态。源码使用useRef来维护lockRef表示锁。 当lockRef.currenttrue的时候表明当前有异步函数在进行故直接返回忽略。 等异步函数完成之后再重置为false

function useLockFn<P extends any[] = any[], V = any>(fn: (...args: P) => Promise<V>) {
  const lockRef = useRef(false);

  return useCallback(
    async (...args: P) => {
      if (lockRef.current) return;
      lockRef.current = true;
      try {
        const ret = await fn(...args);
        lockRef.current = false;
        return ret;
      } catch (e) {
        lockRef.current = false;
        throw e;
      }
    },
    [fn],
  );
}

当然ahook还提供了很多各种各样的hook。在实现的过程中要注重useCallback, useMemo, useRef的应用。 注重状态的清理和变更。 其余根据需求去具体实现即可