背景
最近想多找一找封装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指向的函数
ts
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发生变化, 组件重新渲染。
js
const useUpdate = () => {
const [, setState] = useState({});
return useCallback(() => setState({}), []);
};
useLatest
返回当前最新值的 Hook,可以避免闭包问题。
何为闭包问题呢,我们经常能看到的例子。当我们count发生更改的时候, 是不影响setInterval中访问的count的。 因为它始终访问的是初次渲染时外部的count。
js
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的源码内容
js
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
相关代码修改为如下即可
diff
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包一层防止函数地址变更引起不必要的重新渲染
js
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值的直接切换, 也可以是传入的两个状态值的切换。
react
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是不理会的,始终操作初始传入的值。
js
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的值
js
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。 当变更产生时, 指针变化即可
js
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去处理即可
js
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
js
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进行结合
js
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
js
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的应用。 注重状态的清理和变更。 其余根据需求去具体实现即可