【持续更新中】ahooks 源码阅读系列

144 阅读7分钟

1. 背景

ahooks 是一个 React 里高频使用的 hook 库,里面封装了一些比较方便 hook,比如说 useMountuseMemoizedFn 等等。停留在使用的阶段还是只知其然而不知其所以然,在需要做一些优化的场景下看到这些东西就会一脸懵逼。
因此,本篇文章希望从源码的角度来剖析一下 ahooks 内各种 hook 的实现原理,帮忙自己和大家更深刻的理解这个库。

2. 源码阅读

2.1 useMount

mount 阶段会执行一次传入的 callback,因此这里其实也就是用 useEffect 执行了一次函数(并且没有传入依赖)。

import { useEffect } from 'react';
import { isFunction } from '../utils';
import isDev from '../utils/isDev';

const useMount = (fn: () => void) => {
  if (isDev) {
    if (!isFunction(fn)) {
      console.error(
        `useMount: parameter \`fn\` expected to be a function, but got "${typeof fn}".`,
      );
    }
  }

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

export default useMount;

2.2 useMemoizedFn

useMemoizedFn 是用来解决 useCallback 的一些使用问题的,都有哪些呢?

2.2.1 useCallback 问题一:闭包问题

举个例子:

import React from 'react';

export default function App() {
  const [count, setCount] = React.useState(0);

  const updateCount = React.useCallback(() => {
    setCount(count + 1);
  }, []);

  return (
    <div className='App'>
      <div>count is: {count}</div>
      <button style={{ marginTop: 8 }} onClick={updateCount}>
        update
      </button>
    </div>
  );
}

上面这段代码,无论怎么点 update,count 的值始终都是显示的 1。 为什么呢?

  • updateCount 函数创建时,它所依赖的值是 count,此时形成了闭包。
  • 后续 rerender 时,因为 deps 这里没有传入任何值,导致 updateCount 用的还是原来的函数引用。
  • 后续如果组件有更新,那么 updateCount 还是处于在第一次渲染时的闭包上下文,也就是 count 为 0 的上下文。
2.2.1.1 传统解法一、传入 deps

最简单的解法就是传入 deps,在 count 更新之后重新创建 callback,它就会形成一个对 count 的新的闭包。

import React from 'react';

export default function App() {
  const [count, setCount] = React.useState(0);

  const updateCount = React.useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div className='App'>
      <div>count is: {count}</div>
      <button style={{ marginTop: 8 }} onClick={updateCount}>
        update
      </button>
    </div>
  );
}
2.2.1.2 传统解法二、使用 setState 回调
import React from 'react';

export default function App() {
  const [count, setCount] = React.useState(0);

  const updateCount = React.useCallback(() => {
    setCount(count => count + 1);
  }, []);

  return (
    <div className='App'>
      <div>count is: {count}</div>
      <button style={{ marginTop: 8 }} onClick={updateCount}>
        update
      </button>
    </div>
  );
}
2.2.1.3 传统解法三、使用 useRef
import React from 'react';

export default function App() {
  const [count, setCount] = React.useState(0);
  const countRef = React.useRef(0)

  const updateCount = React.useCallback(() => {
    countRef.current = countRef.current + 1
    setCount(countRef.current)
  }, []);

  return (
    <div className='App'>
      <div>count is: {count}</div>
      <button style={{ marginTop: 8 }} onClick={updateCount}>
        update
      </button>
    </div>
  );
}

这么写能解决问题主要是因为,useRef 生成的 countRef 对象是一个固定的引用,不会因为组件渲染而重新生成。

2.2.2 useCallback 问题二:deps 需要手动更新,心智负担重

RT,使用 useCallback,如果函数内出现了新的 props 或者 state,就需要手动更新到 deps 里面。带来的问题:

  • 出现函数运行的结果和预期的不一致的情况是家常便饭
  • deps 也很容易变得很长,和一些 eslint 规则如 max-line 打架。

2.2.3 useMemoizedFn 使用方法

const callback = useMemoizedFn(() => {
  // do something
})

2.2.4 原理

import { useMemo, useRef } from 'react';
import { isFunction } from '../utils';
import isDev from '../utils/isDev';

type noop = (this: any, ...args: any[]) => any;

type PickFunction<T extends noop> = (
  this: ThisParameterType<T>,
  ...args: Parameters<T>
) => ReturnType<T>;

function useMemoizedFn<T extends noop>(fn: T) {
  if (isDev) {
    if (!isFunction(fn)) {
      console.error(`useMemoizedFn expected parameter is a function, got ${typeof fn}`);
    }
  }

  const fnRef = useRef<T>(fn);

  // why not write `fnRef.current = fn`?
  // 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;
}

export default useMemoizedFn;

流程:

  1. 初始化时直接拿 fn 作为 fnRef 的初始值
  2. fnRef.current 上直接赋值为 useMemo<T>(() => fn, [fn]),只在 fn 发生变化时重新生成引用
  3. memoizedFn.current 绑定一个函数,函数内返回之前声明的 fnRef.current,并绑定 this

这么做的好处:

  1. 使用 useMemo 返回 fn,可以保证在 fn 不出现变化时引用始终是不变的;如果内部使用的 props 或者 state 出现变化,那么就会重新生成一个 fn 的引用。
  2. 使用 memoziedFn.current 返回最终结果,当函数在其他 hook 内使用时,不需要作为 deps 传入,因此不需要考虑引起 deps 发生变化。

2.2.5 对比

用法props / state 变化重新生成引用不需要关注依赖变化不需要关注闭包问题心智负担
callback⭐️
useCallback需要 deps 正确填对⭐️⭐️⭐️⭐️⭐️
useMemoizedFn⭐️⭐

2.3 useCounter

2.3.1 用法

这个 hook 封装了 count 的功能,并且可以直接设置最小最大值来对 count 结果做限制:

const [current, {
  inc,
  dec,
  set,
  reset
}] = useCounter(initialValue, { min, max });

2.3.2 原理

首先, ahooks 实现了一个 getTargetValue 的函数:

function getTargetValue(val: number, options: Options = {}) {
  const { min, max } = options;
  let target = val;
  if (isNumber(max)) {
    target = Math.min(max, target);
  }
  if (isNumber(min)) {
    target = Math.max(min, target);
  }
  return target;
}

它会限制 val 处于 min<=x<=maxmin <= x <= max 的区间内。
接着,是 useCounter 的具体实现:

function useCounter(initialValue: number = 0, options: Options = {}) {
  const { min, max } = options;

  const [current, setCurrent] = useState(() => {
    return getTargetValue(initialValue, {
      min,
      max,
    });
  });

  const setValue = (value: ValueParam) => {
    setCurrent((c) => {
      const target = isNumber(value) ? value : value(c);
      return getTargetValue(target, {
        max,
        min,
      });
    });
  };

  const inc = (delta: number = 1) => {
    setValue((c) => c + delta);
  };

  const dec = (delta: number = 1) => {
    setValue((c) => c - delta);
  };

  const set = (value: ValueParam) => {
    setValue(value);
  };

  const reset = () => {
    setValue(initialValue);
  };

  return [
    current,
    {
      inc: useMemoizedFn(inc),
      dec: useMemoizedFn(dec),
      set: useMemoizedFn(set),
      reset: useMemoizedFn(reset),
    },
  ] as const;
}

流程:

  1. 初始化 current 时调用 getTargetValue 来限制传入的值处于 min 和 max 的限制范围内
  2. 定义 inc、dec、set、reset 等函数,并且用 useMemoizedFn 包了一层
完整源码
import { useState } from 'react';
import useMemoizedFn from '../useMemoizedFn';
import { isNumber } from '../utils';

export interface Options {
  min?: number;
  max?: number;
}

export interface Actions {
  inc: (delta?: number) => void;
  dec: (delta?: number) => void;
  set: (value: number | ((c: number) => number)) => void;
  reset: () => void;
}

export type ValueParam = number | ((c: number) => number);

function getTargetValue(val: number, options: Options = {}) {
  const { min, max } = options;
  let target = val;
  if (isNumber(max)) {
    target = Math.min(max, target);
  }
  if (isNumber(min)) {
    target = Math.max(min, target);
  }
  return target;
}

function useCounter(initialValue: number = 0, options: Options = {}) {
  const { min, max } = options;

  const [current, setCurrent] = useState(() => {
    return getTargetValue(initialValue, {
      min,
      max,
    });
  });

  const setValue = (value: ValueParam) => {
    setCurrent((c) => {
      const target = isNumber(value) ? value : value(c);
      return getTargetValue(target, {
        max,
        min,
      });
    });
  };

  const inc = (delta: number = 1) => {
    setValue((c) => c + delta);
  };

  const dec = (delta: number = 1) => {
    setValue((c) => c - delta);
  };

  const set = (value: ValueParam) => {
    setValue(value);
  };

  const reset = () => {
    setValue(initialValue);
  };

  return [
    current,
    {
      inc: useMemoizedFn(inc),
      dec: useMemoizedFn(dec),
      set: useMemoizedFn(set),
      reset: useMemoizedFn(reset),
    },
  ] as const;
}

2.4 useTimeout

2.4.1 用法

用法比较简单,直接看代码即可:

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


export default () => {
  const [state, setState] = useState(1);
  useTimeout(() => {
    setState(state + 1);
  }, 3000);


  return <div>{state}</div>;
};

2.4.2 原理

import { useCallback, useEffect, useRef } from 'react';
import useMemoizedFn from '../useMemoizedFn';
import { isNumber } from '../utils';

const useTimeout = (fn: () => void, delay?: number) => {
  const timerCallback = useMemoizedFn(fn);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

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

  useEffect(() => {
    if (!isNumber(delay) || delay < 0) {
      return;
    }
    timerRef.current = setTimeout(timerCallback, delay);
    return clear;
  }, [delay]);

  return clear;
};

export default useTimeout;

流程:

  1. 传入的 fn 使用 useMemoizedFn 包一层
  2. useEffect 里执行执行 setTimeout,并且用 timerRef.current 存一份 timeout id
  3. 另外,再定义一个 clear 函数,如果调用方调用 clear 的话就可以直接清除这个定时器~

2.5 useLatest

2.5.1 用法

前面说到了,React Hook 使用过程中很容易就遇到闭包问题。ahook 提供了一个 useLatest 来避免这种情况,具体用法如下:

import React, { useState, useEffect } from 'react';
import { useLatest } from 'ahooks';


export default () => {
  const [count, setCount] = useState(0);
  const latestCountRef = useLatest(count);

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


  return (
    <>
      <p>count(useLatest): {count}</p>
    </>
  );
};

本质上,就是在每次 render 的时候都手动把当前的 state 赋值一遍给 lastestCountRef
这个 hook 其实省不了多少事,但是胜在更优雅~

2.5.2 原理

代码非常简单,直接看就行:

import { useRef } from 'react';

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

  return ref;
}

export default useLatest;

2.6 useUnmount

2.6.1 用法

这个 hook 非常简单,直接调用就行了:

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

2.6.2 原理

众所周知,useEffect 可以返回一个函数,这个函数会在组件销毁的时候调用:

image.png 因此,这个 hook 的原理也非常简单:直接在这里调用用户传入的回调函数即可~

import { useEffect } from 'react';
import useLatest from '../useLatest';
import { isFunction } from '../utils';
import isDev from '../utils/isDev';

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();
    },
    [],
  );
};

export default useUnmount;

为了避免 deps 依赖的问题,这里特意使用了 useLatest 包了一层。

2.7 useUnmountRef

2.7.1 用法

此 hook 用来获取当前组件是否已经处于销毁的状态,写法参考:

const MyComponent = () => {
  const unmountedRef = useUnmountedRef();
  useEffect(() => {
    setTimeout(() => {
      if (!unmountedRef.current) {
        message.info('component is alive');
      }
    }, 3000);
  }, []);


  return <p>Hello World!</p>;
};

2.7.2 原理

原理其实和 useUnmount 大同小异,区别就是这个 hook 只是用来获取组件是否销毁:

import { useEffect, useRef } from 'react';

const useUnmountedRef = () => {
  const unmountedRef = useRef(false);
  useEffect(() => {
    unmountedRef.current = false;
    return () => {
      unmountedRef.current = true;
    };
  }, []);
  return unmountedRef;
};

export default useUnmountedRef;

2.8 useInterval

2.8.1 用法

用法很简单,就是调 useInterval,之后在回调内正常写逻辑即可。

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


export default () => {
  const [count, setCount] = useState(0);


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


  return <div>count: {count}</div>;
};

2.8.2 原理

import { useCallback, useEffect, useRef } from 'react';
import useMemoizedFn from '../useMemoizedFn';
import { isNumber } from '../utils';

const useInterval = (fn: () => void, delay?: number, options: { immediate?: boolean } = {}) => {
  const timerCallback = useMemoizedFn(fn);
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(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 clear;
  }, [delay, options.immediate]);

  return clear;
};

export default useInterval;

流程:

  1. 传入的 fn 会包一层 useMemoizedFn
  2. hook 本身支持 传入 immediate 参数来支持立即执行
  3. timerRef.current 会存一份 interval id,在组件销毁时执行 clear

2.9 useCookieState

2.9.1 用法

这个 hook 可以用来管理 cookie

  1. 初始化时读取 cookie 的值并绑定给 state
  2. 更新时,将对应的值写入 cookie
import React from 'react';
import { useCookieState } from 'ahooks';

export default () => {
  const [message, setMessage] = useCookieState('useCookieStateString');
  return (
    <input
      value={message}
      placeholder="Please enter some words..."
      onChange={(e) => setMessage(e.target.value)}
      style={{ width: 300 }}
    />
  );
};

2.9.2 原理

import Cookies from 'js-cookie';
import { useState } from 'react';
import useMemoizedFn from '../useMemoizedFn';
import { isFunction, isString } from '../utils';

export type State = string | undefined;

export interface Options extends Cookies.CookieAttributes {
  defaultValue?: State | (() => State);
}

function useCookieState(cookieKey: string, options: Options = {}) {
  const [state, setState] = useState<State>(() => {
    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;
}

export default useCookieState;

流程如下:

  1. 初始化 state 时会执行一个函数,这里会根据传入的 cookieKey 来读取 cookie 里的值,读不到则取 options.defaultValue
  2. 定义 updateState,函数内逻辑就是:传 undefined 的时候删掉对应的 cookie,非 undefined 则对 cookie 正常进行写入。
  3. 最终返回 [state, updateState]

2.9.3 知识点

  1. useState 可以接受一个 callback 为入参,并且用它的返回值作为初始化的值。这么做两个好处:

    • 不需要再用 useEffect 来初始化一次 state,减少冗余代码
    • 可以让 hook 的设计看起来更整洁,并且传入的 callback 也只会在 mountState 的时候执行且只会执行一次
  2. as const 可以让返回的数组变成 readonly,如果尝试对它进行修改则 typescript 会进行报错。并且,我们在拿到返回值之后取 typeof values[number],两种写法(加与不加 as const)会有一些差别:

特性不加 as const加上 as const
数组类型string[]readonly ["a", "b", "c"](元组)
元素类型string(泛型)"a" | "b" | "c"(字面量类型)
可变性可以 pushpop只读,不能修改
适用场景动态数组常量、不可变数组