1. 背景
ahooks 是一个 React 里高频使用的 hook 库,里面封装了一些比较方便 hook,比如说 useMount
、useMemoizedFn
等等。停留在使用的阶段还是只知其然而不知其所以然,在需要做一些优化的场景下看到这些东西就会一脸懵逼。
因此,本篇文章希望从源码的角度来剖析一下 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;
流程:
- 初始化时直接拿
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 结果做限制:
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
处于 的区间内。
接着,是 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;
}
流程:
- 初始化 current 时调用
getTargetValue
来限制传入的值处于 min 和 max 的限制范围内 - 定义 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;
流程:
- 传入的
fn
使用useMemoizedFn
包一层 - 在
useEffect
里执行执行setTimeout
,并且用timerRef.current
存一份 timeout id - 另外,再定义一个
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
可以返回一个函数,这个函数会在组件销毁的时候调用:
因此,这个 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;
流程:
- 传入的
fn
会包一层useMemoizedFn
- hook 本身支持 传入
immediate
参数来支持立即执行 timerRef.current
会存一份 interval id,在组件销毁时执行clear
2.9 useCookieState
2.9.1 用法
这个 hook 可以用来管理 cookie
:
- 初始化时读取
cookie
的值并绑定给state
- 更新时,将对应的值写入
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;
流程如下:
- 初始化
state
时会执行一个函数,这里会根据传入的cookieKey
来读取cookie
里的值,读不到则取options.defaultValue
- 定义
updateState
,函数内逻辑就是:传undefined
的时候删掉对应的cookie
,非undefined
则对cookie
正常进行写入。 - 最终返回
[state, updateState]
2.9.3 知识点
-
useState
可以接受一个callback
为入参,并且用它的返回值作为初始化的值。这么做两个好处:- 不需要再用
useEffect
来初始化一次state
,减少冗余代码 - 可以让
hook
的设计看起来更整洁,并且传入的callback
也只会在mountState
的时候执行且只会执行一次
- 不需要再用
-
as const
可以让返回的数组变成readonly
,如果尝试对它进行修改则typescript
会进行报错。并且,我们在拿到返回值之后取typeof values[number]
,两种写法(加与不加as const
)会有一些差别:
特性 | 不加 as const | 加上 as const |
---|---|---|
数组类型 | string[] | readonly ["a", "b", "c"] (元组) |
元素类型 | string (泛型) | "a" | "b" | "c" (字面量类型) |
可变性 | 可以 push 、pop | 只读,不能修改 |
适用场景 | 动态数组 | 常量、不可变数组 |