Skip to content

Scene分类 新增 Hook:useComposingSearch — 感知输入法组词状态的防抖搜索 #2939

@czh6865801

Description

@czh6865801

🤔 问题描述

在使用 useDebounceFn 做搜索防抖时,中文/日文/韩文等 IME 输入法会在拼音阶段就触发回调,产生大量无效请求。

以中文输入"中国"为例,用户输入拼音 "zhongguo" 再选词:

按键 传统 useDebounceFn 期望行为
z search("z") ❌ 跳过
zh search("zh") ❌ 跳过
zho search("zho") ❌ 跳过
zhon search("zhon") ❌ 跳过
zhong search("zhong") ❌ 跳过
zhongg search("zhongg") ❌ 跳过
zhonggu search("zhonggu") ❌ 跳过
zhongguo search("zhongguo") ❌ 跳过
中国(选词确认) search("中国") ✅ search("中国") ✅

9 次请求中有 8 次是无效的。这是所有面向 CJK 用户的 React 应用的共同痛点。

💡 建议方案

经过考虑,有两种实现思路,下面分别说明并对比。


方案 A:给 useDebounceFnimeAware 选项

当前 useDebounceFn 的 Options 只支持 waitleadingtrailingmaxWait 四个纯防抖参数。要走这个方案,必须修改 useDebounceFn 源码,新增 imeAwarecomposingRef 两个选项。

改动后的 API 设想:

// 现有 API(不变)
const { run } = useDebounceFn(fn, { wait: 300 });

// 改动后新增的选项
const { run } = useDebounceFn(searchFn, {
  wait: 300,
  imeAware: true,              // ← 新增:开启 IME 感知
  composingRef: isComposingRef, // ← 新增:传入组词状态 ref
});

但即使改了 useDebounceFn,用户仍然需要自己处理 DOM 事件绑定。完整使用代码:

const inputRef = useRef<HTMLInputElement>(null);
const isComposingRef = useRef(false);

// ⚠️ 这段样板代码无法省略——useDebounceFn 是纯逻辑 hook,不持有 ref
useEffect(() => {
  const input = inputRef.current;
  if (!input) return;
  const onStart = () => { isComposingRef.current = true; };
  const onEnd = () => { isComposingRef.current = false; };
  input.addEventListener('compositionstart', onStart);
  input.addEventListener('compositionend', onEnd);
  return () => {
    input.removeEventListener('compositionstart', onStart);
    input.removeEventListener('compositionend', onEnd);
  };
}, []);

const { run } = useDebounceFn(searchFn, {
  wait: 300,
  imeAware: true,
  composingRef: isComposingRef,
});

return <Input ref={inputRef} onSearch={run} />;

优势:

  • 不新增 hook,表面改动最小
  • 对已有 useDebounceFn 用户只需加一个选项

问题:

  • 需要改 ahooks 源码:当前 useDebounceFn 是纯防抖逻辑,加入 IME 相关参数是对已有 API 的侵入性修改,影响范围不可控
  • 样板代码只减了一个 if 判断:用户仍须自己写 useEffect 绑定 compositionstart/enduseDebounceFn 只帮你省了回调里的那行 if (isComposingRef.current) return;
  • 违反单一职责useDebounceFn 是纯逻辑 hook(不涉及 DOM),加 imeAware 后混入了 DOM 事件语义
  • 静默错误风险:如果用户设了 imeAware: true 但忘了绑定事件或忘了传 composingRef,防抖函数不会报错,只是 IME 感知不生效——这种 bug 很难排查
  • 增加 API 认知负担useDebounceFn 的使用场景不只是搜索,还有提交、保存等。对不关心 IME 的用户,imeAwarecomposingRef 是两个需要理解但永远不会用的参数

方案 B:独立 useComposingSearch hook(✅ 推荐)

独立 hook 将 ref 绑定、事件监听、防抖逻辑三件事封装为一体,用户无需额外操作。

interface UseComposingSearchOptions {
  /** 防抖等待时间,默认 300ms */
  wait?: number;
}

declare function useComposingSearch(
  run: (keyword: string) => void,
  options?: UseComposingSearchOptions,
): {
  debouncedSearch: (keyword: string) => void;
  ref: React.RefObject<HTMLElement>;
};

实际使用:

// 一行搞定,内部自动绑定 ref + 事件 + 防抖
const { debouncedSearch, ref } = useComposingSearch(searchFn, { wait: 300 });

return <Select ref={ref} onSearch={debouncedSearch} showSearch filterOption={false} />;

参考实现:

import { useDebounceFn } from 'ahooks';
import { useEffect, useRef } from 'react';

export function useComposingSearch(
  run: (keyword: string) => void,
  wait: number = 300,
) {
  const isComposingRef = useRef(false);
  const ref = useRef<any>(null);

  useEffect(() => {
    const el =
      (ref.current?.nativeElement as HTMLElement | undefined) ||
      (ref.current as HTMLElement | undefined);
    if (!el) return;

    const input = el.querySelector<HTMLInputElement>('input');
    if (!input) return;

    const onStart = () => { isComposingRef.current = true; };
    const onEnd = () => { isComposingRef.current = false; };

    input.addEventListener('compositionstart', onStart);
    input.addEventListener('compositionend', onEnd);
    return () => {
      input.removeEventListener('compositionstart', onStart);
      input.removeEventListener('compositionend', onEnd);
    };
  }, []);

  const { run: debouncedSearch } = useDebounceFn(
    (keyword: string) => {
      if (isComposingRef.current) return;
      run(keyword);
    },
    { wait },
  );

  return { debouncedSearch, ref };
}

更完整的使用示例(结合 Ant Design Select):

import { useComposingSearch } from 'ahooks';
import { Select } from 'antd';

const GameSearchSelect = () => {
  const [options, setOptions] = useState([]);

  const { debouncedSearch, ref } = useComposingSearch(
    async (keyword) => {
      const result = await fetchGames(keyword);
      setOptions(result);
    },
    { wait: 300 }
  );

  return (
    <Select
      ref={ref}
      showSearch
      onSearch={debouncedSearch}
      options={options}
      filterOption={false}
      placeholder="输入游戏名称或 AppID 搜索"
    />
  );
};

优势:

  • 封装完整:ref 绑定、事件监听、防抖逻辑三位一体,用户零样板代码
  • 单一职责:useDebounceFn 继续做纯防抖,useComposingSearch 专注搜索场景
  • 不可能用错:返回的 ref 必须绑定到组件,漏绑 TypeScript 就会报类型错误
  • 语义清晰:hook 名字直接表达"感知输入法的搜索",见名知义

方案对比

维度 方案 A:useDebounceFn 加选项 方案 B:独立 useComposingSearch
是否需要改 ahooks 源码 ,需改动 useDebounceFn 的接口和实现 ,纯新增 hook
用户代码量 多(需手动写 useEffect 绑定事件) 少(一行调用)
单一职责 违反(纯逻辑 hook 混入 DOM 事件) 符合(搜索场景完整封装)
出错风险 高(开了 imeAware 但忘了绑事件,静默错误) 低(ref 漏绑 TypeScript 报错)
API 认知负担 增加(非搜索场景的用户要理解 imeAware 是什么) 无(不影响现有 hook)
改动范围 改已有 hook,需谨慎 新增 hook,零破坏性

结论:推荐方案 B,独立 hook 封装更完整、更安全、更符合 ahooks 的 API 风格。

🌍 为什么应该放进 ahooks

  1. 面向 CJK 用户的通用痛点 — 任何服务中文、日文、韩文用户的 React 应用都会遇到,覆盖数十亿用户。

  2. 正确实现并不简单 — 直接给 <Input>onCompositionStart/end 看似简单,但实际有多个坑:

    • Ant Design 的 Select / Input 封装了原生 <input>,需要 ref 穿透才能拿到真实 DOM 节点(ref.current.nativeElement
    • Chrome 与其他浏览器的事件触发顺序不同(compositionendonChange 之前 vs 之后),需正确处理
    • useDebounceFn 的协调需要在防抖回调中检查组词状态
  3. 与 ahooks 现有能力的天然契合 — ahooks 已有 useDebounceFnuseThrottleFnuseEventListeneruseComposingSearch 正好处于防抖与 DOM 事件的交叉领域。

📎 参考资料

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions