解密函数式编程:用「纯函数」与「组合」重构你的JavaScript思维

引言:为什么函数式编程在前端越来越重要?

随着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 原理,提升开发效率和代码质量。

如果觉得有帮助,请关注+点赞+收藏,这是对我最大的鼓励! 如有问题,请评论区留言

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序媛小王ouc

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值