Skip to content

常用的高阶函数

高阶函数的定义很简单:接收函数作为参数,或返回一个函数。但这个定义没有回答更重要的问题——为什么需要它们?

每次你写一个高阶函数,你实际上在做三件事之一:

  1. 延迟执行:把"做什么"和"什么时候做"解耦(debounce、throttle、once)
  2. 转换接口:把函数的调用方式从一种变成另一种(curry、partial、compose、pipe)
  3. 增加行为:在函数执行前后插入额外的逻辑(memoize、around advice)

理解了这个分类,就不会把"高阶函数"当成一个需要死记硬背的 API 列表。


延迟执行组:控制函数何时被调用

debounce:等用户停下来

防抖的核心语义:在一连串连续调用中,只执行最后一次。

js
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

每次调用都重置计时器。只要调用间隔小于 delayfn 永远不执行。用户在搜索框中快速输入时,debounce 确保只在用户停止输入后才发请求。

但这只是最简版本。生产级的 debounce 需要三个额外能力:

  • leading edge:第一次调用立即执行(用户输入第一个字符就应该有反馈)
  • maxWait:即使用户一直在操作,也保证最多 N 毫秒执行一次(防止无限等待)
  • cancel:允许外部取消等待中的调用(组件卸载时清理)
js
function debounce(fn, delay, { leading = false, maxWait = Infinity } = {}) {
  let timer = null;
  let lastInvokeTime = 0;
  let leadingInvoked = false;

  function invoke(args) {
    lastInvokeTime = Date.now();
    fn.apply(this, args);
  }

  function debounced(...args) {
    const now = Date.now();

    // leading edge:第一次调用立即执行
    if (leading && !leadingInvoked) {
      leadingInvoked = true;
      invoke.call(this, args);
      return;
    }

    // maxWait:防止无限等待
    if (maxWait !== Infinity && now - lastInvokeTime >= maxWait) {
      invoke.call(this, args);
    }

    clearTimeout(timer);
    timer = setTimeout(() => {
      invoke.call(this, args);
      leadingInvoked = false;
    }, delay);
  }

  debounced.cancel = () => {
    clearTimeout(timer);
    timer = null;
    leadingInvoked = false;
  };

  return debounced;
}

leading: true 适合搜索建议(首字符就有反馈),leading: false 适合窗口 resize 回调(只关心最终尺寸)。

throttle:保证最小间隔

节流的核心语义:在连续调用中,确保至少每隔 N 毫秒执行一次。

js
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) {
      // 距离上次调用已超过 interval → 立即执行
      lastInvokeTime = now;
      fn.apply(this, args);
    } else if (trailing) {
      // 还没到时间 → 设置定时器在 remaining 毫秒后执行
      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✅ 只关心最终尺寸❌ 中间尺寸不需要
按钮防重复点击✅ 只执行一次❌ throttle 会多次执行

once:一次且仅一次

js
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(请求去重)、singleton 工厂、lazy initialization 中反复出现。


接口转换组:改变函数的调用方式

curry vs partial:两种不同的参数预设

curry 和 partial 容易混淆,但两者的语义并不相同。

curry:把一个 fn(a, b, c) 变成 fn(a)(b)(c)。参数数量由 fn.length 决定,每次调用只传一个参数。

partial:把一个 fn(a, b, c) 变成 fn(预设a, 预设b)(c)。一次性预设任意数量的参数。

js
// curry: 每次传一个参数,到达目标数量才执行
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;

// curry 的使用方式
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 6 —— 每次一个参数

// partial 的使用方式
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(参数数量)。

compose vs pipe:组合的方向

js
// 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 的类型推导。简单的实现会丢失中间类型:

ts
// ❌ 类型丢失:返回值类型是 any
const result = pipe(
  (s: string) => s.length,
  (n: number) => n * 2,
  (n: number) => `result: ${n}`
)('hello'); // result 类型是 any

// ✅ 类型安全:需要函数重载
// 简化版——实际生产环境需要逐个声明 2-参数、3-参数、4-参数...的 pipe
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 库(如 fp-ts)为 compose/pipe 提供了完整的类型安全实现,代价是大量的函数重载声明。如果不引入 FP 库,对超过 3 层的 compose/pipe,代码可读性不如直接的函数调用。


行为增强组:在函数周围插入逻辑

memoize:用空间换时间

js
function memoize(fn, { maxSize = 100, ttl = Infinity } = {}) {
  const cache = new Map();

  function memoized(...args) {
    const key = JSON.stringify(args);
    const entry = cache.get(key);

    // TTL 过期
    if (entry && ttl !== Infinity && Date.now() - entry.timestamp > ttl) {
      cache.delete(key);
    } else if (entry) {
      return entry.value;
    }

    const result = fn.apply(this, args);
    // 淘汰策略:满了删最早插入的条目(FIFO,不是 LRU)
    // Map 的迭代顺序是插入顺序,用第一个 key 做淘汰是 FIFO 语义
    // 真正的 LRU 需要每次访问时重新排列条目顺序
    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;
}

三个生产决策:

  1. 缓存 key 用 JSON.stringify(args):简单但有限制——参数必须可序列化、参数顺序敏感(fn(1, 2)fn(2, 1) 是不同的 key)、大对象的序列化成本可能超过 fn 本身的执行成本。对于简单参数这是可接受的折中;对于复杂对象参数,需要自定义 key 生成函数。

  2. maxSize 默认 100:对大多数纯函数足够了(重复输入通常集中在有限集合中)。更大的 maxSize 意味着更多内存占用,需要通过监控数据调整。

  3. TTL 支持:对"参数相同但外部数据可能变化"的场景(比如 API 响应缓存),需要基于时间的失效。若 fn 是纯函数且外部数据不变,TTL 不需要(设置为 Infinity)。

memoize 的性能收益取决于两个因素:fn 的计算成本缓存命中率。如果一个函数的计算只需要 0.001ms 而 JSON.stringify(args) 需要 0.005ms,memoize 反而是负优化。只对计算成本 > 1ms 的纯函数使用 memoize——这是一个有用的经验阈值。


V8 中的数组方法:声明式 vs 命令式的性能边界

mapfilterreduce 这些数组方法也是高阶函数——它们接收回调函数作为参数。但它们和手写 for 循环之间有不可忽视的性能差异。

操作(1M 元素数组)耗时相对 for 循环
for 循环~2ms
forEach~4ms
map (返回新数组)~8ms
filter~5ms2.5×
reduce~3ms1.5×

差异来源:每次回调函数调用都有开销——创建新的执行上下文(虽然不是完整的函数调用,V8 对此有优化,但开销仍在)。for 循环内联执行逻辑,没有函数调用边界。

这并不意味着要用 for 循环替换所有 map。大多数业务场景的数据量都不足以让这种差异成为瓶颈。更常见的取舍仍然是先保留声明式写法,在 profile 确认热点后再局部改成命令式实现。

V8 对数组方法有一系列优化:

  • 内联缓存(IC)map 的回调在多次迭代中走同一 IC 路径,类型稳定时接近 inline。
  • 元素类型(ElementsKind):V8 内部追踪数组的元素类型。如果数组所有元素都是 SMI(小整数),map 走快速路径。
  • holey array 降级:如果数组有空洞([1, , 3]),V8 无法使用快速元素访问,map/filter 性能大幅下降。

高阶函数的组合:复杂度控制

高阶函数可以组合使用,但每多一层嵌套,认知负担就增加一倍:

js
// 两层组合:可读性还好
const debouncedSearch = debounce(searchAPI, 300);

// 三层组合:需要读懂执行顺序
const optimizedSearch = compose(
  memoize,        // 3. 缓存结果
  debounce,       // 2. 等 300ms
)(searchAPI);     // 1. 原始函数

当多个高阶函数组合时,执行顺序从外到内(compose 从右到左,pipe 从左到右),每个包装层都增加了函数调用的开销。对于三层以上的组合,考虑:

  • 显式命名const debouncedAndCached = memoize(debounce(fn, 300)) 比 compose 链更可读
  • 性能叠加:每层包装增加 1-2 次额外的函数调用。3 层包装 = 每次调用多 3-6 次函数调用 → 在热路径上值得关注
  • 调试难度:wrapper 层级越多,stack trace 越难读。在生产代码中,优先保持两层以内的组合

扩展问题

Q1: compose(f, g)(x)f(g(x)) 有什么区别?

直接写 f(g(x))x 的求值在写代码时就确定了——它是"一次性"的。compose(f, g) 返回一个新函数——你可以在任何时候传入不同的 x,可以在 pipeline 中作为中间步骤。

compose 适合用于 x 由上游传入、或需要把组合结果作为一个整体继续传递的场景,例如 arr.map(compose(f, g))

Q2: debounce 的 delay 设多少合适?为什么没有通用答案?

因为 debounce 的"最佳 delay"取决于两个因素:用户的典型操作速度和应用能容忍的延迟上限。

搜索建议:用户在键盘上的输入间隔通常在 50-200ms。delay 设 200-300ms 能让大多数用户在你触发查询前完成输入。但你还需要考虑"用户感知延迟"——如果用户停止输入后等了 300ms 才看到建议,感觉明显比 150ms 慢。所以搜索建议的 delay 通常在 150-250ms。

窗口 resize:delay 设 100-200ms 足够。用户对 resize 的延迟感知不强。

按钮防重复点击:delay 应 > 用户双击的最大间隔。双击间隔一般在 300-500ms。设 500ms 是安全的。

如果要"最优"delay,需要生产数据:埋点记录用户的实际操作间隔分布,取 P95 作为 delay。

Q3: memoize 的缓存 key 用 JSON.stringify 有什么问题?有什么替代方案?

JSON.stringify 的四个问题:

  1. 参数顺序敏感:fn(1, 2)fn(2, 1) 是不同的 key,但语义上可能是同一个调用。
  2. 不可序列化值:undefinedSymbolFunctionBigInt 无法通过 JSON.stringify 正确序列化。
  3. 大对象性能:对于一个 10KB 的对象,JSON.stringify 可能比 fn 本身还慢。
  4. 对象属性顺序:两个语义等价但属性声明顺序不同的对象,JSON.stringify 输出不同。

替代方案:允许用户传入自定义 resolver: (...args) => string。默认用 JSON.stringify 但提供 escape hatch:

js
function memoize(fn, { resolver = (...args) => JSON.stringify(args) } = {}) {
  // 用 resolver 生成 key 而不是硬编码 JSON.stringify
}

对于复杂场景,用户可以传入一个高效的自定义 key 生成器。

Q4: currypartial 在实际项目中使用频率很低,为什么还要学?

curry 和 partial 的真正价值不在日常业务代码中(写 add(1)(2)(3) 确实不如 add(1, 2, 3)),而在库/框架/工具函数的设计中。当你设计一个通用接口时,curry 让你提供"分步配置"的能力——这在配置中间件、事件处理器、callback register 时非常有用。

例子:Express/Koa 的中间件模型本质上就是 partial——预设了 reqres(由框架传入),业务代码只关心 next

js
// express 预先填充了 req, res,只留 next 给业务
app.use((req, res, next) => {
  // 业务逻辑
  next();
});

理解 curry 和 partial 不是为了到处使用它们,而是为了在需要"分步配置"时识别出这个模式并设计正确的接口。

Q5: 在 React 中,useCallback 和手写 memoize 有什么区别?什么时候该用哪个?

useCallback 是基于依赖数组的缓存失效——依赖变了就创建新函数。它是一个"引用稳定性"工具,目的是减少子组件的 re-render,而不是"计算结果缓存"。

手写 memoize 是基于参数值的缓存——相同参数返回相同结果。它是"计算结果缓存"工具,目的是避免重复计算。

useMemo 是两者的中间地带——依赖数组控制的 memoize。但 useMemo 的缓存只在一个组件实例内有效,且 React 保留随时丢弃缓存的权力("useMemo is not a semantic guarantee")。

选择:

  • 保持函数引用稳定以优化子组件渲染 → useCallback
  • 缓存计算开销大的派生值 → useMemo
  • 需要跨组件、跨渲染周期的缓存 → 手写 memoize + useRef