项目写久了会发现一个问题:各个页面都在写重复的工具方法。输入框要防抖,每个页面自己写一遍;手机号校验,每个页面复制一段正则;判断空对象、格式化日期,到处都是类似的代码。改一个小地方要翻遍整个项目,漏改一处就出bug。

正确的做法是把这些通用逻辑抽成独立的工具模块,全项目统一调用。本文基于鸿蒙ArkTS,讲清楚防抖、节流、参数校验这些常用工具怎么封装,代码可以直接复制到项目里用。

01 为什么要封装通用工具

先说说到处复制粘贴的坏处。

**代码重复,维护困难。**同一个手机号校验,10个页面写了10份。哪天正则要改,改了9处漏了1处,那个页面就出问题。

**风格不统一。**A同学写的防抖是300毫秒,B同学写的是500毫秒,同一个项目里输入框体验不一致。

**新人上手慢。**新同学不知道项目里已经有这些工具,又自己写一套,越积越多。

抽成统一的工具模块之后,所有页面调用同一个方法,改一处全项目生效。新人来了看一眼工具目录就知道有哪些能力,不用到处找代码。

工具模块的原则很简单:纯函数、无状态、可复用。输入确定,输出就确定,不依赖页面上下文,不修改外部状态。这样的函数放哪里都能跑,不会出副作用。

02 防抖Debounce实现

防抖是最常用的工具之一。搜索框输入的时候,每敲一个字就发一次请求,太浪费了。防抖的思路是:停止输入一段时间之后,才真正执行一次。比如输入完500毫秒之后再搜,中间连续输入就一直重置计时。

// 防抖:停止触发wait毫秒后才执行
function debounce(func: Function, wait: number = 300): Function {
  let timerId: number = -1;

  return (...args: any[]) => {
    // 每次调用都清掉之前的定时器
    if (timerId !== -1) {
      clearTimeout(timerId);
    }
    // 重新计时
    timerId = setTimeout(() => {
      func.apply(this, args);
      timerId = -1;
    }, wait);
  };
}

用的时候这样写:

@Entry
@Component
struct SearchPage {
  @State keyword: string = '';

  // 搜索请求,防抖500毫秒
  private searchApi = debounce((kw: string) => {
    console.info('发起搜索:', kw);
    // 这里调接口
  }, 500);

  build() {
    Column() {
      TextInput({ placeholder: '搜索' })
        .onChange((value: string) => {
          this.keyword = value;
          this.searchApi(value); // 连续输入不会重复请求
        })
    }
  }
}

防抖的关键点:每次触发都清掉上一个定时器,重新开始计时。只有停下来不操作了,时间到了才真正执行。

除了搜索框,按钮防连点也可以用防抖。用户快速点两次提交按钮,只生效第一次,后面的都被过滤掉。

03 节流Throttle实现

节流和防抖不一样。防抖是"等停下来再执行",节流是"每隔一段时间最多执行一次"。比如页面滚动事件,滚动的时候持续触发,节流可以保证每200毫秒最多处理一次,不会卡。

// 节流:每隔wait毫秒最多执行一次
function throttle(func: Function, wait: number = 200): Function {
  let lastTime: number = 0;
  let timerId: number = -1;

  return (...args: any[]) => {
    const now = Date.now();
    const remaining = wait - (now - lastTime);

    if (remaining <= 0) {
      // 距离上次执行已经超过间隔,直接执行
      if (timerId !== -1) {
        clearTimeout(timerId);
        timerId = -1;
      }
      lastTime = now;
      func.apply(this, args);
    } else if (timerId === -1) {
      // 还在等待期,设个定时器在最后执行一次
      timerId = setTimeout(() => {
        lastTime = Date.now();
        timerId = -1;
        func.apply(this, args);
      }, remaining);
    }
  };
}

防抖和节流的区别:

  • 防抖:等你停下来再执行,适合搜索、输入框

  • 节流:固定频率执行,适合滚动、拖拽、窗口大小变化

比如页面滚动加载更多,用节流保证每200毫秒最多算一次位置,不然滚动一次触发几十次计算,性能直接掉下来。

04 参数校验工具类

表单提交的时候,手机号、邮箱、身份证、必填项,这些校验逻辑每个页面都要写。抽成统一的校验工具类,调用一行就行。

class ValidateUtil {
  // 必填校验
  static required(value: string, label: string = '该项'): string | null {
    if (value === null || value === undefined || value.trim() === '') {
      return `${label}不能为空`;
    }
    return null;
  }

  // 手机号校验
  static phone(value: string): boolean {
    const reg = /^1[3-9]\d{9}$/;
    return reg.test(value);
  }

  // 邮箱校验
  static email(value: string): boolean {
    const reg = /^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
    return reg.test(value);
  }

  // 身份证校验
  static idCard(value: string): boolean {
    const reg = /^\d{17}[\dXx]$/;
    return reg.test(value);
  }

  // 数字范围校验
  static numberRange(value: number, min: number, max: number): boolean {
    return value >= min && value <= max;
  }

  // 长度校验
  static length(value: string, min: number, max: number): boolean {
    return value.length >= min && value.length <= max;
  }
}

表单提交的时候这样用:

function submitForm(phone: string, email: string, age: number): string | null {
  // 依次校验,有错误直接返回
  const phoneError = ValidateUtil.required(phone, '手机号');
  if (phoneError) return phoneError;
  if (!ValidateUtil.phone(phone)) return '手机号格式不正确';

  const emailError = ValidateUtil.required(email, '邮箱');
  if (emailError) return emailError;
  if (!ValidateUtil.email(email)) return '邮箱格式不正确';

  if (!ValidateUtil.numberRange(age, 18, 100)) return '年龄需在18-100岁之间';

  // 全部通过,提交
  console.info('校验通过,提交表单');
  return null;
}

校验逻辑集中在一个类里,以后要加新的校验规则,比如车牌号、银行卡号,直接在ValidateUtil里加方法就行。所有页面自动用最新的校验规则,不用一个个改。

05 其他常用工具

除了防抖节流和校验,还有几个工具方法几乎每个项目都用得上。

**空值判断。**判断一个对象是不是空的,数组是不是空的,字符串是不是全空格。

class ObjectUtil {
  // 判断是否为空(null/undefined/空字符串/空数组/空对象)
  static isEmpty(value: any): boolean {
    if (value === null || value === undefined) return true;
    if (typeof value === 'string') return value.trim() === '';
    if (Array.isArray(value)) return value.length === 0;
    if (typeof value === 'object') return Object.keys(value).length === 0;
    return false;
  }

  // 判断是否不为空
  static isNotEmpty(value: any): boolean {
    return !this.isEmpty(value);
  }
}

**日期格式化。**把时间戳转成"2024-01-15 14:30"这种格式。

class DateUtil {
  // 时间戳转格式化字符串
  static format(timestamp: number, format: string = 'YYYY-MM-DD HH:mm:ss'): string {
    const date = new Date(timestamp);
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    const hours = String(date.getHours()).padStart(2, '0');
    const minutes = String(date.getMinutes()).padStart(2, '0');
    const seconds = String(date.getSeconds()).padStart(2, '0');

    return format
      .replace('YYYY', String(year))
      .replace('MM', month)
      .replace('DD', day)
      .replace('HH', hours)
      .replace('mm', minutes)
      .replace('ss', seconds);
  }

  // 相对时间:"刚刚"、"5分钟前"、"昨天"
  static fromNow(timestamp: number): string {
    const diff = Date.now() - timestamp;
    const minute = 60 * 1000;
    const hour = 60 * minute;
    const day = 24 * hour;

    if (diff < minute) return '刚刚';
    if (diff < hour) return `${Math.floor(diff / minute)}分钟前`;
    if (diff < day) return `${Math.floor(diff / hour)}小时前`;
    return this.format(timestamp, 'YYYY-MM-DD');
  }
}

**深拷贝。**复制一个对象,修改副本不影响原对象。

function deepClone&lt;T&gt;(obj: T): T {
  if (obj === null || typeof obj !== 'object') return obj;
  return JSON.parse(JSON.stringify(obj));
}

这些小工具看起来简单,但到处都用得到。统一封装之后,不用每个页面自己写一遍。

06 完整工具模块组织

工具函数多了,要按类型分文件管理,不要全塞在一个文件里。项目结构大概是这样:

common/utils/
├── DebounceUtil.ets    # 防抖节流
├── ValidateUtil.ets    # 参数校验
├── ObjectUtil.ets      # 对象/空值判断
├── DateUtil.ets        # 日期格式化
├── StringUtil.ets      # 字符串处理
├── DeviceUtil.ets      # 设备信息
└── Index.ets           # 统一导出

Index.ets里统一导出,外面import的时候只从一个入口进:

// common/utils/Index.ets
export { debounce, throttle } from './DebounceUtil';
export { ValidateUtil } from './ValidateUtil';
export { ObjectUtil } from './ObjectUtil';
export { DateUtil } from './DateUtil';
export { deepClone } from './ObjectUtil';

// 外面使用:
// import { debounce, ValidateUtil, DateUtil } from '../common/utils';

这样做的好处是:外面调用的人不用知道具体哪个文件里有什么,统一从Index.ets导出就行。以后工具类内部怎么拆分、怎么重命名,外面调用的代码都不用改。

落地注意事项

**工具函数要纯。**不要在工具函数里改全局变量、不要发网络请求、不要操作UI。纯函数输入输出,这样才好测试,不会出副作用。

**命名要清晰。**防抖就叫debounce,校验就叫validatePhone,不要起什么a、b、test这种名字。看函数名就知道干什么的,不用读实现。

**不要过度封装。**只有两三个地方用到的逻辑,不用急着抽工具类。等真的重复到第三处了,再抽出来。过早封装反而增加复杂度。

**加注释和用例。**复杂一点的工具函数,写清楚参数是什么、返回值是什么、用的时候要注意什么。不然过两个月自己都忘了这个函数是干嘛的。

小结

通用工具模块的核心就是:把重复的逻辑抽出来,全项目统一调用。防抖节流解决高频触发的性能问题,参数校验解决表单重复代码的问题,日期、空值判断这些小工具把常用逻辑集中管理。

实际项目里,工具模块不用一开始就做全。写代码的时候发现某个逻辑在第三个地方重复了,就把它抽出来。时间久了,自然就形成一套完整的工具库。新同学来了看一眼工具目录,就知道项目里有哪些现成能力可以用。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐