技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

Understanding Functional Programming in JavaScript:函数式编程完整指南

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/58-Understanding Functional Programming in Javascript
本文目录12 个章节
  1. 一、函数式编程是什么
  2. 二、函数是一等对象
  3. 三、纯函数的重要性
  4. 四、如何处理不可避免的副作用
  5. 五、用 memoization 缓存纯函数
  6. 六、避免不必要的命令式循环:数组高阶方法
  7. 七、函数组合:把小函数连接起来
  8. 八、柯里化(Currying)
  9. 九、偏应用(Partial Application)
  10. 十、声明式编程的实际边界
  11. 十一、总结
  12. 参考资料

Understanding Functional Programming in JavaScript:函数式编程完整指南

Category(分类): JavaScript Status: 已整理(2026)

原文作者:Andrew Davis Escalona

原文:Understanding Functional Programming in Javascript — A Complete Guide

原文发表于 2020 年,主要参考 Federico Kereki 的《Mastering JavaScript Functional Programming》。本文尽量保留原文从一等函数、纯函数、副作用、高阶函数、声明式数组操作、组合、柯里化到偏应用的完整路线,并修复抓取后的变量拼接、函数命名、不可运行代码和 Lodash API 表述问题。

原文配图:函数式编程主题图

图片来源:Medium 原图

一、函数式编程是什么

函数式编程(Functional Programming,FP)是一种编程范式,常见关注点包括:

  • 函数是一等对象,可以作为值传递、保存和返回;
  • 用纯函数表达尽可能多的业务计算;
  • 尽量减少共享可变状态和原地修改;
  • 把 I/O、网络、时间、随机数等副作用放在边界;
  • 通过组合和声明式数据流描述“要得到什么”。

JavaScript 不是纯函数式语言,而是多范式语言。它同时支持面向对象、命令式、函数式、事件驱动和异步编程。使用 map、闭包或箭头函数并不会自动让代码变成函数式程序;函数式技术是一组帮助我们降低耦合、提高可测试性和可推理性的工具。

函数式代码不保证更短、更快或更容易读。过度抽象、过深的函数组合、复杂的通用 curry 也可能增加认知成本。工程目标仍然是正确性、可维护性、性能和团队可读性。

二、函数是一等对象

在 JavaScript 中,函数和数字、字符串、对象一样可以被赋值、作为参数传递,也可以作为返回值:

const add = (a, b) => a + b

console.log(add(2, 3)) // 5

函数声明、函数表达式和箭头函数都是创建函数的方式:

function multiply(a, b) {
  return a * b
}

const divide = function (a, b) {
  return a / b
}

const subtract = (a, b) => a - b

原文建议“总是使用箭头函数,除非需要 this”。这可以作为个人偏好,但不是规则:普通函数还涉及动态 thisarguments、构造调用、生成器或递归命名等场景;箭头函数没有自己的 thisarguments,也不能作为构造器。

2.1 函数作为参数:回调和高阶函数

setTimeout 的参数就是回调函数:

setTimeout(() => {
  console.log('later')
}, 100)

下面的函数接收一个函数并调用它:

function doSomething(status) {
  console.log(`status: ${status}`)
}

function runWithData(data, callback) {
  callback(data)
}

runWithData('ready', doSomething) // status: ready

能够接收函数、返回函数或同时具备两者的函数,通常称为高阶函数(Higher-Order Function,HOF):

function withPrefix(prefix) {
  return message => `${prefix}: ${message}`
}

const warn = withPrefix('WARN')
console.log(warn('disk almost full'))

三、纯函数的重要性

原文引用的纯函数定义可以概括为:

  1. 相同输入总是得到相同输出;
  2. 计算结果时不产生可观察的副作用,例如 I/O、修改外部状态或修改输入对象。
const getRectangleArea = (sideA, sideB) => sideA * sideB

console.log(getRectangleArea(2, 3)) // 6

这个函数只依赖参数,返回新结果,也没有修改外部状态,因此是纯函数。

3.1 常见副作用

以下行为通常会让函数变得不纯:

  • 读取会变化的全局变量;
  • 修改传入的对象或数组;
  • 写文件、访问数据库、发网络请求;
  • 修改 DOM、打印日志、发送埋点;
  • 读取当前时间或随机数;
  • 调用另一个有副作用的函数。

“使用全局常量就一定纯”也不够精确:如果常量指向一个仍可变的对象,并且函数读取或修改了该对象,结果仍可能受外部状态影响。

const settings = { tax: 0.1 }

function priceWithTax(price) {
  return price * (1 + settings.tax)
}

即使 settings 绑定是 const,外部修改 settings.tax 仍会改变函数结果。因此更稳妥的做法是把依赖显式传入:

function priceWithTax(price, tax) {
  return price * (1 + tax)
}

3.2 不可变更新不是深冻结一切

函数式编程通常避免原地修改,但不代表 JavaScript 中任何对象都必须 Object.freeze,也不代表所有深拷贝都值得执行。常见做法是创建新对象或新数组:

const user = { name: 'Ada', score: 10 }
const promoted = { ...user, score: user.score + 1 }

console.log(user.score) // 10
console.log(promoted.score) // 11

展开运算符只做浅拷贝,嵌套对象仍可能共享引用:

const original = { profile: { city: 'Paris' } }
const copy = { ...original }

copy.profile.city = 'London'
console.log(original.profile.city) // London

是否使用结构化克隆、Immer、持久化数据结构或手动不可变更新,应根据数据规模和业务需求选择。

四、如何处理不可避免的副作用

纯函数很适合计算,但真实程序必然要读文件、访问 API、写数据库、更新界面和记录日志。目标不是制造一个“完全没有副作用”的应用,而是把副作用集中在边界,让核心计算尽量纯粹。

原文配图:副作用不可避免

图片来源:Medium 原图

4.1 把 I/O 和纯计算分开

原文设想了一个 Node.js 程序:读取文本文件、统计单词数,再把结果发送给外部 API。可以把它拆成“副作用外壳 + 纯核心”:

const fs = require('node:fs/promises')

function countWords(text) {
  return text.trim() === '' ? 0 : text.trim().split(/\s+/u).length
}

async function run(filePath, sendResult) {
  const text = await fs.readFile(filePath, 'utf8')
  const count = countWords(text)
  await sendResult({ count })
  return count
}

countWords 可以单独测试;文件读取和网络请求只在 run 这一层发生。实际项目还要考虑标点、Unicode 分词、空白符、编码、错误重试和 API 超时,不能把这个简单例子当作完整的自然语言统计器。

4.2 依赖注入:把随机性放在边界

原文使用随机整数说明 Math.random 会破坏可重复测试:

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min)) + min
}

可以把随机函数作为参数注入:

function getRandomInt(min, max, random = Math.random) {
  if (!Number.isInteger(min) || !Number.isInteger(max) || max <= min) {
    throw new RangeError('需要满足 max > min 的整数范围')
  }

  return Math.floor(random() * (max - min)) + min
}

console.log(getRandomInt(1, 6, () => 0)) // 1
console.log(getRandomInt(1, 6, () => 0.999999)) // 5

这个函数在默认参数下仍然依赖随机源,因此运行时不是纯函数;测试时传入确定性函数后,结果可预测。还应验证注入的 random 确实返回 [0, 1) 范围内的数。

4.3 用高阶函数测量执行时间

原文描述了一个 addTiming 高阶函数,但抓取内容缺少实现。下面给出一个可运行版本:

function addTiming(
  target,
  now = () => Date.now(),
  log = message => console.log(message)
) {
  return function timed(...args) {
    const startedAt = now()

    try {
      const result = target.apply(this, args)
      const elapsed = now() - startedAt
      log(`${target.name || 'anonymous'} took ${elapsed} ms`)
      return result
    } catch (error) {
      const elapsed = now() - startedAt
      log(`${target.name || 'anonymous'} failed after ${elapsed} ms`)
      throw error
    }
  }
}

const calculateRectangleArea = (sideA, sideB) => sideA * sideB
const timedArea = addTiming(calculateRectangleArea)

console.log(timedArea(2, 3)) // 6

nowlog 注入后,可以在测试中使用固定时钟和断言函数。这个版本只测量同步函数;如果 target 返回 Promise,测量到的是“返回 Promise 的时间”,需要使用 async wrapper 等待它:

function addAsyncTiming(target, now = () => Date.now(), log = console.log) {
  return async function timedAsync(...args) {
    const startedAt = now()
    try {
      const result = await target.apply(this, args)
      log(`${target.name || 'anonymous'} took ${now() - startedAt} ms`)
      return result
    } catch (error) {
      log(`${target.name || 'anonymous'} failed after ${now() - startedAt} ms`)
      throw error
    }
  }
}

五、用 memoization 缓存纯函数

对于纯函数,相同输入会得到相同结果,因此可以缓存已经计算过的值。原文用递归 Fibonacci 说明不缓存时调用次数会快速增长:

function fib(n) {
  if (n === 0) return 0
  if (n === 1) return 1
  return fib(n - 2) + fib(n - 1)
}

console.log(fib(10)) // 55

不同机器、运行时和输入的执行时间差异很大,不能把原文中的毫秒数当作固定结论;fib(50) 还可能长时间占用主线程。

针对单个数字参数,可以用 Map 做一个明确的 memoize:

function memoizeNumber(fn) {
  const cache = new Map()

  return number => {
    if (cache.has(number)) return cache.get(number)
    const result = fn(number)
    cache.set(number, result)
    return result
  }
}

const memoizedFibonacci = memoizeNumber(function fibonacci(n) {
  if (n < 0 || !Number.isInteger(n)) {
    throw new RangeError('n 必须是非负整数')
  }
  if (n < 2) return n
  // 递归引用外层绑定,后续递归也会经过 memoize 缓存
  return memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
})

console.log(memoizedFibonacci(40)) // 102334155

上面的 memoizeNumber 只适合单个数字参数。通用 memoization 需要设计键策略:JSON 序列化可能丢失类型、遇到循环引用或产生碰撞;对象参数通常需要 Map/WeakMap 组合或明确的业务 key。Lodash 的 _.memoize 也默认只使用第一个参数作为 key,复杂场景要阅读其配置并自行评估。

原文配图:Fibonacci 递归计算

图片来源:Medium 原图

原文配图:普通计算与缓存计算的对比

图片来源:Medium 原图

memoization 适用于结果稳定、缓存成本可控的计算;不要缓存依赖时间、随机数、网络状态或可变全局环境的函数,除非缓存策略本身明确。

六、避免不必要的命令式循环:数组高阶方法

JavaScript 内置的 reducemapforEachfilterfindfindIndexeverysome 都接收回调函数。它们可以帮助表达数据转换,但并不自动让回调变成纯函数。

6.1 reduce:计算平均值

原文在 reducer 中通过最后一次循环除以数组长度。更直接的写法是先求和再除以长度,并显式处理空数组:

function getAverage(values) {
  if (values.length === 0) return undefined
  const total = values.reduce((sum, value) => sum + value, 0)
  return total / values.length
}

console.log(getAverage([22, 9, 60, 12, 4, 56])) // 27.166666666666668

reduce 不会改变原数组,但 reducer 内部仍可能修改外部对象。reduce 空数组且没有初始值会抛出 TypeError,因此聚合通常应提供初始值。

6.2 map:创建新数组

const markers = [
  { name: 'UY', lat: -34.9, lon: -56.2 },
  { name: 'AR', lat: -34.6, lon: -58.4 },
  { name: 'BR', lat: -15.8, lon: -47.9 },
  { name: 'BO', lat: -16.5, lon: -68.1 }
]

const latitudes = markers.map(marker => marker.lat)
console.log(latitudes) // [-34.9, -34.6, -15.8, -16.5]

map 会创建新数组;如果回调没有显式返回值,对应位置会是 undefined。不要用 map 代替只需要副作用的循环。

6.3 forEach:遍历并执行副作用

function logMarkersData(items) {
  console.log('Data provided:')
  items.forEach(marker => {
    console.log(
      `Country: ${marker.name} ` +
      `Latitude: ${marker.lat} Longitude: ${marker.lon}`
    )
  })
}

logMarkersData(markers)

forEach 的返回值始终是 undefined,也不会等待 async 回调。它适合明确的同步副作用;需要得到新数组使用 map,需要等待异步任务使用 for...ofPromise.all

6.4 filter:筛选元素

const southAmericanB = markers.filter(marker => marker.name.startsWith('B'))
console.log(southAmericanB)
// [{ name: 'BR', lat: -15.8, lon: -47.9 },
//  { name: 'BO', lat: -16.5, lon: -68.1 }]

filter 不改变原数组,返回所有通过测试的元素。

6.5 findfindIndex

const brazil = markers.find(marker => marker.name === 'BR')
const brazilIndex = markers.findIndex(marker => marker.name === 'BR')

console.log(brazil) // { name: 'BR', lat: -15.8, lon: -47.9 }
console.log(brazilIndex) // 2

找不到时,find 返回 undefinedfindIndex 返回 -1。现代环境还提供 findLastfindLastIndex,但需要根据目标浏览器兼容性使用。

6.6 everysome

原文用《Lost》的数字说明 AND/OR 逻辑:

原文配图:every 和 some 示例

图片来源:Medium 原图

const lostNumbers = [4, 8, 15, 16, 23, 42]

console.log(lostNumbers.some(number => number % 2 === 0)) // true
console.log(lostNumbers.every(number => number % 2 === 0)) // false

some 判断至少一个元素通过测试,every 判断所有元素通过测试。两者都可能提前停止;空数组上 some 返回 falseevery 返回 true,这是逻辑量词的空集语义,写校验代码时要留意。

七、函数组合:把小函数连接起来

理想情况下,一个函数只负责一个清晰的步骤。函数组合就是把一个函数的输出交给下一个函数:

const pipe = (...functions) => input =>
  functions.reduce((value, fn) => fn(value), input)

const compose = (...functions) => input =>
  functions.reduceRight((value, fn) => fn(value), input)

const trim = value => value.trim()
const upperCase = value => value.toUpperCase()
const addMark = value => `${value}!`

const pipeline = pipe(trim, upperCase, addMark)
const composition = compose(addMark, upperCase, trim)

console.log(pipeline(' hello ')) // HELLO!
console.log(composition(' hello ')) // HELLO!
  • pipe 从左到右执行;
  • compose 从右到左执行;
  • 第一个函数通常接收外部输入,后续函数的输入输出需要兼容;
  • 不要求所有函数在数学意义上都只能接收一个参数,但一元函数更容易组合。

原文配图:pipeline 与 compose

图片来源:Medium 原图

7.1 Lodash 的 flowflowRight

原文使用 Lodash 的 flowflowRight,并把另一个函数式入口中的 pipe 归为 Lodash API。需要修正:Lodash 常用的是 _.flow_.flowRightlodash/fp 也主要提供 flow/flowRightpipe 并不是 Lodash 核心 API。若想使用 pipe,可以自己实现,或使用 Ramda 等提供该 API 的库。

const _ = require('lodash')

const parseNumber = value => Number(value)
const double = value => value * 2
const format = value => `result: ${value}`

const runLeftToRight = _.flow(parseNumber, double, format)
const runRightToLeft = _.flowRight(format, double, parseNumber)

console.log(runLeftToRight('21')) // result: 42
console.log(runRightToLeft('21')) // result: 42

7.2 XML 数据转换示例

原文假设一个旧 Web Service 返回 XML 书籍数据,然后完成“解析、按价格排序、输出 JSON”的任务。这里保留这个场景,但明确依赖的 xml-js 和 Lodash 需要自行安装,并修复数据获取函数命名等拼写:

const fs = require('node:fs/promises')
const _ = require('lodash')
const xmlJs = require('xml-js')

async function getWsData(filePath) {
  return fs.readFile(filePath, 'utf8')
}

function transformToJsObject(xmlData) {
  const parsed = xmlJs.xml2js(xmlData, { compact: true })
  const books = _.castArray(_.get(parsed, 'Library.Book', []))

  return books.map(book => ({
    author: book.Author?._text,
    title: book.Title?._text,
    year: Number(book.Year?._text),
    price: Number(book.Price?._text)
  }))
}

function sortByPrice(books) {
  return [...books].sort((a, b) => a.price - b.price)
}

const pipeBooks = _.flow(transformToJsObject, sortByPrice, JSON.stringify)

async function main() {
  const xmlData = await getWsData('./books.xml')
  console.log(pipeBooks(xmlData))
}

main().catch(console.error)

原文提醒 Array.prototype.sort 会改变目标数组,这个提醒仍然正确;现代 JavaScript 还提供 toSorted(),可以直接返回排序后的副本:

const sorted = books.toSorted((a, b) => a.price - b.price)

toSorted 需要目标运行环境支持,旧浏览器可使用展开复制后再 sort

八、柯里化(Currying)

柯里化把一个接收多个参数的函数转换成一系列每次接收部分参数的函数:

const sum = (a, b, c) => a + b + c
const curriedSum = a => b => c => a + b + c

console.log(sum(1, 2, 3)) // 6
console.log(curriedSum(1)(2)(3)) // 6

当某些参数在多次调用中保持不变时,柯里化可以复用已经传入的参数:

const calculateDiscount = (discount, price) =>
  price - (price * discount) / 100

const tenPercentOff = discount => price =>
  calculateDiscount(discount, price)

const discount10 = tenPercentOff(10)

console.log(discount10(2000)) // 1800
console.log(discount10(500)) // 450
console.log(discount10(750)) // 675
console.log(discount10(900)) // 810

原文配图:柯里化示例

图片来源:Medium 原图

8.1 自动柯里化的边界

Lodash 的 _.curry 可以根据函数的 length 自动处理参数数量:

const _ = require('lodash')

const calculateDiscount = (discount, price) =>
  price - (price * discount) / 100

const curriedDiscount = _.curry(calculateDiscount)
console.log(curriedDiscount(10)(2000)) // 1800

默认参数、剩余参数和重载函数可能让 fn.length 不能准确表示预期元数;复杂场景应显式传入 arity,或者使用手写的业务函数。柯里化也不是所有函数都需要的“更高级写法”,只有确实带来复用或组合收益时才使用。

九、偏应用(Partial Application)

偏应用和柯里化相关但不相同:

  • 柯里化通常把多参数函数变成逐层接收参数的函数;
  • 偏应用只固定部分参数,剩余参数可以一次或多次传入,不必变成一元函数;
  • 参数不一定从第一个开始固定,placeholder 可以保留中间位置。

原文使用 Winston logger 作为示例。下面用依赖注入的 logger 函数演示同样的思想:

function logMessage(level, message, step, logger) {
  logger(level, { message, step })
}

function consoleLogger(level, data) {
  console.log(`[${level}] ${data.step}: ${data.message}`)
}

logMessage(
  'info',
  'Assigning carrier to order XXX',
  'Order Preparation',
  consoleLogger
)

如果同一流程固定日志级别和步骤,可以手动偏应用:

const logOrderInfo = (message, logger = consoleLogger) =>
  logMessage('info', message, 'Order Preparation', logger)

logOrderInfo('Assigning carrier to order XXX')
logOrderInfo('Validating address')

使用 Lodash placeholder 的版本:

const _ = require('lodash')

const loggerStepInfo = _.partial(
  logMessage,
  'info',
  _,
  'Order Preparation',
  consoleLogger
)

loggerStepInfo('Doing something')
loggerStepInfo('Doing another thing on same step')

不同 Lodash 版本和 lodash/fp 的 placeholder 规则略有差异,团队应统一导入方式并配套测试。原文中的日志库名称存在拼写问题,实际常用库名是 Winston;这里使用 consoleLogger 是为了让示例不依赖日志库,生产代码仍可注入 Winston、Pino 等实现。

原文配图:偏应用日志示例

图片来源:Medium 原图

十、声明式编程的实际边界

命令式代码描述“怎么做”:

const result = []
for (const marker of markers) {
  if (marker.name.startsWith('B')) {
    result.push(marker.name)
  }
}

声明式代码更接近“要什么”:

const result = markers
  .filter(marker => marker.name.startsWith('B'))
  .map(marker => marker.name)

后者并不总是更快,也不意味着循环本身应该消失。性能敏感代码可以通过单次遍历、迭代器、TypedArray 或专门的数据结构减少中间数组;可读性和测量结果比“必须使用某个函数式 API”更重要。

十一、总结

  • JavaScript 支持函数作为值,因此可以使用回调、高阶函数、组合和柯里化;
  • 纯函数的核心是相同输入得到相同输出,并且不产生可观察副作用;
  • 时间、随机数、I/O、网络、DOM 和日志都是需要明确管理的副作用;
  • 函数式工程实践通常是把纯核心与副作用边界分开,而不是追求 100% 纯函数;
  • mapfilterreducefindeverysome 能表达常见数据转换,但回调仍可能有副作用;
  • pipe 从左到右,compose 从右到左;Lodash 常用 flowflowRight,不要把不存在的函数式入口当成 Lodash API;
  • memoization 需要可靠的 key 和缓存生命周期,不能把所有函数结果都永久缓存;
  • 柯里化与偏应用不同,应该根据参数复用和组合需求选择;
  • 函数式编程不是银弹,清晰的命名、边界、测试、性能测量和团队约定仍然重要。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS