小肚教你封装hook:ahooks 的工具类 hook 原理解析

398 阅读12分钟

本系列,小肚带您一起读一下 ahooks 的源码,理解 react hook 的封装。

本期是开胃小菜,讲一下常用工具 hook 的封装。

useLatest

返回当前最新值的 Hook,可以避免一些闭包问题(一些深层组件的闭包还是需要特殊处理)。

还记得在 react 开发过程中,是不是遇到过在定时器或者事件的回调函数中拿不到组件最新的 state 的情况?

useLatest 帮你解决。

使用

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

// 包裹 state
const latestCountRef = useLatest(count);

useEffect(() => {
    const interval = setInterval(() => {
      setCount(latestCountRef.current + 1);
    }, 1000);
    return () => clearInterval(interval);
}, []);

原理

从使用上想必大家也看出来的,有 current,那必然是有 useRef 呀!没错,useLatest 就是把 useRef 包了一层:

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

  return ref;
}

当 count 变化时,会触发组件从顶向下刷新,useLatest 就会执行一次,ref.current 就会刷新了。所以这里 useLatest 内部接受的最好是一个 state,这样他在更新时组件才能够重渲染。

是不是很简单,封装本身没有什么难的,就是把平时经常出现的功能提取出来而已。

useCreation

useCreation 是 useMemo 或 useRef 的替代品。

官方的解释是 useMemo 可能在用户电脑释放内存或者滚动到屏幕外后忘记曾经缓存的值而再次渲染,而 useRef 又容易出现潜在的性能隐患:

const a = useRef(new Subject()); // 每次重渲染,都会执行实例化 Subject 的过程,即便这个实例立刻就被扔掉了
const b = useCreation(() => new Subject(), []); // 通过 factory 函数,可以避免性能隐患

说白了,useCreation 是一个性能有所提升的缓存hook。

使用

import React, { useState } from 'react';
import { useCreation } from 'ahooks';

class Foo {
  constructor() {
    this.data = Math.random();
  }

  data: number;
}

export default function () {
  const foo = useCreation(() => new Foo(), []);
  const [, setFlag] = useState({});
  return (
    <>
      <p>{foo.data}</p>
      <button
        type="button"
        onClick={() => {
          setFlag({});
        }}
      >
        Rerender
      </button>
    </>
  );
}

在疯狂点击 setFlag 从而引起整个组件重渲染时,useCreation 里的 new Foo() 只执行一次。因而 foo.data 的值不会变化。

原理

我们看看他的源码,到底比 useRef 强在哪里。

export default function useCreation<T>(factory: () => T, deps: DependencyList) {
  const { current } = useRef({
    deps,
    obj: undefined as undefined | T,
    initialized: false,
  });
  if (current.initialized === false || !depsAreSame(current.deps, deps)) {
    current.deps = deps;
    current.obj = factory();
    current.initialized = true;
  }
  return current.obj as T;
}

其实他内部还是一个 useRef,只不过自己实现了一个依赖变更检测,当非初始化或者依赖变动时才触发回调。外部组件重渲染,进入到 useCreation 后,检测到依赖没变化就不会重新触发回调。

关键点在如何正确检测依赖变化?

export default function depsAreSame(oldDeps: DependencyList, deps: DependencyList): boolean {
  if (oldDeps === deps) return true;
  for (let i = 0; i < oldDeps.length; i++) {
    if (!Object.is(oldDeps[i], deps[i])) return false;
  }
  return true;
}

他接受上一次的deps和最新变化的deps

如果 === 了,那就一定是一样的(因为依赖是两个数组,比较的是他们的引用),说明这次渲染不是 deps 变化引起的。

否则的话,就遍历旧的依赖,通过 Object.is 比较对应位置上的以来是否一样 (新旧依赖的长度一定是一样的)。

Object.is 怎么就能比较了呢?要知道 React 内部比较依赖也是使用 Object.is 的,它用于比较两个值的严格相等:

值1值2===Object.is
0-0truefalse
NaNNaNfalsetrue
[][]falsefalse
1'1'falsefalse
对象1对象2引用相同,则 true引用相同,则 true

思考:这么看来,既然能保证 deps 是数组,使用 === 不是和 Object.is 一样了么?这里为什么不用 === 呢?

useMount

用于检测组件首次挂载动作。虽然函数式组件没有了生命周期,但页面初始化动作和之后的渲染动作往往处理的逻辑是不一样的,还是需要有挂载这个动作的。

使用

useMount(() => {
    message.info('mount');
});

原理

const useMount = (fn: () => void) => {
  // 校验fn必须是一个回调函数
  if (isDev) {
    if (!isFunction(fn)) {
      console.error(
        `useMount: parameter \`fn\` expected to be a function, but got "${typeof fn}".`,
      );
    }
  }

  useEffect(() => {
    fn?.();
  }, []);
};

一个简单的封装,就显得代码比较整洁,使用统一

useUnmount

在组件卸载时执行的 Hook。

使用

useUnmount(() => {
    message.info('unmount');
});

原理

const useUnmount = (fn: () => void) => {
  if (isDev) {
    if (!isFunction(fn)) {
      console.error(`useUnmount expected parameter is a function, got ${typeof fn}`);
    }
  }

  const fnRef = useLatest(fn);

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

注意,这里 fn 使用 useLatest 解闭包了,因为这时 state 里一般有值了,需要拿最新的状态;而在初始化时不需要。

useDeepCompareEffect

使用 react-fast-compare 进行深比较的 useEffect。

使用

看这个例子

 useEffect(() => {
    effectCountRef.current += 1;
  }, [{}]);

  useDeepCompareEffect(() => {
    deepCompareCountRef.current += 1;
    return () => {
      // do something
    };
  }, [{}]);

当组件重渲染时,由于 {} 每次都会分配一个内存引用,所以 useEffect 每次都会触发,而 useDeepCompareEffect 则只会触发一次。

原理

import { useEffect } from 'react';
import { createDeepCompareEffect } from '../createDeepCompareEffect';

export default createDeepCompareEffect(useEffect);

具体实现 在 createDeepCompareEffect:

import { useRef } from 'react';
import type { DependencyList, useEffect, useLayoutEffect } from 'react';
import { depsEqual } from '../utils/depsEqual';

type EffectHookType = typeof useEffect | typeof useLayoutEffect;
type CreateUpdateEffect = (hook: EffectHookType) => EffectHookType;

// hook 传入的是 useEffect
export const createDeepCompareEffect: CreateUpdateEffect = (hook) => (effect, deps) => {
  const ref = useRef<DependencyList>();
  const signalRef = useRef<number>(0);

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

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

其维护两个 ref,一个存放 deps,一个存放比较计数。deps变化时 ref 更新。hook 的依赖放的是计数,只有当计数变化时才会触发 hook

当 deps 变化时,如果没有 deps,计数+1,触发 hook 的更新;如果有 deps,则 depsEqual 来比较新旧依赖,如果不一致则计数+1。这就比 useEffect 多了一个判断,可以避免很多的渲染浪费。

最后看一下 depsEqual 的实现:

import isEqual from 'react-fast-compare';

export const depsEqual = (aDeps: DependencyList = [], bDeps: DependencyList = []) =>
  isEqual(aDeps, bDeps);

image.png

useToggle

用于在两个状态之间切换的 Hook。很适合页面元素显示隐藏、表单二元开关等。

使用

export default () => {
  const [state, { toggle, set, setLeft, setRight }] = useToggle('Hello', 'World');

  return (
    <div>
      <p>Effects:{state}</p>
      <p>
        <button type="button" onClick={toggle}>
          Toggle
        </button>
        <button type="button" onClick={() => set('Hello')} style={{ margin: '0 8px' }}>
          Set Hello
        </button>
        <button type="button" onClick={() => set('World')}>
          Set World
        </button>
        <button type="button" onClick={setLeft} style={{ margin: '0 8px' }}>
          Set Left
        </button>
        <button type="button" onClick={setRight}>
          Set Right
        </button>
      </p>
    </div>
  );
};

image.png

接受两个可选参数,不传默认是 false、true;state 是当前的值,默认是第一个参数;setLeft, setRight 可以手动操作结果;toggle 就是自动取反切换值。

原理

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];
}

街火速一个默认值,一个反转值;反转值不传,默认是 !默认值;toggle 内部判断当前 state 不是 默认值,就设置翻转,否则就设置默认;其还提供了一个 set 方法便于设置任何一个用户想要的值来提高可扩展性。

注意,封装组件内部都需要使用 useMemo、useCallback 等缓存函数包裹,避免业务上使用时产生不必要的重渲染。

useBoolean

切换 boolean,可以接收默认值

可参考 useToggle:

useBoolean(defaultValue = false) {
   const [state, { toggle, set }] = useToggle(!!defaultValue)
}

useSetState

这个组件很有用,他在函数式组件里实现了用法与 class 组件的 this.setState 基本一致的功能,对象自动合并。

P.S. 要是提供设置完后的回调就更好了😁。

使用

export default () => {
  const [state, setState] = useSetState({
    hello: '',
  });

  return (
    <div>
      <pre>{JSON.stringify(state, null, 2)}</pre>
      <p>
        <button type="button" onClick={() => setState({ hello: 'world' })}>
          set hello
        </button>
        <button type="button" onClick={() => setState({ foo: 'bar' })} style={{ margin: '0 8px' }}>
          set foo
        </button>
      </p>
    </div>
  );
};

点击 set foo 按钮后,state 变为 { hello: '', foo: 'bar' }

同时还支持回调设置:setState((prev) => ({ count: prev.count + 1 }))

原理

来看一下源码吧

export const isFunction = (value: unknown): value is (...args: any) => any =>
  typeof value === 'function';
  
const useSetState = <S extends Record<string, any>>(
  initialState: S | (() => S),
): [S, SetState<S>] => {
  const [state, setState] = useState<S>(initialState);

  const setMergeState = useMemoizedFn((patch) => {
    setState((prevState) => {
      const newState = isFunction(patch) ? patch(prevState) : patch;
      return newState ? { ...prevState, ...newState } : prevState;
    });
  });

  return [state, setMergeState];
};

判断传入的 patch isFunction 时(表示传入的是回调函数),就调用 patch(prevState),返回其执行结果,否则就传 patch 本身。最后再返回合并后的对象,这个函数不难理解。

其中 useMemoizedFn 可以理解为 useMemo + useRef 的高级版,一个缓存,用于将这个箭头函数 (返回的 setState) 使用 useMemo 包裹后给到 useRef 的值, 确保函数不会在每次渲染时被重新创建:

function useMemoizedFn<T extends noop>(fn: T) {
  const fnRef = useRef<T>(fn);

  // why not write `fnRef.current = fn`? 这里为什么要用 useMemo 包一下,还在讨论中
  // https://github.com/alibaba/hooks/issues/728
  fnRef.current = useMemo<T>(() => 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;
}

中间为什么要使用 useMemo 包裹,issue 里有个相对好一些的解释:

image.png

那么理论上生产环境下,useMemo 是可以不要的。

useCookieState

一个可以将状态存储在 Cookie 中的 Hook,它使用了 js-cookie 这个第三方库。

使用

export default function App() {
  const [value, setValue] = useCookieState('useCookieStateOptions', {
    defaultValue: '0',
    path: '/',
    expires: (() => new Date(+new Date() + 10000))(),
  });

  return (
    <>
      <p>{value}</p>
      <button
        type="button"
        style={{ marginRight: 16 }}
        onClick={() =>
          setValue((v) => String(Number(v) + 1), {
            expires: (() => new Date(+new Date() + 10000))(),
          })
        }
      >
        inc + (10s expires)
      </button>
      <button
        type="button"
        style={{ marginRight: 16 }}
        onClick={() =>
          setValue((v) => String(Number(v) - 1), {
            expires: (() => new Date(+new Date() + 10000))(),
          })
        }
      >
        dec - (10s expires)
      </button>
      <button type="button" onClick={() => setValue('0')}>
        reset
      </button>
    </>
  );
}

我们实际跑一下这个例子。

刚初始化时是没有 cookie 生成的:

image.png

当点击按钮执行 setValue 时:

image.png

有一个叫做 useCookieStateOptions 的 cookie, 有效期 10s。

他的配置项可以接受配置属性:默认值、有效时间、路径、域名、协议、跨域等。

原理

import Cookies from 'js-cookie';

function useCookieState(cookieKey: string, options: Options = {}) {
  const [state, setState] = useState<State>(() => {
    // 通过 key 获取 cookie 的值
    const cookieValue = Cookies.get(cookieKey);

    if (isString(cookieValue)) return cookieValue;

    if (isFunction(options.defaultValue)) {
      return options.defaultValue();
    }

    return options.defaultValue;
  });

  // 设置函数
  const updateState = useMemoizedFn(
    (
      newValue: State | ((prevState: State) => State),
      newOptions: Cookies.CookieAttributes = {},
    ) => {
      const { defaultValue, ...restOptions } = { ...options, ...newOptions };
      const value = isFunction(newValue) ? newValue(state) : newValue;

      setState(value);

      if (value === undefined) {
        Cookies.remove(cookieKey);
      } else {
        Cookies.set(cookieKey, value, restOptions);
      }
    },
  );

  return [state, updateState] as const;
}

其本质是将 state 的值存在 cookie 里,使用的时候直接获取,设置时同步更新 cookie 和 state,设置时可以穿入options来控制 cookie 的属性。

usePrevious

用于记录 hook 上一次的状态。

使用

const [count, setCount] = useState(0);
const previous = usePrevious(count);

当 count 变化时,previous 总比 count 慢一个渲染周期,显示上一次的数据。

原理

const defaultShouldUpdate = <T>(a?: T, b?: T) => !Object.is(a, b);

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;
}

可以看到,当 state 变化时,会校验前后两个值是否一样,若不一样,进入 if 判断语句,记录变化前的值到 prevRef 中,并返回其值。

useUpdateEffect

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

我们就不看使用了,直接看原理。

原理

export const createUpdateEffect =
  (hook) => (effect, deps) => {
    const isMounted = useRef(false);

    // for react-refresh
    hook(() => {
      return () => {
        isMounted.current = false;
      };
    }, []);

    hook(() => {
      if (!isMounted.current) {
        isMounted.current = true;
      } else {
        return effect();
      }
    }, deps);
  };
  
export default createUpdateEffect(useEffect);

其实内部就是维护了一个 isMounted,来控制是否是初次加载。

useAsyncEffect

使useEffect 支持异步函数。用在一些前置异步数据加载后才能渲染页面的场合。

使用

  useAsyncEffect(async () => {
    setPass(await mockCheck());
  }, []);

原理

function useAsyncEffect(
  effect,
  deps,
) {
  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;
      }
    }
    execute();
    return () => {
      cancelled = true;
    };
  }, deps);
}

其中 判断是否是异步生成器(Generator函数),使用了 Symbol,Generator函数内部都有一个 Symbol.asyncIterator 的函数:

function isAsyncGenerator(val) {
  return isFunction(val[Symbol.asyncIterator]);
}

可以看到,其内部定义了一个异步函数 execute,并在全局环境下执行,execute 的执行是会被 await 阻塞的,间接地实现了 await 的功能。

注意这一行:

const e = effect();

只有异步函数才能这样写,否则的话就直接执行这个函数了:

image.png

useThrottleFn / useDebounceFn

应用

我们先看一下 ahooks 的 useDebounce 的使用

1.gif

使用示例代码:

import React, { useState } from 'react';
import { useDebounce } from 'ahooks';

export default () => {
  const [value, setValue] = useState<string>();
  const debouncedValue = useDebounce(value, { wait: 500 });

  return (
    <div>
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="Typed value"
        style={{ width: 280 }}
      />
      <p style={{ marginTop: 16 }}>DebouncedValue: {debouncedValue}</p>
    </div>
  );
};

其中 option:

image.png

可以看到它的使用方式:输入框改变的时候,state的设置还是实时的,只是用useDebounce包裹一下,返回一个延迟的debouncedValue,之后的页面渲染用debouncedValue就可以节省渲染的成本了。

原理

去源码里找到 useDebounce 的文件夹下的 index.ts:

function useDebounce<T>(value: T, options?: DebounceOptions) {
  const [debounced, setDebounced] = useState(value);

  const { run } = useDebounceFn(() => {
    setDebounced(value);
  }, options);

  useEffect(() => {
    run();
  }, [value]);

  return debounced;
}

使用 useDebounceFn 包裹,其返回一个 run, 当值变化时,触发 run 的调用。

进入 useDebounceFn查看:

function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) {
  if (isDev) {
    if (!isFunction(fn)) {
      console.error(`useDebounceFn expected parameter is a function, got ${typeof 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,
  };
}

useDebounceEffect

为 useEffect 增加防抖的能力。其内部使用 useDebounceFn 作为核心逻辑。

使用

useDebounceEffect(() => {
    // fn
  },
  [value],
  { wait: 1000},
);

在 value 变化时,等待 1000ms 后执行回调函数。

原理

function useDebounceEffect(
  effect,
  deps,
  options,
) {
  const [flag, setFlag] = useState({});

  const { run } = useDebounceFn(() => {
    setFlag({});
  }, options);

  useEffect(() => {
    return run();
  }, deps);

  useUpdateEffect(effect, [flag]);
}

在 deps 变化时,执行 13 行的逻辑,run 函数被调用,自然就延迟来设置 flag,flag 变化后,触发 useUpdateEffect 来执行回调函数。

思考:为什么不把 effect 函数直接放在 useDebounceFn 中,而是又用 flag 过渡一层呢:const { run } = useDebounceFn(effect, options);

useInterval

一个可以处理 setInterval 的 Hook。

使用

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

  useInterval(() => {
    setCount(count + 1);
  }, 1000);

原理

const useInterval = (fn, delay, options = {}) => {
  const timerCallback = useMemoizedFn(fn);
  const timerRef = useRef(null);

  const clear = useCallback(() => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
    }
  }, []);

  useEffect(() => {
    if (!isNumber(delay) || delay < 0) {
      return;
    }
    if (options.immediate) {
      timerCallback();
    }
    timerRef.current = setInterval(timerCallback, delay);
    
    // 这个return 有啥用?没看懂
    return clear;
  }, [delay, options.immediate]);

  return clear;
};

在初始化渲染时,就启动了一个定时器,可选参数immediate表示立即执行一遍,然后再创建定时器。 在调用 clear 时清理定时器。

useLockFn

用于给一个异步函数增加竞态锁,防止并发执行。可以用于提交表单的按钮、调用 API 的按钮等。

使用

  const submit = useLockFn(async () => {
    message.info('Start to submit');
    await mockApiRequest();
    setCount((val) => val + 1);
    message.success('Submit finished');
  });

一般接受一个异步函数,在函数请求阻塞期间,再次调用 submit 函数便不会响应。

原理

function useLockFn(fn) {
  const lockRef = useRef(false);

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

其实原理很简单,在其内部维护一个 lockRef,在第一次请求的时候,lockRef 是 false,进入到 fn 的调用中,并将 lockRef 置为 true;在请求没有释放时,再次请求就会被拦截,直到 lockRef 变为 false。

笔者认为,调用 useLockFn 时,其内部的异步函数若不是一个静态函数,是否也应该 useCallback 包裹一下?不然 useLockFn 里返回的 useCallback 往往会失去作用

useUpdate

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

使用

const update = useUpdate();

原理

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

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

写法很简单,只需要在 hook 顶层声明一个 state,并在调用返回函数时调用一下 setState,这样 state 变了,就会触发父组件的重新渲染。

在 React 中,组件的重新渲染是由状态(state)或属性(props)的变化触发的。调用 update 后会导致 useUpdate 重新渲染,在 React 目前的监测机制中,认为是 调用 useUpdate 的组件的内部状态变化了,所以会触发其重渲染。

若你将 useState 改为 useRef,则不会触发其重渲染。