高阶函数与函数式编程
高阶函数把函数当作"一等公民"来传递与组合,是函数式编程在 JavaScript 中的基石。掌握 map/filter/reduce、柯里化、组合等工具,可以让代码更简洁、可测试、易维护。本文从高阶函数定义讲起,逐步展开函数式编程的核心理念与常用实践。
一、高阶函数是什么
**高阶函数(Higher-Order Function)**是满足以下任一条件的函数:
- 接收一个或多个函数作为参数;
- 返回一个新函数。
// 接收函数作为参数
function twice(fn, value) {
return fn(fn(value));
}
console.log(twice((n) => n + 1, 5)); // 7
// 返回函数
function makeAdder(x) {
return (y) => x + y;
}
const add5 = makeAdder(5);
console.log(add5(3)); // 8| 类型 | 说明 | 示例 |
|---|---|---|
| 参数是函数 | 由函数处理"行为"而非"数据" | 数组方法、事件监听 |
| 返回值是函数 | 生成定制化函数(工厂) | bind、柯里化、防抖节流 |
| 两者兼备 | 函数装饰器 | withLog、once、memoize |
二、数组高阶方法
数组方法是最常见的高阶函数应用,它们统一"遍历 + 回调"的模式。
2.1 map:映射
对每个元素执行回调,返回新数组,不修改原数组:
const nums = [1, 2, 3];
const doubled = nums.map((n) => n * 2);
console.log(doubled); // [2, 4, 6]
console.log(nums); // [1, 2, 3],原数组不变
const users = [{ name: "张三" }, { name: "李四" }];
console.log(users.map((u) => u.name)); // ['张三','李四']2.2 filter:过滤
返回满足条件的元素组成的新数组:
const nums = [1, 2, 3, 4, 5, 6];
console.log(nums.filter((n) => n % 2 === 0)); // [2, 4, 6]2.3 reduce:归并
把数组收敛为单个值,回调接收累加器与当前元素:
const nums = [1, 2, 3, 4];
const sum = nums.reduce((acc, cur) => acc + cur, 0);
console.log(sum); // 10
// 不传初始值时,第一项作为初始累加器
console.log(nums.reduce((acc, cur) => acc + cur)); // 10
// 进阶:统计频次
const words = ["苹果", "香蕉", "苹果"];
const count = words.reduce((acc, w) => ((acc[w] = (acc[w] || 0) + 1), acc), {});
console.log(count); // { 苹果: 2, 香蕉: 1 }2.4 some / every:存在性判断
const scores = [60, 70, 85];
console.log(scores.some((s) => s >= 80)); // true,存在一个即可
console.log(scores.every((s) => s >= 60)); // true,必须全部满足2.5 find:查找首个匹配元素
const products = [
{ id: 1, name: "手机" },
{ id: 2, name: "耳机" },
];
console.log(products.find((p) => p.id === 2)); // { id: 2, name: '耳机' }
console.log(products.find((p) => p.id === 99)); // undefined2.6 sort:排序(需传比较函数)
const nums = [10, 5, 80, 2];
console.log(nums.sort()); // [10, 2, 5, 80],默认按字符串排序
console.log(nums.sort((a, b) => a - b)); // [2, 5, 10, 80] 升序
console.log(nums.sort((a, b) => b - a)); // [80, 10, 5, 2] 降序2.7 方法对比总表
| 方法 | 返回值 | 是否改原数组 | 用途 |
|---|---|---|---|
map | 新数组(长度相同) | 否 | 逐元素转换 |
filter | 新数组(长度可短) | 否 | 按条件筛选 |
reduce | 任意值 | 否 | 归并为单值 |
some | 布尔值 | 否 | 是否存在满足项 |
every | 布尔值 | 否 | 是否全部满足 |
find | 元素或 undefined | 否 | 查找首个匹配 |
findIndex | 下标或 -1 | 否 | 查找下标 |
sort | 原数组引用 | 是 | 排序 |
2.8 链式调用:数据处理管道
const orders = [
{ name: "手机", price: 3000, qty: 2 },
{ name: "耳机", price: 200, qty: 5 },
{ name: "键盘", price: 500, qty: 1 },
{ name: "鼠标", price: 100, qty: 10 },
];
const result = orders
.filter((o) => o.price >= 200) // 过滤低价值商品
.map((o) => ({ ...o, total: o.price * o.qty })) // 计算小计
.sort((a, b) => b.total - a.total) // 按小计降序
.reduce((acc, o) => acc + o.total, 0); // 汇总总价
console.log(result); // 3000*2 + 200*5 + 500*1 = 7500三、函数组合(compose)
组合(composition)是把多个单参数函数"串"成一个新函数:前一个的输出是后一个的输入,数据自右向左流动:
const compose =
(...fns) =>
(x) =>
fns.reduceRight((acc, fn) => fn(acc), x);
const toUpperCase = (s) => s.toUpperCase();
const exclaim = (s) => s + "!";
const shout = compose(exclaim, toUpperCase);
console.log(shout("hello")); // HELLO!也可以定义从左向右流动的版本(通常叫 pipe,见第八节)。组合让每个小函数保持单一职责,再拼装出复杂行为。
四、柯里化与偏函数
4.1 柯里化(curry)
柯里化把"接收多个参数的函数"拆成"一系列只接收一个参数的函数",每次调用返回下一个函数,直到参数收集完毕:
// 普通函数
const add = (a, b, c) => a + b + c;
console.log(add(1, 2, 3)); // 6
// 柯里化版本
const curryAdd = (a) => (b) => (c) => a + b + c;
console.log(curryAdd(1)(2)(3)); // 6
// 通用 curry 工具
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return (...more) => curried(...args, ...more);
};
}
const curriedAdd = curry((a, b, c) => a + b + c);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 64.2 偏函数应用(partial application)
偏函数是"固定部分参数,生成参数更少的新函数",不一定要求每次只传一个参数:
function multiply(a, b, c) {
return a * b * c;
}
// 用 bind 预置前两个参数
const doubleBy = multiply.bind(null, 2, 3);
console.log(doubleBy(4)); // 24
// 通用 partial 工具
function partial(fn, ...preset) {
return (...rest) => fn(...preset, ...rest);
}
const pow2 = partial(Math.pow, 2);
console.log(pow2(10)); // 1024| 对比项 | 柯里化 | 偏函数 |
|---|---|---|
| 传参方式 | 每次一个参数 | 可一次固定多个 |
| 结果 | 返回函数直到参数齐 | 固定部分参数的新函数 |
| 共同点 | 都靠闭包"记住"已给参数 | 都靠闭包"记住"已给参数 |
4.3 柯里化的实际价值
// 预设校验逻辑,批量生成专用校验器
const validate = (rules) => (data) => rules.every((r) => r(data));
const isNotEmpty = (v) => v.trim() !== "";
const isPhone = (v) => /^1\d{10}$/.test(v);
const checkUser = validate([isNotEmpty, isPhone]);
console.log(checkUser("13800138000")); // true
console.log(checkUser("123")); // false五、纯函数与副作用
**纯函数(Pure Function)**满足两个条件:相同输入永远得到相同输出;不产生任何可观察的副作用(不修改外部状态、不读写文件、不打印等)。
// 纯函数:同样输入,同样输出,不碰外部状态
function pureAdd(a, b) {
return a + b;
}
// 不纯:依赖外部可变状态
let taxRate = 0.1;
function impure(price) {
return price * (1 + taxRate); // 外部 taxRate 变化,输出就变
}
// 不纯:修改了传入对象
function impure2(user) {
user.age += 1; // 副作用:修改外部对象
return user;
}
// 纯函数版本:返回新对象
function pure2(user) {
return { ...user, age: user.age + 1 };
}常见副作用来源:修改全局/外部变量、console.log、DOM 操作、网络请求、随机数、当前时间。
纯函数的优点:可预测、易测试、可缓存、并发安全。在 React 中组件要求"给定 props 渲染结果一致"正是纯函数思想的体现。
六、不可变数据
函数式编程倾向于不修改已有数据,而是创建新数据。这避免了一处修改导致多处意外变化的问题:
const user = { name: "张三", address: { city: "北京" } };
// 修改:直接改原对象(不推荐)
user.name = "李四";
// 不可变更新:返回新对象
const updated = { ...user, name: "李四" };
console.log(user); // 原对象不变
console.log(updated); // { name: '李四', address: {...} }
// 数组同理
const nums = [1, 2, 3];
const added = [...nums, 4]; // 追加
const removed = nums.filter((n) => n !== 2); // 删除
const mapped = nums.map((n) => n * 2); // 转换
console.log(nums, added, removed, mapped);注意浅拷贝的局限:{ ...user } 只拷贝一层,嵌套对象仍是共享引用。深层结构需要递归拷贝或使用 Immutable.js 等库。
七、惰性求值
惰性求值(Lazy Evaluation)是"用到才计算"。JavaScript 的数组方法默认是急切的(每次调用都会完整遍历),但生成器可以按需产出值,配合高阶函数实现惰性管道:
// 生成器:按需生成,不一次性算完
function* naturalNumbers() {
let n = 1;
while (true) {
yield n++;
}
}
// 惰性取用:只取前 5 个偶数
const gen = naturalNumbers();
const firstFiveEvens = [];
for (const num of gen) {
if (num % 2 === 0) {
firstFiveEvens.push(num);
if (firstFiveEvens.length === 5) break;
}
}
console.log(firstFiveEvens); // [2, 4, 6, 8, 10]
// 惰性取值器
function take(iterable, count) {
const result = [];
for (const item of iterable) {
result.push(item);
if (result.length === count) return result;
}
return result;
}
console.log(take(naturalNumbers(), 3)); // [1, 2, 3]急切求值适合小规模数据;处理无限序列或超大集合时,惰性求值能显著节省计算与内存。
八、防抖与节流
防抖(debounce)与节流(throttle)是高阶函数 + 闭包的经典应用,用于限制高频触发的函数执行次数。
8.1 防抖:只执行最后一次
连续触发时不断重置计时器,停止触发 delay 毫秒后才执行一次:
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer); // 每次触发都取消上一次
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 场景:搜索框输入、窗口 resize 停止后执行8.2 节流:固定间隔最多一次
第一次触发立即执行,之后在 interval 内忽略后续触发:
function throttle(fn, interval = 300) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
// 场景:滚动监听、鼠标移动、按钮防连点8.3 对比
| 对比项 | 防抖 | 节流 |
|---|---|---|
| 行为 | 停止触发后才执行 | 按固定间隔执行 |
| 结果 | 连续触发只执行一次 | 高频触发降频执行 |
| 典型场景 | 输入搜索、表单校验 | 滚动、拖拽、游戏刷新 |
// 使用示例
const onSearch = debounce((kw) => fetch(`/search?q=${kw}`), 500);
const onScroll = throttle(() => console.log("处理滚动"), 200);九、常用高阶函数工具
9.1 once:只执行一次
function once(fn) {
let called = false;
let result;
return function (...args) {
if (called) return result; // 第二次调用直接返回缓存结果
called = true;
result = fn(...args);
return result;
};
}
let n = 0;
const init = once(() => {
n += 1;
return `初始化第 ${n} 次`;
});
console.log(init()); // 初始化第 1 次
console.log(init()); // 初始化第 1 次(不再执行)
console.log(init()); // 初始化第 1 次
// 典型场景:只执行一次的初始化、按钮一次性支付9.2 memoize:结果缓存
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key); // 命中缓存直接返回
const result = fn(...args);
cache.set(key, result);
return result;
};
}
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
const fastFib = memoize(fib);
console.time("首次");
console.log(fastFib(30)); // 832040
console.timeEnd("首次");
console.time("缓存命中");
console.log(fastFib(30)); // 832040,直接返回缓存
console.timeEnd("缓存命中");9.3 pipe:从左到右的数据管道
const pipe =
(...fns) =>
(x) =>
fns.reduce((acc, fn) => fn(acc), x);
const trim = (s) => s.trim();
const toNumber = (s) => Number(s);
const double = (n) => n * 2;
const process = pipe(trim, toNumber, double);
console.log(process(" 21 ")); // 429.4 工具函数总览
| 工具 | 作用 | 原理 |
|---|---|---|
once | 函数只执行一次 | 闭包记录是否已调用 |
memoize | 缓存相同参数的返回值 | 闭包持有 Map 缓存 |
pipe | 从左到右组合函数 | reduce 串联 |
compose | 从右到左组合函数 | reduceRight 串联 |
debounce | 停止触发后执行 | 闭包持有定时器 |
throttle | 固定间隔执行 | 闭包记录上次时间 |
curry | 多参函数化为一元链 | 闭包收集参数 |
十、函数式编程范式特点与 JS 实践
函数式编程(FP)是一种编程范式,核心思想可以概括为:用"值"和"函数的变换"描述程序,而不是用"状态的修改"描述程序。
| 特性 | 命令式风格 | 函数式风格 |
|---|---|---|
| 关注点 | 如何一步步做(怎么做) | 数据如何变换(做什么) |
| 状态 | 大量可变变量 | 尽量不可变 |
| 循环 | for/while | map/filter/reduce |
| 函数 | 过程式语句 | 纯函数、组合、柯里化 |
| 测试 | 依赖外部状态,难测 | 输入输出清晰,易测 |
对比同一任务的两版实现:
// 命令式:改状态、写循环
const nums = [1, 2, 3, 4, 5, 6];
let evens = [];
for (let i = 0; i < nums.length; i++) {
if (nums[i] % 2 === 0) evens.push(nums[i]);
}
let squared = [];
for (let i = 0; i < evens.length; i++) {
squared.push(evens[i] * evens[i]);
}
let total = 0;
for (let i = 0; i < squared.length; i++) {
total += squared[i];
}
console.log(total); // 56
// 函数式:声明式管道,无中间可变状态
const nums2 = [1, 2, 3, 4, 5, 6];
const total2 = nums2.filter((n) => n % 2 === 0).map((n) => n * n).reduce((a, b) => a + b, 0);
console.log(total2); // 56在 JavaScript 中实践函数式的常见落点:
- 用
map/filter/reduce替代手写循环; - 优先返回新数据,避免修改入参与外部状态;
- 把复杂逻辑拆成小纯函数,再用
compose/pipe组合; - 用柯里化与偏函数生成可复用的专用函数;
- 用
once/memoize/debounce/throttle等装饰器增强函数行为。
函数式编程不是银弹,但掌握高阶函数与纯函数思想,能显著提升代码的清晰度、可测试性与可复用性,是每位 JavaScript 开发者都应具备的基本功。