阅读 958

在javascript中使用纯函数处理副作用

在javascript中使用纯函数处理副作用

今天给大家带来一片译文, 详情请点击这里.可能在墙内哦

开始了, 如果你点开这篇文章, 就证明你已经开始涉及函数式编程了, 这距离你知道纯函数的概念不会很久. 如果你继续下去, 你就知道纯函数是真重要, 没有它你将寸步难行.你可能听过这样的话: "纯函数让你理清你的代码", "纯函数不可能会引起副作用","纯函数式引用透明的". 这些话没错, 纯函数是个好东西, 不过它还是存在着一些问题...

纯函数的概念

一个纯函数是一个没有副作用的函数, 但是如果你对编程有所了解, 你就知道副作用是不可避免的. 我们把π计算到一百位但是又没有人能去记住它(最强大脑就忽略不计了) 所以我们要在某个地方把他打印出来, 我们就需要写一个 console 或者把数据传入打印机, 或者把他给某个能展示他的东西; 我们要把数据放进数据库, 我们需要阅读输入设备的数据, 需要从网络上获取信息, 这些都是副作用. 但是呢, 函数式编程主要靠的又是纯函数, 那么我们如何去用函数式编程去管理副作用呢?

简单的回答是: 干数学家干的事情(障眼法) 表面上, 数学家们技术上沿着规则来进行研究, 但是他们又会从那些规则中找到一个漏洞, 并且会奋力的把这些漏洞放大到能让一只大象走过去.

有两个主要的漏洞来干这个事情

  1. 依赖注入 dependency injection
  2. 使用 effect functor (名词: 只能意会, 不能言传)

依赖注入

依赖注入是我们处理副作用的第一种方法, 在这个例子中, 我们在代码中引入一些不纯的东西,比如, 打印一个字符串;

// logSomething :: String -> String      
// 上面是函数的一种描述, logSomething 是函数的名字, 后面一个是参数类型, 一个是返回值类型
function logSomething(something) {
    const dt = (new Date())toISOString();
    console.log(`${dt}: ${something}`);
    return something;
}
复制代码

我们的 logSomething 有两个不纯的东西, 一个是我们创建了一个 Date 实例, 一个是我们执行了 console.log, 我们不仅仅是执行了 IO 操作, 而且每次执行这个函数, 都会有不同的结果. 所以, 你要怎么把这个函数变成纯函数呢? 用依赖注入, 我们把不纯的源头都变成函数的参数, 所以, 我们的这个函数需要用 3 个参数, 修改后的代码如下;

// logSomething: Date -> Console -> String -> *
function logSomething(date, console, something) {
    const dt = date.toISOString();
    console.log(`${dt}: ${something}`);
    return something;
}
复制代码

然后我们可以执行他了, 但是我们必须要明确的知道哪一个参数会引起副作用.

const something = "Curiouser and curiouser!"
const d = new Date();
logSomething(d, console, something);
复制代码

现在你可能会想, "这也太傻了吧! 我们做的事情, 只是把问题转移到上一级, 我们的这个函数依然是不纯的啊.... " yes that right 确实, 好像并没有什么卵用.

这就好比假装不知道: "oh 不 长官, 我完全不知道在 console 上调用 log() 会引发 IO 操作, 这是别人传给我的一个参数, 我甚至都不知道这个对象是从哪里来的"; 这看起来有点蹩脚.

虽然是这样, 但是这并没有看起来的那么愚蠢, 注意下我们的 logSomething 函数中的下面这一点, 如果你想让他做一些不纯的东西, 你不得不让他是不纯的. 我们只需要很简单的传入一个不同的参数, 他就可以变成纯的.

const d = {toISOString: () => '1865-11-26T16:00:00.000Z'};
const cnsl = {
    log: () => {
        // do nothing
    },
};
logSomething(d, cnsl, "Off with their heads!");
复制代码

现在我们的函数除了返回一个字符串, 什么东西也没做, 但是他是一个纯函数, 如果你使用同样的参数去调用这个函数, 它总会返回同样的值, 这就是重点. 要让它不纯, 我们需要刻意的去做, 或者换一种方式去做, 这个函数的所有依赖都已经在参数中了, 他不会接受任何像 console, Date 这样的全局对象, 这让一切都很清晰.

还有一个很重要的点, 我们可以传入一个函数到我们原来不纯的函数, 让我们来看另外一个例子, 想象一下我们在 form 表单的某处有一个 username, 我们可能回这样取得它的值:

// getUserNameFromDOM :: () -> String
function getUserNameFromDOM() {
    return document.querySelector('#username').value;
}

const username = getUserNameFromDOM();
username;
复制代码

在这个例子中, 我们尝试去检索 DOM 去获取数据, 这是不纯的, 因为 document 它是一个在任何地方任何时候都可以改变的全局变量, 如果要让我们这个函数变纯, 我们可以把全局对象 document 作为一个函数的参数传递进来, 但是,我们还可以直接把 querySelector() 传进来啊

// getUserNameFromDOM :: (String -> Element) -> String
function getUserNameFromDOM($) {
    return $('#username').value;
}

// qs :: String -> Element
const qs = document.querySelector.bind(document);

const username = getUserNameFromDOM(qs);
username;
复制代码

现在你可能还是会想, 这还是很傻啊, 我们只是把不纯的代码从 getUserNameFromDom 转移了出去, 副作用还在啊, 它并没有消失, 这看起来他除了让代码更长, 更多以外, 并没有什么实质性的作用了, 原本我们只有一个不纯的函数, 现在我们有了两个函数, 其中一个还是不纯的...

再忍受我一下, 想象一下我们要给 getUserNameFromDOM() 写一个测试用例, 现在, 我们来对比一下纯的版本和不纯的版本, 哪一个更容易被测试? 为了让不纯的版本工作起来, 我们需要一个 document 的全局对象, 最重要的是, 他还要有一个 id 是 username 的标签, 如果我在浏览器外对他进行测试, 那么我就需要引入 jsDOM 或者 无头浏览器(headless browser), 所有的这些都只是为了测试这么一个小小的函数, 但是如果使用纯的版本, 我可以直接这样来测试:

const qsStub = () => ({value: 'mhatter'});
const username = getUserNameFromDOM(qsStub);
// 断言 (接触测试框架比较少的小伙伴可以了解一下 jest)
assert.strictEqual('mhatter', username, `Expected username to be ${username}`);
复制代码

这不意味着你不应该在真是的浏览器上新建一个测试, 但是现在, getUserNameFromDOM() 是完全的可预测的, 如果我们总是传递 qsStub, 那么这个函数总是会返回 mhatter, 我们把不可预测转移到了另外一个函数 qs

如果我们需要, 我们可以把不可预测推到更远的函数上, 最终, 我们会把他推到我们代码的边缘, 对应于函数栈, 就是推到最后一个函数, 如果你要构建一个很大的 APP, 那么可预测性会变得越发的重要.

依赖注入的缺点

构建一个庞大的, 复杂的应用是可能的, 因为原作者自己就搞了一个, 测试会变得更容易, 这使得所有的函数依赖都会明确, 但是这还是有一些缺点, 最主要的一个就是, 要传递的参数真的太多了...

function app(doc, con, ftch, store, config, ga, d, random) {
    // Application code goes here
 }

app(document, console, fetch, store, config, ga, (new Date()), Math.random);
复制代码

也没有那么不好是吧... 但是如果你的参数有 穿针 (穿针名词解释: 在我的家乡,没有碰到篮筐就进的球, 叫穿针球)问题. 你可能在函数栈比较接近低的位置需要一些参数, 那么你就需要把这些参数一层一层的往下传, 这是很苦恼的, 例如, 你可能需要传递一些数据穿透5个中间函数, 而那些函数并没有使用到这些数据, 这些数据只是为了第六个函数准备的(穿针), 这还不算严重, 毕竟他的依赖关系还是很清晰,但是这还是很苦恼, 然而, 我们还有另外一种方法来避免副作用

lazy functions (延迟函数)

让我们来看一下函数式程序员使用的第二个漏洞, 这个漏洞是这样的: "当一个副作用还没有发生(执行)的时候, 他不是一个副作用." 听起来很神秘, 我也这么觉得, 那让我们来看看代码, 让他更清晰.

// fZero :: () -> Number
function fZero() {
    console.log('Launching nuclear missiles');
    // Code to launch nuclear missiles goes here (这里会执行一些副作用的代码, 发射火箭)
    return 0;
}
复制代码

我知道, 这是一个没有什么技术含量的例子, 如果我们需要一个 0 我们可以直接写一个 0 ...

但是我们来举一个例子, 我知道我们不可能用 javascript 来发射一支火箭, 但是这可以帮助我们说明这个情况, 那么现在, 就让我们用javascript 来发射一支火箭, 这是一段不纯的代码, 他打印了一条信息, 还发射了一支火箭. 想象我们想得到那个0, 再想象一个场景,我们想要在火箭发射之后要计算一些东西, 我们可能需要启动一个计数器, 或者类似的东西, 我们需要在火箭发射的时候非常的专注, 我们不希望火箭的意外发射会影响到我们的计算, 那么, 如果我们把 fZero() 包裹在另一个仅仅只是返回它的函数上,会发生什么. 像是一种安全的包裹

// fZero :: () -> Number
function fZero() {
    console.log('Launching nuclear missiles');
    // Code to launch nuclear missiles goes here
    return 0;
}

// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
    return fZero;
}
复制代码

我可以执行 returnZeroFunc() 任意多次, 只要我不执行他返回的函数, 那么我们的火箭就不会发射, 理论上, 我的计算就是安全的.

const zeroFunc1 = returnZeroFunc();
const zeroFunc2 = returnZeroFunc();
const zeroFunc3 = returnZeroFunc();
// No nuclear missiles launched. 没有火箭发射
复制代码

现在, 我们来定义一个纯函数, 然后更详细的审查 returnZeroFunc() 如果一个函数式纯的, 他有以下的两点特征

  1. 它没有副作用
  2. 引用透明, 意味着, 给它相同的参数, 它总会返回相同的值.

让我们来审查一下 returnZeroFunc() 它有副作用吗? 它只是把函数返回了, 除非你继续执行它返回的函数, 不然它不会发射火箭, 所以在这, 它没有副作用.

那它引用透明吗? 传相同的参数, 会返回相同的值吗? oh 他总是返回同一个函数, 在这, 他引用是透明的, 我们可以测试一下

zeroFunc1 === zeroFunc2; // true
zeroFunc2 === zeroFunc3; // true
复制代码

但, 它还不是完全纯, 因为他引用了外部的一个变量, 但是我们可以这样来改写它

// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
    function fZero() {
        console.log('Launching nuclear missiles');
        // Code to launch nuclear missiles goes here
        return 0;
    }
    return fZero;
}
复制代码

现在我们的函数纯了, 但是我们每次返回的函数都不是同一个函数, 因为, 每一次执行都会重新定义一个 fZero, 这是 javascript 给我们开的一个玩笑, 不过并没有什么大碍

这是一个优雅的小漏洞, 但是, 我们可以在实际的项目中使用这样的代码吗? 答案是肯定的, 但是在我们要把他引入实际代码之前, 我们来把目标放得更长远一点, 回到我们的不纯的 fZero() 函数.

// fZero :: () -> Number
function fZero() {
    console.log('Launching nuclear missiles');
    // Code to launch nuclear missiles goes here
    return 0;
}
复制代码

我们来使用一下 fZero() 返回的 0 , 但是我们不发射里面的火箭. 我们会新建一个函数, 这个函数会携带着 fZero() 返回的 0, 然后给它加 1

// fIncrement :: (() -> Number) -> Number
function fIncrement(f) {
    return f() + 1;
}

fIncrement(fZero);
复制代码

卧槽, 我们意外的发射了火箭... 我们再来一次, 这次我们不会返回一个数字(number), 我会回返回一个会返回数字的函数 这里其实就是上面的函数的安全包裹(延迟函数)

// fIncrement :: (() -> Number) -> (() -> Number)
function fIncrement(f) {
    return () => f() + 1;
}

fIncrement(fZero);
复制代码

oh, 火箭不发射了, 我们继续, 有了这两个函数, 我们可以构建一系列的函数区做我们想要做的事情,.;

const fOne   = fIncrement(fZero);
const fTwo   = fIncrement(one);
const fThree = fIncrement(two);
// And so on…
复制代码

我们还可以创建一推使用上面函数的函数来做一些更高级的事情.

// fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fMultiply(a, b) {
    return () => a() * b();
}

// fPow :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fPow(a, b) {
    return () => Math.pow(a(), b());
}

// fSqrt :: (() -> Number) -> (() -> Number)
function fSqrt(x) {
    return () => Math.sqrt(x());
}

const fFour = fPow(fTwo, fTwo);
const fEight = fMultiply(fFour, fTwo);
const fTwentySeven = fPow(fThree, fThree);
const fNine = fSqrt(fTwentySeven);
// No console log or thermonuclear war. Jolly good show!
复制代码

你知道我在这里都干了什么吗? 我想做的任何的事情, 都是从那个 fZero() 返回的0开始的, 我把所有的计算逻辑都写好了, 我的火箭还是没有发射, 我们可以通过调用最终的函数拿到我们最后的值, 并且发射火箭, 这里有个数学理论, 叫'isomorphism', 感兴趣的可以去看一下.

如果这里还不是很明白, 我们可以换一种说法, 我们可以把这当作是我们想要获得的那个数字跟 0 的一种映射关系, 我们可以通过一系列的操作, 把 0 映射成我们想要的那个值, 而且这个关系是一一对应的, 我们通过这个操作, 只能获取得那个值, 因为都是纯函数. 这听起来很兴奋.

包裹着那些函数的函数是一个合法的策略, 只要我们想, 我们可以一直让函数隐藏在最后一步, 只要我们不调用实际执行的函数, 他们理论上全是纯的, 而且不会发射任何的火箭. 在那些纯的代码中, 我们最终还是需要副作用的(不然怎么发射火箭), 把所有的一切都包裹在一个函数里面, 可以让我们精确的控制管理好那些副作用, 当那些副作用发生的时候, 我们可以精确的知道他发生了, 但是, 声明那么多得函数管理起来是很痛苦的, 而且我们还要为每一个函数都创建一个被包裹的版本, 我们需要一些像 Math.sqrt() 这样的 javascript 语言内置的完美的函数去干这个事情, 如果我们有一种方式, 可以用我们的延迟值去使用那些普通函数, 这就太好了. 现在让我们来引入 effect functor (名词不翻译, 请大家意会)

the effect functor

从我们的目的出发, effect functor 不过就是一个持有我们的延迟函数的对象, 所以我们会把 fZero() 放进去, 但是, 在我们干这个事情之前, 我们来看一个简单一点的例子.

// 这是我们的延迟函数
// zero :: () -> Number
function fZero() {
    console.log('Starting with nothing');
    // Definitely not launching a nuclear strike here.
    // But this function is still impure.
    return 0;
}
复制代码

现在, 我们来创建一个可以为我们新建 effect 对象的构造器(工厂函数, 不用使用new)

// Effect :: Function -> Effect
function Effect(f) {
    return {};
}
复制代码

目前为止, 东西并不多, 任何 javascript 开发者都能看懂这个代码, 很简单, 现在, 我们来搞一些有用的东西, 我们现在把 Effect 跟我们普通的 fZero() 函数一起使用, 我们来写一个带着普通的函数的方法, 最终我们会把他应用到我们的延迟值上, 但是, 我们不会触发 副作用 ,我们把他叫做 map 这是因为, 他会在常规的函数和 Effect 函数之间建立一种映射的关系, 这看起来可能会是这样的.

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(g(f(x))
        } 
    };
}
复制代码

如果你不是函数式编程的新手, 而且你很专注的看到这里, 你可能会发现, 这跟 compose 很像, 我们会在稍后来说这个问题, 现在我们来试试这样干

const zero = Effect(fZero);
const increment = x => x + 1; // A plain ol' regular function.
const one = zero.map(increment);
复制代码

嗯哼, 我们现在还没有方法知道我们干了什么, 我们想在进阶一下, 给 Effect 加一个方法

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        }
    }
}

const zero = Effect(fZero);
const increment = x => x + 1; // Just a regular function.
const one = zero.map(increment);

one.runEffects();
// ⦘ Starting with nothing
// ← 1
复制代码

我们可以一直调用 Effect 的 map 方法

const double = x => x * 2;
const cube = x => Math.pow(x, 3);
const eight = Effect(fZero)
    .map(increment)
    .map(double)
    .map(cube);

eight.runEffects();
// ⦘ Starting with nothing
// ← 8
复制代码

现在, 事情变得有趣了, 我们把这个东西叫 functor, 所有有 map() 方法的 Effect 都是, 它有一些规则, 这些规则不是限制什么你不能做, 而是限制了什么你可以做的, 就像是特权, 因为 Effect 只是 functor 的一种, 他们其中的一个规则是 composition rule 他看起来像是这样的.

如果我们有一个 Effect 叫 e 和两个函数叫 f 和 g 那么 e.map(g).map(f) 等于 e.map(f(g(x)))

换一种说法, 连续的执行两个 map 等于 合并两个函数,执行一次map 意味着, Effect 可以做这样做这个事情

const incDoubleCube = x => cube(double(increment(x)));
// If we're using a library like Ramda or lodash/fp we could also write:
// const incDoubleCube = compose(cube, double, increment);
const eight = Effect(fZero).map(incDoubleCube);
复制代码

当我们这样做的时候, 我们保证,这个和上面的 三个map 的版本得到的结果是一样的, 我们可以用这个去重构我们的代码而且不会破坏原有的代码, 我们甚至可以通过交换两个函数的顺序来提升性能. 现在我们再来做一些进阶.

创建 Effect 的捷径

我们的 Effect 构造器带着一个函数做参数, 这很方便, 因为多数的副作用都是在一个函数中发生的, 例如, Math.random() console.log 和类似的这些函数, 要是我们想在 Effect 中放一个 其他类型的值呢?(例如放一个 对象), 想象一下, 我们需要在浏览器的 window 对象上绑定一个配置对象, 我们想获取他的值, 但是, 它是一个全局的对象, 它可能在任何时候被修改, 这是副作用, 我们可以写一个方法来让创建 Effect 的方式变得更丰富.

// 这是一个静态的方法
// of :: a -> Effect a
Effect.of = function of(val) {
    return Effect(() => val);
}
复制代码

为了让你们知道这是多么的有用, 想象一下我们现在在一个 web app 上, 这个应用有一些固定的功能, 例如,文章列表 还有作者的信息, 但是, 这个应用的 HTML 内容会根据不同的用户而在线更新, 我们是一个聪明的工程师, 我们决定把他们的信息的位置记录在一个全局的配置对象上,那么我们就可以总是找到他们,像这个样子

window.myAppConf = {
    selectors: {
        'user-bio':     '.userbio',
        'article-list': '#articles',
        'user-name':    '.userfullname',
    },
    templates: {
        'greet':  'Pleased to meet you, {name}',
        'notify': 'You have {n} alerts',
    }
};
复制代码

现在, 使用我们的捷径方法, 我们可以把我们想要的值放进 Effect 里面

const win = Effect.of(window);
userBioLocator = win.map(x => x.myAppConf.selectors['user-bio']);
// ← Effect('.userbio')
// 现在我们得到的是一个 Effect 然后里面装着一个函数 () => '.userbio'
// 一会会回到这里继续讲解
复制代码

嵌套 Effect 和 扁平 Effect

Effect 的映射可以让我们走很长的路, 但是有的时候, 我们会映射一个返回 Effect 的函数, 这就尴尬了. 比如, 我们现在想真的找到上面的那个选择器的 DOM 节点, 我们就需要另外一个不纯的API document.querySelector() 噢 又是一个副作用, 所以我们打算把他放进 一个返回 Effect 的函数中

// $ :: String -> Effect DOMElement
function $(selector) {
    return Effect.of(document.querySelector(s));
}
复制代码

现在, 如果我们想把这个 $ 和上面的 userBioLocator 一起使用(他们为什么要一起使用不用解释吧...), 我们需要使用map

const userBio = userBioLocator.map($);
// ← Effect(Effect(<div>))
复制代码

到了这一步就有点尴尬了, 如果我们想要访问那个 div 我们就要继续 map一个函数, 而那个函数里面 还要再次 map 才可以得到我们真正想要的值, (Effect 嵌套了) 如果我们想要访问div的 innerHTML 那么代码可能是这样的.

const innerHTML = userBio.map(eff => eff.map(domEl => domEl.innerHTML));
// ← Effect(Effect('<h2>User Biography</h2>'))
复制代码

现在我们来从新的捋一捋思路, 我们回到 userBio 那一步, 会有点繁琐, 但是能让我们更清晰的看看他是怎么嵌套的. 我们上面的 Effect('.userbio') 这个描述可能有点迷惑, 它其实是下面这样的

ps: 下面这个过程还有疑惑的请在掘金平台评论区回复, 我看见会回答.

 Effect(() => '.userbio')
复制代码

其实我们还可以继续的展开,

Effect(() => window.myAppConf.selectors['user-bio']);
复制代码

我们的 map 相当于将 参数里的函数, 跟 Effect 内部保管的函数相合并, 所以当我们传入一个 $ 的时候, 他就变成这个样子的

Effect(() => $(window.myAppConf.selectors['user-bio']));
复制代码

把 $ 展开我们得到

Effect(
    () => Effect.of(document.querySelector(window.myAppConf.selectors['user-bio']))
);
复制代码

现在我们再把 Effect.of 展开

Effect(
    () => Effect(
        () => document.querySelector(window.myAppConf.selectors['user-bio'])
    )
);
复制代码

see 嵌套了, 然而, 副作用还是保持在内部的Effect 它并没有影响到外部的 Effect 可以说外部的 Effect 已经毫无作用了

Join

为什么我要把它展开, 因为我想把这个 嵌套的 Effect 给扁平了, 让它变成一个 Effect , 而且是在不触发副作用的条件下把它扁平掉, 不知道你想到了没有, 我们现在已经有一个方法可以获取到 Effect 的值了, 是的, 就是 runEffects, 我们可以直接在外部的Effect 行执行 runEffects 就可以拿到内部的 Effect, 但是, 我们的 runEffects 最初是用来执行我们的延迟值函数的, 而延迟值会触发副作用, 那不是有歧义了吗, 因为我们默认 runEffects 会触发副作用, 但是我们的 扁平化 是不触发副作用的, 所以我们需要新建另外一个函数, 来干相同的事情.这会让我们清楚的看函数调用就知道, 我们实际干了啥. 即使, 这两个函数是一模一样的

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        },
        join(x) {
            return f(x);
        }
    }
}
复制代码

我现在可以用这个来 扁平掉 我上面的那个嵌套例子了

const userBioHTML = Effect.of(window)
    .map(x => x.myAppConf.selectors['user-bio'])
    .map($)
    .join()
    .map(x => x.innerHTML);
// ← Effect('<h2>User Biography</h2>')
复制代码

Chain

想上面的这种, map 完了以后马上就 join 的写法会有很多, 这就好像是捆绑的操作, 所以我们可以给他们来一个 快捷方式, 这让我们可以很安全的一直 map jion map join.

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        },
        join(x) {
            return f(x);
        },
        chain(g) {
            return Effect(f).map(g).jion()
        }
    }
}
复制代码

我们把这个函数叫 chain (具有链子的意思, 而且标准也规定了这个名字, 你们可以去查阅) 是因为他可以把两个 Effect 给连接在一起. 现在来看看我们重构过的代码

const userBioHTML = Effect.of(window)
    .map(x => x.myAppConf.selectors['user-bio'])
    .chain($)
    .map(x => x.innerHTML);
// ← Effect('<h2>User Biography</h2>')
复制代码

不幸的是, 其他的函数式程序员, 会用他们自己的命名, 如果你们会阅读到他们的文章, 可能回对你造成一点困惑, 有时候他会叫 flatMap 熟悉吧, 我记得 rxjs 就是使用的这个, 还有一些库会使用 bind 所以当你们看到这些词的时候注意一下, 他们其实是相同的概念.

结合 Effect

使用 Effect 的时候还有一个场景可能会比较尴尬, 就是我们需要用一个函数去组合多个 Effect 的时候. 例如, 当我们要在 DOM 节点中获取用户名然后把它插入到我们配置的模板(template)中, 所以我们需要一个这样的操作模板的函数

// tpl :: String -> Object -> String
const tpl = curry(function tpl(pattern, data) {
    return Object.keys(data).reduce(
        (str, key) => str.replace(new RegExp(`{${key}}`, data[key]),
        pattern
    );
});
// 不知道curry的都要去了解一下哦, 很重要的一个概念
复制代码

一切都很好, 现在来获取我们的用户名了

const win = Effect.of(window);
const name = win.map(w => w.myAppConfig.selectors['user-name'])
    .chain($)
    .map(el => el.innerHTML)
    .map(str => ({name: str});
// ← Effect({name: 'Mr. Hatter'});

const pattern = win.map(w => w.myAppConfig.templates('greeting'));
// ← Effect('Pleased to meet you, {name}');
复制代码

我们已经有模板函数了, 它需要两个参数, 一个字符串(模板), 一个对象(config对象), 然后返回一个字符串. 但是我们的字符串和对象都包裹在 Effect 里面, 所以我们只能在 Effect 内部传递参数给 tpl

现在我们来看一下在 pattern 上把 tpl 函数传入 map 里面会发生什么

pattern.map(tpl)
// ← Effect([Function])
复制代码

别混乱啊, 这里要好好捋一捋, 我们传入了 tpl 而tpl 是一个 curry 过的函数, 这个函数在接受到他的两个参数之前, 他是不会执行的, 也就是说 我们的 pattern.map() 返回的 Effect 是一个包裹了 tpl('Pleased to meet you, {name}', ?) 的 Effect , 他还需要一个配置对象才会返回他真正想要返回的值.

现在, 我们需要把config 对象传进 Effect 里面的那个 已经具有一个参数的 tpl 函数了, 但是我们好像还没有方法去干这个事情, 我们现在来创建一个(我们把这个方法叫 ap).

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        }
        join(x) {
            return f(x);
        }
        chain(g) {
            return Effect(f).map(g).join();
        }
        ap(eff) {
             // If someone calls ap, we assume eff has a function inside it (rather than a value).
            // We'll use map to go inside off, and access that function (we'll call it 'g')
            // Once we've got g, we apply the value inside off f() to it
            // 我们默认传进来的是一个 Effect 
            return eff.map(g => g(f()));
        }
    }
}
复制代码

好好的看一看这个函数, 好好理解一下, 这个不好解释, 展开了就懂了.

现在我们可以使用一下这个函数了

const win = Effect.of(window);
const name = win.map(w => w.myAppConfig.selectors['user-name'])
    .chain($)
    .map(el => el.innerHTML)
    .map(str => ({name: str}));

const pattern = win.map(w => w.myAppConfig.templates('greeting'));

const greeting = name.ap(pattern.map(tpl));
// ← Effect('Pleased to meet you, Mr Hatter')
复制代码

我们已经达到了我们的目的, 但还是有一点不足, 我发现 ap() 有时候会有点尴尬, 就是很难去记住我的函数要 map 了一个参数才可以传进去 ap(), 如果我忘记了这个函数的参数的顺序那我就GG了, 这有一个方法, 大多时候, 我们会把普通函数提升到全应用的级别, 就是, 我有一个普通的函数, 我想让它跟与一个拥有 ap() 方法的 Effect 的类似的东西一起工作, 我们可以写一个函数为我们做这个事情.

// liftA2 :: (a -> b -> c) -> (Applicative a -> Applicative b -> Applicative c)
const liftA2 = curry(function liftA2(f, x, y) {
    return y.ap(x.map(f));
    // We could also write:
    //  return x.map(f).chain(g => y.map(g));
});
复制代码

我们把他叫 liftA2 是因为他 提升了具有两个参数的函数, 我们可以类似的创建一个 liftA3.

// liftA3 :: (a -> b -> c -> d) -> (Applicative a -> Applicative b -> Applicative c -> Applicative d)
const liftA3 = curry(function liftA3(f, a, b, c) {
    return c.ap(b.ap(a.map(f)));
});
复制代码

注意一下, 我们这里并没有涉及到 Effect 我刚才说了, 是一个与拥有 ap() 的 Efect 类似的东西, 这些函数可以与任何拥有 ap() 方法的对象一起工作

我们可以用我们的 liftA2 来重写我们上面的代码

const win = Effect.of(window);
const user = win.map(w => w.myAppConfig.selectors['user-name'])
    .chain($)
    .map(el => el.innerHTML)
    .map(str => ({name: str});

const pattern = win.map(w => w.myAppConfig.templates['greeting']);

const greeting = liftA2(tpl)(pattern, user);
// ← Effect('Pleased to meet you, Mr Hatter')
复制代码

完了吗?

到了这里, 你可能会觉得为了避免这样或者那样的副作用, 我们可是煞费苦心了. 但是这又怎么样呢, 当你意识到他的好处的时候, 这样点点的麻烦完全OJBK

就到这里吧, 原文下面还有一段引申, 不过难度有点深(关于计算机的, 机器学习的一些描述), 我也没懂(其实上面我也是勉强看懂了, 收益确实良多)... 就不翻译了...

文章分类
前端
文章标签