Skip to content
高阶函数:参数与返回之外的工程设计
概述
高阶函数的标准定义是:接收函数作为参数,或返回一个函数。
这个定义本身无误,但并没有回答“为什么要用高阶函数”。在工程实践中,几乎所有高阶函数的用途都可以归入三个方向:
- 延迟执行 —— 将“做什么”与“什么时候做”解耦(debounce、throttle、once)
- 接口转换 —— 改变函数的调用方式(curry、partial、compose、pipe)
- 行为增强 —— 在函数执行前后插入额外逻辑(memoize)
下面分别讨论这三个方向。
延迟执行:控制调用时机
debounce
基本概念
debounce 的语义是:在一连串连续调用中,只执行最后一次。
javascript
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}每次调用都会重置计时器。只要两次调用的间隔小于 delay,fn 就不会执行。典型的应用是搜索框输入——快速键入时,只有停止输入才会真正向服务端发请求。
增强选项
基础实现只覆盖了最核心的语义。工程中使用的 debounce 通常还需要处理:
- leading edge:首次调用是否立即执行(用户输入第一个字符即触发反馈,无需等待
delay) - trailing edge:在调用停止后是否执行最后一次(默认行为)
- maxWait:即便持续操作,也要保证每 N 毫秒至少执行一次(防止无限等待)
- cancel:允许外部取消还在等待中的调用(组件销毁时清理定时器)
下面是一个支持这些选项的实现:
javascript
function debounce(fn, delay, {
leading = false,
trailing = true,
maxWait = Infinity
} = {}) {
let timer = null;
let lastInvokeTime = 0;
let lastArgs;
let leadingInvoked = false;
function invoke() {
lastInvokeTime = Date.now();
fn.apply(this, lastArgs);
}
function debounced(...args) {
lastArgs = args;
const now = Date.now();
// leading 首次立即执行
if (leading && !leadingInvoked) {
leadingInvoked = true;
fn.apply(this, args);
}
// maxWait 保证间隔内至少执行一次
if (maxWait !== Infinity && now - lastInvokeTime >= maxWait) {
invoke.call(this);
}
clearTimeout(timer);
timer = setTimeout(() => {
if (trailing && lastArgs) {
invoke.call(this);
}
leadingInvoked = false;
timer = null;
}, delay);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
leadingInvoked = false;
};
return debounced;
}注意点
leading 与 trailing 的组合行为需要根据场景显式设置。
leading: false, trailing: true(默认):只在调用停止后执行最后一次,适合窗口 resize 回调(中间尺寸无意义)。leading: true, trailing: false:首次立即执行,后续防抖期内不再触发,适合按钮防重复点击(只响应首次点击)。leading: true, trailing: true:首次立即执行,并且在调用停止后还会执行最后一次。搜索建议场景有时用此配置——用户输入第一个字符即刻反应,停止输入后再次确认。注意这会导致一次交互周期内函数被调用两次。maxWait用于限制防抖的最长等待,防止用户持续操作时函数永远得不到执行。
throttle
基本概念
throttle 的语义是:在连续调用中,保证至少每隔 N 毫秒执行一次。
javascript
function throttle(fn, interval, { leading = true, trailing = true } = {}) {
let timer = null;
let lastInvokeTime = 0;
function throttled(...args) {
const now = Date.now();
if (lastInvokeTime === 0 && !leading) {
lastInvokeTime = now;
}
const remaining = interval - (now - lastInvokeTime);
if (remaining <= 0) {
lastInvokeTime = now;
fn.apply(this, args);
} else if (trailing) {
clearTimeout(timer);
timer = setTimeout(() => {
lastInvokeTime = leading ? Date.now() : 0;
fn.apply(this, args);
}, remaining);
}
}
throttled.cancel = () => {
clearTimeout(timer);
timer = null;
lastInvokeTime = 0;
};
return throttled;
}应用场景
debounce 与 throttle 的区分不在于优劣,而在于各自适合不同的需求:
| 场景 | debounce | throttle |
|---|---|---|
| 搜索框输入 | 等待用户停止后才查询 | 中间结果用户不关心 |
| 滚动事件 | 停止滚动才更新,可能出现空白 | 每 16ms 更新一次,视觉流畅 |
| 窗口 resize | 只关心最终尺寸 | 中间尺寸无意义 |
| 按钮防重复点击 | 只执行第一次或最后一次 | 持续点击下仍会多次触发 |
once
javascript
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}once 是形式最简单的高阶函数之一。它通过闭包维护一个标志位来限制函数的执行权限。同样的模式在 single-flight(请求去重)、单例工厂、懒初始化等场景中反复出现。
接口转换:改变调用方式
curry 与 partial
curry 和 partial 都用于参数的预设,但语义不同。
- curry:将
fn(a, b, c)转换为fn(a)(b)(c)。参数个数由fn.length决定,每次调用只传一个参数。 - partial:将
fn(a, b, c)转换为fn(预设a, 预设b)(c)。一次性预设任意数量的参数。
javascript
// curry:每次接收一个参数,累计到 fn.length 后才真正执行
function curry(fn, args = []) {
return (...nextArgs) => {
const all = [...args, ...nextArgs];
return all.length >= fn.length ? fn(...all) : curry(fn, all);
};
}
// partial:一次性预设部分参数,返回接收剩余参数的新函数
function partial(fn, ...presetArgs) {
return (...laterArgs) => fn(...presetArgs, ...laterArgs);
}
const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 6
const addFive = partial(add, 2, 3);
addFive(4); // 9 — 等价于 add(2, 3, 4)curry 的作用是分步提供参数,使函数可以逐层注入配置,适合在 pipeline 中逐步构造参数。partial 的作用则是预填充固定前置参数,例如绑定 API endpoint 或默认选项。
curry 的一个工程限制在于它依赖 fn.length 来判断参数的数量。如果函数包含默认参数或剩余参数,fn.length 的值并不反映真实的可调用参数数量。实用的 curry 实现通常会支持显式指定 arity(参数个数)。
使用场景
curry 和 partial 本身在日常业务代码中出现频率并不高——写 add(1)(2)(3) 不会比 add(1, 2, 3) 更自然。它们的价值更多体现在库、框架和工具函数的设计层面。当需要设计一个支持“分步配置”的通用接口时,curry 提供了一种结构化的方式。例如 Express/Koa 的中间件模型本质上是 partial 的体现——req 和 res 由框架预先填充,业务代码只需关注 next。
掌握 curry 和 partial,是为了在需要分步配置时能够识别这一模式,进而设计出合理的接口,而不是不加区别地在所有地方使用它们。
compose 与 pipe
javascript
// compose:从右到左执行,f(g(h(x)))
const compose = (...fns) => x => fns.reduceRight((v, fn) => fn(v), x);
// pipe:从左到右执行,x → h → g → f
const pipe = (...fns) => x => fns.reduce((v, fn) => fn(v), x);compose 与 pipe 的选择主要取决于阅读习惯。数据处理流水线(pipeline)通常用 pipe(数据从左到右自然流动)。数学风格的函数变换多用 compose(与数学记号 f ∘ g ∘ h 的方向一致)。
对 compose/pipe 而言,方向只是表面问题。更复杂的地方在于 TypeScript 中的类型推导。简单的实现会导致中间类型丢失:
typescript
// 返回类型被推断为 any
const result = pipe(
(s: string) => s.length,
(n: number) => n * 2,
(n: number) => `result: ${n}`
)('hello');要保留类型安全,就需要为不同参数数量的 pipe 提供重载声明:
typescript
function pipe<A, B>(a: (x: A) => B): (x: A) => B;
function pipe<A, B, C>(a: (x: A) => B, b: (x: B) => C): (x: A) => C;
function pipe<A, B, C, D>(a: (x: A) => B, b: (x: B) => C, c: (x: C) => D): (x: A) => D;
function pipe(...fns: Function[]) {
return (x: any) => fns.reduce((v, fn) => fn(v), x);
}函数式编程库(如 fp-ts)为 pipe 提供了完整的类型安全实现,代价是大量的函数重载声明。如果不引入这类库,对于超过三层的组合,直接写函数调用在可读性上通常更为清晰。
行为增强:为函数添加辅助逻辑
memoize
javascript
function memoize(fn, { maxSize = 100, ttl = Infinity } = {}) {
const cache = new Map();
function memoized(...args) {
const key = JSON.stringify(args);
const entry = cache.get(key);
if (entry && ttl !== Infinity && Date.now() - entry.timestamp > ttl) {
cache.delete(key);
} else if (entry) {
return entry.value;
}
const result = fn.apply(this, args);
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, { value: result, timestamp: Date.now() });
return result;
}
memoized.cache = cache;
memoized.clear = () => cache.clear();
return memoized;
}该实现涉及三个设计决策:
缓存 key 的生成方式
默认使用JSON.stringify(args)。优点是实现简单,缺点是参数必须可序列化,且参数顺序敏感(fn(1, 2)与fn(2, 1)的 key 不同,但语义上可能是同一个调用)。对于参数较复杂的函数,应允许通过配置项传入自定义的 key 生成函数。javascriptfunction memoize(fn, { resolver = (...args) => JSON.stringify(args) } = {}) { // 使用 resolver 生成 key }maxSize 的默认值
100 对大部分纯函数场景已经足够——重复输入通常集中在有限的取值集合中。可根据实际的命中率监控数据进行调整,但更大的缓存意味着更高的内存占用。TTL 机制
当fn的返回值不仅仅由参数决定(例如依赖外部数据)时,需要基于时间的缓存失效。如果fn是纯函数且外部数据不会变化,TTL 可以设为Infinity以关闭定期失效。
memoize 是否有效取决于两个因素:fn 的计算成本,以及参数的重复概率。如果 JSON.stringify(args) 的耗时已经大于 fn 本身的计算时间,memoize 反而会拖慢整体性能。一个可供参考的经验阈值是:仅对单次计算耗时超过 1ms 的纯函数使用 memoize。
高阶函数的组合与限制
高阶函数可以嵌套组合,但层数直接影响认知负载和执行开销。
javascript
// 两层组合:意图清晰
const debouncedSearch = debounce(searchAPI, 300);
// 三层组合:需要理清执行顺序
const optimizedSearch = compose(
memoize, // 3. 缓存结果
debounce // 2. 延迟 300ms
)(searchAPI); // 1. 原始函数compose 从右到左执行,pipe 从左到右。每增加一层包装都会额外增加函数调用。当组合超过两层时,可以从以下几个方面审视:
- 可读性:
const debouncedAndCached = memoize(debounce(fn, 300))通常比组合链更直观。 - 性能叠加:三层包装意味着每次调用额外增加 3–6 次函数调用路径。在热点路径上应关注这一开销。
- 调试难度:包装层数越多,调用栈越深。保持包装在两层以内有利于排查问题。
常见问题
1. compose(f, g)(x) 和 f(g(x)) 有什么区别?
直接写 f(g(x)) 时,x 在写代码时就已经求值——这是一个“一次性”的表达式。compose(f, g) 返回一个新函数,可以接收任何 x,也可以作为中间步骤放到 pipeline 中继续组合。
compose 适合用在“x 由上游传入”或“需要把组合结果作为整体向外传递”的场景,例如 arr.map(compose(f, g))。
2. debounce 的 delay 该设为多少?
delay 没有一成不变的数值,取决于用户的典型操作速度和系统能容忍的延迟上限。
搜索建议场景中,用户键盘输入间隔通常在 50–200ms。delay 设为 200–300ms 能让多数用户在查询触发前完成输入。但用户的感知延迟也需考虑——如果停止输入后还要等 300ms 才看到建议,体感会比 150ms 明显慢。因此搜索建议的 delay 多在 150–250ms。窗口 resize 场景下,delay 设为 100–200ms 就足够。按钮防重复点击的 delay 应大于用户双击的最大间隔(约 300–500ms),设为 500ms 是保守但安全的取值。
如果需要更精确的 delay,可通过埋点记录用户的实际操作间隔分布,取 P95 作为参考值。
3. memoize 使用 JSON.stringify 做缓存 key 有哪些限制?
- 参数顺序敏感:
fn(1, 2)和fn(2, 1)的 key 不同,但语义上可能等价。 - 不可序列化值:
undefined、Symbol、Function、BigInt无法被JSON.stringify正确处理。 - 大对象性能开销:对于 10KB 级别的对象参数,序列化的耗时可能超过
fn本身。 - 对象属性顺序差异:两个语义等价但属性声明顺序不同的对象,序列化的输出不同。
应对方式是为 resolver 提供自定义实现,以便针对实际参数结构生成 key。
4. React 的 useCallback 与手写 memoize 的区别是什么?
useCallback 的缓存失效依据是依赖数组——依赖项发生变化时,返回新的函数引用。其目标不是缓存计算结果,而是保持函数引用的稳定性,以减少子组件的重复渲染。
手写 memoize 的缓存失效依据是参数值——相同参数返回相同结果。其目标是缓存计算结果,避免重复执行高开销的纯函数。
useMemo 介于两者之间,它是依赖数组驱动的 memoize。但 useMemo 的缓存仅在同一组件实例中有效,且 React 保留在任何时机丢弃缓存的权力。
选择依据:
- 保持函数引用稳定以避免子组件渲染 →
useCallback - 缓存高开销的派生计算结果 →
useMemo - 需要跨组件、跨渲染周期的缓存 → 手写
memoize配合useRef
