HeyBinyang

自定义 Hook 的设计与拆分——从复用逻辑到复用心智模型

·技术·visibility1 阅读 ·React

很多人第一次自定义 Hook,出发点都差不多:

  • 组件里有三四个 useState

  • 多个 useEffect

  • 还有一点事件处理逻辑

  • 代码越来越长,看着有些累

于是顺手抽出一个 useXxx.ts,把代码搬过去。 文件是变短了,但新的问题很快又来了:

  • 这个 Hook 的输入很多,返回值也很多。

  • 逻辑虽然搬走了,但读起来还是乱。

  • 子组件调用时,不知道哪些返回值是“状态”,哪些是“动作”,哪些只是内部实现。

这说明问题不在“有没有抽 Hook”,而在于:

自定义 Hook 不是“把代码换个文件放”,而是把一段状态逻辑重新设计成一个稳定的接口。

如果组件是 UI 的组合单位,那自定义 Hook 就是状态逻辑的组合单位

一、先别急着抽:什么时候值得做成自定义 Hook?

React 官方对 custom hook 的定义很朴素:

  • 它是一个以 use 开头的函数;

  • 它内部可以调用其他 Hook;

  • 它的目的,是在多个组件之间复用有状态逻辑。

但在日常项目里,“复用”只是其中一种理由。 真正常见的抽取时机,通常有三类:

1. 同一段状态逻辑在多个组件里重复出现

比如:

  • useWindowSize

  • useOnlineStatus

  • useDebouncedValue

  • useFetchUser

这种最直观,就是典型的逻辑复用。

2. 一个组件里的 Hooks 组合已经形成了“一个完整能力”

比如:

  • 搜索输入 + 防抖 + 请求 + 取消旧请求

  • 弹窗开关 + ESC 关闭 + 点击外部关闭

  • 表单值 + 校验 + 提交 + 重置

这时候即使暂时只有一个组件用,也值得抽。 因为你不是为了“复用代码行数”,而是在复用一块完整的交互语义。

3. 你想把组件从“业务控制器”还原成“UI 描述”

这类是我觉得最值得抽的情况。

很多组件一开始是这样的:

  • 上面一排 useState

  • 中间三段 useEffect

  • 下面五个 handler

  • 最后才是 JSX

读起来像一个微型控制器,而不是一个视图。 这时候抽 Hook,不是为了炫技,而是为了让组件重新变回“展示层”。

二、自定义 Hook 抽的不是代码,而是“问题边界”

这是整篇最重要的一句。

很多人抽 Hook 时,容易按“技术点”拆:

  • 一个 Hook 管 state

  • 一个 Hook 管 effect

  • 一个 Hook 管 callback

结果拆出来一堆 useAuseBuseC,谁都看不懂。

更好的方式是按“问题边界”拆,也就是按业务上要解决的那件事拆。

比如你做一个搜索面板,不要拆成:

  • useKeywordState

  • useDebounce

  • useSearchEffect

而更应该优先思考:

  • 这段逻辑整体上是不是“搜索能力”?

  • 这个能力对外最重要的输入和输出是什么?

于是你就得到一个更语义化的接口:

const {
  query,
  setQuery,
  results,
  loading,
  error,
} = useSearchUsers();

这样一来,调用方不需要知道内部到底用了 useEffectuseRef 还是 useCallback。 它只关心:这个 Hook 提供了一个“用户搜索能力”。

所以设计自定义 Hook 时,优先回答这两个问题:

  1. 这个 Hook 在解决什么完整问题?

  2. 调用它的组件,最少需要知道什么?

三、一个坏例子:只是把组件代码搬走,不叫设计

先看一段常见的“抽了但没完全抽”的代码。

组件里原来的逻辑

function SearchPanel() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);

    fetch(`/api/users?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [query]);

  return ...
}

很多人会直接抽成:

function useSearchPanelLogic() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    ...
  }, [query]);

  return { query, setQuery, users, loading };
}

这不算错,但也谈不上“设计”。 因为它只是把原组件逻辑整块搬到另一个文件,名字换成了 Hook。

问题在于:

  • useSearchPanelLogic 这个名字太贴近页面,不是能力名。

  • 如果以后别的地方也要“搜索用户”,这个 Hook 可能并不通用。

  • 它把“页面”和“能力”绑在一起了。

更好的做法,是抽成一个语义更清楚的 Hook:

function useUserSearch(initialQuery = '') {
  const [query, setQuery] = useState(initialQuery);
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (!query) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .catch(err => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [query]);

  return {
    query,
    setQuery,
    users,
    loading,
    error,
  };
}

这时候它已经不是“页面逻辑搬家”,而是一个真正有边界的状态能力模块了。

四、设计自定义 Hook 时,输入输出最好保持“少而稳”

自定义 Hook 最容易失控的地方,是返回值越来越多。

比如:

const {
  query,
  setQuery,
  users,
  setUsers,
  loading,
  setLoading,
  error,
  setError,
  refetch,
  reset,
  inputRef,
} = useUserSearch();

这类 API 一看就有点不对劲。 原因不是“值太多”本身,而是它把内部状态管理权也暴露出去了。

一个 Hook 设计得越成熟,对外暴露的东西通常越像两类:

  • 状态

  • 动作

比如:

const {
  query,
  users,
  loading,
  error,
  setQuery,
  refetch,
  reset,
} = useUserSearch();

这里就很清楚:

  • query/users/loading/error 是状态。

  • setQuery/refetch/reset 是动作。

而像 setUserssetLoadingsetError 这种,一般都属于 Hook 内部实现,不应该随便暴露给调用者。 否则这个 Hook 就失去了自己的“约束力”,外部可以随意把它状态改乱。

所以有个很实用的设计原则: > Hook 的返回值,优先暴露“可读状态”和“语义动作”,少暴露内部 setter。

这和组件封装是一个道理。

五、把前几篇讲过的规则,真正沉淀进 Hook 里

自定义 Hook 最有价值的地方,不是“复用代码”,而是把前面几篇的最佳实践,沉淀成团队可复用的默认写法。

也就是说,一个好的自定义 Hook,应该天然具备这些特征:

  • 不滥用 effect。

  • 不同步派生状态。

  • 处理好闭包和依赖数组。

  • 只在必要时使用 ref 和 callback。

比如我们前面讲过“防抖搜索”,如果每个组件都自己手写一遍:

  • 有的人会忘记清理 timer;

  • 有的人会写出 stale closure;

  • 有的人会把中间状态同步成一团。

这时候把它封成 Hook,其实是在统一一套正确做法:

function useDebouncedValue<T>(value: T, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(id);
  }, [value, delay]);

  return debouncedValue;
}

然后业务组件就可以写成:

const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);

这样,调用者根本不需要重新思考防抖 effect 的边界问题。 这就是自定义 Hook 的真正价值:把判断成本前置一次,后面的人只管用。

六、3 个最值得在项目里沉淀的基础 Hook

这一节不追求花哨,讲三个最容易落地、也最能体现设计感的 Hook。

1. usePrevious

function usePrevious<T>(value: T) {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

这个 Hook 很适合做对比逻辑,比如:

  • 判断值是否刚刚变化

  • 做简单动画触发

  • 对比前后 props

它的意义不是代码复用本身,而是把“前一个值”的语义提炼出来。

2. useDebouncedValue

function useDebouncedValue<T>(value: T, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(id);
  }, [value, delay]);

  return debouncedValue;
}

这个 Hook 非常适合:

  • 搜索框

  • 表单联动

  • 频繁输入后的延迟请求

它把“防抖的时间语义”从组件里抽掉了。

3. useEventCallback / latest-callback 模式

function useEventCallback<T extends (...args: any[]) => any>(fn: T) {
  const fnRef = useRef(fn);

  useEffect(() => {
    fnRef.current = fn;
  }, [fn]);

  return useCallback((...args: Parameters<T>) => {
    return fnRef.current(...args);
  }, []);
}

这个 Hook 的场景是:

  • 你想拿到一个稳定函数引用;

  • 它内部始终执行最新逻辑。

这类模式特别适合第三方事件系统、定时器工具、订阅桥接。

不过这里也要提醒一句:

  • 它是工具模式,不是业务组件默认写法。

  • 普通场景老实写依赖,往往更简单。

七、什么时候不该抽自定义 Hook?

这一节很重要。 不是组件里出现了两个 useState,就必须抽 Hook。

下面几种情况,通常不值得抽:

1. 逻辑非常短,而且只在当前组件里出现一次

比如:

const [open, setOpen] = useState(false);

这种抽成 useModalOpen() 反而画蛇添足。 因为它没有形成一个真正的能力边界,只是把简单逻辑包了一层名字。

2. 抽出去之后,接口比原组件还难理解

如果一个 Hook 需要:

  • 8 个参数

  • 12 个返回值

  • 一堆 options 配置

那你大概率不是在抽象,而是在把复杂度隐藏起来。 复杂度没有消失,只是换了个地方。

3. 逻辑和页面结构强绑定,暂时看不出稳定边界

有些东西现在就只属于这个页面,而且你还没摸清它最终会怎么演化。 这时候宁可先留在组件里,也别急着抽一个半成品 Hook。

抽 Hook 的时机,通常是“边界已经浮现”,不是“代码刚一变长就赶紧拆”。

八、一个完整重构示例:从页面控制器到自定义 Hook

先看一个比较常见的页面代码:

function UserSearchPage() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const debouncedQuery = useDebouncedValue(query, 400);

  useEffect(() => {
    if (!debouncedQuery) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users?q=${debouncedQuery}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .catch(err => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [debouncedQuery]);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      {loading && <div>Loading...</div>}
      {error && <div>{error.message}</div>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

这段还算清楚,但随着需求变多,很快就会变厚。 这时候可以抽成:

function useUserSearch(delay = 400) {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const debouncedQuery = useDebouncedValue(query, delay);

  useEffect(() => {
    if (!debouncedQuery) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users?q=${debouncedQuery}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .catch(err => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [debouncedQuery]);

  const reset = useCallback(() => {
    setQuery('');
    setUsers([]);
    setError(null);
    setLoading(false);
  }, []);

  return {
    query,
    setQuery,
    users,
    loading,
    error,
    reset,
  };
}

然后页面变成:

function UserSearchPage() {
  const {
    query,
    setQuery,
    users,
    loading,
    error,
    reset,
  } = useUserSearch();

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      <button onClick={reset}>清空</button>
      {loading && <div>Loading...</div>}
      {error && <div>{error.message}</div>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

这样改完最大的好处不是“代码少了”,而是页面终于重新像页面了:

  • 它负责展示。

  • useUserSearch 负责状态能力。

这才是一个组件 + Hook 的自然分工。

九、这一篇可以带走的结论

  1. 自定义 Hook 的目标不是单纯复用代码,而是复用一段状态逻辑的边界和语义。

  2. 该抽 Hook 的时机,通常是逻辑已经形成一个完整能力,或者你想把组件重新还原成展示层。

  3. 抽 Hook 时要按“问题边界”拆,而不是按 state/effect/callback 这种技术点拆。

  4. Hook 对外优先暴露“状态 + 动作”,少暴露内部 setter。

  5. 一个好的自定义 Hook,应该把前面几篇讲过的正确心智模型沉淀进去,而不是把复杂度原样搬家。

分享