如何无侵入式地为 React Native 按钮添加防抖功能

用户快速连点时,若不处理,onPress 会多次触发,导致重复提交、重复跳转等问题。给 PressableTouchableOpacity 等按钮组件加上防抖是常见需求。

若希望业务里继续写 import { Pressable } from 'react-native',只需在 props 里多传 debounceTimedebounceDisabledpending 等即可生效,可采用 无侵入增强 React Native 组件方案 中的 Babel 别名 + react-native-proxy + Shim 方案,对 PressableTouchableOpacity 等返回按钮 Shim。同时通过 TypeScript 模块增强react-native.d.ts)声明这些额外属性,让类型和 IDE 自动补全能感知。

思路

  1. 用 Babel 的 module-resolverreact-native 指到项目内的 react-native-proxy.js(若已为其他 Shim 配过则复用)。
  2. proxy 对 PressableTouchableOpacityTouchableHighlightTouchableWithoutFeedback 返回对应的 Shim,其余组件透传。
  3. 每个 Shim 从 props 中读取 debounceTime / debounceDisabled / pending,用统一的 debounce 工具 生成防抖后的 onPress,再传给真实 RN 组件;并把这三个自定义 props 从传给原生组件的 props 里剔除。
  4. 在项目根目录的 react-native.d.ts 里对 PressablePropsTouchableOpacityProps 等做模块增强,声明上述三个可选属性,这样 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.jsmodule-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 里去掉 debounceTimedebounceDisabledpending,避免 RN 报未知 prop。
  • useDebouncedOnPressleading: 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 结构相同,仅把 PressablePropsRN.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.jsoninclude 包含该文件(例如 "include": ["**/*.ts", "**/*.tsx", "react-native.d.ts"]),或把它放在已被 include 的目录下。

完成后,在业务组件里使用 debounceTimedebounceDisabledpending 时会有类型提示和检查,也不会被标记为未知属性。

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 aliasreact-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 态,且类型安全。

上次更新: