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 原图。

图片来源: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)
一个纯函数满足两个核心条件:
- 相同输入总是得到相同输出;
- 不产生可观察的外部副作用。
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
可以抽取通用的 compose 和 pipe:
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 map、filter、reduce
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、时间、随机性和框架生命周期集中管理。
十四、总结
函数式编程倾向于:
- 用纯函数替代隐藏共享状态;
- 用不可变更新替代随意的原地修改;
- 用组合和高阶函数复用行为;
- 用
map、filter、reduce表达数组数据流; - 用声明式代码表达结果,而不是堆叠控制步骤;
- 把副作用隔离到明确的边界,而不是假设应用可以没有副作用。
最重要的不是背诵 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,组合“过滤、提取名称、排序”等步骤。