写在前面
当笔者在函数式编程的海洋中探索,看着项目中函数声明式代码越来越多时开始沾沾自喜。如获至宝的写下了函数式编程的异步请求和函数式编程的mock方案两篇心得,自认为小有所得。然而最终现实(需求)打败了我,当我手持上面两把“武器”,自认为可以天下无敌,剑走江湖,双剑下天山拯救世界。现实(需求)狠狠得给我上了一课,甩了两个耳刮子,让我回去好好修炼。说明武器库还是太少,上面两把武器不足以应付更为复杂的异步场景。
清流
当笔者开始大量运用函数式编程去编写代码,如果只是简单应用,则无往不利,如果是稍微复杂的异步业务,如果不加设计,那么异步代码比远古时代的回调地狱相差无几,难以阅读和维护。 下面举一个例子先说明一下什么是声明式的异步请求:
const [detail, setDetail] = useState(null);
const getUserDetail = compose(action(setDetail), GetHttp('/api/getDetail'));
useEffect(() => {
getUserDetail('lemon')
}, []);
稍微讲解一下以上的函数式代码执行流程。compose组合函数,接收外部的参数,依次从右至左执行。
getUserDetail接收字符串“lemon”,传入GetHttp('/api/getDetail'),这是个柯里化并且封装了axios的函数,接收参数,并且返回一个函子Task,这时候异步请求并不发出。
Task再继续传入action(setDetail),这也是一个柯里化函数,接收Task调用函子的fork接口触发axios,并在数据返回处setDetail。
相比async/await 这种风格的代码宛如一股清流。
现实
然而现实(需求)变得没那么友好,他对“lemon”要求更为严格,setDetail需要的是lemon的父母的信息,lemon的父母的信息依赖于对lemon进行getUserDetail返回的id去请求getUserParents。
先来看一般做法,getUserDetail的action中的处理结果不能再直接处理setDetail,先把它拿出来,
再用相同的声明式代码去声明我们需要的getUserParents,再把getUserParents放进getUserDetail的action中去,同时用map去料理原始的返回数据,再把需要的id放到下一个流程中。
const [detail, setDetail] = useState(null);
const getUserParents = compose(action(setDetail), GetHttp('/api/getParents'))
const getUserDetail = compose(action(getUserParents), map(prop('id')),GetHttp('/api/getDetail'));
useEffect(() => {
getUserDetail('lemon')
}, []);
看起来似乎不错,功能也符合预期。但是需求又开始作妖了,高呼:setDetail搞错啦!不需要lemon的父母的信息了,需要的是lemon的爷爷奶奶的信息,但是爷爷奶奶的信息需要从lemon父母的id中去请求getUserGrandParents。
🤷♂️🤷♂️🤷♂️🤷♂️🤷♂️🤷♂️🤷♂️🤷♂️除了摊手,心中只有草泥马在草原蹦腾了。
没办法我们依样画葫芦,把getUserParents的action中的setDetail拿出来,新造一个getUserGrandParents放进去。
const [detail, setDetail] = useState(null);
const getUserGrandParents = compose(action(setDetail), GetHttp('/api/getGrandParents'))
const getUserParents = compose(action(getUserGrandParents), map(prop('id')), GetHttp('/api/getParents'))
const getUserDetail = compose(action(getUserParents), map(prop('id')),GetHttp('/api/getDetail'));
useEffect(() => {
getUserDetail('lemon')
}, []);
强忍着行将吐出的一口老血,我瞒着自己的良心说看起来似乎“不错”,功能也符合预期,但是需求还是继续折腾。。这次需要的是祖父祖母的信息...好了别说了,我知道该怎么做!😠
const [detail, setDetail] = useState(null);
const getUserDoubleGrandParents = compose(action(setDetail), GetHttp('/api/getDoubleGrandParents'))
const getUserGrandParents = compose(action(getUserDoubleGrandParents), map(prop('id')), GetHttp('/api/getGrandParents'))
const getUserParents = compose(action(getUserGrandParents), map(prop('id')), GetHttp('/api/getParents'))
const getUserDetail = compose(action(getUserParents), map(prop('id')),GetHttp('/api/getDetail'));
useEffect(() => {
getUserDetail('lemon')
}, []);
相信聪明的读者已经看出来问题了,这样的代码不仅可读性低可维护性差,没有谁愿意接收如此让人难堪的代码,而且重复代码过多,稍加不注意,代码的逻辑顺序理顺起来成本巨大。最重要的一点:容易使人心情变得糟糕。
走到这里为止,说明一般解决方法已经遇到瓶颈了,这种叠罗汉的解决方式每叠一次带来的危机感便成倍增加。毕竟谁都不想体验债台高筑的感觉。好了,chain函数,该你登场了。
Chain
chain函数有什么作用,下面这张图特别形象👇
就像锁链一般一环扣一环,在函数式编程中,函子是核心的概念,可以把函数式编程理解成把业务抽象成操作不同的函子,而一旦函子返回函子,返回的函子又继续返回函子,那么就会出现叠罗汉的困境,就像上面的需求需要从lemon自个的id去依次获得父母->爷爷奶奶->祖父祖母的信息了。
先来看看如何用chain来解决我们的叠罗汉窘境。
第一步我们先来抽象出重复造出Task 并且从Task返回中取出id的行为
// mapCreateGetIdTask.ts
const createGetKeyFunc = key => map(prop(key));
const getId = createGetKeyFunc('id');
const getIdFromTask = api => compose(getId, GetHttp(api));
export const mapCreateGetIdTask = map(url=> getIdFromTask(url));
第二步,用mapCreateGetIdTask整理出所需要的Task
const apiUrls = ['/api/getUserDetail', '/api/getUserParents', '/api/getUserGrandParents'];
const [getUserId, ...userForwardParents] = mapCreateGetIdTask(apiUrls);
const [getParentsIdTask, getGrandParentsIdTask] = userForwardParents.map(f => id => f(id));
const getDoubleGrandParents = id => GetHttp('/api/getUserDoubleGrandParents')(id)
第三步 chain链接我们需要的道路
const getDoubleGrandParentsByUserName = compose(action(setDetail), chain(getDoubleGrandParents), chain(getGrandParentsIdTask), chain(getParentsIdTask), getUserId);
完整代码如下:
// mapCreateGetIdTask.ts
const createGetKeyFunc = key => map(prop(key));
const getId = createGetKeyFunc('id');
const getIdFromTask = api => compose(getId, GetHttp(api));
export const mapCreateGetIdTask = map(url=> getIdFromTask(url));
// index.tsx
// @ts-nocheck
import { GetHttp } from "@/function/api";
import { compose, map, prop } from "@/function/ramda";
import { useEffect, useState } from "react";
import action from '@/function/action';
import chain from "@/function/chain";
import {mapCreateGetIdTask} from './mapCreateGetIdTask'
export default function Test() {
const [detail, setDetail] = useState(null);
const apiUrls = ['/api/getUserDetail', '/api/getUserParents', '/api/getUserGrandParents'];
const [getUserId, ...userForwardParents] = mapCreateGetIdTask(apiUrls);
const [getParentsIdTask, getGrandParentsIdTask] = userForwardParents.map(f => id => f(id));
const getDoubleGrandParents = id => GetHttp('/api/getUserDoubleGrandParents')(id)
const getDoubleGrandParentsByUserName = compose(action(setDetail), chain(getDoubleGrandParents), chain(getGrandParentsIdTask), chain(getParentsIdTask), getUserId);
useEffect(() => {
getDoubleGrandParentsByUserName('lemon')
}, []);
return detail;
}
咋看起来好像代码数变多了,但是可阅读性大大提升了,可以通过chain明确的知道业务逻辑的走向,不同的业务逻辑都完全独立,保持了独立可测试性,一个业务不需依赖其他的业务就像一条条的平行线一样,要知道一开始的做法业务之前彼此相互依赖,而chain函数就像回形针,把一条条的平行线链接起来。还有什么好处呢?可扩展性大大增强!比如需求说,先从userDetail获取id 再去获取lemon的祖父母的信息id 接着再去获取祖父母的父母的id 最后获取祖父母的父母的祖父母的信息?如果按照一开始叠罗汉的写法,恐怕代码需要经过大改。但是经过我们优化过后的代码仅仅需要改动一行代码。
// 改动前
const getDoubleGrandParentsByUserName = compose(action(setDetail), chain(getDoubleGrandParents), chain(getGrandParentsIdTask), chain(getParentsIdTask), getUserId);
// 改动后
const getDoubleGrandParentsByUserName = compose(action(setDetail), chain(getDoubleGrandParents), chain(getParentsIdTask), chain(getGrandParentsIdTask), getUserId);
变动量只是将两个chain中的函数调换了位置而已!可谓神迹~
感叹神乎其神之余,让我们走进源码去解析chain的魔法把。
源码解析
Task.prototype.chain = function _chain(f) {
var fork = this.fork;
var cleanup = this.cleanup;
return new Task(function(reject, resolve) {
return fork(function(a) {
return reject(a);
}, function(b) {
return f(b).fork(reject, resolve);
});
}, cleanup);
};
原理其实很简单,当Task函子遇到chain,会返回一个新的函子,并把上一个遇到的Task的fork函数作为闭包引用放入task内部执行,当这个Task遇到了action,则会执行上一个Task内部的函数,并把执行结果放入chain的参数f中。
return f(b).fork(reject, resolve);
这也是为什么我们的例子中chain链接的函数都是id => new Task的形式。
const getDoubleGrandParents = id => GetHttp('/api/getUserDoubleGrandParents')(id)
getDoubleGrandParents在chain中接收到了上一步异步的结果,将会执行GetHttp('/api/getUserDoubleGrandParents', id) 这又会返回一个Task,这一步不会发起请求。接着继续执行.fork(reject, resolve)执行一次真正的请求。
const getDoubleGrandParentsByUserName = compose(action(setDetail), chain(getDoubleGrandParents), chain(getGrandParentsIdTask), chain(getParentsIdTask), getUserId);
// 解析以上的代码流程
getDoubleGrandParentsByUserName('lemon');
1.首先调用getUserId('lemon');
相当于GetHttp('/api/getUserDetail', 'lemon');
这时候我们产生了TaskA
TaskA= new Task((rej, resolve) => {
axios.get('/api/getUserDetail', data).then(res => resolve).catch(err => rej(err);
});
4.接下来TaskA遇到了chain(getParentsIdTask)
会调用TaskA的chain方法,
首先将函数 简称functionA = (rej, resolve) => {
axios.get('/api/getUserDetail', data).then(res => resolve).catch(err => rej(err);
}
保存起来放到fork内 执行完chain以后
这时候会返回一个新的TaskB = new Task((rej, resolve) => {
return fork(function(a) {
return reject(a);
}, function(b) {
return f(b).fork(reject, resolve);
});
}, cleanup);
});
注意 此时
function(a) {
return reject(a);
}成了functionA的rej方法
function(b) {
return f(b).fork(reject, resolve);
} 成了functionA的resolve方法
到这里为止我们先来做个简单的测试
const test = compose(action(setDetail), chain(getParentsIdTask), getUserId)
test('lemon');
TaskB遇到了action(setDetail)。也就是会执行TaskB内部的fork函数 并把setDetail当作resolve传入。
TaskB内部的fork函数又会执行TaskA内部的fork函数先去请求axios.get('/api/getUserDetail', lemon)
有结果之后会调用f(b).fork(reject, resolve)
也就是axios.get('/api/getParents', b) => 发起请求 -> resolve 也就是setDetail(结果)
如果我们再继续chain会发生什么事呢?
const test = compose(action(setDetail),chain(getGrandParentsIdTask), chain(getParentsIdTask), getUserId)
test('lemon');
也就是会把上面段简单的测试的流程都扔进fork保留起来,放到新的task中去,等到新的task遇到action开始执行,再把结果丢到getGrandParentsIdTask中去。
大体上就是以此实现异步的串联链式调用。
总结
chain函数就像一个擀面杖一样,把层层的罗汉一拍给拍平了,把高纬度的逻辑降纬到一纬度来处理。 同时保持每个函数和业务的解耦,只是在让相互关联的部分通过参数进行输入和输出。
好的,把chain放进了背后,笔者又要下山了,前方或许还有更为难缠的对手,不禁已经开始期待下一次被现实(需求)上课的时候~