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

显示模式

登录
ARCHIVE DOCUMENTJS

Master the JavaScript Interview:What is Functional Programming?

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/50-Master the JavaScript Interview What is Functional
本文目录16 个章节
  1. 一、什么是函数式编程
  2. 二、核心概念
  3. 三、纯函数(Pure Function)
  4. 四、函数组合(Function Composition)
  5. 五、共享状态(Shared State)
  6. 六、不可变性(Immutability)
  7. 七、副作用(Side Effects)
  8. 八、一等函数和高阶函数
  9. 九、Functor、容器、列表和流
  10. 十、声明式与命令式
  11. 十一、柯里化和部分应用
  12. 十二、异步函数式实践
  13. 十三、函数式编程的优点和边界
  14. 十四、总结
  15. 练习
  16. 参考资料

Master the JavaScript Interview:What is Functional Programming?

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

本文整理自 Eric Elliott 2017 年的文章,保留原文关于纯函数、组合、共享状态、不可变数据、副作用、高阶函数、函子、声明式编程和数组练习的主线,并把原文抓取时缺失的示例补齐。原文中的“函数式编程避免一切可变数据”更适合作为指导倾向,而不是 JavaScript 的硬性规则:JavaScript 是多范式语言,工程实践通常是在可控边界内使用不可变更新和纯计算。

原文:Master the JavaScript Interview: What is Functional Programming?

作者头像

图片来源:Medium 原图

Structure Synth:Orihaus

图片来源:Medium 原图

一、什么是函数式编程

函数式编程(Functional Programming,简称 FP)是一种编程范式,常见的思想包括:

  • 用纯函数组合程序逻辑;
  • 尽量避免共享状态;
  • 尽量避免原地修改数据;
  • 把副作用放在明确的边界;
  • 用声明式的数据流表达“要得到什么”。

原文把函数式编程与面向对象编程对比:面向对象代码经常把状态和操作状态的方法放在对象中,而函数式代码倾向于让数据流经一组函数。这个对比有助于入门,但不是互斥关系。现代 JavaScript 项目经常同时使用 class、对象、闭包、纯函数、不可变更新和异步 API。

函数式代码有时更容易测试、复用和推理,但并不保证更短、更快或更简单。过度抽象、过深的组合和不熟悉的术语也会降低可读性。好的函数式代码仍需要清晰的命名、边界和错误处理。

二、核心概念

如果要理解函数式编程在 JavaScript 中的实践,至少需要理解:

  • 纯函数(pure function);
  • 函数组合(function composition);
  • 共享状态(shared state);
  • 可变与不可变数据(mutable/immutable data);
  • 副作用(side effect);
  • 一等函数和高阶函数(first-class/higher-order functions)。

三、纯函数(Pure Function)

一个纯函数满足两个核心条件:

  1. 相同输入总是得到相同输出;
  2. 不产生可观察的外部副作用。
function add(left, right) {
  return left + right
}

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

这个函数不读取外部可变变量,也不修改传入对象,因此容易测试:

function totalPrice(items) {
  return items.reduce((total, item) => total + item.price, 0)
}

console.log(totalPrice([{ price: 2 }, { price: 3 }])) // 5

3.1 反例:依赖外部状态

let taxRate = 0.1

function priceWithTax(price) {
  return price * (1 + taxRate)
}

taxRate = 0.2
console.log(priceWithTax(100)) // 120

相同的 100 因为外部状态变化而产生不同结果。可以把依赖作为参数传入:

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

console.log(priceWithTax(100, 0.1)) // 110
console.log(priceWithTax(100, 0.2)) // 120

3.2 反例:时间、随机数和 I/O

function getNow() {
  return Date.now()
}

function randomValue() {
  return Math.random()
}

function logValue(value) {
  console.log(value)
}

这些函数不一定“错误”,但它们读取时间、随机源或写入控制台,因而不是严格意义上的纯函数。可以把外部输入作为参数、把输出交给调用方:

function formatTimestamp(timestamp) {
  return new Date(timestamp).toISOString()
}

const text = formatTimestamp(Date.now()) // 读取时间的动作在边界处
console.log(text) // 输出也是边界副作用

函数式编程的工程目标通常不是消灭时间、网络、DOM 和日志,而是让核心计算不直接依赖这些副作用。

3.3 引用透明性

如果一个函数调用可以在不改变程序意义的情况下替换成它的结果,就具有引用透明性(referential transparency):

const result = add(2, 3)

// 在纯函数语境下,下面两种写法等价
console.log(result)
console.log(5)

当函数读取全局变量、修改对象、发起请求或依赖当前时间时,就不能随意进行这种替换。

四、函数组合(Function Composition)

函数组合是把多个函数连接成新的函数。数学记号 f ∘ g 表示先计算 g(x),再把结果交给 f,在 JavaScript 中类似 f(g(x))

const trim = value => value.trim()
const toLowerCase = value => value.toLowerCase()
const addPrefix = value => `user:${value}`

const normalizeUserId = value => addPrefix(toLowerCase(trim(value)))

console.log(normalizeUserId('  ADA  ')) // user:ada

可以抽取通用的 composepipe

function compose(...functions) {
  return value => functions.reduceRight((result, fn) => fn(result), value)
}

function pipe(...functions) {
  return value => functions.reduce((result, fn) => fn(result), value)
}

const normalizeWithPipe = pipe(trim, toLowerCase, addPrefix)
const normalizeWithCompose = compose(addPrefix, toLowerCase, trim)

console.log(normalizeWithPipe('  Ada  ')) // user:ada
console.log(normalizeWithCompose('  Ada  ')) // user:ada

组合顺序仍然重要:f(g(x)) 通常不等于 g(f(x))。组合减少的是隐藏共享状态和调用时序依赖,不会让所有操作自动满足交换律。

五、共享状态(Shared State)

共享状态可以是全局变量、闭包中的可变变量,或在多个模块/函数之间传递并被修改的对象。共享状态的困难在于:理解一个函数的结果,往往需要知道其他函数此前对同一状态做了什么。

5.1 竞态示例

原文用保存用户资料说明竞态:第二次保存请求可能先返回,旧请求随后返回并覆盖新数据:

let currentUser = { name: 'Ada', avatar: 'old.png' }

async function saveUser(user) {
  const response = await fetch('/api/user', {
    method: 'POST',
    body: JSON.stringify(user)
  })
  return response.json()
}

async function updateAvatar(avatar) {
  currentUser = { ...currentUser, avatar }
  currentUser = await saveUser(currentUser)
}

问题不在于 async 本身,而在于多个异步任务共享并覆盖同一个可变变量。可以用版本号、请求序号或取消旧请求保护结果:

let currentUser = { name: 'Ada', avatar: 'old.png' }
let latestRequest = 0

async function updateAvatarSafely(avatar) {
  const requestId = ++latestRequest
  const nextUser = { ...currentUser, avatar }
  const savedUser = await saveUser(nextUser)

  if (requestId === latestRequest) {
    currentUser = savedUser
  }

  return savedUser
}

在真实应用中还要处理错误、取消请求、服务端版本冲突和用户登出。不可变更新能降低“函数偷偷改了谁”的风险,但不会自动解决异步竞态。

5.2 使用不可变更新

原文使用 Object.assign({}, x) 复制对象,再把更新结果传给下一个函数:

const user = { name: 'Ada', score: 10 }
const updated = Object.assign({}, user, { score: user.score + 1 })

console.log(user) // { name: 'Ada', score: 10 }
console.log(updated) // { name: 'Ada', score: 11 }
console.log(updated !== user) // true

现代写法通常使用展开语法:

const updatedAgain = { ...user, score: user.score + 1 }

展开语法和 Object.assign 都是浅层复制;嵌套对象仍然共享引用。需要更新嵌套字段时,逐层创建新对象:

const state = {
  user: {
    profile: { name: 'Ada' }
  }
}

const nextState = {
  ...state,
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      name: 'Grace'
    }
  }
}

console.log(state.user.profile.name) // Ada
console.log(nextState.user.profile.name) // Grace

六、不可变性(Immutability)

可变对象创建后可以被修改,不可变值或不可变数据结构则不允许原地修改。函数式编程重视不可变性,因为保留旧状态可以帮助调试、撤销、时间旅行和比较更新前后的状态。

6.1 const 不等于不可变

const 只限制变量绑定不能重新赋值,不会冻结对象:

const user = { name: 'Ada' }
user.name = 'Grace' // 合法

// user = {} // TypeError:不能重新给 const 绑定赋值
console.log(user.name) // Grace

6.2 Object.freeze 只有浅冻结

const user = Object.freeze({
  name: 'Ada',
  profile: { online: true }
})

// user.name = 'Grace' // 严格模式下抛出 TypeError
user.profile.online = false // 嵌套对象仍然可变
console.log(user.profile.online) // false

需要递归冻结时,可以使用带循环保护的工具,但它也只是运行时约束,不会把所有外部资源变成纯值:

function deepFreeze(value, seen = new WeakSet()) {
  if (value === null || typeof value !== 'object' || seen.has(value)) {
    return value
  }

  seen.add(value)
  for (const key of Reflect.ownKeys(value)) {
    deepFreeze(value[key], seen)
  }

  return Object.freeze(value)
}

const config = deepFreeze({
  api: { timeout: 1000 }
})

console.log(Object.isFrozen(config.api)) // true

Object.freeze 不会冻结 Map、Set 的内部条目:

const map = Object.freeze(new Map([['count', 1]]))
map.set('count', 2) // 仍然可以修改 Map 的内部数据
console.log(map.get('count')) // 2

如果要实现不可变集合,需要不暴露原地修改 API、使用持久化数据结构,或使用 Immer、Immutable.js 等工具,并了解其代理/结构共享语义。

6.3 结构共享与持久化数据结构

持久化数据结构不会复制整棵对象树,而是让新版本复用没有改变的子树,这叫结构共享(structural sharing):

const previous = {
  settings: { theme: 'dark' },
  items: ['a', 'b']
}

const next = {
  ...previous,
  settings: { ...previous.settings, theme: 'light' }
}

console.log(next.items === previous.items) // true:未改变的分支可共享
console.log(next.settings === previous.settings) // false

在大型状态树中,结构共享可以让引用相等比较快速发现未变化分支。但它需要遵守“不原地修改共享分支”的约定。

原文提到 Immutable.js 和 Mori。它们是历史上有影响力的不可变数据结构库;现代项目也常见 Immer、Redux Toolkit 等方案。选择库时应考虑包体积、类型支持、团队习惯和维护状态。

七、副作用(Side Effects)

副作用是函数返回值之外,能被函数外部观察到的状态变化或交互,包括:

  • 修改全局变量、外部对象或闭包变量;
  • 修改传入对象;
  • 写控制台、DOM、文件或网络;
  • 读取时间、随机数、环境变量;
  • 启动定时器、订阅事件、触发外部进程;
  • 调用其他带副作用的函数。
let total = 0

function impureAdd(value) {
  total += value // 修改外部状态
  console.log(total) // I/O 副作用
  return total
}

可以把核心计算和副作用拆开:

function calculateTotal(current, value) {
  return current + value
}

function updateTotal(value) {
  total = calculateTotal(total, value)
  console.log(total)
}

这样并没有消灭副作用,而是把它限制在边界,让 calculateTotal 可独立测试。网络请求、DOM 更新和日志本来就是应用必须完成的事情;“函数式”不等于禁止这些动作,而是避免让它们散落在所有业务函数内部。

关于 monad 的原文说明可以作为历史阅读线索,但不要把 Promise 简单等同于所有语言理论中的 monad。Promise 是 JavaScript 的异步抽象,具有 thenable 解析和微任务调度等具体语义。

八、一等函数和高阶函数

JavaScript 函数是一等值,可以:

  • 赋值给变量;
  • 放入数组或对象;
  • 作为参数传给其他函数;
  • 作为函数返回值;
  • 保存闭包状态。
const double = value => value * 2
const operations = [double, value => value + 1]

console.log(operations[0](3)) // 6

高阶函数(higher-order function)是接收函数作为参数、返回函数,或同时具备两者的函数:

function withLogging(fn) {
  return function logged(...args) {
    console.log('args:', args)
    const result = fn(...args)
    console.log('result:', result)
    return result
  }
}

const loggedAdd = withLogging((a, b) => a + b)
console.log(loggedAdd(1, 2)) // 3

高阶函数常用于:

  • 抽象回调、重试、缓存和异步流程;
  • 创建可以处理不同数据的通用工具;
  • 部分应用和柯里化;
  • 函数组合;
  • 隔离日志、权限和性能统计等横切逻辑。

九、Functor、容器、列表和流

原文用 functor(函子)解释“可以被 map 的容器”。在 JavaScript 语境中,这更适合作为一种抽象类比:一个值容器提供 map 操作,把函数应用到内部值并返回同类容器。ECMAScript 没有统一规定所有函子的内置接口,数组、Promise、第三方数据类型的 map/转换语义也不完全相同。

9.1 Array 的 map

const numbers = [1, 2, 3]
const doubled = numbers.map(value => value * 2)

console.log(doubled) // [2, 4, 6]
console.log(numbers) // [1, 2, 3]

Array.prototype.map 只作用于数组(以及通过借用方法处理的数组类对象),不会自动把任意对象、字符串或数字都当作可 map 容器。字符串需要先转换成字符数组,普通对象需要 Object.entries 或自定义映射:

const chars = [...'abc'].map(char => char.toUpperCase())
const object = { a: 1, b: 2 }
const doubledObject = Object.fromEntries(
  Object.entries(object).map(([key, value]) => [key, value * 2])
)

console.log(chars) // ['A', 'B', 'C']
console.log(doubledObject) // { a: 2, b: 4 }

9.2 Promise 的异步容器类比

Promise 没有 map 方法,但 then 可以把成功值转换成另一个 Promise,并自动解析返回的 thenable:

Promise.resolve(2)
  .then(value => value * 2)
  .then(value => console.log(value)) // 4

这只是帮助理解“值经过抽象容器流动”的类比,不代表数组的 map 与 Promise 的 then 具有完全相同的调度和错误语义。

9.3 列表和流

数组是一个已经存在于内存中的列表。按时间持续产生的事件可以看作流,但实际实现可能是 AsyncIterable、Web Streams、Node.js streams 或 Observable;它们各自有背压、取消和错误传播规则,不能只把它们当作普通数组:

async function* numbersOverTime() {
  yield 1
  await new Promise(resolve => setTimeout(resolve, 10))
  yield 2
}

async function readStream() {
  const result = []
  for await (const value of numbersOverTime()) {
    result.push(value * 2)
  }
  return result
}

readStream().then(console.log) // [2, 4]

十、声明式与命令式

命令式代码明确描述步骤(How):

const source = [1, 2, 3, 4]
const doubled = []

for (const value of source) {
  doubled.push(value * 2)
}

console.log(doubled) // [2, 4, 6, 8]

声明式写法更多描述数据流(What):

const doubledDeclaratively = [1, 2, 3, 4].map(value => value * 2)
console.log(doubledDeclaratively) // [2, 4, 6, 8]

声明式不代表没有任何步骤,也不代表一定比循环快。现代 JavaScript 引擎对两种写法都有优化空间;在性能敏感场景中应测量,而不是只按“函数式”三个字判断。

10.1 mapfilterreduce

const items = [
  { name: 'sword', points: 2 },
  { name: 'shield', points: 4 },
  { name: 'potion', points: 3 }
]

const names = items.map(item => item.name)
const valuableItems = items.filter(item => item.points >= 3)
const totalPoints = items.reduce((total, item) => total + item.points, 0)

console.log(names) // ['sword', 'shield', 'potion']
console.log(valuableItems) // shield、potion
console.log(totalPoints) // 9

reduce 很强大,但不应为了追求函数式而把所有逻辑塞进一个巨大 reducer;如果普通循环更清晰,使用循环也是合理的工程选择。

十一、柯里化和部分应用

部分应用是预先固定部分参数;柯里化则把多参数函数转换为连续的一参数函数。二者相关但不完全相同:

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

function partiallyApply(fn, ...preset) {
  return (...later) => fn(...preset, ...later)
}

const multiplyByTwo = partiallyApply(multiply, 2)
console.log(multiplyByTwo(3, 4)) // 24

部分应用经常和组合、高阶函数一起使用,但要注意参数顺序、this、异步返回值和错误边界。不要为了“看起来函数式”而牺牲 API 可读性。

十二、异步函数式实践

Promise 链让异步结果继续流经纯转换函数:

function parseUser(response) {
  return response.json()
}

function pickName(user) {
  return user.name
}

fetch('/api/user')
  .then(parseUser)
  .then(pickName)
  .then(name => console.log(name))
  .catch(error => console.error('request failed', error))

多个相互独立的请求可以用 Promise.all 并行等待;它按输入顺序返回结果,但任一 Promise 拒绝时整体拒绝:

const loadUser = fetch('/api/user').then(response => response.json())
const loadSettings = fetch('/api/settings').then(response => response.json())

Promise.all([loadUser, loadSettings])
  .then(([user, settings]) => ({ user, settings }))
  .then(renderPage)
  .catch(handleError)

function renderPage(data) {
  console.log(data)
}

function handleError(error) {
  console.error(error)
}

网络请求、渲染和日志都是副作用,应在边界处处理;数据转换和校验可以尽量保持纯函数。

十三、函数式编程的优点和边界

函数式思想常带来:

  • 更小、更容易单测的纯函数;
  • 更明确的数据流;
  • 更少的隐式共享状态;
  • 更方便的组合和复用;
  • 更清晰的异步错误传播和状态更新边界。

它也有代价:

  • 不可变更新可能创建更多短生命周期对象;
  • 深层嵌套的组合不一定容易调试;
  • 递归和高阶抽象可能增加调用栈或学习成本;
  • 纯函数无法替代数据库、网络、DOM 和文件等副作用;
  • 过度追求无副作用会把简单逻辑包装得复杂。

实践中可以采用“纯核心 + 副作用边界”的折中:把输入转换、校验、计算和状态更新设计为纯函数,把 I/O、时间、随机性和框架生命周期集中管理。

十四、总结

函数式编程倾向于:

  • 用纯函数替代隐藏共享状态;
  • 用不可变更新替代随意的原地修改;
  • 用组合和高阶函数复用行为;
  • mapfilterreduce 表达数组数据流;
  • 用声明式代码表达结果,而不是堆叠控制步骤;
  • 把副作用隔离到明确的边界,而不是假设应用可以没有副作用。

最重要的不是背诵 functor、monad 等术语,而是能判断一个函数是否依赖外部状态、是否修改了共享数据、是否能被安全测试,以及数据和副作用的边界是否清楚。

练习

给定:

const items = [
  { name: 'sword', points: 2 },
  { name: 'shield', points: 4 },
  { name: 'potion', points: 3 }
]

尝试分别使用以下方法:

  • map 得到 ['sword', 'shield', 'potion']
  • filter 找出 points >= 3 的物品;
  • reduce 求出总分 9
  • 写一个 pipe,组合“过滤、提取名称、排序”等步骤。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS