Appearance
常用的高阶函数
高阶函数的定义很简单:接收函数作为参数,或返回一个函数。但这个定义没有回答更重要的问题——为什么需要它们?
每次你写一个高阶函数,你实际上在做三件事之一:
- 延迟执行:把"做什么"和"什么时候做"解耦(debounce、throttle、once)
- 转换接口:把函数的调用方式从一种变成另一种(curry、partial、compose、pipe)
- 增加行为:在函数执行前后插入额外的逻辑(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);
};
}每次调用都重置计时器。只要调用间隔小于 delay,fn 永远不执行。用户在搜索框中快速输入时,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;
}三个生产决策:
缓存 key 用
JSON.stringify(args):简单但有限制——参数必须可序列化、参数顺序敏感(fn(1, 2)和fn(2, 1)是不同的 key)、大对象的序列化成本可能超过 fn 本身的执行成本。对于简单参数这是可接受的折中;对于复杂对象参数,需要自定义 key 生成函数。maxSize 默认 100:对大多数纯函数足够了(重复输入通常集中在有限集合中)。更大的 maxSize 意味着更多内存占用,需要通过监控数据调整。
TTL 支持:对"参数相同但外部数据可能变化"的场景(比如 API 响应缓存),需要基于时间的失效。若
fn是纯函数且外部数据不变,TTL 不需要(设置为 Infinity)。
memoize 的性能收益取决于两个因素:fn 的计算成本和缓存命中率。如果一个函数的计算只需要 0.001ms 而 JSON.stringify(args) 需要 0.005ms,memoize 反而是负优化。只对计算成本 > 1ms 的纯函数使用 memoize——这是一个有用的经验阈值。
V8 中的数组方法:声明式 vs 命令式的性能边界
map、filter、reduce 这些数组方法也是高阶函数——它们接收回调函数作为参数。但它们和手写 for 循环之间有不可忽视的性能差异。
| 操作(1M 元素数组) | 耗时 | 相对 for 循环 |
|---|---|---|
for 循环 | ~2ms | 1× |
forEach | ~4ms | 2× |
map (返回新数组) | ~8ms | 4× |
filter | ~5ms | 2.5× |
reduce | ~3ms | 1.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 的四个问题:
- 参数顺序敏感:
fn(1, 2)和fn(2, 1)是不同的 key,但语义上可能是同一个调用。 - 不可序列化值:
undefined、Symbol、Function、BigInt无法通过JSON.stringify正确序列化。 - 大对象性能:对于一个 10KB 的对象,
JSON.stringify可能比 fn 本身还慢。 - 对象属性顺序:两个语义等价但属性声明顺序不同的对象,
JSON.stringify输出不同。
替代方案:允许用户传入自定义 resolver: (...args) => string。默认用 JSON.stringify 但提供 escape hatch:
js
function memoize(fn, { resolver = (...args) => JSON.stringify(args) } = {}) {
// 用 resolver 生成 key 而不是硬编码 JSON.stringify
}对于复杂场景,用户可以传入一个高效的自定义 key 生成器。
Q4: curry 和 partial 在实际项目中使用频率很低,为什么还要学?
curry 和 partial 的真正价值不在日常业务代码中(写 add(1)(2)(3) 确实不如 add(1, 2, 3)),而在库/框架/工具函数的设计中。当你设计一个通用接口时,curry 让你提供"分步配置"的能力——这在配置中间件、事件处理器、callback register 时非常有用。
例子:Express/Koa 的中间件模型本质上就是 partial——预设了 req 和 res(由框架传入),业务代码只关心 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
