背景
- 迁移项目中,随处可见
ahooks
的身影,在抱有好奇心以及探索 React 中 自定义 hook 的最佳实践的过程中。于是便有这篇了ahooks
源码解析系列。 ahooks
中有大量的TS
定义,可以从中吸取到很多的代码设计,快速上手React+TS
开发模式。- 简单快速即可上手阅读
ahooks
源码,低耦合性也让代码结构更加清晰,调试者也不需要关注复杂的逻辑。
一) 介绍
ahooks,发音 [eɪ hʊks],是一套高质量可靠的 React Hooks 库。在当前 React 项目研发过程中,一套好用的 React Hooks 库是必不可少的,希望 ahooks 能成为您的选择。
特性
- 易学易用
- 支持 SSR
- 对输入输出函数做了特殊处理,且避免闭包问题
- 包含大量提炼自业务的高级 Hooks
- 包含丰富的基础 Hooks
- 使用 TypeScript 构建,提供完整的类型定义文件
安装
$ npm install --save ahooks # or $ yarn add ahooks # or $ pnpm add ahooks
使用
import { useRequest } from 'ahooks';
二)拉取ahooks代码
将仓库 ahooks clone 到本地
拉起下来的代码里面会有很多工程化的文件,这里就不会做过多介绍了,因为是即便完全不懂这些东西,也不妨碍你可以轻松的调试ahooks
源码。
在CONTRIBUTING.zh-CN
文件中有其贡献指南和启动项目的流程。
pnpm install pnpm run init
代码运行起来之后就可以在本地看到一份和官网一模一样的文档了。
三)常用Hook源码解析
目前部门主要采用的是 Mobx+React+TS
以及自研组件库,关于操作视图层的hooks
这里就不做过多介绍了,感兴趣的可以自己研究一下~
3.1 useDebounceFn
用来处理防抖函数的 Hook。用法和 debounce
非常类似。
const [value, setValue] = useState(0); const { run } = useDebounceFn( () => { setValue(value + 1); }, { wait: 500, }, );
补充一点:
- 空值合并运算符 ?? ,
a = b ?? c
只要 b 不为null
orundefined
则a = b
,否则a = c
- 空值合并操作符(
??
)是一个逻辑操作符,当左侧的操作数为null
或者undefined
时,返回其右侧操作数,否则返回左侧操作数。
let a = 1 let b = 2 const c = a ?? b // c = 1 b = undefined const d = b ?? a // d = 1
核心代码
import debounce from 'lodash/debounce'; import { useMemo } from 'react'; import type { DebounceOptions } from '../useDebounce/debounceOptions'; import useLatest from '../useLatest'; import useUnmount from '../useUnmount'; import { isFunction } from '../utils'; type noop = (...args: any) => any; function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) { ... 省去部分代码 // 永远使用最新的fn const fnRef = useLatest(fn); // 空值校验 const wait = options?.wait ?? 1000; // 思考一下 这里为什么要使用useMemo来包一层呢 ? // 其实hook也是一个函数,当组件reRender的时候hook也会重新执行一变,所以需要useMemo来记录已经保存下来的结果 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, }; } export default useDebounceFn;
useLastest.ts
这个 hook 的使用场景目前还没找到很好的答案。如果按照这个 实现,每次获取最新的值,那为什么不直接使用 value 呢?
在和一位大佬探讨后,目前得到的结果就是为了适应某些闭包场景。
import { useRef } from 'react'; function useLatest(value) { var ref = useRef(value); ref.current = value; return ref; } export default useLatest;
从useDebounceFn可以学习到的TS编码
type noop = (...args: any) => any; function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions)
在TS中extends关键字可以对传入的进来的范型类型进行限制。
举个简单的🌰
function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) { ... const debounced = useMemo( () => debounce( (...args: Parameters<T>): ReturnType<T> => { return fnRef.current(...args); }, wait, options, ), [], ); .... }
Parameters
和ReturnType
可以分别获取TS中函数的入参类型和返回类型。
3.2 useCreation
useCreation是 useMemo
或 useRef
的替代品。
举个简单的🌰
这里每次修改count
的值,getRrandomNum
都会被重新执行(执行两次是因为React中的严格模式...)
换成useCreaction
就完美解决了这个问题 sandbox
核心代码
import type { DependencyList } from 'react'; import { useRef } from 'react'; import depsAreSame from '../utils/depsAreSame'; export default function useCreation<T>(factory: () => T, deps: DependencyList) { const { current } = useRef({ deps, obj: undefined as undefined | T, initialized: false, }); / * * 虽然useCreation函数会随着组件的reRender而重新执行 * 但是factory函数只有首次进来或者deps依赖发生改变才会重新执行 */ if (current.initialized === false || !depsAreSame(current.deps, deps)) { current.deps = deps; current.obj = factory(); current.initialized = true; } return current.obj as T; }
depsAreSame
功能:对比两个依赖是否相等
import type { DependencyList } from 'react'; function depsAreSame(oldDeps: DependencyList, deps: DependencyList): boolean { if (oldDeps === deps) return true; for (let i = 0; i < oldDeps.length; i++) { if (!Object.is(oldDeps[i], deps[i])) return false; } return true; }
从useCreation中学到的TS技巧
import type ... from
让编译器知道要导入的内容绝对是一种类型。详情见 你不知道的import type)undefined as undefined | T
当给一个变量值,并且需要限制类型的时候,可以通过 as 类型断言操作。
3.3 useSize
监听 DOM 节点尺寸变化的 Hook。
使用场景: 当某个元素的大小发生改变时,需要进行一系列操作。
举个🌰: 当我们使用 echarts
绘制图标的时候就会出现这样的问题。当 echarts
的容器的大小是自适应单位,如rem vw
等。我们希望绘制出来的图标也可以跟随容器大小改变而改变。
const size = useSize(EchartsDomRef); useEffect(() => { if (chartRef.current) { chartRef.current.resize(); // 当容器宽度发生改变的时候,调用resize方法重新渲染echarts } }, [size.width]); useEffect(() => { chartRef.current = echarts.init(EchartsDomRef.current); }, []);
核心代码
import ResizeObserver from 'resize-observer-polyfill'; import useRafState from '../useRafState'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useIsomorphicLayoutEffectWithTarget from '../utils/useIsomorphicLayoutEffectWithTarget'; type Size = { width: number; height: number }; function useSize(target: BasicTarget): Size | undefined { const [state, setState] = useRafState<Size>(); useIsomorphicLayoutEffectWithTarget( () => { const el = getTargetElement(target); if (!el) { return; } const resizeObserver = new ResizeObserver((entries) => { entries.forEach((entry) => { const { clientWidth, clientHeight } = entry.target; setState({ width: clientWidth, height: clientHeight, }); }); }); resizeObserver.observe(el); return () => { resizeObserver.disconnect(); }; }, [], target, ); return state; } export default useSize;
3.3 useUnmountedRef
获取当前组件是否已经卸载的 Hook。
使用场景: 发送网络请求前/后,判断组件是否已经销毁。如果销毁取消本次请求/减少后续的一系列操作。
举个🌰
const unMounted = useUnmountedRef(); getData.then(res => { if (unMounted.current) { return; } ....一些列耗时的操作 })
核心代码
这个hook的实现很简单。在组件挂载在dom上的时候设置值false
,当组件销毁的时候设置为true
。
import { useEffect, useRef } from 'react'; const useUnmountedRef = () => { const unmountedRef = useRef(false); useEffect(() => { unmountedRef.current = false; return () => { unmountedRef.current = true; }; }, []); return unmountedRef; }; export default useUnmountedRef;
3.4 useMemoizedFn
持久化 function 的 Hook,理论上,可以使用 useMemoizedFn 完全代替 useCallback。
核心代码
使用过 Vue3
的一定不会陌生WatchEffect
的自动收集依赖机制;
这里实现也很巧妙,re-render 阶段不断更新 fn.Ref.current
的引用值。 但是memoizedFn.current
指向的函数返回值就是函数 fn 最新的返回值,同时memoizedFn
只会在挂载阶段赋值一次,这样就确保了memoizedFn.current
的引用地址保持不变。
import { useMemo, useRef } from 'react'; 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) { const fnRef = useRef<T>(fn); // why not write fnRef.current = fn? // https://github.com/alibaba/hooks/issues/728 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; }
3.5 useLockFn
用于给一个异步函数增加 竞 态锁,防止并发执行。
使用场景: 在许多场景中都可以使用useLockFn
来减少网络上的开销。
举个简单的🌰 如果现在有个上拉加载的函数loadMore
。这个函数需要进行网络请求才会返回最终的结果。一般的做法就是通过设置isLoading
状态来判断函数是否执行。但是有了useLockFn
我们的代码就会变得简易许多,业务逻辑也会变得更加清晰。
const loadMore = async () => { if(isLoading) return isLoading = true const data = await getMockData(...parasms) isLoading = false }
核心代码
实现也非常简单,就是利用了useRef
在整个生命周期只会初始化一次,来记录一个状态变量,判断 fn
是否执行完毕。
import { useRef, useCallback } from 'react'; function useLockFn<P extends any[] = any[], V extends any = 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], ); }
但这里有个小问题 ?大家知道为什么这里需要使用useCallback
来包一层嘛,而不是直接返已经处理了 竞态🔒 逻辑的函数呢?
答案也很简单,当传入函数fn并不是一个临时函数,而是一个引用。当fn的引用地址未发生改变的时候,就防止了useLockFn函数返回结果发生改变,有可能会造成子组件的re-render。
四)Other hook
4.1 useUpdateEffect
useUpdateEffect
用法等同于 useEffect
,但是会忽略首次执行,只在依赖更新时执行。
import { useRef } from 'react'; import type { useEffect, useLayoutEffect } from 'react'; type EffectHookType = typeof useEffect | typeof useLayoutEffect; export const createUpdateEffect: (hook: EffectHookType) => EffectHookType = (hook) => (effect, deps) => { const isMounted = useRef(false); // for react-refresh hook(() => { return () => { isMounted.current = false; }; }, []); // update hook(() => { if (!isMounted.current) { isMounted.current = true; } else { return effect(); } }, deps); }; const useUpdateEffect = createUpdateEffect(useEffect);
首次进入函数会执行两次 hook 这里其实就是useEffect
设置状态变量isMounted
为true
,接下来每次更新就直接执行effect
函数。
4.2 useSetState
管理 object 类型 state 的 Hooks,用法与 class 组件的 this.setState
基本一致。
import { useCallback, useState } from 'react'; import { isFunction } from '../utils'; export type SetState<S extends Record<string, any>> = <K extends keyof S>( state: Pick<S, K> | null | ((prevState: Readonly<S>) => Pick<S, K> | S | null), ) => void; 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) => { const newState = isFunction(patch) ? patch(prevState) : patch; return newState ? { ...prevState, ...newState } : prevState; }); }, []); return [state, setMergeState]; };
4.3 usePrevious
保存上一次状态的 Hook。一般用于缓存状态。
import { useRef } from 'react'; export type ShouldUpdateFunc<T> = (prev: T | undefined, next: T) => boolean; const defaultShouldUpdate = <T>(a?: T, b?: T) => !Object.is(a, b); function usePrevious<T>( state: T, shouldUpdate: ShouldUpdateFunc<T> = defaultShouldUpdate, ): T | undefined { const prevRef = useRef<T>(); const curRef = useRef<T>(); // 进行 shallow equal 比较 if (shouldUpdate(curRef.current, state)) { prevRef.current = curRef.current; curRef.current = state; } return prevRef.current; }
四)总结
最后!!!学习 ahooks 一定是你 React 新手进阶最好的源码库。
第一篇关于 Ahooks源码解析 就这样结束了,感觉useRequest
和useUrlState
这两个 hook 可以单独拿出来讲讲。期待下一篇吧。