使用compose函数优化代码提高可读性及扩展性
作者:掘金安东尼
前言
本瓜知道前不久写的《JS 如何函数式编程》系列各位可能并不感冒,因为一切理论的东西如果脱离实战的话,那就将毫无意义。
于是乎,本瓜着手于实际工作开发,尝试应用函数式编程的一些思想。
最终惊人的发现:这个实现过程并不难,但是效果却不小!
实现思路:借助 compose 函数对连续的异步过程进行组装,不同的组合方式实现不同的业务流程。
这样不仅提高了代码的可读性,还提高了代码的扩展性。我想:这也许就是高内聚、低耦合吧~
撰此篇记之,并与各位分享。
场景说明
在和产品第一次沟通了需求后,我理解需要实现一个应用 新建流程,具体是这样的:
- 第 1 步:调用 sso 接口,拿到返回结果 res_token;
- 第 2 步:调用 create 接口,拿到返回结果 res_id;
- 第 3 步:处理字符串,拼接 Url;
- 第 4 步:建立 websocket 链接;
- 第 5 步:拿到 websocket 后端推送关键字,渲染页面;
注:接口、参数有做一定简化
上面除了第 3 步、第 5 步,剩下的都是要调接口的,并且前后步骤都有传参的需要,可以理解为一个连续且有序的异步调用过程。
为了快速响应产品需求,于是本瓜迅速写出了以下代码:
/** * 新建流程 * @param {*} appId * @param {*} tag */ export const handleGetIframeSrc = function(appId, tag) { let h5Id // 第 1 步: 调用 sso 接口,获取token getsingleSignOnToken({ formSource: tag }).then(data => { return new Promise((resolve, reject) => { resolve(data.result) }) }).then(token => { const para = { appId: appId } return new Promise((resolve, reject) => { // 第 2 步: 调用 create 接口,新建应用 appH5create(para).then(res => { // 第 3 步: 处理字符串,拼接 Url this.handleInsIframeUrl(res, token, appId) this.setH5Id(res.result.h5Id) h5Id = res.result.h5Id resolve(h5Id) }).catch(err => { this.$message({ message: err.message || '出现错误', type: 'error' }) }) }) }).then(h5Id => { // 第 4 步:建立 websocket 链接; return new Promise((resolve, reject) => { webSocketInit(resolve, reject, h5Id) }) }).then(doclose => { // 第 5 步:拿到 websocket 后端推送关键字,渲染页面; if (doclose) { this.setShowEditLink({ appId: appId, h5Id: h5Id, state: true }) } }).catch(err => { this.$message({ message: err.message || '出现错误', type: 'error' }) }) } const handleInsIframeUrl = function(res, token, appId) { // url 拼接 const secretId = this.$store.state.userinfo.enterpriseList[0].secretId let editUrl = res.result.editUrl const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?')) editUrl = res.result.editUrl.replace(infoId, `from=a2p&${infoId}`) const headList = JSON.parse(JSON.stringify(this.headList)) headList.forEach(i => { if (i.appId === appId) { i.srcUrl = `${editUrl}&token=${token}&secretId=${secretId}` } }) this.setHeadList(headList) }
这段代码是非常自然地根据产品所提需求,然后自己理解所编写。
其实还可以,是吧?🐶
需求更新
但你不得不承认,程序员和产品之间有一条无法逾越的沟通鸿沟。
它大部分是由所站角度不同而产生,只能说:李姐李姐!
所以,基于前一个场景,需求发生了点 更新 ~
除了上节所提的 【新建流程】 ,还要加一个 【编辑流程】 ╮(╯▽╰)╭
编辑流程简单来说就是:砍掉新建流程的第 2 步调接口,再稍微调整传参即可。
于是本瓜直接 copy 一下再作简单删改,不到 1 分钟,编辑流程的代码就诞生了~
/** * 编辑流程 */ const handleToIframeEdit = function() { // 编辑 iframe const { editUrl, appId, h5Id } = this.ruleForm // 第 1 步: 调用 sso 接口,获取token getsingleSignOnToken({ formSource: 'ins' }).then(data => { return new Promise((resolve, reject) => { resolve(data.result) }) }).then(token => { // 第 2 步:处理字符串,拼接 Url return new Promise((resolve, reject) => { const secretId = this.$store.state.userinfo.enterpriseList[0].secretId const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?')) const URL = editUrl.replace(infoId, `from=a2p&${infoId}`) const headList = JSON.parse(JSON.stringify(this.headList)) headList.forEach(i => { if (i.appId === appId) { i.srcUrl = `${URL}&token=${token}&secretId=${secretId}` } }) this.setHeadList(headList) this.setShowEditLink({ appId: appId, h5Id: h5Id, state: false }) this.setShowNavIframe({ appId: appId, state: true }) this.setNavLabel(this.headList.find(i => i.appId === appId).name) resolve(h5Id) }) }).then(h5Id => { // 第 3 步:建立 websocket 链接; return new Promise((resolve, reject) => { webSocketInit(resolve, reject, h5Id) }) }).then(doclose => { // 第 4 步:拿到 websocket 后端推送关键字,渲染页面; if (doclose) { this.setShowEditLink({ appId: appId, h5Id: h5Id, state: true }) } }).catch(err => { this.$message({ message: err.message || '出现错误', type: 'error' }) }) }
需求再更新
老实讲,不怪产品,咱做需求的过程也是逐步理解需求的过程。理解有变化,再正常不过!(#^.^#) 李姐李姐......
上面已有两个流程:新建流程、编辑流程。
这次,要再加一个 重新创建流程 ~
重新创建流程可简单理解为:在新建流程之前调一个 delDraft 删除草稿接口;
至此,我们产生了三个流程:
- 新建流程;
- 编辑流程;
- 重新创建流程;
本瓜这里作个简单的脑图示意逻辑:
我的直觉告诉我:不能再 copy 一份新建流程作修改了,因为这样就太拉了。。。没错,它没有耦合,但是它也没有内聚,这不是我想要的。于是,我开始封装了......
实现上述脑图的代码:
/** * 判断是否存在草稿记录? */ judgeIfDraftExist(item) { const para = { appId: item.appId } return appH5ifDraftExist(para).then(res => { const { editUrl, h5Id, version } = res.result if (h5Id === -1) { // 不存在草稿 this.handleGetIframeSrc(item) } else { // 存在草稿 this.handleExitDraft(item, h5Id, version, editUrl) } }).catch(err => { console.log(err) }) }, /** * 选择继续编辑? */ handleExitDraft(item, h5Id, version, editUrl) { this.$confirm('有未完成的信息收集链接,是否继续编辑?', '提示', { confirmButtonText: '继续编辑', cancelButtonText: '重新创建', type: 'warning' }).then(() => { const editUrlH5Id = h5Id this.handleGetIframeSrc(item, editUrl, editUrlH5Id) }).catch(() => { this.handleGetIframeSrc(item) appH5delete({ h5Id: h5Id, version: version }) }) }, /** * 新建流程、编辑流程、重新创建流程; */ handleGetIframeSrc(item, editUrl, editUrlH5Id) { let ws_h5Id getsingleSignOnToken({ formSource: item.tag }).then(data => { // 调用 sso 接口,拿到返回结果 res_token; return new Promise((resolve, reject) => { resolve(data.result) }) }).then(token => { const para = { appId: item.appId } return new Promise((resolve, reject) => { if (!editUrl) { // 新建流程、重新创建流程 // 调用 create 接口,拿到返回结果 res_id; appH5create(para).then(res => { // 处理字符串,拼接 Url; this.handleInsIframeUrl(res.result.editUrl, token, item.appId) this.setH5Id(res.result.h5Id) ws_h5Id = res.result.h5Id this.setShowNavIframe({ appId: item.appId, state: true }) this.setNavLabel(item.name) resolve(true) }).catch(err => { this.$message({ message: err.message || '出现错误', type: 'error' }) }) } else { // 编辑流程 this.handleInsIframeUrl(editUrl, token, item.appId) this.setH5Id(editUrlH5Id) ws_h5Id = editUrlH5Id this.setShowNavIframe({ appId: item.appId, state: true }) this.setNavLabel(item.name) resolve(true) } }) }).then(() => { // 建立 websocket 链接; return new Promise((resolve, reject) => { webSocketInit(resolve, reject, ws_h5Id) }) }).then(doclose => { // 拿到 websocket 后端推送关键字,渲染页面; if (doclose) { this.setShowEditLink({ appId: item.appId, h5Id: ws_h5Id, state: true }) } }).catch(err => { this.$message({ message: err.message || '出现错误', type: 'error' }) }) }, handleInsIframeUrl(editUrl, token, appId) { // url 拼接 const secretId = this.$store.state.userinfo.enterpriseList[0].secretId const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?')) const url = editUrl.replace(infoId, `from=a2p&${infoId}`) const headList = JSON.parse(JSON.stringify(this.headList)) headList.forEach(i => { if (i.appId === appId) { i.srcUrl = `${url}&token=${token}&secretId=${secretId}` } }) this.setHeadList(headList) }
如此,我们便将 新建流程、编辑流程、重新创建流程 全部整合到了上述代码;
需求再再更新
上面的封装看起来似乎还不错,但是这时我害怕了!想到:如果这个时候,还要加流程或者改流程呢??? 我是打算继续用 if...else 叠加在那个主函数里面吗?还是打算直接 copy 一份再作删改?
我都能遇见它会充斥着各种判断,变量赋值、引用飞来飞去,最终成为一坨💩,没错,代码屎山的💩
我摸了摸左胸的左心房,它告诉我:“饶了接盘侠吧~”
于是乎,本瓜尝试引进了之前吹那么 nb 的函数式编程!它的能力就是让代码更可读,这是我所需要的!来吧!!展示!!
compose 函数
我们在 《XDM,JS如何函数式编程?看这就够了!(三)》 这篇讲过函数组合 compose!没错,我们这次就要用到这个家伙!
还记得那句话吗?
组合 ———— 声明式数据流 ———— 是支撑函数式编程最重要的工具之一!
最基础的 compose 函数是这样的:
function compose(...fns) { return function composed(result){ // 拷贝一份保存函数的数组 var list = fns.slice(); while (list.length > 0) { // 将最后一个函数从列表尾部拿出 // 并执行它 result = list.pop()( result ); } return result; }; } // ES6 箭头函数形式写法 var compose = (...fns) => result => { var list = fns.slice(); while (list.length > 0) { // 将最后一个函数从列表尾部拿出 // 并执行它 result = list.pop()( result ); } return result; };
它能将一个函数调用的输出路由跳转到另一个函数的调用上,然后一直进行下去。
我们不需关注黑盒子里面做了什么,只需关注:这个东西(函数)是什么!它需要我输入什么!它的输出又是什么!
composePromise
但上面提到的 compose 函数是组合同步操作,而在本篇的实战中,我们需要组合是异步函数!
于是它被改造成这样:
/** * @param {...any} args * @returns */ export const composePromise = function(...args) { const init = args.pop() return function(...arg) { return args.reverse().reduce(function(sequence, func) { return sequence.then(function(result) { // eslint-disable-next-line no-useless-call return func.call(null, result) }) }, Promise.resolve(init.apply(null, arg))) } }
原理:Promise 可以指定一个 sequence,来规定一个执行 then 的过程,then 函数会等到执行完成后,再执行下一个 then 的处理。启动sequence 可以使用 Promise.resolve() 这个函数。构建 sequence 可以使用 reduce 。
我们再写一个小测试在控制台跑一下!
let compose = function(...args) { const init = args.pop() return function(...arg) { return args.reverse().reduce(function(sequence, func) { return sequence.then(function(result) { return func.call(null, result) }) }, Promise.resolve(init.apply(null, arg))) } } let a = async() => { return new Promise((resolve, reject) => { setTimeout(() => { console.log('xhr1') resolve('xhr1') }, 5000) }) } let b = async() => { return new Promise((resolve, reject) => { setTimeout(() => { console.log('xhr2') resolve('xhr2') }, 3000) }) } let steps = [a, b] // 从右向左执行 let composeFn = compose(...steps) composeFn().then(res => { console.log(666) }) // xhr2 // xhr1 // 666
它会先执行 b ,3 秒后输出 "xhr2",再执行 a,5 秒后输出 "xhr1",最后输出 666
你也可以在控制台带参 debugger 试试,很有意思:
composeFn(1, 2).then(res => { console.log(66) })
逐渐美丽起来
测试通过!借助上面 composePromise 函数,我们更加有信心用函数式编程 composePromise 重构 我们的代码了。
实际上,这个过程一点不费力~
实现如下:
/** * 判断是否存在草稿记录? */ handleJudgeIfDraftExist(item) { return appH5ifDraftExist({ appId: item.appId }).then(res => { const { editUrl, h5Id, version } = res.result h5Id === -1 ? this.compose_newAppIframe(item) : this.hasDraftConfirm(item, h5Id, editUrl, version) }).catch(err => { console.log(err) }) }, /** * 选择继续编辑? */ hasDraftConfirm(item, h5Id, editUrl, version) { this.$confirm('有未完成的信息收集链接,是否继续编辑?', '提示', { confirmButtonText: '继续编辑', cancelButtonText: '重新创建', type: 'warning' }).then(() => { this.compose_editAppIframe(item, h5Id, editUrl) }).catch(() => { this.compose_reNewAppIframe(item, h5Id, version) }) },
敲黑板啦!画重点啦!
/** * 新建应用流程 * 入参: item * 输出:item */ compose_newAppIframe(...args) { const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_appH5create, this.step_getsingleSignOnToken] const handleCompose = composePromise(...steps) handleCompose(...args) }, /** * 编辑应用流程 * 入参: item, draftH5Id, editUrl * 输出:item */ compose_editAppIframe(...args) { const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_getsingleSignOnToken] const handleCompose = composePromise(...steps) handleCompose(...args) }, /** * 重新创建流程 * 入参: item,draftH5Id,version * 输出:item */ compose_reNewAppIframe(...args) { const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_appH5create, this.step_getsingleSignOnToken, this.step_delDraftH5Id] const handleCompose = composePromise(...steps) handleCompose(...args) },
我们通过 composePromise 执行不同的 steps,来依次执行(从右至左)里面的功能函数;你可以任意组合、增删或修改 steps 的子项,也可以任意组合出新的流程来应付产品。并且,它们都被封装在 compose_xxx 里面,相互独立,不会干扰外界其它流程。同时,传参也是非常清晰的,输入是什么!输出又是什么!一目了然!
对照脑图再看此段代码,不正是对我们需求实现的最好诠释吗?
对于一个阅读陌生代码的人来说,你得先告诉他逻辑是怎样的,然后再告诉他每个步骤的内部具体实现。这样才是合理的!
功能函数(具体步骤内部实现):
/** * 调用 sso 接口,拿到返回结果 res_token; */ step_getsingleSignOnToken(...args) { const [item] = args.flat(Infinity) return new Promise((resolve, reject) => { getsingleSignOnToken({ formSource: item.tag }).then(data => { resolve([...args, data.result]) // data.result 即 token }) }) }, /** * 调用 create 接口,拿到返回结果 res_id; */ step_appH5create(...args) { const [item, token] = args.flat(Infinity) return new Promise((resolve, reject) => { appH5create({ appId: item.appId }).then(data => { resolve([item, data.result.h5Id, data.result.editUrl, token]) }).catch(err => { this.$message({ message: err.message || '出现错误', type: 'error' }) }) }) }, /** * 调 delDraft 删除接口; */ step_delDraftH5Id(...args) { const [item, h5Id, version] = args.flat(Infinity) return new Promise((resolve, reject) => { appH5delete({ h5Id: h5Id, version: version }).then(data => { resolve(...args) }) }) }, /** * 处理字符串,拼接 Url; */ step_splitUrl(...args) { const [item, h5Id, editUrl, token] = args.flat(Infinity) const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?')) const url = editUrl.replace(infoId, `from=a2p&${infoId}`) const headList = JSON.parse(JSON.stringify(this.headList)) headList.forEach(i => { if (i.appId === item.appId) { i.srcUrl = `${url}&token=${token}` } }) this.setHeadList(headList) this.setH5Id(h5Id) this.setShowNavIframe({ appId: item.appId, state: true }) this.setNavLabel(item.name) return [...args] }, /** * 建立 websocket 链接; */ step_createWs(...args) { return new Promise((resolve, reject) => { webSocketInit(resolve, reject, ...args) }) }, /** * 拿到 websocket 后端推送关键字,渲染页面; */ step_getDoclose(...args) { const [item, h5Id, editUrl, token, doclose] = args.flat(Infinity) if (doclose) { this.setShowEditLink({ appId: item.appId, h5Id: h5Id, state: true }) } return new Promise((resolve, reject) => { resolve(true) }) },
功能函数的输入、输出也是清晰可见的。
至此,我们可以认为:借助 compose 函数,借助函数式编程,咱把业务需求流程进行了封装,明确了输入输出,让我们的代码更加可读了!可扩展性也更高了!这不就是高内聚、低耦合?!
阶段总结
你问我什么是 JS 函数式编程实战?我只能说本篇完全就是出自工作中的实战!!!
这样导致本篇代码量可能有点多,但是这就是实打实的需求变化,代码迭代、改造的过程。(建议通篇把握、理解)
当然,这不是终点,代码重构这个过程应该是每时每刻都在进行着。
对于函数式编程,简单应用 compose 函数,这也只是一个起点!
已经讲过,偏函数、函数柯里化、函数组合、数组操作、时间状态、函数式编程库等等概念......我们将再接再厉得使用它们,把代码屎山进行分类、打包、清理!让它不断美丽起来
更多关于compose优化代码可读性扩展性的资料请关注脚本之家其它相关文章!