likes
comments
collection
share

粗略没效果,颗粒易套娃,如何寻找逻辑复用的代码设计边界?

作者站长头像
站长
· 阅读数 17

想找「神之一手」?

《棋魂》 是我很喜欢的一部电视剧,里面的主人公之一褚嬴,作为曾经的南梁围棋第一人,一直在找寻领悟围棋的最高境界**“神之一手”**。

“神之一手”,即棋手在下棋的过程中,领悟到了如同神一般的技艺,在关键时刻走出的影响全盘棋局的一步。

而作为开发者的我,也一直期待能在编写程序的过程中,设计出令自己“拍案”的“神之一手”

粗略没效果,颗粒易套娃,如何寻找逻辑复用的代码设计边界?

每期都做设计,聊胜于无?

很长一段时间内,我都在寻找我的“神之一手”。

我不停的尝试,每一期功能都细致的做方案。聚合、分离、组合、中间件...

大圣有七十二种变化,我有七十二种设计模式。

粗略没效果,颗粒易套娃,如何寻找逻辑复用的代码设计边界?

开发设计确实帮助我提升了效率,并且简化了复杂的交互,提升了代码的复用率和可读性。

但是,也带来了三个问题

  • 复杂场景过于追求逻辑复用,前期确实节省了开发量,但是后期额外增加了阅读代码的时间,以及可能发生新增功能有遗漏的情况。
  • 设计焦虑。如果某一期的功能过于简单,或者重复开发,没有新的设计方案,我会有焦虑感。
  • 过渡设计。粗略的功能开发没效率,但是过于颗粒化的设计又会导致出现深层的嵌套。本来简单的修改,但是需要读十几分钟代码。

悟已往之不合理

"纸上得来终觉浅",我举几个例子,帮助大家有更具体的观感。

场景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畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。

如果看完文章有所收获,欢迎点赞👍 | 收藏⭐️ | 留言📝。