写在前面
对于控制异步请求,大家会想到什么?
promise? async/await? 生成器yield? 又或者古老时期的回调地狱?如果说可以控制请求中的异步流程而不用上述任何一种解决方案,或许很多人和我一样都觉得不可能吧。如果你感兴趣可以接着往下看。
笔者学习函数式编程有一段时间了,初窥其门径,感慨其抽象与组合之美,接下来将叙述一下将函数式编程应用于项目中异步请求的编程经历。本文将不会细说函数柯里化/compose/以及函子的概念,直接进入函数式编程应用,后续将会对函数式编程进行一个系列的学习和应用的总结博文,如果感兴趣不妨点个关注。
常规请求
先来看看一个最为基本的请求范例,这几乎是任何一个异步请求的模范案例。
getList = async (id: number) => {
const [res] = await Get<string[]>({
url: '/api/getList',
data: {
id,
}
});
return res ? res : []
}
ps:(插个题外话,这是笔者拥抱了ts之后封装过的异步请求,之前写过一篇文章拥抱ts之后更优雅的异步请求处理。)可以说,这大概是业内用的最为普遍的请求策略:async/await。可是万一这对于含有复杂逻辑的请求业务会怎么样呢?比如说: 我们在hook里 需要过滤返回中含有空格的数据,然后把不含空格的数据全部大写处理,然后setState改变视图状态。
const [list, setList] = useState([]);
getList = async (id: number) => {
const [res] = await Get<string[]>({
url: '/api/getList',
data: {
id,
}
});
if (res) {
const upperList = res.filter(li => !/\s+/g.test(li)).map(li => li.toUpperCase());
setList(upperList);
} else {
return [];
}
}
这可能是大部分人都会给出的代码实现,充分体现出了命令式的代码风格,特点就是他的可理解性:从上至下可以非常清晰的读懂getList函数做了哪些事。
但是这样的实现也存在问题,1.不可测试性。2.如果说getList中又含有其他的请求,同时也处理复杂的业务,我们小小的getList承受了太多会变得臃肿不堪。
对于以上问题,笔者想到的是可以剥离请求之外的代码,让这些代码独立具备可测试性,对于内部的其他请求和业务再次进封装,可是最后就会发现,我们不得不因为这些问题,导致越来越多的函数被生成,以及必须仔细考虑之后他们的组合关系。
最后,当我们开始认真考虑这些函数的组合关系的时候,其实,函数式编程已经在向你招手并报以微笑。
函数式编程中的异步请求
在正式处理它之前,我们需要一些工具。
来自ramda的curry/compose 他们是一群可爱的小伙伴,函数式编程是他们尽情施展才华的舞台。
来自data.task的Task,这是非常厉害的函子实现了相当多的接口。
好了我们可以正式开始干活,第一步先抛开之前的请求工具,我们用Task独立封装一个请求工具。
Get
基于axios和Task,并返回curry化函数
// api.js
function axiosGet(url: string, data: any) {
return new Task(function (reject, resolve) {
axios.get(url, {
params: data
}).then(function (response) {
resolve(response);
}).catch(function (error) {
reject(error);
});
})
}
export const Get = curry(axiosGet);
curry的魔力
curry一些常用工具函数,比如会用到的filter和map
// tool.js
function _filter (f, ary) {
return ary.filter(f);
}
function _test (reg, str) {
return reg.test(str + '');
}
function _map (f, ary) {
return ary.map(f);
}
function _fork (f, g, m) {
return m.fork(f, g);
}
const filter = curry(_filter);
const test = curry(_test);
const map = curry(_map);
const fork = curry(_fork);
const testSpace = test(/\s+/g);
const withoutSpace = curry(str => !testSpace(str));
const upperCase = li => li.toUpperCase();
// 最终输出的都是后面所需要的
export const safeArray = ary => ary ? ary : [];
export const filterWithoutSpace = filter(withoutSpace);
export const mapUpperCase = map(upperCase);
export const forkWithLog = fork(err => console.log('请求异常了', err));
从上面的代码可以很容易看出来,即使我不讲curry函数的原理,但是curry的魔法在于可以轻松的从一个函数生成另一个函数,这本身就是一种强大。
优雅的组合
当我们已经整理出了我们所需要的工具,就开始回到了困扰我们问题:如何组合所有的函数呢?compose给了我们答案。
const [list, setList] = useState([]);
const handleListData = compose(mapUpperCase, filterWithoutSpace, safeArray);
const getList = compose(forkWithLog(setList), map(handleListData), Get('/api/getList'));
// how to use getList
getList(12306);
相比之前的getList,函数式编程之后的getList十分简单并且优雅而不失可读性,从右到左分别是请求api接口数据,数据拿到handleListData处理,处理完毕交给setList,数据就像货品一样在函数的管道中流通。
而且不依赖于promise、async/await、也没有看到任何的回调地狱便可完成异步请求的流程控制,当笔者第一次看到这种风格的时候不免惊呼并且感到兴奋:函数式编程实在太强大了。
并且几乎所有的函数都可以独立测试,让我们的单元测试跑起来可以一帆风顺~
Task
优雅的背后必然少不了优雅的实现。简单来说,Task是个函子(函数式编程中最为重要的概念),实现了许多的接口map只是其中一个接口,借助于实现了接口的函子,我们可以把数据与函数交给函子进行传递也可以反过来应用于函子,这些都是后话了。
下面将从源码部分介绍下Task做了哪些设计:
// 构造函数部分
function Task(computation, cleanup) {
this.fork = computation;
this.cleanup = cleanup || function() {};
}
// map接口
Task.prototype.map = function _map(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 resolve(f(b));
});
}, cleanup);
};
摘取了需要用到的部分,其实原理也很简单,从某种角度来说也可以称他为回调函数。
const getList = compose(forkWithLog(setList), map(handleListData), Get('/api/getList'));
// how to use getList
getList(12306);
// getList首先会接受到参数,然后把参数传入Get('/api/getList');
function axiosGet(url: string, data: any) {
return new Task(function (reject, resolve) {
axios.get(url, {
params: data
}).then(function (response) {
resolve(response);
}).catch(function (error) {
reject(error);
});
})
}
export const Get = curry(axiosGet);
// Get('/api/getList') 其实是一个柯里化过的axiosGet
// axiosGet 会带着'/api/getList' 和 12306两个参数开始执行并返回内部带有axios.get的new Task
// 可以看出来到了这一步,请求都是没有发出来的,因为还没到时间
// 此时这个Task内部的fork指向传入的axios.get
this.fork = function (reject, resolve) {
axios.get(url, {
params: data
}).then(function (response) {
resolve(response);
}).catch(function (error) {
reject(error);
});
}
// 接着这个上一步返回的new Task实例,task经过了map(handleListData)
// 这时候会调用Task的接口map
// map接口
Task.prototype.map = function _map(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 resolve(f(b));
});
}, cleanup);
};
// map接口再继续返回一个new Task 只不过在外部保存了fork也就是我们的axios.get 然后在新的task的fork中执行上一个task的fork 并且把map中的handleListData应用于接收到的b
// map接口再继续返回一个new Task最后会经过forkWithLog(setList)
function _fork (f, g, m) {
return m.fork(f, g);
}
export const forkWithLog = fork(err => console.log('请求异常了', err));
// forkWithLog是curry化过后的_fork
// 会调用接收到的task的fork方法,并把reject 和 resolve的函数传入,也就是在这一步才会发起请求
// 也就是console.log和setList 传入
// 也就是说整个getList可以用下面的代码来理解
function axiosGet(url: string, data: any) {
axios.get(url, {
params: data
}).then(function (response) {
setList(handleListData(response));
}).catch(function (error) {
console.log('请求异常了', err)
});
}
axiosGet('/api/getList', 12306);
总结
函数式背后的技术原理简单说无非就是闭包,curry,compose,以及functor(函子),但是背后的理论支撑涉及范畴论和抽象部分却十分高深令人难以琢磨,庆幸的是,目前我们还不需要理解那些难以理解的部分,借助于函数的组合以及含有丰富接口的函子也足够能写出优雅并且point free的代码。
回头去看看我们的getList,不妨再一次领略一遍这种point free的声明式代码。
感谢curry compose functor,让我成为了快乐的程序员。
函数的力量,超乎你想象!!!