ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Promise-aware防抖节流:TypeScript异步函数封装实践

Promise-aware防抖节流:TypeScript异步函数封装实践 平时用 lodash 的debounce和throttle处理输入防抖、按钮节流时大多数场景都是同步回调用起来很顺手。可一旦业务函数改成async函数问题就暴露出来了防抖窗口内的多次调用各自返回新的 Promise调用方等待的结果可能是undefined也可能是被丢弃的旧请求结果甚至因为某个请求 reject 导致界面出现Uncaught (in promise)错误。最近我把社区里常见的防抖节流封装重新梳理了一遍用 TypeScript 实现了一套Promise-aware 的 debounce 和 throttle 方案把返回值语义、错误传播、类型推导这些问题都处理好了。这篇文章会从原理讲到完整代码再给出单测示例和排错思路适合有 TypeScript 基础、正在做前端中后台项目或封装通用工具的开发者阅读。学完之后你可以直接把这套封装接入自己的项目。1. 背景为什么需要 Promise-aware 的防抖与节流1.1 防抖与节流的基本概念防抖debounce和节流throttle是前端性能优化中最常用的两类时间控制函数。防抖的核心逻辑是函数在连续触发时不会执行只有最后一次触发结束并等待指定时间后才会执行一次。最典型的场景是搜索框输入联想。用户连续输入时如果每次都请求后端接口会产生大量无效请求使用防抖后输入停顿 300ms 才发起一次搜索请求。节流的核心逻辑是函数在指定时间窗口内最多执行一次。无论触发多少次都只按固定频率执行。典型场景是页面滚动事件上报、窗口 resize 后重新计算布局、用户连续点击提交按钮。常规的 debounce 实现只处理同步函数核心状态是定时器 ID 和最新参数。函数返回值如果不是 Promise调用方往往不需要关心“未执行的那几次调用返回了什么”。1.2 传统实现处理异步函数时的痛点当业务函数变成异步函数时传统实现会暴露出三个问题。第一个问题是调用方拿不到结果。普通的 debounce 封装中连续调用返回的都是undefined因为函数根本没有立即执行。对于需要等待搜索结果的场景调用方无法await到一个真实值。第二个问题是Promise 状态无人处理。如果异步函数内部抛错错误会成为一个没有被监听 reject 的 Promise浏览器控制台就会报出Uncaught (in promise)。这通常不是业务逻辑错误而是封装层没有把错误传递给调用方。第三个问题是结果错乱。即使每次调用都返回一个新的 Promise由于 debounce 会丢弃前面的调用这些 Promise 也永远不会被 resolve。调用方等待的可能是第一次调用的 Promise但真正执行的却是最后一次调用二者没有关联。Promise-aware 的封装要解决的核心问题就是让所有被合并的调用共享同一个 Promise并且按照确定性的规则 resolve/reject。1.3 Promise-aware 的设计目标一套合格的 Promise-aware debounce/throttle 封装应当满足以下目标设计目标说明返回值是 Promise调用方可以await也可以显式catch合并调用共享结果窗口内被合并的调用最终拿到与真正执行那次相同的结果错误正确传播异步函数 throw 时所有等待该结果的调用方都收到 reject类型完整输入函数参数类型和返回值类型能自动推导可配置debounce 支持maxWaitthrottle 支持 leading/trailing下面我们就从环境准备开始完整实现一套这样的工具库。2. 环境准备与项目初始化2.1 开发环境说明本文的核心代码是纯 TypeScript 类型和函数不依赖任何运行时框架所以环境要求非常宽松。Node.js建议使用 18 或更高版本方便在本地直接运行 TypeScript 脚本。TypeScript建议使用 4.5 及以上版本因为代码中用到了Awaited工具类型。Awaited是在 TypeScript 4.5 版本中引入的。包管理器npm、pnpm、yarn 都可以本文以 npm 为例。需要说明的是当前 TypeScript 版本对baseUrl配置项逐渐弃用项目可以不使用baseUrl通过moduleResolution: Bundler或相对路径即可完成模块解析避免后续升级到 TypeScript 7.0 时出现配置废弃问题。2.2 创建项目并安装依赖先在本地创建一个空目录mkdir promise-debounce-throttle cd promise-debounce-throttle npm init -y安装开发依赖npm install -D typescript tsx vitesttypescript提供类型检查和编译能力。tsx用于直接运行 TypeScript 文件不用先编译再运行。vitest用于编写单测验证 debounce 和 throttle 的行为。2.3 TypeScript 配置文件创建tsconfig.json{ compilerOptions: { target: ES2020, module: ESNext, moduleResolution: Bundler, lib: [ES2020, DOM], strict: true, declaration: true, outDir: dist, skipLibCheck: true }, include: [src, test, examples] }几个关键配置项说明target: ES2020允许代码使用Promise、setTimeout等 ES2020 标准 API。lib中加入DOM是因为浏览器环境和 Node.js 环境都有setTimeout的类型定义。moduleResolution: Bundler是较新的解析策略适合使用 npm 包和相对路径混合的场景。strict: true必须开启这套封装的类型安全依赖于严格空值检查。2.4 项目目录结构后续代码会放在下面的结构中promise-debounce-throttle/ ├── package.json ├── tsconfig.json ├── src/ │ ├── debounce.ts │ └── throttle.ts ├── examples/ │ └── demo.ts └── test/ ├── debounce.test.ts └── throttle.test.ts创建目录mkdir src examples test3. 实现 Promise-aware debounce3.1 函数签名设计先定义 debounce 的输入输出类型。我们期望传入一个异步函数并返回一个新的函数。新函数参数类型与原函数保持一致但返回值必须是一个 Promise。type AnyFunction (...args: any[]) any; export interface DebounceOptions { /** * 最大等待时间毫秒。 * 如果持续有调用进来防抖延迟可能永远不会到期 * 通过 maxWait 可以保证函数一定执行一次。 */ maxWait?: number; } export function debouncePromiseF extends AnyFunction( fn: F, wait: number, options: DebounceOptions {} ): (...args: ParametersF) PromiseAwaitedReturnTypeF { // ... }这里最关键的是返回值类型PromiseAwaitedReturnTypeF。如果F是(keyword: string) PromiseSearchResult那么返回类型就是(keyword: string) PromiseSearchResult调用处的类型信息不会丢失。如果F是同步函数(x: number) number返回类型也是(x: number) Promisenumber语义上依然是正确的同步函数也会被 Promise 包装。3.2 核心状态说明实现 debounce 需要维护以下状态状态变量作用timer防抖延迟定时器每次新调用都会重置maxTimer最大等待定时器一旦设置不会因新调用重置latestArgs最新一次调用的参数执行时使用它pendingPromise当前窗口内所有调用共享的 PromiseresolveRef对 pendingPromise 的 resolve 引用rejectRef对 pendingPromise 的 reject 引用pendingPromise是整套设计的核心。窗口内第一次调用时创建后续调用直接返回同一个 Promise。真正执行异步函数后再通过resolveRef或rejectRef让所有等待方拿到结果或错误。3.3 完整实现代码如下建议直接复制到src/debounce.ts// src/debounce.ts type AnyFunction (...args: any[]) any; export interface DebounceOptions { /** * 最大等待时间毫秒。 * 如果持续有调用进来防抖延迟可能永远不会到期 * 通过 maxWait 可以保证函数一定执行一次。 */ maxWait?: number; } export function debouncePromiseF extends AnyFunction( fn: F, wait: number, options: DebounceOptions {} ): (...args: ParametersF) PromiseAwaitedReturnTypeF { let timer: ReturnTypetypeof setTimeout | null null; let maxTimer: ReturnTypetypeof setTimeout | null null; let latestArgs: ParametersF | null null; let resolveRef: ((value: AwaitedReturnTypeF) void) | null null; let rejectRef: ((reason?: unknown) void) | null null; let pendingPromise: PromiseAwaitedReturnTypeF | null null; const clearTimers () { if (timer) { clearTimeout(timer); timer null; } if (maxTimer) { clearTimeout(maxTimer); maxTimer null; } }; const settle ( result: AwaitedReturnTypeF | undefined, error: unknown ) { const resolve resolveRef; const reject rejectRef; pendingPromise null; resolveRef null; rejectRef null; if (error ! undefined) { reject?.(error); } else { resolve?.(result as AwaitedReturnTypeF); } }; const execute async () { const args latestArgs; if (!args) { return; } clearTimers(); latestArgs null; try { const result await fn(...args); settle(result, undefined); } catch (error) { settle(undefined, error); } }; return (...args: ParametersF): PromiseAwaitedReturnTypeF { latestArgs args; if (timer) { clearTimeout(timer); } if (!pendingPromise) { pendingPromise new PromiseAwaitedReturnTypeF((resolve, reject) { resolveRef resolve; rejectRef reject; }); } timer setTimeout(() { void execute(); }, wait); const { maxWait } options; if (maxWait !maxTimer) { maxTimer setTimeout(() { void execute(); }, maxWait); } return pendingPromise; }; }3.4 关键设计说明settle方法负责最终 resolve 或 reject Promise并且在真正调用 resolve 之前就把内部引用清空。这样做的目的是避免“Promise 已经被 resolve但pendingPromise仍然指向旧对象”的边界情况。如果有新调用在微任务间隙到达它会创建一个全新的 Promise而不是拿到一个已经结束的旧 Promise。execute方法读取latestArgs作为本次执行的参数。clearTimers()会把timer和maxTimer都清掉避免同一个窗口内的定时器被触发两次。如果latestArgs为空说明任务已被其他路径处理直接返回。maxWait的逻辑值得单独说明。如果用户持续输入防抖的wait定时器会不断重置导致函数迟迟不执行。maxWait定时器只在第一次创建后续调用不会重置它。这样即使防抖窗口被无限刷新maxWait到期后也会强制执行一次
返回列表