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

1. 背景

ahooks 是一个 React 里高频使用的 hook 库,里面封装了一些比较方便 hook,比如说 useMountuseMemoizedFn 等等。停留在使用的阶段还是只知其然而不知其所以然,在需要做一些优化的场景下看到这些东西就会一脸懵逼。

因此,本篇文章希望从源码的角度来剖析一下 ahooks 内各种 hook 的实现原理,帮忙自己和大家更深刻的理解这个库。

2. 源码阅读

2.1 useMount

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

typescript 复制代码
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 问题一:闭包问题

举个例子:

typescript 复制代码
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 的新的闭包。

typescript 复制代码
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 回调
typescript 复制代码
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
typescript 复制代码
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 使用方法

typescript 复制代码
const callback = useMemoizedFn(() => {
  // do something
})

2.2.4 原理

typescript 复制代码
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 结果做限制:

typescript 复制代码
const [current, {
  inc,
  dec,
  set,
  reset
}] = useCounter(initialValue, { min, max });

2.3.2 原理

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

typescript 复制代码
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 处于 m i n < = x < = m a x min <= x <= max min<=x<=max 的区间内。

接着,是 useCounter 的具体实现:

typescript 复制代码
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 包了一层

完整源码

typescript 复制代码
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;
}
相关推荐
Pedantic1 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘1 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆2 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
YFF菲菲兔3 小时前
调度系统和调和系统的桥梁
react.js
浏览器工程师3 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆3 小时前
VSCode自动格式化三要素
前端
爱勇宝3 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen4 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518136 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端