我想大家对 JS 中数组上的一些方法都很熟悉吧,想必大家都写过类似这样的代码:
const sum = [-2, -1, 1, 2, 3]
.filter(x => x > 0)
.map(x => x * 2)
.reduce((acc, x) => acc + x, 0) // 12
我们的数组通过借助this,把一系列数组上的方法“串“起来了,最终得到了我们的结果。如果你写过 jQuery
的话,你肯定对它的链式调用不陌生:
$("#p1")
.css("color", "red")
.slideUp(2000)
.slideDown(2000)
像这种 OOP
风格的链式调用,叫做 method chaining。现在如果我们想把我们自写的函数和上面的计算结果串起来就会出现问题,比如现在我们就没办法把下面的 reject
和 negate
与上面的计算结果串起来。
const reject = (xs = [], f) => xs.filter(x => !f(x))
const negate = x => -x
接下来我们来看看怎么突破这个限制,把计算结果和我们自己定义的函数串起来。
Method Chaining
如果你熟悉 Lodash
/Underscore
的话,我用就可以借助这两个库把自定义的函数串起来:
const _ = require('lodash')
const reject = (xs, f) => xs.filter(x => !f(x))
const negate = x => -x
const reduce = (xs, f, initial) => Array.prototype.reduce.call(xs, f, initial)
_.mixin({ reject, negate, reduce })
const sum = _.chain([-2, -1, 1, 2, 3])
.reject(x => x < 0)
.reduce((acc, x) => acc + x, 0)
.negate()
.value() // -6
这段代码其实是通过操作 lodash
的 prototype
实现的,mixin
会把我们自己定义的函数加到 lodash
的原型上(即 _.prototype
上)。_.chain
其实是把我们传进去的数组包装成一个 lodash
实例。下面我们来看一下它们的简化实现:
// _ 是一个 class function,包裹传进来的 value
const _ = function (value) {
if (value instanceof _) return value
if (!(this instanceof _)) return new _(value)
this._wrapped = value
}
// mixin 会把我们自定义的函数写到 prototype 上,
// 为了简化我们这里直接返回了 this, _ 的实例,行为与 lodash 不同
_.mixin = function (obj) {
Object.keys(obj).forEach((name) => {
const f = obj[name]
_.prototype[name] = function (...args) {
this._wrapped = f(this._wrapped, ...args)
return this
}
})
return this
}
// 返回被包裹的值
_.prototype.value = function () {
return this._wrapped
}
_.mixin({ reject, negate, reduce })
const sum = _([-2, -1, 1, 2, 3]) // 注意我们这里直接生成一个 _ 的实例,为了简化我们 chain 函数
.reject(x => x < 0)
.reduce((acc, x) => acc + x, 0)
.negate()
.value() // -6
这样做可以解决我们的问题,但是不够完美: - lodash
的所有函数都放到 prototype
上全量引入 lodash
,导致包体积增大。 - 我们自定义的函数,需要用 mixin
来直接修改 prototype
, 可能会覆盖 lodash
原生的一些函数。 - 涉及 JS 中恼人的 this
和 prototype
。
有没有什么更好的解决方案呢?答案是有的,下面我们来看看函数式风格的链式调用,在函数式编程里我们一般把它叫做 function composition
.
Function Composition
在介绍 function composition
之前,我们先要了解一下两个概念:curry
和 compose
。
Curry
柯里化大家多少都应该听说过吧,我们看个例子说明一下:
// 这里定义一个 add 函数,接受三个参数相加
const add = (a, b, c) => a + b + c
add(1) // NaN, 我们只传一个参数, b, c 就是 undefined,相加得到 NaN
add(1, 2) // NaN
add(1, 2, 3) // 6
// 一个柯里化的 add 是啥样的呢,如果我们只给它传一个参数,他会返回一个新的函数接受剩下的参数,
// 这个新返回的函数也是柯里化的,直到接受到参数个数 >= 3,我们返回计算结果,以此类推接受多个参数的情况
const curriedAdd = curry(add)
curriedAdd(1, 2, 3) // 6
curriedAdd(1, 2)(3) // 6
curriedAdd(1)(2)(3) // 6
curriedAdd(1)(2, 3) // 6
这样的 curry
函数我们可以用递归实现:
const curry = (f) => {
const { length } = f
const currify = (...args1) => {
if (args1.length >= length) {
return f(...args1)
}
return (...args2) => currify(...args1, ...args2)
}
return currify
}
这里是我写的一个简化版的 curry
,像 ramda
/lodash
里的 curry
还会考虑到 context
(this
指向)以及性能等问题,这里就不说了。
Compose
compose
函数会接收一组函数作为参数,按照从右到左的顺序,把这些生成的函数组合起来生成一个新的函数。下面用例子来说明:
const composedF = compose(
fa,
fb,
fc
)
composedF(data) // 这里 data 就相当于经过了这样的处理:fa(fb(fc(data)))
我们用 reduce
就可以很容易实现这样一个 compose
函数:
const compose = (...fns) => {
if (fns.length === 0) {
return x => x
}
if (fns.length === 1) {
return fns[0]
}
return fns.reduce((a, b) => (...args) => a(b(...args)))
}
我们这里 compose
的实现就是 redux
里 compose
的实现。好了,我们现在有了 curry
和 compose
, 我们看看怎么把上面定义的函数串起来:
// 我们先来改造一下 reject 和 reduce,
// 把它们变成柯里化的,然把数据 xs 放到最后一个参数(后面可以看到这样做的原因)
const reject = curry((f, xs) => xs.filter(x => !f(x)))
const reduce = curry((f, initial, xs) => Array.prototype.reduce.call(xs, f, initial))
const negate = x => -x
compose(
negate, // 接收 reduce 处理的结果
reduce((acc, x) => acc + x, 0), // 返回一个新的函数,接收 reject 处理的的结果
reject(x => x < 0),
)([-2, -1, 1, 2, 3]) // -6
现在为什么把 data
放在最后是不是很明显了,在设计 API
时, 把 data
放在最后,更容易写出 point-free
风格的代码,也不会有通过修改原型链实现的那些问题。像其它函数式编程语言里有 pipe operator
(类似
Linux
里的管道) 也可以把函数串起来。
本文介绍了两种串联函数的方式,希望本篇文章,能对大家有所帮助。