JavaScript 函数式编程到底是个啥
Category(分类): JavaScript Status: 已整理(2026)
原文是一篇入门性质的学习笔记,保留了“从面向过程代码切入,再观察数组、事件和异步操作”的叙述方式。本文补充一个重要边界:JavaScript 是支持函数式风格的多范式语言,不是纯函数式语言;函数式编程也不等于“所有代码都必须是一行链式调用”。
一、什么是函数式编程
函数式编程(Functional Programming,FP)把计算看成函数之间的组合,并强调用输入和输出描述程序。它受到 lambda 演算影响,常见关注点包括:
- 函数是一等值,可以保存、传递和返回;
- 尽量使用纯函数,让同样输入得到同样输出,并减少可观察副作用;
- 尽量使用不可变数据或局部更新,降低共享可变状态带来的耦合;
- 通过组合、映射、过滤、归约等方式表达数据变换;
- 在适合的场景中使用惰性迭代,避免不必要的中间结果。
这些是风格和设计目标,不是 JavaScript 的强制语法。let 和 var 并不会因为名字本身违反函数式编程;真正重要的是变量是否被重新赋值、对象是否被共享并修改,以及副作用是否处于可控边界。
JavaScript 支持函数作为一等值,也支持闭包、高阶函数、生成器和迭代器,因此可以写出函数式风格。但 JavaScript 同时拥有对象、类、可变数组、异常、I/O 和异步 API,实际项目通常是多种范式混合使用。
二、几个容易混淆的概念
1. 一等函数和高阶函数
一等函数意味着函数可以像数字、字符串和对象一样被放入变量、数组或对象,也可以作为参数和返回值。接收函数、返回函数或两者兼有的函数通常称为高阶函数:
function applyTwice(fn, value) {
return fn(fn(value))
}
const double = value => value * 2
console.log(applyTwice(double, 3)) // 12
回调函数是高阶函数的一个常见应用,但“使用了回调”并不自动意味着代码就是纯函数式代码。回调内部仍然可能修改全局状态、操作 DOM 或发起网络请求。
2. 纯函数与副作用
一个实用的纯函数定义是:
- 对相同输入总是产生相同结果;
- 除返回值外,不产生调用方可观察的副作用,也不依赖不可控的外部状态。
function add(a, b) {
return a + b
}
let total = 0
function addToTotal(value) {
total += value // 修改外部状态,因此不是纯函数
return total
}
读取固定配置、访问当前时间、随机数、网络、日志、DOM 和修改外部对象都可能成为副作用。纯函数不是“函数体里绝对不能有对象”,局部创建且不泄漏的对象不会自动造成外部副作用。
3. 引用透明
如果一个表达式可以安全地替换成它的结果而不改变程序行为,就称为具有引用透明性。它通常依赖纯函数:
const price = 10
const tax = 0.13
const total = price * (1 + tax)
// 对固定输入而言,计算函数可被结果替换
const calculateTotal = (value, rate) => value * (1 + rate)
console.log(calculateTotal(price, tax)) // 11.3
Date.now()、Math.random() 和依赖可变全局变量的函数不能简单地按这种方式替换。实际代码可以保留副作用,但最好把它们集中在应用边界,让核心计算保持容易测试的纯函数。
4. 不可变性不是“不能用 let”
不可变性是指一个值创建后不再被原地改变。JavaScript 没有强制所有对象不可变的机制,const 只禁止重新绑定变量,并不冻结对象:
const user = { name: 'Ada' }
user.name = 'Grace' // 合法:对象仍然可变
const frozenUser = Object.freeze({ name: 'Ada' })
// frozenUser.name = 'Grace' // 严格模式下会抛出异常
Object.freeze() 只做浅冻结,嵌套对象仍可能被修改。可以用复制和展开创建新值,但展开同样是浅复制:
const state = {
count: 1,
profile: { name: 'Ada' }
}
const nextState = {
...state,
count: state.count + 1,
profile: {
...state.profile,
name: 'Grace'
}
}
console.log(state.count, nextState.count) // 1 2
5. 惰性计算
惰性计算会尽量把表达式推迟到真正需要结果时再执行。JavaScript 的普通数组方法是急切的:filter() 立即创建数组,后面的 map() 再遍历这个新数组。引擎可能进行优化,但不能把这种优化当成规范保证。
可以使用生成器表达一个简单的惰性管道:
function* filter(iterable, predicate) {
for (const value of iterable) {
if (predicate(value)) yield value
}
}
function* map(iterable, transform) {
for (const value of iterable) {
yield transform(value)
}
}
const numbers = [1, 2, 3, 4]
const result = map(filter(numbers, value => value % 2 === 0), value => value * 10)
console.log([...result]) // [20, 40]
生成器只在 next() 或 for...of 消费时推进。异步数据可以使用异步生成器和 for await...of。Lodash 的 _.chain、RxJS、迭代器库和流式库也能提供类似能力,但应根据依赖体积、可读性和数据规模选择。
三、从数组操作理解 FP
原文用“筛选年龄再提取姓名”作为例子。保留过程式写法有助于比较,但原代码中存在全角空格、分号风格不一致和重复声明问题,下面整理为可运行版本:
const users = [
{ age: 10, name: '张三' },
{ age: 20, name: '李四' },
{ age: 30, name: '王五' }
]
// 过程式:明确控制循环和结果容器
const namesByLoop = []
for (const user of users) {
if (user.age > 15) namesByLoop.push(user.name)
}
// 函数式风格:描述“筛选什么”和“映射什么”
const namesByMethods = users
.filter(user => user.age > 15)
.map(user => user.name)
console.log(namesByLoop) // ['李四', '王五']
console.log(namesByMethods) // ['李四', '王五']
filter 和 map 会产生中间数组。对于普通业务数据,这种可读性通常比微优化更有价值;对于超大数据、热路径或流式数据,可以考虑单次循环、生成器、异步迭代器或专门的流处理方案。不能简单地说“函数式一定慢”或“链式调用一定快”。
reduce 可以表达归约,但不是所有问题都应该使用它。若目标是过滤,使用 filter;若目标是映射,使用 map;若要判断是否存在,使用 some;只有需要把一组值归并为一个结果时才使用 reduce:
const orders = [
{ amount: 10, paid: true },
{ amount: 20, paid: false },
{ amount: 30, paid: true }
]
const paidTotal = orders
.filter(order => order.paid)
.reduce((sum, order) => sum + order.amount, 0)
console.log(paidTotal) // 40
四、事件处理:函数式思维不等于消灭状态
原文用点击节流说明状态管理问题。节流本身就需要记住上一次时间,强行消灭状态并不会让程序更正确:
let count = 0
let lastClickAt = -Infinity
const interval = 1000
const button = document.querySelector('button')
button.addEventListener('click', () => {
const now = Date.now()
if (now - lastClickAt >= interval) {
lastClickAt = now
count += 1
console.log(`Clicked ${count} times`)
}
})
也可以把状态封装在函数内部,减少外部可见状态:
function throttle(fn, wait) {
let lastTime = -Infinity
return function (...args) {
const now = Date.now()
if (now - lastTime < wait) return
lastTime = now
return fn.apply(this, args)
}
}
const handleClick = throttle(() => {
console.log('处理一次点击')
}, 1000)
const button = document.querySelector('button')
button.addEventListener('click', handleClick)
这里仍然有计时器状态和 DOM 副作用,只是把状态封装在组合单元内部。RxJS 可以把事件表示为 Observable,再用操作符组合:
import { fromEvent, scan, throttleTime } from 'rxjs'
const clicks = fromEvent(button, 'click')
const count$ = clicks.pipe(
throttleTime(1000),
scan(count => count + 1, 0)
)
const subscription = count$.subscribe(count => {
console.log(`Clicked ${count} times`)
})
// 页面销毁时释放监听
subscription.unsubscribe()
RxJS 版本、导入路径和打包方式可能不同;上例表达的是 API 思路,不代表浏览器内置了 Rx 全局变量。
五、柯里化、组合和管道
柯里化是把一个接收多个参数的函数转换为一系列逐个接收参数的函数:
const multiply = a => b => a * b
const double = multiply(2)
console.log(double(5)) // 10
函数组合把一个函数的输出交给下一个函数:
const compose = (...fns) => value =>
fns.reduceRight((result, fn) => fn(result), value)
const doubleThenAddOne = compose(
value => value + 1,
value => value * 2
)
console.log(doubleThenAddOne(3)) // 7
从左到右的 pipe 往往更符合数据处理的阅读顺序:
const pipe = (...fns) => value =>
fns.reduce((result, fn) => fn(result), value)
const normalizeName = pipe(
value => value.trim(),
value => value.toLowerCase(),
value => value[0].toUpperCase() + value.slice(1)
)
console.log(normalizeName(' aDA ')) // Ada
这些工具适合步骤稳定、函数粒度清晰的场景。若组合后需要在脑中展开很多匿名函数,命名中间步骤往往更易读。
六、React、Vue 与函数式风格
React 的函数组件通常把 props 映射成 UI,渲染函数应尽量保持纯净;但 React 应用仍然包含 state、事件处理和副作用,因此不能简单地说“React 就是函数式编程”或“React 只有 View 层”。
Vue 也支持组合式函数、计算属性和不可变数据等函数式风格,但 Vue 本身是一个包含响应式系统、模板和组件模型的框架。框架是否采用某种范式,不能替代对具体函数副作用的分析。
比较稳妥的边界是:
- 把格式化、筛选、权限判断、状态转换写成纯函数;
- 把网络、计时器、DOM、日志等副作用放在明确的适配层或生命周期中;
- 不直接修改输入对象,除非该约定已经明确;
- 对异步流程显式处理取消、错误和资源释放;
- 用测试验证纯函数,用集成测试验证副作用边界。
七、函数式编程的优势与代价
优势
- 局部推理更容易:纯函数不依赖隐藏的外部状态,测试和复用成本较低。
- 组合能力较强:小函数可以按数据流重新组合。
- 副作用更集中:将 I/O 放在边界,核心业务更容易验证。
- 并发思考更清晰:减少共享可变状态可以降低部分竞态风险,但并不能自动解决所有并发问题。
代价
- 额外的中间结果:数组链可能创建多个数组,热路径需要根据数据规模测量。
- 抽象学习成本:高阶函数、柯里化和组合如果命名不清,会降低可读性。
- 状态不会消失:UI、网络和计时器都需要状态;正确封装比假装没有状态更重要。
- 工具和运行时成本:引入响应式/流式库会增加 API、依赖和调试成本。
- JavaScript 的语义仍然存在:
this、异常、Promise、可变对象和类型转换不会因为使用map就消失。
八、总结
函数式编程更像一组改善程序设计的思维工具:让数据流更明确,让纯计算和副作用分离,让变化通过参数和组合表达。它不是“拒绝循环、拒绝 let、拒绝对象”,也不是代码越短越函数式。
在 JavaScript 中,可以从三个小步骤开始:
- 先把可测试的计算提取为纯函数;
- 不直接修改共享输入,必要时创建新值;
- 在副作用边界明确订阅、取消订阅、错误处理和资源释放。