从 ahooks 的实现看自定义 hook 开发指南

1,609 阅读7分钟

围绕业界大佬 ahooks 的设计实现,本文讨论以下问题:

  • 如何组织自定义 hook 的输入和输出?
  • 哪些能力是常用且适合 hooks 实现的?
  • 具体实现中有哪些技巧?

ahooks 是一个 React Hooks 库,致力提供常用且高质量的 Hooks。

ahooks 文档

react 提供基础的 hooks 函数,但在实际场景中,我们需要在基础函数上再封装。ahooks 在 hooks 开发的丰富度和标准化上都出类拔萃,我们可以向它学习 hooks 设计和开发技巧。


如何组织自定义 hook 的输入和输出?

hooks 是函数,那么什么样的输入和输出规范易用呢?

ahooks:api 规范

输出

::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 都有两个特点:

  1. 返回的函数/值依赖化
  2. 只有 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 的管理渠道