alibaba / alibaba/hooks

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

Open
#2,939 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
15k
Forks
2.8k
Avg merge
15h 7m
Merged PRs (30d)
2

Description

## 🤔 问题描述

在使用 `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:给 `useDebounceFn` 加 `imeAware` 选项

当前 `useDebounceFn` 的 Options 只支持 `wait`、`leading`、`trailing`、`maxWait` 四个纯防抖参数。要走这个方案,**必须修改 `useDebounceFn` 源码**,新增 `imeAware` 和 `composingRef` 两个选项。

改动后的 API 设想:

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

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

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

```tsx
const inputRef = useRef(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 ;
```

**优势:**
- 不新增 hook,表面改动最小
- 对已有 `useDebounceFn` 用户只需加一个选项

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

---

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

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

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

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

实际使用:

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

return ;
```

参考实现:

```typescript
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(null);

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

const input = el.querySelector('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):

```tsx
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 (

);
};
```

**优势:**
- 封装完整: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. **正确实现并不简单** — 直接给 `` 加 `onCompositionStart/end` 看似简单,但实际有多个坑:
- Ant Design 的 `Select` / `Input` 封装了原生 ``,需要 ref 穿透才能拿到真实 DOM 节点(`ref.current.nativeElement`)
- Chrome 与其他浏览器的事件触发顺序不同(`compositionend` 在 `onChange` 之前 vs 之后),需正确处理
- 与 `useDebounceFn` 的协调需要在防抖回调中检查组词状态

3. **与 ahooks 现有能力的天然契合** — ahooks 已有 `useDebounceFn`、`useThrottleFn`、`useEventListener`,`useComposingSearch` 正好处于防抖与 DOM 事件的交叉领域。

## 📎 参考资料

- [MDN - CompositionEvent](https://developer.mozilla.org/zh-CN/docs/Web/API/CompositionEvent)
- [React 输入法问题的长期讨论](https://github.com/facebook/react/issues/3926)
- [Chrome 与其他浏览器的事件顺序差异](https://developer.aliyun.com/article/604992)
- Vue 的 `v-model` 已自动处理此问题:https://vuejs.org/guide/essentials/forms.html

Contributor guide

Open the contributing guide

Research direction

Start by locating the existing useDebounceFn entry point and the package's hook export surface; no source file or test is named in the issue. Verify the proposed ref with native inputs and wrapped components such as Ant Design Select, then add coverage for compositionstart/compositionend suppression and confirmed searches. Done means composition updates are skipped while confirmed searches invoke run with the existing debounce behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
frontend
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.