ahooks - Effect

867 阅读8分钟

useUpdateEffect:忽略首次执行,只在依赖更新的时候执行

使用上等同 useEffect

useUpdateLayoutEffect:忽略首次执行,只在依赖更新的时候执行

使用上等同 useEffect

useUpdateEffect 和 useUpdateLayoutEffect 可以一起阅读,因为同时都使用 createUpdateEffect 来创建 hooks:

// useUpdateEffect:传递了 useEffect 来创建 hooks
export default createUpdateEffect(useEffect);

// useUpdateLayoutEffect:传递了 useLayoutEffect 来创建 hooks
export default createUpdateEffect(useLayoutEffect);

createUpdateEffect 部分:

  • 使用 isMounted 来标记是不是初始化
  • hook(() => {}, []) 为第一次加载,设置 isMounted 为 false
  • hook(() => {}, [deps]) 时,首先判断 isMounted 是不是 false,如果是则设置 isMounted 为 true,再下一次更新时,isMounted 已经是 true 了,那么判断到 isMounted 是 true 的时候,则执行 effect() 回调函数
import { useRef } from 'react';
import type { useEffect, useLayoutEffect } from 'react';

type effectHookType = typeof useEffect | typeof useLayoutEffect;

export const createUpdateEffect: (hook: effectHookType) => effectHookType =
  (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);
  };

useAsyncEffect:useEffect 的异步函数版本

  • effect 接收 generator 或者异步函数
  • 关于 generator 生成器函数(developer.mozilla.org/zh-CN/docs/…
    • yield 关键字后面的表达式值可以返回给生成器调用者
    • yield 关键字实际返回了一个迭代器对象,有两个属性 value 和 done,代表【返回值】和【是否完成】
    • 调用者使用 next() 配合使用,next() 可以无限调用
    • yield 表达式本身没有返回值,后面不接表达式值的话,即返回 undefined
    • 关于 yield 相关更深入的例子:www.jianshu.com/p/36c74e4ca…
    • developer.mozilla.org/zh-CN/docs/…
import type { DependencyList } from 'react';
import { useEffect } from 'react';

function useAsyncEffect(
  effect: () => AsyncGenerator<void, void, void> | Promise<void>,
  deps: DependencyList,
) {
  // 检查是不是 generator
  function isGenerator(
    val: AsyncGenerator<void, void, void> | Promise<void>,
  ): val is AsyncGenerator<void, void, void> {
    return typeof val[Symbol.asyncIterator] === 'function';
  }
  useEffect(() => {
    const e = effect();
    let cancelled = false;
    async function execute() {
      if (isGenerator(e)) {
        // 这里是个死循环,只要当前组件没销毁,并且生成器还未 done
        // 就可以一直执行(await e.next())
        while (true) {
          const result = await e.next();
          if (cancelled || result.done) {
            break;
          }
        }
      } else {
        // 这里是异步的情况
        await e;
      }
    }
    execute();
    return () => {
      cancelled = true;
    };
  }, deps);
}

源码可以分开几段阅读:

  • 核心部分 execute()
// 1.execute 是个异步函数,然后在外层调用
async function execute() {
  ...
}
execute();


// 2. 内部首先是个 if else 判断
// 其实就是判断是不是生成器,如果是生成器的话就走一个逻辑,是异步就走另一个逻辑
if (isGenerator(e)) { ... } else { ... }


// 3. 先来看【不是】生成器的情况,简单粗暴的 await 调用就好了
await e
    
                                  
// 4. 再来看【是】生成器的情况,是个 while 死循环                              
while (true) {
  const result = await e.next();
  if (cancelled || result.done) {
    break;
  }
}
// 把里面的 if 去掉的话,其实就是不停的去执行 next()
while (true) {
  const result = await e.next();
  ...
}
// 再来看下死循环退出的条件:
// 要不就是 cancelled 的时候退出
// 要不就是 next() 执行到返回 done 的时候推出,即生成器函数一步一步执行结束了
if (cancelled || result.done) { break; }
  • 再来看 cancenled 变量是怎么管理的,就是 useEffect return 回调函数里面,将 cancelled 标记成 true 了,说明当组件销毁的时候,会标记成需要取消
function useAsyncEffect(
  effect: () => AsyncGenerator<void, void, void> | Promise<void>,
  deps: DependencyList,
) {
  ...
  
  useEffect(() => {
    const e = effect();
    let cancelled = false;
    
    ...
    
    return () => {
      cancelled = true;
    };
  }, deps);
}

ahooks 关于 generator 的使用,给的 demo 例子描述得不太直观清晰,在原来的 demo 修改了下可以看出效果:

  • 在 input 上输入值,模拟异步检查正确性,2s 后返回输入值的正确性(length > 0 则为 true,否则为 false),在 2s 过程内,点击按钮将组件隐藏(即触发 unmounted),在 yield 语句之后 console.log,查看是否依然输出
import React, { useState } from "react";
import { useAsyncEffect } from "ahooks";

function mockCheck(val: string): Promise<boolean> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(val.length > 0);
    }, 2000);
  });
}

const Test = () => {
  const [value, setValue] = useState("");
  const [pass, setPass] = useState<boolean>(null);

  useAsyncEffect(
    async function* () {
      setPass(null);
      const result = await mockCheck(value);
      yield; // Check whether the effect is still valid, if it is has been cleaned up, stop at here.
      console.log('?????')
      setPass(result);
    },
    [value]
  );

  return (
    <div>
      <input
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
        }}
      />
      <p>
        {pass === null && "Checking..."}
        {pass === false && "Check failed."}
        {pass === true && "Check passed."}
      </p>
    </div>
  );
};

export default () => {
  const [show, setShow] = useState<boolean>(true);
  return (
    <>
      <button onClick={() => setShow(false)}>unmounted it</button>
      {show && <Test />}
    </>
  );
};
  • 测试可以看到,2s 内如果将组件隐藏,yield 后的语句是不会执行的

useDebounceEffect:为 useEffect 增加防抖

  • 依赖 useDebounceFn,返回一个防抖函数
  • 依赖 useUpdateEffect,当某个 deps 变化的时候才执行,忽略第一次加载执行
  • 整体思路就是使用一个 flag 标记,针对 flag set 值进行防抖,当传入的 deps 更新变动的时候,触发 flag 更新的防抖函数。因为 flag 的更新是防抖过的,所以使用 useUpdateEffect 监听 flag 的变化,当 flag 值变化的时候,才触发传入进来的 effect 回调函数
import { useEffect, useState } from 'react';
import type { DependencyList, EffectCallback } from 'react';
import type { DebounceOptions } from '../useDebounce/debounceOptions';
import useDebounceFn from '../useDebounceFn';
import useUnmount from '../useUnmount';
import useUpdateEffect from '../useUpdateEffect';

function useDebounceEffect(
  effect: EffectCallback,
  deps?: DependencyList,
  options?: DebounceOptions,
) {
  const [flag, setFlag] = useState({});

  // 触发 flag 更新的防抖函数
  const { run, cancel } = useDebounceFn(() => {
    setFlag({});
  }, options);

  // deps 依赖变化的时候,触发 flag 的防抖更新函数
  useEffect(() => {
    return run();
  }, deps);

  useUnmount(cancel);

  // flag 的更新是防抖过的,所以 effect 的触发也是
  useUpdateEffect(effect, [flag]);
}

useDebounceFn:处理函数的防抖

是 useDebounceEffect 与 useDebounce 的基底 hooks

意外的惊喜,对于 wait 参数的赋值,使用了 ?? 运算符 developer.mozilla.org/en-US/docs/…

  • || 运算符,当为 null / undefined / 0 / false / '' 时都会命中右值
  • ?? 运算符,只有当 null 或者 undefined 才会命中右值
const wait = options?.wait ?? 1000;

进入正题:

  • 使用的是 lodash 的 debounce:lodash.com/docs/4.17.1…,options 参数实则 lodash debounce 的 options 参数,wait 参数也是 lodash 支持的参数(暂不深究 lodash debounce 源码)
  • 传入 fn 回调函数,经过 lodash debounce 包装后,返回防抖后的函数,lodash debounce 返回的防抖函数还另外附赠了两个功能:cancel 为取消该防抖函数,flush 为立即执行该函数
interface DebouncedFunc<T extends (...args: any[]) => any> {
        /**
         * Call the original function, but applying the debounce rules.
         *
         * If the debounced function can be run immediately, this calls it and returns its return
         * value.
         *
         * Otherwise, it returns the return value of the last invocation, or undefined if the debounced
         * function was not invoked yet.
         */
        (...args: Parameters<T>): ReturnType<T> | undefined;

        /**
         * Throw away any pending invocation of the debounced function.
         */
        cancel(): void;

        /**
         * If there is a pending invocation of the debounced function, invoke it immediately and return
         * its return value.
         *
         * Otherwise, return the value from the last invocation, or undefined if the debounced function
         * was never invoked.
         */
        flush(): ReturnType<T> | undefined;
}
  • 有了以上前提,可以看到该 hooks 返回了几个基础操作,run 为 经过 lodash debounce 包装后的防抖函数,同时再把该防抖函数的 cancel 与 flush 函数暴露出来。同时增加钩子 useUnmount 在注销时,取消该函数防抖
import debounce from 'lodash/debounce';
import { useMemo } from 'react';
import type { DebounceOptions } from '../useDebounce/debounceOptions';
import useLatest from '../useLatest';
import useUnmount from '../useUnmount';

type noop = (...args: any) => any;

function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) {
  if (process.env.NODE_ENV === 'development') {
    if (typeof fn !== 'function') {
      console.error(`useDebounceFn expected parameter is a function, got ${typeof fn}`);
    }
  }

  const fnRef = useLatest(fn);

  const wait = options?.wait ?? 1000;

  const debounced = useMemo(
    () =>
      debounce<T>(
        ((...args: any[]) => {
          return fnRef.current(...args);
        }) as T,
        wait,
        options,
      ),
    [],
  );

  useUnmount(() => {
    debounced.cancel();
  });

  return {
    run: debounced as unknown as T,
    cancel: debounced.cancel,
    flush: debounced.flush,
  };
}

useThrottleFn:处理函数的节流

是 useThrottle 与 useThrottleEffect 的基底 hooks,整体流程大致与 debounce 是一样的

  • 使用的是 lodash 的 throttle lodash.com/docs/4.17.1…,options 参数实则 lodash throttle 的 options 参数,wait 参数也是 lodash 支持的参数(暂不深究 lodash throttle 源码)
  • 传入 fn 回调函数,经过 lodash throttle 包装后,返回节流后的函数,lodash throttle 返回的节流函数还另外附赠了两个功能:cancel 为取消该节流函数,flush 为立即执行该函数
  • throttle 返回的是 DebouncedFunc 类型,与 debounce 返回值类型相同
throttle<T extends (...args: any) => any>(func: T, wait?: number, options?: ThrottleSettings): DebouncedFunc<T>;
  • 有了以上前提,可以看到该 hooks 返回了几个基础操作,run 为 经过 lodash throttle 包装后的节流函数,同时再把该节流函数的 cancel 与 flush 函数暴露出来。同时增加钩子 useUnmount 在注销时,取消该函数节流
import throttle from 'lodash/throttle';
import { useMemo } from 'react';
import useLatest from '../useLatest';
import type { ThrottleOptions } from '../useThrottle/throttleOptions';
import useUnmount from '../useUnmount';

type noop = (...args: any) => any;

function useThrottleFn<T extends noop>(fn: T, options?: ThrottleOptions) {
  if (process.env.NODE_ENV === 'development') {
    if (typeof fn !== 'function') {
      console.error(`useThrottleFn expected parameter is a function, got ${typeof fn}`);
    }
  }

  const fnRef = useLatest(fn);

  const wait = options?.wait ?? 1000;

  const throttled = useMemo(
    () =>
      throttle<T>(
        ((...args: any[]) => {
          return fnRef.current(...args);
        }) as T,
        wait,
        options,
      ),
    [],
  );

  useUnmount(() => {
    throttled.cancel();
  });

  return {
    run: throttled as unknown as T,
    cancel: throttled.cancel,
    flush: throttled.flush,
  };
}

useThrottleEffect:为 useEffect 增加节流

  • 依赖 useThrottleFn,返回一个防抖函数
  • 依赖 useUpdateEffect,当某个 deps 变化的时候才执行,忽略第一次加载执行
  • 整体思路就是使用一个 flag 标记,针对 flag set 值进行节流,当传入的 deps 更新变动的时候,触发 flag 更新的节流函数。因为 flag 的更新是节流过的,所以使用 useUpdateEffect 监听 flag 的变化,当 flag 值变化的时候,才触发传入进来的 effect 回调函数
import { useEffect, useState } from 'react';
import type { DependencyList, EffectCallback } from 'react';
import type { ThrottleOptions } from '../useThrottle/throttleOptions';
import useThrottleFn from '../useThrottleFn';
import useUnmount from '../useUnmount';
import useUpdateEffect from '../useUpdateEffect';

function useThrottleEffect(
  effect: EffectCallback,
  deps?: DependencyList,
  options?: ThrottleOptions,
) {
  const [flag, setFlag] = useState({});

  // 触发 flag 更新的节流函数
  const { run, cancel } = useThrottleFn(() => {
    setFlag({});
  }, options);

  // 当传入的 deps 更新时,触发 flag 更新节流函数
  useEffect(() => {
    return run();
  }, deps);

  useUnmount(cancel);

  // 当 flag 更新时,触发 effect 回调函数,flag 的更新是节流过的
  useUpdateEffect(effect, [flag]);
}

useDeepCompareEffect:deps 比较不一致,才触发的 useEffect

  • 使用的是 lodash isEqual 进行判断:lodash.com/docs/4.17.1…
  • 整个 deps 列表不一样,才会触发 effect 回调函数
  • 使用 signalRef 标记是否更新,如果有的话则 +1
  • useEffect 监听 signalRef.current 的变化,如果变化了,才触发 effect
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]);
};

useInterval:setInterval

针对 setInterval 的封装,动态修改 delay 值可以设置时间间隔变化,或者取消计时器

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

function useInterval(
  fn: () => void,
  delay: number | undefined,
  options?: {
    immediate?: boolean;
  },
) {
  const immediate = options?.immediate;

  const fnRef = useLatest(fn);

  useEffect(() => {
    // 如果是 false 或者 undefined 则取消计时器
    if (typeof delay !== 'number' || delay <= 0) return;
    
    // 是否在注册时立即执行
    if (immediate) {
      fnRef.current();
    }
    
    // 计时器
    const timer = setInterval(() => {
      fnRef.current();
    }, delay);
    
    // 注销时,取消计时器
    return () => {
      clearInterval(timer);
    };
  }, [delay]); // 监听 delay 值的变化
}

useTimeout:setTimeout

针对 setInterval 的封装,动态修改 delay 值可以设置时间间隔变化,或者取消 setTimeout

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

function useTimeout(fn: () => void, delay: number | undefined): void {
  const fnRef = useLatest(fn);

  useEffect(() => {
    // 取消
    if (typeof delay !== 'number' || delay <= 0) return;
    
    // 计时器
    const timer = setTimeout(() => {
      fnRef.current();
    }, delay);
    
    // 注销取消
    return () => {
      clearTimeout(timer);
    };
  }, [delay]); // 监听 delay 变化
}

useLockFn:给异步函数增加竞态锁,防止并发执行

思路也比较简单,使用 lockRef 来记录是否在执行

import { useRef, useCallback } from 'react';

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

  return useCallback(
    async (...args: P) => {
      // 如果在执行,直接 return
      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],
  );
}

useUpdate:强制刷新组件重新渲染

设置了个空的 state,每次都强行设置 state,就能触发更新

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

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