如何无侵入式地为 React Native 按钮添加防抖功能
用户快速连点时,若不处理,onPress 会多次触发,导致重复提交、重复跳转等问题。给 Pressable、TouchableOpacity 等按钮组件加上防抖是常见需求。
若希望业务里继续写 import { Pressable } from 'react-native',只需在 props 里多传 debounceTime、debounceDisabled、pending 等即可生效,可采用 无侵入增强 React Native 组件方案 中的 Babel 别名 + react-native-proxy + Shim 方案,对 Pressable、TouchableOpacity 等返回按钮 Shim。同时通过 TypeScript 模块增强(react-native.d.ts)声明这些额外属性,让类型和 IDE 自动补全能感知。
思路
- 用 Babel 的
module-resolver把react-native指到项目内的react-native-proxy.js(若已为其他 Shim 配过则复用)。 - proxy 对
Pressable、TouchableOpacity、TouchableHighlight、TouchableWithoutFeedback返回对应的 Shim,其余组件透传。 - 每个 Shim 从 props 中读取
debounceTime/debounceDisabled/pending,用统一的 debounce 工具 生成防抖后的onPress,再传给真实 RN 组件;并把这三个自定义 props 从传给原生组件的 props 里剔除。 - 在项目根目录的
react-native.d.ts里对PressableProps、TouchableOpacityProps等做模块增强,声明上述三个可选属性,这样 TypeScript 和编辑器能正确识别与补全。
这样业务侧写 <Pressable debounceTime={400} onPress={...} /> 即可防抖,且类型安全。
1. 安装依赖
本方案依赖 Babel 的 module-resolver 做别名解析,以及防抖逻辑依赖 lodash.debounce(或自实现 debounce)。若尚未安装(若已为其他 Shim 配过可跳过):
# 若尚未安装:用于在 babel.config.js 中配置 react-native 别名
yarn add -D babel-plugin-module-resolver
# 防抖实现依赖
yarn add lodash.debounce
yarn add -D @types/lodash.debounce
2. Babel 配置
在 babel.config.js 的 module-resolver 里为 react-native 配置别名,指向项目中的 proxy 文件(路径按项目结构调整;若已为其他 Shim 配过可跳过):
// babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
[
'module-resolver',
{
root: ['./'],
extensions: ['.ts', '.tsx', '.ios.js', '.android.js', '.js', '.json'],
alias: {
'^react-native$': './app/utils/react-native-proxy.js',
// ... 其他 alias
},
},
],
],
};
3. react-native-proxy 中为按钮组件返回 Shim
在 proxy 里为四个按钮组件返回 Shim:
// app/utils/react-native-proxy.js
const RN = require('../../node_modules/react-native');
module.exports = new Proxy(RN, {
get(target, prop) {
if (prop === 'Pressable') {
if (!module.exports.__PressableShim) {
module.exports.__PressableShim = require('./shim/PressableShim').default;
}
return module.exports.__PressableShim;
}
if (prop === 'TouchableOpacity') {
if (!module.exports.__TouchableOpacityShim) {
module.exports.__TouchableOpacityShim = require('./shim/TouchableOpacityShim').default;
}
return module.exports.__TouchableOpacityShim;
}
if (prop === 'TouchableHighlight') {
if (!module.exports.__TouchableHighlightShim) {
module.exports.__TouchableHighlightShim = require('./shim/TouchableHighlightShim').default;
}
return module.exports.__TouchableHighlightShim;
}
if (prop === 'TouchableWithoutFeedback') {
if (!module.exports.__TouchableWithoutFeedbackShim) {
module.exports.__TouchableWithoutFeedbackShim =
require('./shim/TouchableWithoutFeedbackShim').default;
}
return module.exports.__TouchableWithoutFeedbackShim;
}
// View、Text、Image 等若已有 Shim 可在此一并代理
return target[prop];
},
});
4. 防抖工具(debounce-helpers)
抽成公共模块,供各按钮 Shim 复用:计算实际防抖时间、从 props 中剔除自定义字段、生成防抖后的 onPress(支持 pending 时直接不触发)。
// app/utils/shim/debounce-helpers.ts
import debounce from 'lodash.debounce';
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
const DEFAULT_DEBOUNCE_TIME = 300;
export function getDebounceTime(props: {
debounceTime?: number;
debounceDisabled?: boolean;
}): number {
if (props.debounceTime != null && typeof props.debounceTime === 'number') {
return props.debounceTime;
}
if (props.debounceDisabled === true) return 0;
return DEFAULT_DEBOUNCE_TIME;
}
export function omitDebounceProps<T extends Record<string, any>>(
props: T
): Omit<T, 'debounceTime' | 'debounceDisabled' | 'pending'> {
const { debounceTime: _dt, debounceDisabled: _dd, pending: _p, ...rest } = props;
return rest as Omit<T, 'debounceTime' | 'debounceDisabled' | 'pending'>;
}
export function useDebouncedOnPress(
onPress: ((...args: any[]) => void) | undefined,
debounceTime: number,
debounceDisabled: boolean,
pending: boolean | undefined
) {
const callbackRef = useRef(onPress);
const pendingRef = useRef(pending);
useLayoutEffect(() => {
callbackRef.current = onPress;
pendingRef.current = pending;
});
const stableOnPress = useCallback((...args: any[]) => {
if (pendingRef.current) return;
if (callbackRef.current) callbackRef.current(...args);
}, []);
return useMemo(() => {
if (debounceDisabled || debounceTime <= 0) return stableOnPress;
return debounce(stableOnPress, debounceTime, { leading: true, trailing: false });
}, [stableOnPress, debounceTime, debounceDisabled]);
}
- getDebounceTime:优先用
debounceTime,若debounceDisabled === true则返回 0(不防抖),否则默认 300ms。 - omitDebounceProps:从传给原生组件的 props 里去掉
debounceTime、debounceDisabled、pending,避免 RN 报未知 prop。 - useDebouncedOnPress:
leading: true表示首次点击立即触发,后续在 debounce 时间内忽略;pending === true时直接不调用onPress,便于提交中禁用点击。
5. 按钮 Shim 实现
以 Pressable 为例,其余 Touchable* 同理:从 props 解出 debounceTime / debounceDisabled / pending,用 useDebouncedOnPress 得到防抖后的 onPress,用 omitDebounceProps 得到干净 props 再传给 RN.Pressable。
// app/utils/shim/PressableShim.tsx
import React from 'react';
import type { PressableProps } from 'react-native';
import { getDebounceTime, omitDebounceProps, useDebouncedOnPress } from './debounce-helpers';
const RN = require('../../node_modules/react-native');
const PressableShim = (props: PressableProps) => {
const { onPress, debounceDisabled: dd, pending, ...rest } = props;
const debounceTime = getDebounceTime(props);
const debouncedOnPress = useDebouncedOnPress(
onPress ?? undefined,
debounceTime,
dd === true,
pending
);
const cleanProps = omitDebounceProps({
...rest,
onPress: onPress != null ? debouncedOnPress : undefined,
});
return <RN.Pressable {...cleanProps} />;
};
export default PressableShim;
TouchableOpacity / TouchableHighlight / TouchableWithoutFeedback 的 Shim 结构相同,仅把 PressableProps 和 RN.Pressable 换成对应组件即可。
6. 让 TypeScript 感知额外属性(react-native.d.ts)
业务里会写 <Pressable debounceTime={400} onPress={...} />,若不做类型声明,TypeScript 会报 debounceTime 不存在。通过 模块增强 在 react-native 的类型上扩展这些 props,即可被 TypeScript 和 IDE 识别。
在项目根目录新建(或合并到已有)react-native.d.ts:
// react-native.d.ts(项目根目录)
import 'react-native';
declare module 'react-native' {
export interface PressableProps {
/** 防抖时间,单位毫秒 (默认: 300) */
debounceTime?: number;
/** 是否禁用防抖 (默认: false) */
debounceDisabled?: boolean;
/** 是否处于 pending 状态,为 true 时不触发 onPress (默认: false) */
pending?: boolean;
}
export interface TouchableOpacityProps {
debounceTime?: number;
debounceDisabled?: boolean;
pending?: boolean;
}
export interface TouchableHighlightProps {
debounceTime?: number;
debounceDisabled?: boolean;
pending?: boolean;
}
export interface TouchableWithoutFeedbackProps {
debounceTime?: number;
debounceDisabled?: boolean;
pending?: boolean;
}
export * from 'react-native';
}
注意:
- 必须保留
export * from 'react-native',这样原有类型仍从官方定义解析,我们只是在对应*Props接口上追加字段。 - 确保
tsconfig.json的include包含该文件(例如"include": ["**/*.ts", "**/*.tsx", "react-native.d.ts"]),或把它放在已被 include 的目录下。
完成后,在业务组件里使用 debounceTime、debounceDisabled、pending 时会有类型提示和检查,也不会被标记为未知属性。
7. 使用示例
import { Pressable, Text } from 'react-native';
function SubmitButton() {
const [pending, setPending] = useState(false);
const handleSubmit = async () => {
setPending(true);
try {
await doSubmit();
} finally {
setPending(false);
}
};
return (
<Pressable
debounceTime={400}
pending={pending}
onPress={handleSubmit}
>
<Text>提交</Text>
</Pressable>
);
}
debounceTime={400}:400ms 内多次点击只触发一次。pending={pending}:请求进行中时不响应点击,避免重复提交。
小结
| 步骤 | 说明 |
|---|---|
| Babel alias | 将 react-native 指向 react-native-proxy.js |
| proxy | 对 Pressable、TouchableOpacity、TouchableHighlight、TouchableWithoutFeedback 返回对应 Shim |
| debounce-helpers | 提供 getDebounceTime、omitDebounceProps、useDebouncedOnPress |
| 按钮 Shim | 从 props 读防抖参数,生成防抖 onPress,用 omitDebounceProps 后传给真实 RN 组件 |
| react-native.d.ts | 对 PressableProps 等做模块增强,声明 debounceTime、debounceDisabled、pending,让 TypeScript 与 IDE 感知 |
业务侧继续 import { Pressable } from 'react-native',只需增加 debounceTime / debounceDisabled / pending 即可获得防抖与 pending 态,且类型安全。