围绕业界大佬 ahooks 的设计实现,本文讨论以下问题:
- 如何组织自定义 hook 的输入和输出?
- 哪些能力是常用且适合 hooks 实现的?
- 具体实现中有哪些技巧?
ahooks 是一个 React Hooks 库,致力提供常用且高质量的 Hooks。
react 提供基础的 hooks 函数,但在实际场景中,我们需要在基础函数上再封装。ahooks 在 hooks 开发的丰富度和标准化上都出类拔萃,我们可以向它学习 hooks 设计和开发技巧。
如何组织自定义 hook 的输入和输出?
hooks 是函数,那么什么样的输入和输出规范易用呢?
输出
::hooks 通常需要至多返回两种东西:「值 value」和「改变值的操作 action」::,每种又可能有多个。
ahooks 中,根据两种东西返回的数量组织返回结构。
- 不需要返回任何东西:通常用在生命周期中,类似 useEffect;
- 返回「单个 value」:
const documentVisibility = useDocumentVisibility();
; - 返回「单个 value」+「单个 action」:类似 useState,用二元组组织,
const [state, setState] = useLocalStorageState(...)
; - 返回「单个 value」+「多个 action」:仍用二元组组织,但多个action集合到一个对象中。
const [current, { inc, dec, set, reset }] = useCounter(...);
- 返回「多个 value」:多个 value 集合到一个对象中。
const {text, left, right, ...} = useTextSelection();
- 返回「多个 value」+「多个 action」:value、action 都集合到一个对象中。
const {data, error, loading, run} = useRequest(...);
规范很清晰:优先返回 [value, action]
二元组,多个返回就集合到对象中。
权衡二元组和对象
这就牵扯出一个经典问题「::为什么 useState 返回数组?::」。因为数组解构能直接赋值到任意命名的变量,避免多次调用下对象解构的大规模重新赋值。
但如果超过二元还用数组,就会造成困惑「哪几个是 value,哪几个又是 action?」。所以用对象组织起来,这样是可行的,因为第一步解构就直接划分了命名空间,后续调用也不会冲突。
// 单 value 多 action 的 hook
const [counterValue, counterActions] = useCounter(...);
const handleClick = () => {
// 调用 action,并不会产生命名冲突
counterActions.inc();
};
输入
原则上不允许超过两个参数。
函数入参过多一样会造成困惑,特别是必选/非必选混合时。ahooks 把入参分为「必选参数」和「非必选参数」,再用对象(超出两个的时候)组织到两个参数内。
- 无参数:
useDocumentVisibility();
; - 单个参数:
useSize(dom)
; - 双必选参数:直接传入,
useKeyPress(keyFilter, eventHandler)
- 多必选/非必选参数:对象,
useDrop({onText?, onFiles?, onURI?, onDOM?});
- 单多组合:
useRequest(service, {manual?, initialData?, onSuccess?})
- 多多组合:
useTextSelection(items, defaultSelected?);
哪些能力是常用且适合 hooks 实现的?
不如直接看看 ahooks 实现了哪些,它把 hooks 分为:
- 【异步请求】主要是 useRequest。业务中有大量异步请求,需要统一的触发时机、请求方式、数据、状态、回调等处理。
- 【Side Effect】
- 一种是 interval、timeout,这种在正常调用 useEffect 实现时容易出错的场景
- 另一种是防抖和节流
- 【life sycle】模拟 class 组件中的声明周期,优化调用
- 【state】丰富 state 的管理渠道和模型,比如
- 向 url、history、cookie、localStorage 中同步 state
- 管理 toggle、计数、map、set 的 state 模型
- 【DOM】和【UI】
- 【Advanced】对基础 hooks 的优化
具体实现中有哪些技巧?
这部分结合另一篇 Hooks - effect 范式下的组件状态和依赖 食用
useEffect 模拟「生命周期」
事实上在 effect 范式下还惦记生命周期只会让思维混乱,但「模拟生命周期」又是最常被拿出来展示「入门级 hooks 小妙招」的用法。
模拟 componentDidMount/ componentWillUnmount,入门级的,不展开了。
useUpdate
强制组件重新渲染的 hook
这个 hook 返回一个 update 函数,调用后在不改变 state 的情况下强制刷新。
const update = useUpdate();
return <button onClick={() => update()}>refresh</button>
这个其实好办,hook 内部包一个 state(组件用不上),每次引用 set 一下就好。(想想为什么用 useCallback 包上呢?)
const useUpdate = () => {
const [, setState] = useState(0);
return useCallback(() => setState((num: number): number => num + 1), []);
};
用 useRef 跟踪最新状态
本质上,::useRef 就像是可以在其 .current 属性中保存一个可变值的“盒子”。:: —— useRef api
你可以把它当个外部变量使。
useInterval
因为 capture value,直接用 useEffect 实现一个「初次渲染启动,后续翻转值的 interval」是容易掉坑的。因为 useEffect 只拿到了第一次渲染的闭包,永远 set 成 true。
const [ foo, setFoo ] = useState(false);
useEffect(() => {
const int = setInterval(() => setFoo(!foo), 1000);
return () => clearInterval(int);
}, []);
useInterval 实现了一个放心用的 interval,让闭包内取到的变量都是最新的。每次渲染都会调用 useInterval,传入当前上下文的 handler。
useInterval(() => {
setFoo(!foo);
}, 1000);
在 useInterval 内部,把最新的 handler 暂存到 ref 上,这样 interval 即使只在第一次 render 闭包下声明一次,却总能拿到最新的上下文。
// useInterval 源码片段
const timerRef = useRef<() => void>();
timerRef.current = fn;
useEffect(() => {
const timer = setInterval(() => {
timerRef.current?.();
}, delay);
return () => clearInterval(timer);
}, []);
useDebounceFn
做函数防抖也类似,要求连续触发完成 X s 后执行最新一次触发。
const { run } = useDebounceFn(
() => setValue(value + 1),
{ wait: 500 },
);
内部就要存最新上下文,再配上触发时机(不帖代码了)
用 useRef 固定引用
::比「盒子」更值得一用的是 ref 的不变性。::
::useRef 返回一个可变的 ref 对象,在组件的整个生命周期内保持不变。:: 请记住,当 ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性不会引发组件重新渲染。 —— useRef api
洁癖:useCallback 让普通的函数声明也「依赖化」
我们在组件内声明一个内部函数,只是变量,但洁癖告诉我们,::「组件内的一切东西最好都依赖化」::。
那就非 useCallback 莫属了。
在 ahooks 的实现和 pr 里,你可以看到大面积这种用法和讨论。
比如前面的 useUpdate,返回的就是个可依赖的变量(调用者可以不用,但你最好给)
// 源码
return useCallback(() => setState((num: number): number => num + 1), []);
// 调用
const update = useUpdate(); // update 可依赖
罕见场景
在某些罕见场景中,你可能会需要用 useCallback 记住一个回调,但由于内部函数需要经常重新创建,记忆效果不是很好。 —— hooks 官方文档
就是说用 const fn = useCallback(() => {}, [])
包裹的函数,虽然 fn 引用不变,但内部函数只在依赖变化时声明一次,也永远在那一次的闭包下。但我们有时候想要一个「一直重新声明,引用还不变的函数」。
usePersistFn
所以借助 useRef, ahooks 又实现了一个 useCallback 替代品。
与 useCallback 保持一致,理念就是直接替换 useCallback,以前怎么用 useCallback,现在就怎么用 usePersistFn —— ahooks 的 issue
那怎么实现呢?
你可以 把 ref 当做实例变量 来用,并手动把最后提交的值保存在它当中。 —— hooks 官方文档
usePersistFn 实现了这个思路:ref.current 在变,总重新声明函数,并能用到最新函数上下文,但 ref 不变,函数引用就不变。
function usePersistFn<T extends noop>(fn: T) {
const ref = useRef<any>();
ref.current = fn;
const persistFn = useCallback(((...args) => ref.current(...args)) as T, [ref]);
return persistFn;
}
useMemo 优化计算
useCallback(fn, deps) 相当于 useMemo(() => fn, deps) 。
useCallback、useMemo 都有两个特点:
- 返回的函数/值依赖化
- 只有 deps 改变时才会声明或执行
不过在 useCallback 我们更关注 1,在 useMemo 我们更关注 2。
ahooks 中很多地方用 useMemo 避免不必要的计算(复杂计算或者大量函数声明),比如 useToggle:
// actions 依赖化
const actions = useMemo(() => {
// 避免这一坨每次都声明
const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R;
const toggle = (value?: D | R) => {
setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue));
};
const setLeft = () => setState(defaultValue);
const setRight = () => setState(reverseValueOrigin);
return {
toggle,
setLeft,
setRight,
};
}, [defaultValue, reverseValue]);
useState 的函数式参数:把 state 同步到别的地方
ahooks 里有很多这样的 hooks:useUrlState、useCookieState、useHistoryTravel、useLocalStorageState、useSessionStorageState、useTitle……
思路大体一致:
- 在 state 初始化时加个塞,优先从「别的地方」获取值
- 在 setState 前加个塞,把 state 同步到「别的地方」
这得益于 state 初始化和 set 都接收函数类型的参数,在这个函数里做额外的事情。
总结
- 元组的输入输出参数很灵活,但要控制量避免困惑,可借助一层对象分类传递
- hooks 常用来封装实现:异步请求、Side Effect、life sycle、state 扩展管理、DOM、UI、优化
- useEffect 能直接对依赖变化作出反应,「模拟生命周期」
- useRef 可以当外部变量用,跟踪最新状态且固定引用
- useState 的函数式参数可以当钩子用,丰富 state 的管理渠道