【持续更新中】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 处于 <math xmlns="http://www.w3.org/1998/Math/MathML"> m i n < = x < = m a x min <= x <= max </math>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;
}
相关推荐
HaanLen1 分钟前
React19源码系列之Hooks(useRef)
javascript·react.js
前端大白话1 分钟前
React 中shouldComponentUpdate生命周期方法的作用,如何利用它优化组件性能?
react.js
渔樵江渚上1 分钟前
再谈H5首页白屏时间太久问题优化
前端·javascript·面试
凉生阿新2 分钟前
【React】基于 React+Tailwind 的 EmojiPicker 选择器组件
前端·react.js·前端框架
公子小六7 分钟前
ASP.NET Core WebApi+React UI开发入门详解
react.js·ui·c#·asp.net·.netcore
James5069 分钟前
Ubuntu平台下安装Node相关环境
前端·javascript·vue.js·node·yarn·pm2·nvm
修复bug41 分钟前
Uniapp自定义TabBar组件全封装实践与疑难问题解决方案
前端·javascript·vue.js·uni-app·开源
Riesenzahn1 小时前
为什么说css的选择器一般不要超过三级?
前端·javascript
喝西瓜汁的兔叽Yan1 小时前
自定义指令--【v-lockScroll】用来锁定滚动条
前端
学渣y1 小时前
React-响应事件
前端·javascript·react.js