1. 背景
ahooks 是一个 React 里高频使用的 hook 库,里面封装了一些比较方便 hook,比如说 useMount
、useMemoizedFn
等等。停留在使用的阶段还是只知其然而不知其所以然,在需要做一些优化的场景下看到这些东西就会一脸懵逼。
因此,本篇文章希望从源码的角度来剖析一下 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;
流程:
- 初始化时直接拿
fn
作为fnRef
的初始值 fnRef.current
上直接赋值为useMemo<T>(() => fn, [fn])
,只在fn
发生变化时重新生成引用memoizedFn.current
绑定一个函数,函数内返回之前声明的fnRef.current
,并绑定this
这么做的好处:
- 使用
useMemo
返回fn
,可以保证在fn
不出现变化时引用始终是不变的;如果内部使用的props
或者state
出现变化,那么就会重新生成一个fn
的引用。 - 使用
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;
}
流程:
- 初始化 current 时调用
getTargetValue
来限制传入的值处于 min 和 max 的限制范围内 - 定义 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;
}