本文系统介绍JavaScript函数式编程的核心概念,涵盖函数作为一等公民、高阶函数的应用、纯函数与副作用控制、柯里化技巧以及函子(Functor)的实现。通过对比面向对象编程,解析如何利用函数抽象简化代码逻辑,提升代码的可复用性与可测试性,并结合lodash等库的实际案例,帮助开发者掌握现代前端开发中的函数式思维。
为什么要学习函数式编程
随着Vue 3.*(One Piece 海贼王)时代的到来,setup语法的引入使得前端开发风格逐渐向React靠拢,未来或许会形成以少数主流框架为主导的局面。这种趋势对开发者的JavaScript基础能力提出了更高要求,无论是Hooks还是setup,都深度依赖函数式编程思想,旨在抽离可复用的逻辑并优化代码组织结构。值得注意的是,函数式编程有助于摆脱对this关键字的依赖,正如Douglas Crockford在《JavaScript: The Good Parts》中所指出的那样,this往往带来复杂性。因此,面对快速迭代的前端技术,重新夯实JavaScript基础,特别是函数式编程思维,显得尤为重要。
什么是函数式编程
函数式编程(Functional Programming,简称FP)是主要的编程范式之一。除了函数式编程,常见的编程范式还包括面向过程编程和面向对象编程。
面向对象编程强调封装、继承和多态三大特性,用以描述事物间的联系。若从更宏观的角度看,抽象也是面向对象的核心,但因其本质即为抽象,故通常不单独列为特性之一。
函数式编程的核心思想是对运算过程进行抽象,将其视为一个“黑盒”:输入特定数据,经过内部处理后返回结果。这可以类比为数学中的函数关系 y = f(x)。
- 程序的基本本质是根据输入执行某种运算,从而得到相应的输出。
- 这一过程可表示为 x -> f(映射关系) -> y,即 y = f(x)。
- 在函数式编程中,函数对应于数学中的映射关系。
- 纯函数的关键特征是:对于相同的输入,始终产生相同的输出。
- 函数式编程的一个重要优势是代码的可复用性。
前置知识
函数是一等公民
对于具备一定经验的前端开发者而言,“函数是一等公民”这一概念并不陌生。
根据MDN权威文档的定义,当一门编程语言允许函数像变量一样使用时,该语言即拥有头等函数(First-class Function)。具体表现为:函数可以作为参数传递给其他函数,可以作为另一个函数的返回值,也可以被赋值给变量。
函数可以储存在变量中
let fn = function() {
console.log('Hello First-class Function')
}
fn()
函数作为参数
function foo(arr, fun) {
for (let i = 0; i < arr.length; i++) {
fun(arr[i])
}
}
const array = [1, 2, 3, 4]
foo(array, function(a) { console.log(a) })函数作为返回值
function fun() {
return function () {
consoel.log('哈哈哈')
}
}
const fn = fun()
fn()高阶函数
什么是高阶函数
高阶函数是指满足以下任一条件的函数:
- 1. 接收函数作为参数。
- 2. 返回函数作为结果。
函数作为参数(为避免文章篇幅过长,后续演示代码省略测试部分,读者可自行复制代码在本地编辑器中调试)。
function filter(array, fn) {
let results = []
for (let i = 0; i < array.length; i++) {
if (fn(array[i])) {
results.push(array[i])
}
}
return results
}
// 测试
let arr = [1, 3, 4, 7, 8]
const results = filter(arr, function(num) {
return num > 7
})
console.log(results) // [8]函数作为返回值
// 考虑一个场景,在网络延迟情况下,用户点击支付,你一定不想要用户点完支付没反应后点击下一次支付再重新支付一次,不然,你的公司就离倒闭不远了。
// 所以考虑一下once函数
function once(fn) {
let done = false
return function() {
if (!done) {
done = true
return fn.apply(this, arguments)
}
}
}
let pay = once(function (money) {
console.log(`支付: ${money} RMB`)
})
pay(5)
pay(5)
pay(5)
pay(5)
// 5使用高阶函数的意义
- 抽象能够帮助我们屏蔽实现细节,使开发者只需关注目标结果。
- 高阶函数的主要作用是抽象通用问题,提高代码的通用性。
常用高阶函数
- forEach(已实现)
- map
const map = (array, fn) => {
let results = []
for (let value of array) {
results.push(fn(value))
}
return results
}- filter
- every
const every = (array, fn) => {
let result = true
for (let value of array) {
result = fn(value)
if (!result) {
break
}
}
return result
}
- some
const some = (array, fn) => {
let result = false
for (let value of array) {
result = fn(value)
if (result) {
break
}
}
return result
}
- find/findIndex
- reduce
- sort
闭包
闭包(Closure):函数与其周围状态(词法环境)的引用捆绑在一起形成的结构。
闭包的本质:函数执行时会压入执行栈,执行完毕后从栈中移除。然而,堆上的作用域成员因被外部引用而无法释放,因此内部函数仍能访问外部函数的成员。
function makePower(power) {
return function (num) {
return Math.pow(num, power)
}
}
// 求平方及立方
let power2 = makePower(2)
let power3 = makePower(3)
console.log(power2(4)) // 16
console.log(power2(5)) // 25
console.log(power3(4)) // 64function maekSalary(base) {
return function (performance) {
return base + performance
}
}
let salaryLevel1 = makeSalary(12000)
let salaryLevel2 = makeSalary(15000)
console.log(salaryLevel1(2000)) // 14000
console.log(salaryLevel2(3000)) // 18000上述两个示例原理相似,均通过维持对原函数内部成员的引用来实现。读者可通过浏览器调试工具进一步观察。
纯函数
纯函数概念
纯函数的定义:相同的输入永远会得到相同的输出。
Lodash是一个提供纯函数功能的库,涵盖数组、数字、对象、字符串及函数等操作。尽管ECMAScript演进使得许多Lodash方法在ES6+中得以实现,但学习Lodash仍有必要。例如,防抖和节流是前端开发中的常见需求,手动实现需要扎实的JS功底,而Lodash提供了现成的工具函数。
接下来,我们对比数组的两个方法:slice和splice。
- slice方法返回数组的指定部分,且不改变原数组。
- splice方法对数组进行操作并返回该数组,会改变原数组。
let array = [1, 2, 3, 4, 5]
// 纯函数
console.log(array.slice(0, 3))
console.log(array.slice(0, 3))
console.log(array.slice(0, 3))
// 不纯的函数
console.log(array.splice(0, 3))
console.log(array.splice(0, 3))
console.log(array.splice(0, 3))纯函数的好处
可缓存
由于纯函数对相同输入始终产生相同结果,因此可以将其结果缓存起来,以提高性能。
function getArea(r) {
console.log(r)
return Math.PI * r * r
}
function memoize(f) {
let cache = {}
return function() {
let key = JSON.stringify(arguments)
cache[key] = cache[key] || f.apply(f, arguments)
return cache[key]
}
}
let getAreaWithMemory = memoize(getArea)
console.log(getAreaWithMemory(4))
console.log(getAreaWithMemory(4))
console.log(getAreaWithMemory(4))
// 4
// 50.26548245743669
// 50.26548245743669
// 50.26548245743669
可测试
纯函数的确定性使得测试更加便捷。
并行处理
在多线程环境下,并行操作共享内存数据可能导致意外情况。
纯函数不依赖外部状态,也不访问共享内存数据,因此在并行环境中可以安全运行(如Web Worker)。
副作用
// 不纯的
let mini = 18
function checkAge (age) {
return age >= mini
}
// 纯的(有硬编码,后续可以通过柯里化解决)
function checkAge (age) {
let mini = 18
return age >= mini
}副作用使函数变得不纯。如前例所示,若函数依赖外部状态,则无法保证输出一致,从而引入副作用。
柯里化
柯里化概念:当一个函数有多个参数时,先传递部分参数(这些参数后续固定不变),然后返回一个新函数来接收剩余参数并返回结果。
柯里化可以有效解决代码中的硬编码问题。
// 普通的纯函数
function checkAge(min, age) {
return age >= min
}
// 函数的柯里化
function checkAge(min) {
return function(age) {
return age >= min
}
}
// 当然,上面的代码也可以用ES6中的箭头函数来改造
const checkAge = (min) => (age => age >= min)下面我们来手写一个curry函数。
function curry(func) {
return function curriedFn(...args) {
if (args.length < func.length) {
return function() {
return curriedFn(...args.concat(Array.from(arguments)))
}
}
return func(...args)
}
}
函数组合
阅读上述代码可能会发现,函数内部包含大量return语句,代码结构不够美观。为此,我们需要引入函数组合的概念。
纯函数和柯里化容易导致“洋葱代码” h(g(f(x))) 的形式。
例如,获取数组最后一个元素并转换为大写字母:.toUpper(.first(_.reverse(array)))(此处引用lodash方法)。
函数组合允许我们将细粒度的函数重新组合,生成一个新的函数。
可以将函数组合想象为管道:将fn拆分为fn1、fn2、fn3三个子管道,将不同处理逻辑封装在独立函数中,再通过compose函数整合为一个函数。
fn = compose(f1, f2, f3)
b = fn(a)
Functor(函子)
什么是Functor
- 容器:包含值以及值的变形关系(该变形关系即为函数)。
- 函子:是一种特殊容器,通过普通对象实现,该对象具备map方法,用于对值进行处理(变形关系)。
// Functor 函子 一个容器,包裹一个值
class Container {
constructor(value) {
this._value = value
}
// map 方法,传入变形关系,将容器里的每一个值映射到另一个容器
map(fn) {
return new Container(fn(this._value))
}
}
let r = new Container(5)
.map(x => x + 1)
.map(x => x * x)
console.log(r) // 36总结
- 函数式编程的运算不直接操作值,而是由函子完成。
- 函子是一个实现了map契约的对象。
- 可以将函子想象为一个盒子,盒子里封装了一个值。
- 要处理盒子中的值,需向盒子的map方法传递一个处理值的函数(纯函数),由该函数对值进行处理。
- 最终,map方法返回一个包含新值的盒子(即新的函子)。
若你不习惯在代码中使用new关键字,可以在容器中实现一个of方法。
class Container {
static of (value) {
return new Container(value)
}
constructor(value) {
this._value = value
}
map(fn) {
return Container.of(fn(this._value))
}
}MayBe函子
若传入null或undefined,上述代码会抛出错误,因此需要实现额外方法以增强健壮性。
class MayBe {
static of(value) {
return new MayBe(value)
}
constructor(value) {
this._value = value
}
map(fn) {
return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
}
isNothing() {
return this._value == null // 此处双等号等价于this._value === null || this._value === undefined
}
}观察上述代码,其健壮性是否有所提升?
Either函子
在MayBe函子中,难以确认哪一步产生了空值问题。为此,引入了Either函子。
class Left {
static of(value) {
return new Left(value)
}
constructor(value) {
this._value = value
}
map(fn) {
return this
}
}
class Right {
static of(value) {
return new Right(value)
}
constructor(value) {
this._value = value
}
map(fn) {
return Right.of(fn(this._value))
}
}
function parseJSON(str) {
try {
return Right.of(JSON.parse(str))
} catch (e) {
return Left.of({ error: e.message })
}
}到此,关于JavaScript函数式编程实现的介绍就结束了。更多相关JS函数式编程内容请搜索本站以前的文章或继续浏览下面的相关文章,希望大家以后多多支持本站!