【持续更新中】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;
}
相关推荐
mldong4 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排4 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60615 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角6 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!6 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰6 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi8 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen8 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马9 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒9 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端