原文发表在 Lambda Academyjavascript
前段时间 Composing Software 更新到 Lens 了,我看到掘金上也有人翻译了。我总算学的速度超过 Eric Elliott 更新的速度了(主要是他更新比较慢……)。他的那篇文章介绍了 Lens 的理论背景和简单应用,但还不够深刻。本文将展现 Lens 的完整实现和更多的应用场景,并试图证实,搬砖时是能够用点奇技淫巧的。java
Lens 最早诞生于 Haskell。它是函数式 getter 和 setter,用来处理对复杂数据集的操做。网上全部关于 JavaScript lenses 的文章,目前我还没找到介绍 Lens 怎么实现的,多是由于代码太难解释了。并且,学会怎么用 lens 其实就好了,内部黑盒细节不须要明白。我最近一段时间在折腾 lens 的实现,弄出了好几个版本,都没 100% 还原,老是差一点。最终只好祭出大杀器,去逆向 Ramda 源码。编程
我先展现下我折腾出的最终版本 lens 实现。下面的代码可能会让你头和蛋一块儿疼。数组
// 工具函数,实现函数柯里化
const curry = fn => (...args) =>
args.length >= fn.length ? fn(...args) : curry(fn.bind(undefined, ...args))
// 先别蛋疼,这是 K combinator,放在特定上下文才有意义
const always = a => b => a
// 实现函数组合
const compose = (...fns) => args => fns.reduceRight((x, f) => f(x), args)
// Functor,提供计算上下文,我以前的文章介绍过
const getFunctor = x =>
Object.freeze({
value: x,
map: f => getFunctor(x),
})
// 同上,注意比较和上面的区别
const setFunctor = x =>
Object.freeze({
value: x,
map: f => setFunctor(f(x)),
})
// 简单,取对象的 key 对应的值
const prop = curry((k, obj) => (obj ? obj[k] : undefined))
// 简单,更新对象的 key 对应的值并返回新对象
const assoc = curry((k, v, obj) => ({ ...obj, [k]: v }))
// 黑魔法发生的地方,复习下惰性求值
const lens = curry((getter, setter) => F => target =>
F(getter(target)).map(focus => setter(focus, target))
)
// lens 的简写,避免上面函数调用时都要手动传 getter 和 setter
const lensProp = k => lens(prop(k), assoc(k))
// 读取 lens 聚焦的值
const view = curry((lens, obj) => lens(getFunctor)(obj).value)
// 对 lens 聚焦的值进行操做
const over = curry((lens, f, obj) => lens(y => setFunctor(f(y)))(obj).value)
// 更新 lens 聚焦的值
const set = curry((lens, val, obj) => over(lens, always(val), obj))
复制代码
若是上面连续返回的箭头函数让你头疼,记住这只是黑盒细节,并不会体如今你的应用层代码中。讲道理,你是不会抱怨 V8 源码难读的。函数式编程
我以前的文章《优雅代码指北 -- 巧用 Ramda》介绍过 Lens 在 React 和 Redux 中的应用。这篇文章讲下其它应用场景。函数
先来看下 Lens 的简单操做。工具
const obj = { foo: { bar: { ha: 6 } } }
const lensFoo = lensProp('foo')
const lensBar = lensProp('bar')
const lensHa = lensProp('ha')
view(lensFoo, obj) // => {bar: {ha: 6}}
set(lensFoo, 5, obj) // => {foo: 5}
复制代码
lens 还能组合:ui
const lensFooBar = compose(
lensFoo,
lensBar
)
view(lensFooBar, obj) // => {ha: 6}
set(lensFooBar, 10, obj) // => {foo: {bar: 10}}
复制代码
注意到 lens 是独立于被操做的数据的,这意味着 getter 和 setter 不用知道数据长什么样。这样作也意味着极大的复用性和代码的可组合性。spa
上面组合 lens 的写法能够提供不少灵活空间,但若是我想一会儿取第三层数据,难道还要分别写三个 lens 而后组合吗?再加个辅助函数很容易作到:翻译
const lensPath = path => compose(...path.map(lensProp))
const lensHa = lensPath(['foo', 'bar', 'ha'])
const add = a => b => a + b
view(lensHa, obj) // => 6
over(lensHa, add(2), obj) // => {foo: {bar: {ha: 8}}}
复制代码
再来看些实用点的例子。
假设有这样一条数据,记录了当前的华氏温度:
const temperature = { fahrenheit: 68 }
复制代码
华氏温度和摄氏温度转换公式以下:
const far2cel = far => (far - 32) * (5 / 9)
const cel2far = cel => (cel * 9) / 5 + 32
复制代码
若是让你根据华氏温度取出摄氏温度,你第一个想法确定是先从 temperature
中取出华氏温度,再用 far2cel
转换一下。这样作看上去没什么,但还有更好的办法。
咱们已经知道了华氏和摄氏是高耦合的两个单位,出现一个的时候通常都有转换成另外一个单位的需求,咱们能够利用 lens 让这个转换作到更顺滑一点。
const fahrenheit = lensProp('fahrenheit')
const lcelsius = lens(far2cel, cel2far)
const celsius = compose(
fahrenheit,
lcelsius
)
view(celsius, temperature) // => 20
复制代码
我不知道你看到上面代码有没有很激动,我看到这种写法的时候直拍案叫绝。用这种数据读取方式,给 view
函数提供不一样的“镜头”,它返回不一样的数据,我都没教他怎么转换数据(固然 celsius lens 有转换细节,但我调用时是隐藏的)。并且,我只是用不一样的“镜头”在读数据,原数据我都没动。若是业务场景再复杂一点,想象一下这种写法多爽。
还有更厉害的。
假设用户直接操做了摄氏值,咱们要同步更新华氏值。猜到怎么实现了吗?
set(celsius, -30, temperature) // => {fahrenheit: -22}
over(celsius, add(10), temperature) // => {fahrenheit: 86}
复制代码
若是用传统过程式写法,我猜没有更简洁的写法。固然过程式有合理的使用场景,我以前的文章实现惰性求值的 Lazy 函数,有大量过程式代码。
再举个例子。
假设有条记录时间的数据,该数据包含了小时和分钟数,对分钟数进行操做时,若是分钟数大于 60,则把分钟数减 60,同时把小时数加 1,若是分钟数小于 0,则把分钟数加 60,把小时数减 1。很好理解的需求:
const clock = { hours: 4, minutes: 50 }
复制代码
先实现两条数据的 lens:
const hours = lensProp('hours')
const minutes = lensProp('minutes')
复制代码
再根据需求定制化 setter:
// 先别蛋疼,这个函数很好用的
const flip = fn => a => b => fn(b)(a)
const minutesInvariant = lens(view(minutes), (value, target) => {
if (value > 60) {
return compose(
set(minutes, value - 60),
over(hours, add(1))
)(target)
} else if (value < 0) {
return compose(
set(minutes, value + 60),
over(hours, flip(add)(-1))
)(target)
}
return set(minutes, value, target)
})
复制代码
而后就能直接操做分钟数了:
view(minutesInvariant, clock) // => 50
set(minutesInvariant, 62, clock) // => {hours: 5, minutes: 2}
over(minutesInvariant, add(59), clock) // => {hours: 5, minutes: 49}
over(minutesInvariant, add(-70), clock) // => {hours: 3, minutes: 40}
复制代码
我这个版本的 lens 实现没有兼容数组。若是要在生产环境使用,建议仍是用 Ramda。若是你有兴趣折腾,也能够基于本文代码实现兼容数组。
lens 在纯函数式编程里面还有更多玩法,好比在 Traversable 和 Foldable 数据中的应用。之后我可能会继续介绍。