大白话之JS函数式编程 | 七日打卡

873 阅读13分钟

大白话之JS函数式编程

前言:相信各位同学在技(mo)术(yu)社(hua)区(shui)时不乏有看到 柯里化、函数式编程、纯函数 这些字眼。刚入前端时候经常看到,对于还没有经验的我来说基本都是云里雾里,不懂这些花里胡哨的操作有啥用,老夫向来都是直接 Jquery 一把梭(逃)。现工作几年回过头来看,逐渐体会到其中的奥妙之处。这篇文章是在学习过程中顺便记录分享,尽量简单通俗的整理和表达,对 函数式编程 的理解。

1、函数式编程与命令式编程

函数式编程(Functional Programming,简称 FP),对于很多刚入门的前端会觉得这个词很高大上,望而生畏,想着我平时写法也足够对应需求了,何必再去学习什么函数式编程,增加负担。其实包括我也都会有这种想法,但是,在实际编码过程中,不少同学应该会遇到这样问题:

B页面的某段功能逻辑和A页面有点类似,但参数又有点不同,有些许差别。于是干脆献祭出 CV 大法,将一大段函数复制过去,修修改改,搞定收工。隔天C页面也有类似,还是直接 CV 复制,继续修修改改。突然某天,有个需求,这段逻辑里判断条件需要修改下。。。 (啊这!!不讲码德啊!!)后面相信大家都清楚了,只能 Ctrl + F 查找,逐个替换。

函数式编程就能很好解决这种问题,其实在前端里,也挺多函数式编程的影子,比如 ES6中的箭头函数、React.memo() 等等。说了那么多,是时候进入主题。将函数式编程之前,先了解下 命令式编程

命令式编程

现在有个需求,一个产品列表:

let shoppingCart = [
    { productTitle: "Functional Programming", type: "books", amount: 10 },
    { productTitle: "Kindle", type: "eletronics", amount: 30 },
    { productTitle: "Shoes", type: "fashion", amount: 20 },
    { productTitle: "Clean Code", type: "books", amount: 60 }
]

现在要计算购物车中所有图书的总金额。(:这个简单我会,操起笔来一气呵成写下洋洋洒洒代码如下:

let bookAmount = 0;

for (let i = 0; i < shoppingCart.length; i++) {
    if (shoppingCart[i].type === 'books') {
        bookAmount += shoppingCart[i].amount;
    }
}

console.info(bookAmount);

没毛病,简单直观,要什么数据就写什么,这就是命令式编程。但代码复用性没有那么高效,比如想计算电子产品的类目的总价,或者说可能说有后期要乘以数量。无疑需要改动的地方就很多了。如果是用函数式的话,又该怎么写呢

函数式编程

还是上面那个例子,如果使用函数式编程的话如下:(暂时先忽略 compose ,后面会详细讲到)

// 注意: 以下 map 等方法和原生不太一样,均来自于 ramda 库

const byBooks = (order) => order.type == "books";
const getAmount = (order) => order.amount;
const sumAmount = (acc, amount) => acc + amount;

const getTotalAmount = compose(
    reduce(sumAmount, 0),
    map(getAmount),
    filter(byBooks)
)

getTotalAmount(shoppingCart); // 70

当我们想改变计算类目时候,只需要替换掉 byBooks 函数就可。或者说加入了数量,需要相乘数量。那只需要在 compose 里面增加多一个操作就可以。这样用到这个函数的地方全部自动生效。

compose ,顾名思义,就是组合。把传进去的函数组合起来,上一个函数的输出作为下一个函数的输入,有点类似于管道 pipe 。每个函数相当于工作台,对传过来的数据进行加工,再输出给下一个,流水线处理。compose 调用的顺序是自右向左(自下而上)。简单图解如下:

2、几个概念

上面讲了很多函数式编程相关概念,其实到这应该了解差不多了。一些优秀的库诸如 ramda 也已经提供了现成的 API 调用实现函数式编程。本文后面主要简述函数式编程的内部实现,像 currycompose

让我们重新回过头来想,为什么 javascript 这门语言可以实现函数式编程。相信很多同学在阅读相关技术书时总会看到一句:

函数是 “一等公民”

为什么都这样说,因为 函数 有如下的权力:

  • 可以使用变量命名
  • 可以作为其他函数的参数
  • 可以由函数作为结果返回

看下上面的例子,像 map 函数可以直接接收一个函数进行处理。

纯函数(无副作用)

使用过 React 的同学应该都了解纯函数的概念,简单定义就是:对于相同的输入,总是会有相同的输出,完全不依赖外部的变化,不会引起任何可观察到的副作用。先来举个例子:

实现一个计算圆面积的函数,书写如下:

const PI = 3.14
const calcualteArea = (radius) => radius * radius * PI;
calcualteArea(10) // 314

这没问题,但是我们会说它是一个 不纯的函数 ,为什么呢?实际上,在 calcualteArea 函数中,PI 是一个全局的变量,而这个函数依赖了这个全局,想象下一种情况,当 PI 值实际上是 42 的时候,改变了全局变量的值。导致了 10 * 10 * 42 = 4200 。对于相同的输入值 10 得到的输出值不同。那什么才是纯函数呢,修改后的代码如下:

const PI = 3.14
const calcualteArea = (radius, pi) => radius * radius * pi;
calcualteArea(10, PI) // 314

现在,我们直接把 PI 的值作为参数传递给函数。这样函数就没有直接依赖外部,只是获取的是传递给函数的参数。对于相同的输入,总是能得到相同的输出。

接下来再看下上面多次提到的 副作用 ,简单讲就是跟函数外部环境发生交互的就都是副作用,还是举一个栗子:

let counter = 1;

function increaseCounter(value) {
    counter = value + 1
}

increaseCounter(counter)
console.log(counter) // 2

上面那个函数,实现了接受一个整数值并返回增加1的值,但修改了全局的对象,如果有其他函数用到这个 counter 就可能会造成毁灭性的BUG。我们做如下修改:

let counter = 1;

function increaseCounter(value) {
    return value + 1
}

increaseCounter(counter) // 2
console.log(counter) // 1

这样,counter 的值还是保持原来的。

遵循一个 纯函数无副作用 两个原则,会使我们的程序更加可控。每一块功能都是独立的,不会影响到我们的系统。这对于函数式编程来说非常重要的。后面会介绍到 柯里化的使用,可以避免我们在写纯函数的时候一直陷入于传递参数的漩涡中。

高阶函数

通常我们说的 高阶函数 是指:

  • 将一个或者多个函数作为参数 或
  • 返回一个函数作为结果

经常使用的 map、filter、reduce 就是典型的高阶函数,参数是一个函数。

const arr = [2, 3, 4]
const mapfun = (val) => val * 2
arr.map(mapfun)

来看一个返回一个函数作为结果的例子,假设要实现数组内可以根据任意传进去的 key 进行排序的功能。我们可以这样写:

const compareByKeys = (key) => {
    return (a, b) => {
        return a[key] - b[key]
    }
}

arr = [{ name: 'John', age: 22 }, { name: 'Tom', age: 18 }, { name: 'Ada', age: 25 }, { name: 'Tim', age: 12 }]

arr.sort(compareByKeys(age)) // 按照年龄排序输出

上面 compareByKeys 函数里返回了一个函数,并传到 sort 里面去调用。正是因为 函数是一等公民 ,才能实现如此操作。利用这点,我们可以实现函数的连续调用像 fn(a)(b)(c) 这样。

React 中的 高阶组件 也是同样的道理,接收一个组件,并返回另一个组件。在高阶组件里可以统一对 props 做一些处理,注入一些逻辑等

3、必不可少的操作

上面主要讲解了为什么 javascript 能实现函数式编程,以及一些相关的概念。后面主要讲解实现函数式编程的几个 必不可少的操作。

柯里化

柯里化这个名字听起来很高大上,其实很简单,用大白话讲就是:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数 。比如一个函数可以接收三个参数,柯里化之后,可以先传递第一个参数并返回一个函数,再用调用这个函数传入第二个参数或者多个参数,依次类推直到满足该函数所需要的参数。话不多说,还是来看例子:

var add = function(x) {
    return function(y) {
        return x + y
    }
}
var increment = add(1)
increment(2); // 3

var addTen = add(10)
addTen(2); // 12

上面例子中,定义了一个 add 函数,接收了一个参数 x ,并返回了另外一个函数,该函数接收另外一个参数 y ,然后返回 x + y 的值。当调用 add 之后,正常来说 x 应该被回收,但返回的函数中又有用到参数 x ,因此没有被系统回收。没错,这就是大家熟悉的 闭包 ,返回的函数通过闭包记住了第一个参数 x ,才能在后面的函数中访问的。这便是一个简单的柯里化的例子。

简单白话总结下,柯里化就是可以让你的逻辑分步执行的过程。比方说,你在写一个简单的输入框数据处理,去除字符串中的空格并用 '-' 字符隔开。可能某天另外一个需求的输入框处理方式是 去除字符串的空格并用 '/' 字符隔开。基于这样,我们可以先将 去除字符串的空格 这个功能 “预先加载” ,再根据传入的不同分隔符返回对应的内容,学费了嘛。

显然,上面简单例子只是简单两个参数柯里化过程,实际过程中我们需要支持多参数的、更加通用的柯里化。

先讲解下大致的 思路, 主要有两点:

  • 确定参数个数,当传入的参数已经满足了函数所需的参数时候,就可以直接运行函数,返回结果
  • 当参数还没满足的时候,必须返回一个函数,继续收集参数。

大致流程图如下:

具体代码实现:

function curry(fn){
    return function f(){
        const args = [].slice.call(arguments)
        if (args.length < fn.length) {
            return function() {
                return f.apply(this, args.concat([].slice.call(arguments)))
            }
        } else {
            return fn.apply(this, args)
        }
    }
}

当然,这是个简单的实现方式,并不能说很完善,比如缺少了 参数占位符。有兴趣的可以读下 ramda 中关于柯里化的实现代码。现在让我们用柯里化实现下上面说的 字符串空格替换问题

const replace = curry((a, b, str) => str.replace(a, b)) // 先制造一个柯里化函数,接收三个参数,被替换的、要替换的、目标字符串

const replaceSpaceWith = replace(/\s/g) // 预加载一个替换空格的函数

const replaceSpaceWithDash = replaceSpaceWith('-') // 构造替换成 - 的函数

const replaceSpaceWithSlash = replaceSpaceWith('/') // 构造替换成 / 的函数

replaceSpaceWithDash('a b c') // a-b-c
replaceSpaceWithSlash('a b c') // a/b/c

如上代码所示,我们先将 替换空格这个操作先存储起来,然后就可以根据实际的情况传入不同的替换字符,灵活可变。通常,我们使用柯里化来让某个函数变得 单值化,增加函数的可适用性、多样性。

但更重要的是,单值函数对于下面要讲的 函数组合 有更大的意义。

函数组合

函数组合,前面也有简单提过,就是 将函数组合起来。假设现在有个需求:需要将一个数组反转并取第一个元素大写最后打印出来,按照往常我们会这样写:

const reverse = (arr) => arr.reverse()
const upper = (arr) => arr[0].toUpperCase()
const log = (arr) => console.log(arr)

const arr = ['a', 'b', 'c']
log(upper(reverse(arr)))

这种 套娃 的方式不少同学应该或多或少写过,既不优雅,看起来也难受,如果换成 compose 写法又如何呢?

const change = compose(log, upper, reverse)
const arr = ['a', 'b', 'c']
change(arr)

是不是清晰很多,这种组合式的写法有点像管道,只不过是 从右往左运行,依次调用,将前一个函数的执行结果作为参数传入。这样函数在我们手中,就变得像一块块乐高积木,我们可以任意拼凑,组合出各种各样的功能,这就是魅力所在。

前面我们说过,柯里化就是让函数可以 单值化,这正好符合函数组合中间的函数一定是 只接受一个参数 的做法。先将一段多参数函数进行 加工(柯里化) ,然后根据实际需求进行 组装(函数组合),这就是两者的结合。

接下来看下 compose 方法的简单实现,先分点理清思路(这是一个很好的方法):

  • compose 接受需要组合函数,并 返回一个包装好的函数
  • 需要利用闭包记录下函数的列表,并依次从 右边向左边执行,每次执行的结果都要作为下一个要执行函数的参数,可以用 reduceRight 实现

实现代码如下:

const compose = (...fns) => {
    return (...args) => {
        return fns.reduceRight((val, fn) => { return fn.apply(null, [].concat(val)) }, args)
    }
}

// 简化版本如下

const compose = (...fns) => (...args) => fns.reduceRight((val, fn) => fn.apply(null, [].concat(val)), args)

4、实践

该例子来源于 一个函数式的 flickr

说了那么多都是纸上谈兵,下面直接通过一个例子来感受下:

写一个简单的功能,从 flickr 上获取图片并在页面上展示出来,按照我们一般的写法,都是 通过ajax发起请求,从返回的数据中取出对应的数据,循环添加到页面上去。下面我们用函数式的写法来实现:

// 首先引入 jquery 以及 ramda
requirejs.config({
    paths: {
        ramda:'https://cdnjs.cloudflare.com/ajax/libs/ramda/0.13.0/ramda.min',
        jquery: 'https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min'
    }
})

// 程序
reuire([
    'ramda',
    'jquery'
    ],
    function (_, $) {
        // 先定义两个不纯函数,即请求API以及渲染到html上,使其变得可控
        // 利用 柯里化 先进行加工,变成单值函数
        var Impure = {
            getJSON: _.curry((callback, url) => {
                $.getJSON(url, callback)
            }),

            setHtml: _.curry((sel, html) => {
                $(sel).html(html)
            })
        }
        
        // 构造 url 传给 Impure.getJSON 函数中的 url 参数
        var url = (term) =>'https://api.flickr.com/services/feeds/photos_public.gne?tags=' + term + '&format=json&jsoncallback=?'
        
        ///////////////////////////////////////////
        // 下面是请求获取到数据后整合提取 
        // 注:prop用于获取对象的值 类似于a[prop]
        var mediaUrl = _.compose(_.prop('m'), _.prop('media'))
       
        var srcs = _.compose(_.map(mediaUrl), _.prop('items'))
        
        // items就是获取到的数组,可以用上面的地址请求api看看返回的数据结构
        // 下一步就是设置到页面上展示
        
        var img = (url) => { return $('<img />', { src: url }) }
        
        var images = _.compose(_.map(img), srcs)
        var renderImages = _.compose(Impure.setHtml("body"), images)
        var app = _.compose(Impure.getJSON(renderImages), url)
    }
)

完成,首先我们用柯里化将多参数的函数变成单值,方便用在后面函数的组合上。可以看到,基本思路就是构造一块块很小的功能函数,然后慢慢组合起来。虽然直观上可能没有直接命令式编程来的快,但胜在清晰明了,每一步操作都有迹可循,更容易追溯问题。不会陷在一大堆逻辑中找不到方向。当然上面还有地方可以优化,详情可以看上面的例子链接。

5、小结

本文浅显的讲了函数式编程的一些相关概念,和重要的两个工具 柯里化和函数组合。其实,在我们面向业务当中可以说基本很少使用到函数式编程,都是三大框架一顿敲。学习了解函数式编程,并不是说一定要用,为了用而用,而是可以拓宽一下自己的编程思路,有一种豁然开朗的感觉:哦!!还可以有这种写法! 也许在某些遇到瓶颈的时刻,这些储备的知识就能派上用场。

最后的最后,如果这篇文章有帮到你的话,不要吝啬你的赞哟! ❤❤❤

参考资料

函数式编程指北
浅析函数式编程与前端
函数式编程在Redux/React中的应用
简明 JavaScript 函数式编程——入门篇
【函数式编程】为什么学习函数式编程?
函数式编程,以及在 JavaScript, React 中的简单应用
柯里化-Ramda源码中的实现