粗略没效果,颗粒易套娃,如何寻找逻辑复用的代码设计边界?
想找「神之一手」?
《棋魂》 是我很喜欢的一部电视剧,里面的主人公之一褚嬴,作为曾经的南梁围棋第一人,一直在找寻领悟围棋的最高境界**“神之一手”**。
“神之一手”,即棋手在下棋的过程中,领悟到了如同神一般的技艺,在关键时刻走出的影响全盘棋局的一步。
而作为开发者的我,也一直期待能在编写程序的过程中,设计出令自己“拍案”的“神之一手”。
每期都做设计,聊胜于无?
很长一段时间内,我都在寻找我的“神之一手”。
我不停的尝试,每一期功能都细致的做方案。聚合、分离、组合、中间件...
大圣有七十二种变化,我有七十二种设计模式。
开发设计确实帮助我提升了效率,并且简化了复杂的交互,提升了代码的复用率和可读性。
但是,也带来了三个问题:
- 复杂场景过于追求逻辑复用,前期确实节省了开发量,但是后期额外增加了阅读代码的时间,以及可能发生新增功能有遗漏的情况。
- 设计焦虑。如果某一期的功能过于简单,或者重复开发,没有新的设计方案,我会有焦虑感。
- 过渡设计。粗略的功能开发没效率,但是过于颗粒化的设计又会导致出现深层的嵌套。本来简单的修改,但是需要读十几分钟代码。
悟已往之不合理
"纸上得来终觉浅",我举几个例子,帮助大家有更具体的观感。
场景1
场景描述
一个统计数量的功能的功能,根据需要在页面展示统计总量或失败数量。
- 总量:数量相加之和。
- 失败数量:数量相加之和乘以失败率。
功能设计
两个类型虽然不同,但是大致的逻辑是相似的,可以找到相似之处,做统一处理。
- 先定义一个公共方法:commonHandle。
- 在 commonHandle 里又调用了一个处理运算的方法:getAdd。
- getAdd 中根据操作类型进行计算,将得到的计算结果赋值到对应的赋值方法中。
/** @name 计算总量 */
const [count, setCount] = useState(0);
/** @name 失败的数量 */
const [num, setNum] = useState(0);
/**
* 加法
*/
const add = (x, y) => {
return x + y;
};
/**
* 处理运算
*/
const getAdd = (type, params) => {
let sum;
// 条件判断
if (type === 1) {
sum = add(params.a, params.b);
}
if (type === 2) {
sum = params.k * add(params.a, params.b);
}
// 或
// 枚举
const sumObj = {
1: add(params.a, params.b),
2: params.k * add(params.a, params.b),
};
sum = sumObj[type];
params.setFunc(sum);
};
/**
* 公共处理
*/
const commonHandle = (type, params) => {
getAdd(type, params);
};
/**
* 操作-全部
*/
const allOk = (a, b) => {
// 公共处理
commonHandle(1, { a, b, setFunc: setCount });
// 其他处理
};
/**
* 操作-失败
*/
const failed = (a, b, k) => {
// 公共处理
commonHandle(2, { a, b, k, setFunc: setNum });
// 其他处理
};
上面代码中,将计算单独抽离出来,根据 type 值的不同,增加不同的条件判断和计算处理。
咋一看,没什么毛病,或者说把条件判断改成枚举的方式也可以。
实际关键的点,在于,简单的计算真的需要再抽离一次吗?
如果,我需要改获取失败数量的计算规则,我寻找的链路是:
failed→commonHandle→getAdd
这还是举例简单的情况,链路层数看着不是很多。
在这里例子中,不做功能的抽离,代码实现也很简单:
/** @name 计算总量 */
const [count, setCount] = useState(0);
/** @name 失败的数量 */
const [num, setNum] = useState(0);
/**
* 加法
*/
const add = (x, y) => {
return x + y;
};
/**
* 操作-全部
*/
const allOk = (a, b) => {
const countSum = add(a, b);
setCount(countSum);
// 其他处理
};
/**
* 操作-失败
*/
const failed = (a, b, k) => {
const numSum = k * add(a, b);
setNum(numSum);
// 其他处理
};
场景2
场景描述
选择商品的弹窗。可根据关键字进行搜索,在搜索的结果中选择需要购买的商品,并在选择之后关闭弹窗,页面回显商品信息。
功能设计
这个功能并不难实现。里面包含的变量和操作略多。
对于弹窗中的多个变量,采用对象变量的方式,这样赋值函数可以用一个就好。
const [goodInfo, setGoodInfo] = useState({
goodList: [], // 搜索到的商品列表
good: {}, // 选择的商品
goodName: '', // 搜索关键字
});
const [show, setShow] = useState(false);
/**
* 打开选择商品弹窗
*/
const openGoodModal = () => {
setShow(true);
};
/**
* 关闭选择商品弹窗
*/
const closeGoodModal = () => {
setShow(false);
};
/**
* 选择操作
* @param {Object} item 选择的商品对象
*/
const chooseGood = item => {
setGoodInfo({
...goodInfo,
good: item,
});
setShow(false);
};
/**
* 输入框change事件
* @param {Event} e
*/
const inputChange = e => {
let value = e.target.value;
setGoodInfo({
...goodInfo,
goodName: value,
});
getGoodList(value);
};
/**
* 根据输入的关键字获取商品列表
* @param {string} value
*/
const getGoodList = value => {
// 通过远程接口获取商品列表:res.list
let list = res.list;
setGoodInfo({
...goodInfo,
goodList: list,
});
};
对于上面代码中的多个操作中,使用 setGoodInfo 函数赋值的逻辑,可复用也可不复用。
从代码阅读理解、可扩展、维护成本,上面的设计和下面做了复用之后的设计,相差不大。
/**
* 设置商品对象的值
*/
const initGoodInfoValues = (params = {}) => {
setGoodInfo({
...goodInfo,
...params,
});
};
/**
* 输入框change事件
* @param {Event} e
*/
const inputChange = e => {
let value = e.target.value;
initGoodInfoValues({ goodName: value });
getGoodList(value);
};
/**
* 根据输入的关键字获取商品列表
* @param {string} value
*/
const getGoodList = value => {
// 通过远程接口获取商品列表:res.list
let list = res.list;
initGoodInfoValues({ goodList: list });
};
场景3
场景描述
有一个商品不同性质的介绍的聚合页面,每个模块都可以跳转到对应的详情页,而每个详情链接都需要通过请求对应的远程接口获取。
功能设计
- 每个获取函数中,使用对应的远程接口进行异步请求,获取返回的结果。
- 拿到结果中的链接变量:url。
- 使用 window.location.href 方法打开该链接。
/** @name 实际的商品id */
const id = 1;
/**
* 性质1-获取的详情页接口
*/
const goToDetail1 = () => {
getApi1({ id }).then(res => {
let { url } = res;
window.location.href = url;
});
};
/**
* 性质2-获取的详情页接口
*/
const goToDetail2 = () => {
getApi2({ id }).then(res => {
const { url } = res;
window.location.href = url;
});
};
/**
* 性质3-获取的详情页接口
*/
const goToDetail3 = () => {
getApi3({ id }).then(res => {
const { url } = res;
window.location.href = url;
});
};
/**
* 性质4-获取的详情页接口
*/
const goToDetail4 = () => {
getApi4({ id }).then(res => {
const { url } = res;
window.location.href = url;
});
};
/**
* 性质5-获取的详情页接口
*/
const goToDetail5 = () => {
getApi5({ id }).then(res => {
const { url } = res;
window.location.href = url;
});
};
看这个代码,多么的板正,除了接口函数不同,其他一模一样。
等等,一模一样,那岂不是可以做点设计。
/**
* 获取的详情页接口的公共方法
* @param {Function} api 请求接口
*/
const goToDetailCommon = api => {
api({ id }).then(res => {
let { url } = res;
window.location.href = url;
});
};
/**
* 性质1-获取的详情页接口
*/
const goToDetail1 = () => {
goToDetailCommon(getApi1);
};
/**
* 性质2-获取的详情页接口
*/
const goToDetail2 = () => {
goToDetailCommon(getApi2);
};
/**
* 性质3-获取的详情页接口
*/
const goToDetail3 = () => {
goToDetailCommon(getApi3);
};
/**
* 性质4-获取的详情页接口
*/
const goToDetail4 = () => {
goToDetailCommon(getApi4);
};
/**
* 性质5-获取的详情页接口
*/
const goToDetail5 = () => {
goToDetailCommon(getApi5);
};
小结
上面一共列举了三个实际业务场景,分别对应三种不同的逻辑复用建议:
不建议抽离的过于细致、可抽离也可不抽离、建议抽离
某些情况下,抽离的过于细致,函数嵌套过深。再次修改时,不容易想找到修改的位置。
有时候不做抽离,功能也十分简单。
所以:
- 逻辑复用,并不是抽离的越精细越好。
- 如果做完复用,增加了额外的条件判断或者枚举,需要考虑其必要性。
知来者之可优化
为什么提边界?
除了上面提到的,有些过度设计导致的不容易找到修改位置,代码阅读不便。 还有一个同样重要的问题。那就是老代码改动不全或现有功能不兼容。
如果没注意到这个问题,且提测的时候没有进行特别的说明,可能导致线上Bug的发生。
复用设计,不同之处也很重要
这个单拎出来写,是因为之前吃过亏。
前面提到了功能设计可能会带来的三个问题,其中第一个问题,对于复杂的功能,一味的关注相同之处,去做逻辑复用,导致后续出现了新增功能有遗漏的情况。
我真的碰到,才在开头处提到这种情况。
场景描述
简单描述一下之前的功能:
商品管理中绑定的赠品,在订单管理中同样有。
虽然入口不同,但是通过入口跳转的赠品列表相似。
商品管理跳转到赠品列表可进行增、删、改、导出操作。
订单管理跳转到赠品列表只可以进行导出操作。
对于数据请求则是:
商品管理和订单管理是不同的接口,且接口请求入参不同。
第一期开发的时候,我将绑定赠品的页面设计为一个页面,根据页面链接中携带的参数不同,区分可进行的操作。
在第二期的需求中,额外增加了许多功能,其中一个需求是商品如果上架之后,不可对赠品进行增、删、改操作。
代码加好之后,我忽略了现有功能,所以上线之后出现了一个比较严重的情况:非上架状态的商品跳转赠品页,展示了全部赠品。
好在仅为后台展示,不是购买页面真实的赠品。但是如果是敏感数据,可能会导致泄漏的情况。
一期设计
来看一下第一期的设计
1、商品管理跳转处理
const goToGift = record => {
history.push(`/gift?type=goodManage&goodId=${record.id}`);
};
2、赠品页
const getList = () => {
let params = {};
let api=api1;
// 商品管理跳转传参
if (goodManage) {
params.goodId = goodId;
}
// 订单管理跳转传参
if (orderView) {
params.orderId = orderId;
api = api2;
}
api(params).then(res => {
setList(res.list);
});
};
二期补充
1、商品管理跳转处理
跳转前做了类区分,其中当跳转类型是goodView,不展示增、删、改的操作。
const goToGift = record => {
record.type = record.status === 1 ? 'goodManage' : 'goodView';
history.push(`/gift?type=${record.type}&goodId=${record.id}`);
};
但是我忽略了赠品页请求数据时的入参的处理,所以导致返回了全部数据。
优化
有两种优化方案。
1、方案一:把赠品页的请求改一下,请求入参不做区分,有值就传
const getList = () => {
let params = {
goodId: goodId,
orderId: orderId,
};
let api=api1;
// 订单管理跳转传参
if (orderView) {
api = api2;
}
api(params).then(res => {
setList(res.list);
});
};
2、方案二:不同入口跳转的页面从一个拆分成多个
不单单因为数据获取更加安全,还有很重要的一点,随着业务的发展,赠品页复杂程度增加。
因为业务拓展是间断的,所以再次开发时,已经忘记了一些之前的功能。再次阅读也比较困难,老代码改动也会影响现有功能。
const goToGift = record => {
if (record.status === 1) {
return history.push(`/gift?goodId=${record.id}`);
} else {
return history.push(`/gift/view?goodId=${record.id}`);
}
};
所以我后来选择了方案二,做了一次拆分。
也因此有了感悟:
相似度较高、复杂性也很高的功能,推荐按照性质分类,区分为不同的板块。采用相似的功能设计成公共组件的方式,实现逻辑复用。且各个板块修改不同之处互不影响。
锦瑟难解,边界难觅
如果说边界到底在哪,我很难说出类似“符号看象限”这样的数学口诀或者定律。我一般会根据业务走向做适当思维发散。
不过,我尽量总结几个大致的思路:
- 不同之处,尽量不用条件判断去处理,在各自的函数体中处理,做到"物理"隔离。
- 相同之处,增删改需要覆盖全部引用的代码模块。比如A、B、C三个引用代码模块,只有A、B需要,建议额外增加逻辑复用处理,不再原来的复用代码处修改。如果必须再原处修改,做好文字注释。
总结
写这篇文章,还是主要想跟大家分享我之前做的不合理的开发设计的经验。
这个念头一起,再碰上每日"三省吾身"的时刻,思维一发散。当即决定写一篇文章。
文章内容其实做了修改和删减,而其中一些观点,虽然我反复进行了审视,但并不能保证百分百正确。
欢迎留言讨论。
作者介绍
非职业「传道授业解惑」的开发者叶一一。
《趣学前端》、《CSS畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。
如果看完文章有所收获,欢迎点赞👍 | 收藏⭐️ | 留言📝。
转载自:https://juejin.cn/post/7239058077273456696