引言:为什么函数式编程在前端越来越重要?
随着React Hooks的普及和Vue 3.0的Composition API,函数式编t程思想已经深入到现代前端开发的方方面面。掌握它,不仅能让你写出更简洁的代码,还能大幅提升代码的可维护性和可测试性。
系列文章目录
解密JavaScript面向对象(一):从新手到高手,手写call/bind实战
解密JavaScript面向对象(二):深入原型链,彻底搞懂面向对象精髓
解密作用域与闭包:从变量访问到闭包实战一网打尽
深度解密JavaScript异步编程:从入门到精通一站式搞定
解密浏览器事件与请求核心原理:从事件流到Fetch实战,前端通信必备指南
解密JavaScript模块化演进:从IIFE到ES Module,深入理解现代前端工程化基石
深度解密JavaScript内存管理与运行机制:从原理到实战一站式搞定
一、一等公民的函数
在JavaScript中,函数是"一等公民"。这意味着函数可以像其他数据类型一样被使用。
核心思想:函数就是值,可以像数字、字符串一样被传递和使用。
// 函数可以被赋值给变量
const greet = function(name) {
return `Hello, ${name}!`;
}
// 函数可以作为参数传递
function processUser(name, callback) {
const processed = name.toUpperCase();
return callback(processed);
}
// 函数可以作为返回值
function createMultiplier(factor) {
return function(number) {
return number * factor;
};
}
const double = createMultiplier(2);
console.log(double(5)); // 10
有一点必须得指出是,一定要非常小心 this 值,这一点与面向对象代码类似。如果一个底层函数使用了 this,而且是以一等公民的方式被调用的,那就很容易掉进this的坑里。
var fs = require('fs');
// 不好的写法
fs.readFile('freaky_friday.txt', Db.save);
// 好一点
fs.readFile('freaky_friday.txt', Db.save.bind(Db));
this 有利有弊,如果不熟悉,尽量避免使用它,而且在函数式编程中根本用不到它。
二、纯函数:可预测的代码基石
2.1 什么是纯函数
纯函数有两个核心特征:
· 相同的输入,永远得到相同的输出
· 不产生副作用(不修改外部状态)
// 纯函数示例
function add(a, b) {
return a + b;
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// 非纯函数示例
let counter = 0;
function increment() {
counter++; // 修改了外部状态
return counter;
}
function getCurrentTime() {
return new Date(); // 每次调用结果不同
}
从定义上来说,纯函数必须要能够根据相同的输入返回相同的输出;如果函数需要跟外部事物打交道,那么就无法保证这一点了。
与外部事物打交道,包括但不限于:
1)更改文件系统;
2)往数据库插入记录;
3)发送一个 http 请求;
4)可变数据;
5)打印/log;
6)获取用户输入;
7)DOM 查询;
8)访问系统状态…
2.2 追求纯函数的原因
2.2.1 可缓存性
首先,纯函数总能够根据输入来做缓存。实现缓存的一种典型方式是memoize技术。
var squareNumber = memoize(function(x){ return x*x; });
squareNumber(4); //16
squareNumber(4); // 从缓存中读取输入值为 4 的结果:16
squareNumber(5); // 25
squareNumber(5); // 从缓存中读取输入值为 5 的结果:25
值得注意的一点是,可以通过延迟执行的方式把不纯的函数转换为纯函数。
var pureHttpCall = memoize(function(url, params){
return function() { return $.getJSON(url, params); }
});
这个函数之所以有资格成为纯函数,是因为它总是会根据相同的输入返回相同的输出:给定了 url 和 params 之后,它就只会返回同一个发送 http 请求的函数。
memoize 函数工作起来没有任何问题,虽然它缓存的并不是 http 请求所返回的结果,而是生成的函数。
PS:memoize函数实现
function memoize(fn) {
const cache = new Map();
return function(...args) {
// 生成key值
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
// 调用函数生成结果-函数作为参数传递进来
const result = fn.apply(this, args);
// 缓存到cache中
cache.set(key, result);
return result;
};
}
// 调用
const memoizedAdd = memoize((a, b) => a + b);
2.2.2 可移植性/自文档化
首先,纯函数的依赖很明确,因此更易于观察和理解。其次,通过强迫“注入”依赖,或者把它们当作参数传递,我们的应用也更加灵活。纯函数与环境无关,只要我们愿意,可以在任何地方运行它。
// 不纯的
var signUp = function(attrs) {
var user = saveUser(attrs); //副作用:写入数据库
welcomeUser(user); // 副作用:发送邮件
// 无返回值,只产生副作用
};
// 纯的
var signUp = function(Db, Email, attrs) {
return function() { // 返回一个函数,不立即执行
var user = saveUser(Db, attrs); // 副作用在返回的函数中
welcomeUser(Email, user);
};
};
2.2.3 可测试性
纯函数让测试更加容易。只需简单地给函数一个输入,然后断言输出就好了。
// 纯函数测试非常简单
test('add function should return correct sum', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
});
2.2.4 合理透明性
纯函数最大的好处是引用透明性(referential transparency)。如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。
由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性。
var Immutable = require('immutable');
// 纯函数
var decrementHP = function(player) {
return player.set("hp", player.hp-1);
};
// 纯函数
var isSameTeam = function(player1, player2) {
return player1.team === player2.team;
};
// 纯函数
var punch = function(player, target) {
if(isSameTeam(player, target)) {
return target;
} else {
return decrementHP(target);
}
};
// 调用
var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"});
var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});
punch(jobe, michael);
decrementHP、isSameTeam 和 punch 都是纯函数,所以是引用透明的。
我们可以使用一种叫做“等式推导”(一对一替换)的方法来看。
首先内联 isSameTeam 函数,我们可以直接把 team 替换为实际值:
var punch = function(player, target) {
if("red" === "green") { // if分支去掉
return target;
} else {
return decrementHP(target);
}
};
//替换为
var punch = function(player, target) {
return decrementHP(target);
};
如果再内联 decrementHP,我们会发现这种情况下,punch 变成了一个让 hp 的值减 1 的调用。
var decrementHP = function(player) {
return player.set("hp", player.hp-1);
};
2.2.5 并发安全
纯函数没有共享状态,可以安全地在多线程环境下运行。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态。
三、柯里化:函数的高级用法
3.1 什么是柯里化
柯里化是把接受多个参数的函数变换成接受一个单一参数的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
简单来说就是:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
基础柯里化实现:
// 普通函数
function add(a, b, c) {
return a + b + c;
}
// 手动柯里化
function curryAdd(a) {
return function(b) {
return function(c) {
return a + b + c;
};
};
}
// 参数个数受限
console.log(curryAdd(1)(2)(3)); // 6
// 通用柯里化函数
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return function(...args2) {
return curried.apply(this, args.concat(args2));
};
}
};
}
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
3.2 柯里化的实际应用
curry 的用处非常广泛,只需传给函数一些参数,就能得到一个新函数。
用 map 简单地把参数是单个元素的函数包裹一下,就能把它转换成参数为数组的函数。
// 创建特定类型的日志函数
const createLogger = (level) => (message) => {
console.log(`[${level}] ${new Date().toISOString()}: ${message}`);
};
const errorLog = createLogger('ERROR');
const infoLog = createLogger('INFO');
errorLog('数据库连接失败'); // [ERROR] 2024-01-01T10:00:00.000Z: 数据库连接失败
infoLog('用户登录成功'); // [INFO] 2024-01-01T10:00:00.000Z: 用户登录成功
四、函数组合:像乐高一样构建复杂逻辑
4.1 什么是函数组合
函数组合是将多个函数组合成一个新函数的过程。
// f 和 g 都是函数,x 是在它们之间通过“管道”传输的值。
const compose = function(f,g) {
return function(x) {
return f(g(x));
};
};
// 示例函数
const toUpperCase = str => str.toUpperCase();
const exclaim = str => str + '!';
const repeat = str => str.repeat(2);
// 组合使用
const dramatic = compose(repeat, exclaim, toUpperCase);
console.log(dramatic('hello')); // HELLO!HELLO!
4.2 pointfree模式
pointfree 模式指的是,永远不必说出你的数据,即函数无须提及将要操作的数据是什么样的。一等公民的函数、柯里化(curry)以及组合协作起来非常有助于实现这种模式。
// 非 pointfree,因为提到了数据:word
var snakeCase = function (word) {
return word.toLowerCase().replace(/\s+/ig, '_');
};
// pointfree 返回组合函数将replace和toLowerCase相结合
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);
pointfree 模式能够帮助我们减少不必要的命名,让代码保持简洁和通用。不过pointfree 是一把双刃剑,有时候也能混淆视听。并非所有的函数式代码都是 pointfree 的,可以使用它的时候就使用,不能使用的时候就用普通函数。
五、实际项目应用场景
场景1:React函数式组件优化
import React, { useMemo, useCallback } from 'react';
// 使用useMemo缓存纯函数计算结果
const ExpensiveComponent = ({ data, filter }) => {
const filteredData = useMemo(() => {
return data.filter(item =>
item.name.includes(filter) &&
item.status === 'active'
);
}, [data, filter]); // 依赖项变化时才重新计算
// 使用useCallback缓存函数
const handleClick = useCallback((id) => {
console.log(`Item ${id} clicked`);
}, []);
return (
<div>
{filteredData.map(item => (
<div key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</div>
))}
</div>
);
};
场景2:数据转换管道
// 数据处理管道
const processUserData = pipe(
// 清理数据
user => ({
...user,
name: user.name.trim(),
email: user.email.toLowerCase()
}),
// 验证数据
user => {
if (!user.email.includes('@')) {
throw new Error('Invalid email');
}
return user;
},
// 转换数据格式
user => ({
id: user.id,
fullName: `${user.firstName} ${user.lastName}`.toUpperCase(),
contact: user.email
})
);
// 使用示例
const rawUser = {
id: 1,
name: 'XiaoWang',
email: 'XiaoWang@EXAMPLE.COM'
};
try {
const processed = processUserData(rawUser);
console.log(processed);
} catch (error) {
console.error('数据处理失败:', error);
}
场景3:中间件架构
// 中间件组合器(类似Koa/Express的中间件机制)
// 功能:将多个中间件函数组合成一个链式执行函数
function composeMiddleware(middlewares) {
return function(context, next) {
// index: 用于追踪当前执行的中间件索引
let index = -1;
// dispatch函数:核心调度器,负责按顺序执行中间件
function dispatch(i) {
// 检查是否多次调用next() - 防止重复执行同一中间件
if (i <= index) {
return Promise.reject(new Error('next() called multiple times'));
}
index = i;
// 获取当前要执行的中间件函数
let fn = middlewares[i];
// 如果i等于中间件数组长度,说明所有中间件已执行完毕, 此时将fn指向传入的next函数(最终处理函数)
if (i === middlewares.length) fn = next;
// 如果没有函数可执行(既无中间件也无next),返回一个已解决的Promise
if (!fn) return Promise.resolve();
try {
// 执行当前中间件/next函数:
// 1. fn(context, nextParam): 调用中间件,传入上下文和next函数
// 2. nextParam = dispatch.bind(null, i + 1): 下一个中间件的dispatch调用
// 3. Promise.resolve(): 确保返回值是Promise,支持async/await
// 4. 整个表达式:将中间件执行结果包装为Promise
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
return dispatch(0);
};
}
// 使用示例
const middlewares = [
async (ctx, next) => {
console.log('Middleware 1 start');
await next();
console.log('Middleware 1 end');
},
async (ctx, next) => {
console.log('Middleware 2 start');
ctx.data = 'processed';
await next();
console.log('Middleware 2 end');
}
];
const composed = composeMiddleware(middlewares);
composed({}).then(() => console.log('All middlewares completed'));
六、常见高频面试题解析
问题1:实现一个通用的柯里化函数
题目:实现一个curry函数,能够将任意函数柯里化
function curry(fn) {
return function curried(...args) {
// 如果参数数量足够,直接执行
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
// 参数不足,返回新函数等待剩余参数
return function(...args2) {
return curried.apply(this, args.concat(args2));
};
}
};
}
// 测试
function sum(a, b, c) {
return a + b + c;
}
const curriedSum = curry(sum);
console.log(curriedSum(1)(2)(3)); // 6
console.log(curriedSum(1, 2)(3)); // 6
console.log(curriedSum(1)(2, 3)); // 6
问题2:什么是纯函数?有什么好处?
参考答案:
· 纯函数是没有副作用并且输出只由输入决定的函数。
好处:
可缓存性:同样的输入总有同样的输出,可以缓存结果
可移植性:纯函数的依赖很明确,通过强迫“注入依赖
可测试性:不依赖外部状态,测试简单
并发安全:没有共享状态,可以在多线程环境安全运行
引用透明:函数调用可以被其返回值替代
七、总结
函数式编程的核心优势
代码更可预测:纯函数让bug更易追踪
更易测试:不依赖外部状态,单元测试简单
更易维护:函数小而专一,符合单一职责原则
更好的可读性:声明式代码比命令式代码更易理解
✅ 推荐做法:
多使用纯函数处理数据转换
利用柯里化创建可复用的小函数
使用函数组合构建复杂业务逻辑
在React/Vue中合理使用useMemo/useCallback
❌ 避免做法:
避免在函数中修改输入参数
避免过度柯里化导致代码难以理解
不要为了函数式而函数式,保持代码可读性
下期预告
下一篇将解密TypeScript高级类型系统,读懂Typescript 原理,提升开发效率和代码质量。
如果觉得有帮助,请关注+点赞+收藏,这是对我最大的鼓励! 如有问题,请评论区留言
32

被折叠的 条评论
为什么被折叠?



