背景
最近想多找一找封装React hook
的感觉。 要了解hook
能够, 适合去处理什么事情, 怎么去处理事情。
嘿, 这里不妨直接看业界大佬开源的ahooks
处理,提供了什么hook
以及该hook
是如何实现的。
常用基础hook
在开发中其实也经常用到的一些hook
, 包括ahook
的很多hook
源码中都涉及到了。 这一节主要理解比较常见, 基础的hook
的源码
useMemoizedFn
useMemoizedFn
的作用是持久化function
。 保证函数地址永远不会变化。
ahook
的输出函数规范中: ahooks
所有的输出函数,地址都是不会变化的。
那么如何实现呢, 首先我们想到的应该就是useCallback
去做一层缓存。但是单纯使用useCallback
能力是有限的, 当useCallback
的依赖项更新的时候, 地址也会发生变化。故可以分成以下两种情况:
- 返回的函数无需关注后续其他状态的更新,也就是说没有其他依赖项, 那么直接使用
useCallback
- 返回的函数需要关注其他状态的更新, 那么使用
useMemoizedFn
至于前者能不能使用后者的方法呢, 当然是可以的。 只不过又多了消耗, 没有这个必要
在实现上要实现传入的函数可变, 传出的函数地址不变, 那么就至少需要两个变量。
- 一个来控制“变”: 始终接受传入的新的函数, 使执行的时候能够执行新的函数的内容。源码中使用的是
fnRef
- 一个来控制“不变”: 只有初始化的时候才赋值,此后不变, 使其能够始终返回不变的函数地址。源码中使用的是
memoizedFn
。 memoizedFn.current
指向的函数内部再去调用fnRef.current
指向的函数
function useMemoizedFn<T extends noop>(fn: T) {
const fnRef = useRef<T>(fn);
fnRef.current = useMemo(() => 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;
}
useUpdate
React
组件的更新源于状态的变化。 当我们想要强制要求组件更新的时候, 此时就可以封装一个hook
利用无意义的状态强制更新。
useUpdate
会返回一个函数,调用该函数会强制组件重新渲染。
它的源码很简单, 就是利用了当组件的state
产生变化的时候, 组件会重新渲染的原理。 该hook
返回的函数的利用useCallback
进行了缓存。 当用户调用该函数的时候, state
发生变化, 组件重新渲染。
const useUpdate = () => {
const [, setState] = useState({});
return useCallback(() => setState({}), []);
};
useLatest
返回当前最新值的 Hook
,可以避免闭包问题。
何为闭包问题呢,我们经常能看到的例子。当我们count
发生更改的时候, 是不影响setInterval
中访问的count
的。 因为它始终访问的是初次渲染时外部的count
。
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
console.log("setInterval:", count);
}, 1000);
}, []);
return (
<div>
count: {count}
<br />
<button onClick={() => setCount((val) => val + 1)}>增加 1</button>
</div>
);
}
有两种解决方法, 第一种就是将count
加入依赖项, 当count
产生变化的时候对应的effect
函数也重新生成, 此时访问的就是最新的count
。 第二种就可以借助useRef
返回的对象引用不变的基础上, 改变current
指向数值。 这个也就是useLastest
的源码内容
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
相关代码修改为如下即可
function App() {
const [count, setCount] = useState(0);
+ const value = useLatest(count);
useEffect(() => {
const interval = setInterval(() => {
+ console.log("setInterval:", value.current);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div>
count: {count}
<br />
<button onClick={() => setCount((val) => val + 1)}>增加 1</button>
</div>
);
}
State相关源码
此处介绍跟state
相关的hook
源码。 在我看来state
相关的hook
主要分为两类。
- 一类为扩展状态变化的多样性。 比如
useSetState
,useToggle
,useBoolean
,useDebounce
,useThrottle
,useMap
,useSet
等 - 一类是与其他数据类型结合的状态管理,提供获取/更新等操作。 比如
useCookieState
,useLocalStorageState
,useSessionStorageState
,useUrlState
useSetState
普通的useState
在处理普通数据类型的时候比较方便, 但是遇上object
类型就相对麻烦了毕竟值是全覆盖的。我们一般是通过setValue{...preValue, ...newState}
来处理。 诶, 这个冗余的步骤直接封装到hook
不就可以了。
useSetState
管理 object
类型 state
的 Hooks
,用法与class
组件的 this.setState
基本一致。
也就是说他返回的方法要有自动进行浅合并的功能, 且若传入回调函数时可以获取到最新的state
值。那么就是将useState
的set
函数进行扩展即可。 注意这里使用了useCallback
包一层防止函数地址变更引起不必要的重新渲染
const useSetState = <S extends Record<string, any>>(
initialState: S | (() => S),
): [S, SetState<S>] => {
const [state, setState] = useState<S>(initialState);
const setMergeState = useCallback((patch) => {
setState((prevState) => {
// 若为函数则传入prevState调用函数拿到newState, 否则则为传入的newState
const newState = isFunction(patch) ? patch(prevState) : patch;
// 然后进行合并
return newState ? { ...prevState, ...newState } : prevState;
});
}, []);
return [state, setMergeState];
};
useToggle
用于在两个状态值间切换的Hook
。 那么可以是boolean
值的直接切换, 也可以是传入的两个状态值的切换。
const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue?: boolean);
const [state, { toggle, set, setLeft, setRight }] = useToggle<T>(defaultValue: T);
const [state, { toggle, set, setLeft, setRight }] = useToggle<T, U>(defaultValue: T, reverseValue: U);
可以看到传入的defaultValue
默认为false
, 当reverseValue
不传入的话则默认取反去处理。 操作方法对象用useMemo
包了一层, 也就是说, 当传入的defaultValue
和reverseValue
变化的时候, 该hook
是不理会的,始终操作初始传入的值。
function useToggle<D, R>(defaultValue: D = false as unknown as D, reverseValue?: R) {
const [state, setState] = useState<D | R>(defaultValue);
const actions = useMemo(() => {
const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R;
const toggle = () => setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue));
const set = (value: D | R) => setState(value);
const setLeft = () => setState(defaultValue);
const setRight = () => setState(reverseValueOrigin);
return {
toggle,
set,
setLeft,
setRight,
};
}, []);
return [state, actions];
}
useBoolean hook
其实就是基于useToggle
再进行了一层封装
useCookieState
该hook
可以将状态存储在 Cookie
。 也就是说可以直接通过该hook
去操作cookie
。
实现上无非就是在初始化state
和更新state
的两个过程, 都进行扩展
- 初始化的时候根据
cookiekey
从cookie
拿取值或options
默认值初始化state
值 - 更新的时候根据传入的值顺便去更新
cookie
中对应的cookieKey
的值
function useCookieState(cookieKey: string, options: Options = {}) {
const [state, setState] = useState<State>(() => {
// 通过cookieKey拿到cookie值
const cookieValue = Cookies.get(cookieKey);
// 成功拿到了的话则为state的初始值
if (isString(cookieValue)) return cookieValue;
// 没有的话则看options是否有传入的默认值, 可以是值也可以是方法
if (isFunction(options.defaultValue)) {
return options.defaultValue();
}
return options.defaultValue;
});
// 注意这里又使用了useMemoizedFn, 因为传入的options是支持更新的
const updateState = useMemoizedFn(
(
newValue: State | ((prevState: State) => State),
newOptions: Cookies.CookieAttributes = {},
) => {
// 获取到新传入的state值并且更新状态
const { defaultValue, ...restOptions } = { ...options, ...newOptions };
const value = isFunction(newValue) ? newValue(state) : newValue;
setState(value);
// 再根据state值去操作cookie
if (value === undefined) {
Cookies.remove(cookieKey);
} else {
Cookies.set(cookieKey, value, restOptions);
}
},
);
return [state, updateState] as const;
}
useLocalStorageState
,useSessionStorageState
都是差不多的思路,useUrlState
则相对再麻烦一点, 它涉及React-router
和qs
, 且它有独立的package.json
是提供以独立打包的
usePrevious
该hook
的作用是保存上一次的状态。那么内部就两个指针, curRef
指向当前的state
, prevRef
指向上一次的state
。 当变更产生时, 指针变化即可
function usePrevious<T>(
state: T,
shouldUpdate: ShouldUpdateFunc<T> = defaultShouldUpdate,
): T | undefined {
const prevRef = useRef<T>();
const curRef = useRef<T>();
if (shouldUpdate(curRef.current, state)) {
prevRef.current = curRef.current;
curRef.current = state;
}
return prevRef.current;
}
Effect相关的源码
useUpdateEffect
useUpdateEffect
用法等同于 useEffect
,但是会忽略首次执行,只在依赖更新时执行。
实现上重点关注首次执行即可, 其实就是需要一个flag
。 首次执行时变更状态且不执行erffect
。 此后放行。 该flag
使用useRef
去处理即可
export default createUpdateEffect(useEffect);
export const createUpdateEffect: (hook: EffectHookType) => EffectHookType =
(hook) => (effect, deps) => {
const isMounted = useRef(false); // flag
// 销毁的时候, 重置状态
hook(() => {
return () => {
isMounted.current = false;
};
}, []);
hook(() => {
if (!isMounted.current) { // 首次执行的时候, 重置状态, 不处理effect
isMounted.current = true;
} else {
return effect(); // 否则调用
}
}, deps);
};
useUpdateLayoutEffect
同理
useAsyncEffect
React
中的useEffect
是不支持异步函数的, 当你直接在useEffect
使用async...await...
的时候, 会直接抛出如下错误。 报错中也提供了建议的写法。
诶那么如果我就是觉得这样写不够优雅, 就想按照正常的effect
去写呢。 此时就可以抽取相关逻辑为hook
。
源码逻辑上和建议写法一样, 就是多了Generator
函数的处理。 注意通过 useAsyncEffect
实现的写法没有 useEffect
返回函数中执行清除副作用的功能。原因可见 DefinitelyTyped/issues
function useAsyncEffect(
effect: () => AsyncGenerator<void, void, void> | Promise<void>,
deps?: DependencyList,
) {
useEffect(() => {
const e = effect();
let cancelled = false;
async function execute() {
if (isAsyncGenerator(e)) { // 这里处理Generator函数
while (true) {
const result = await e.next();
if (result.done || cancelled) {
break;
}
}
} else {
await e; // 不是Generator函数的话就await
}
}
execute();
return () => {
cancelled = true;
};
}, deps);
}
useDebounceFn
用来处理防抖函数的 Hook
。关于防抖的处理使用的是loadsh
提供的方法。
useDebounce
和useDebounceEffect
都是基于该hook
实现的。useDebounce
与useState
进行结合,useDebounceEffect
和useEffect
进行结合
import debounce from 'lodash/debounce';
function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) {
// 拿到最新的fn
const fnRef = useLatest(fn);
const wait = options?.wait ?? 1000;
const debounced = useMemo(
() =>
debounce(
(...args: Parameters<T>): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
options,
),
[],
);
// 组件销毁时,取消防抖函数调用。防止造成内存泄漏
useUnmount(() => {
debounced.cancel();
});
return {
run: debounced,
cancel: debounced.cancel,
flush: debounced.flush,
};
}
useLockFn
useLockFn
用于给一个异步函数增加竞态锁,防止并发执行。
要加锁的话其实就是需要有一个参数来表明状态。 当异步函数完成之前,触发该函数都直接返回不执行。 等异步函数完成之后或抛错时, 再重置状态。源码使用useRef
来维护lockRef
表示锁。 当lockRef.current
为true
的时候表明当前有异步函数在进行故直接返回忽略。 等异步函数完成之后再重置为false
function useLockFn<P extends any[] = any[], V = any>(fn: (...args: P) => Promise<V>) {
const lockRef = useRef(false);
return useCallback(
async (...args: P) => {
if (lockRef.current) return;
lockRef.current = true;
try {
const ret = await fn(...args);
lockRef.current = false;
return ret;
} catch (e) {
lockRef.current = false;
throw e;
}
},
[fn],
);
}
当然
ahook
还提供了很多各种各样的hook
。在实现的过程中要注重useCallback
,useMemo
,useRef
的应用。 注重状态的清理和变更。 其余根据需求去具体实现即可