24种代码坏味道和重构手法

24种代码坏味道和重构手法

最近,小李感觉公司女生们看他的眼神不太对劲了,那种笑容好像是充满慈爱的、姨母般的笑容。

作为一名老实本分的程序员,小李不太习惯这种被人过度关注的感觉,他不知道发生了什么。

······

小李和小王的关系似乎过于亲密,还经常挤在一个工位上办公,一直到半夜。

这个流言直到某天他们的聊天内容被某个运营小姐姐听到,他们之间的秘密才被大家发现。

小李和小王的故事

“这串代码看起来不太好。” 小李指着屏幕,眉头紧锁。

“咋了,这段代码是我写的,因为这里要实现客户的一个定制化需求,所以看起来有点复杂。” 小王凑了过来。

“我也说不出来哪里不对劲,我现在要添加一个新特性,不知道从哪里下手。” 小李挠了挠头。

小王把凳子搬了过来:“来来,我给你讲讲,这段代码,这段调用.....”

·····

中午 12 点,办公室的空气中弥漫着饭菜的香气,还有成群结队约饭的小伙伴,休闲区也成了干饭的主战场。

“噢?原来小李和小王这种叫做结对编程?” 运营小姐姐目光扫向还在座位上的小李小王。

“嗯...偶尔的结对编程是正常的,但是长期的结对编程说明出现了一些坏味道。”老工程师说完,端起饭盆,干完了最后一口饭。

“oh...是那种...味道吗?”运营小姐姐试探道。

在座一起吃饭的 HR、UI 小姐姐们被这句话又点燃了八卦之魂,发出了一阵愉快的笑声。

“NoNoNo...老耿的意思,代码里出现了坏味道。”高级工程师大宝扶了扶眼镜。

“什么叫代码里出现了坏味道呀?”

“就是软件架构要开始分崩离析的前兆,代码里面充斥着坏味道,这些味道都散发着恶心、腐烂的信号,提示你该重构了。”

小姐姐们眉头一皱,不太满意大宝在吃饭的时候讲了个这么倒胃口的例子。

老耿喝了口水,放下水杯:“阿宝说的没错。很显然,小李和小王在写程序的时候,并没有发现代码中存在的坏味道,导致他们现在欠下了一大批的技术债务,正在分期还债。”

“看来是时候给他们做个培训了,教他们如何识别代码中的坏味道。”

代码中的坏味道

小李和小王顶着两个大黑眼圈来到会议室,老耿早已经在会议室等着了。

小李看了一眼投影仪的内容 “24 种常见的坏味道及重构手法 —— 告别加班,可持续发展”。

“呐,给你们俩准备的咖啡,打起精神来哈。”大宝用身子推门进来,手上端着两杯热腾腾的咖啡,这是他刚从休息区亲手煮的新鲜咖啡。

“谢谢宝哥” 小李和小王齐声道。

“听说你们俩最近老是一起加班到半夜。” 老耿把手从键盘上拿开,把视线从电脑屏幕上移到小李小王的身上。

“嘿嘿...” 小李不好意思的笑了笑 “最近需求比较难,加一些新功能比较花时间。”

“嗯,看来你们也发现了一些问题,为什么现在加新功能花的时间会比以前多得多呢?”老耿把凳子往后挪了挪,看着两人。

“因为产品越来越复杂了,加新功能也越来越花时间了。”小王接话道。

“对,这算是一个原因。还有吗?”老耿接着问。

“因...因为我们之前写的代码有点乱,可能互相理解起来比较花时间...” 小李挠了挠头。

“但其实我都写了注释的,我觉得还是因为产品太复杂了。”小王对小李这个说法有点不认可。

“好,其实小李说到了一个关键问题。”老耿觉得是时候了,接着说道:“Martin Fowler 曾经说过,当代码库看起来就像补丁摞补丁,需要细致的考古工作才能弄明白整个系统是如何工作的。那这份负担会不断拖慢新增功能的速度,到最后程序员恨不得从头开始重写整个系统。”

“我想,你们应该有很多次想重写系统的冲动吧?”老耿笑了笑,继续说道:“而内部质量良好的软件可以让我在添加新功能时,很容易找到在哪里修改、如何修改。”

老耿顿了顿,“所以,小王说的也对。产品复杂带来了软件的复杂,而复杂的软件一不小心就容易变成了补丁摞补丁。软件代码中充斥着 “坏味道”,这些 “坏味道” 最终会让软件腐烂,然后失去对它的掌控。”

“对了,小王刚才提到的注释,也是一种坏味道。”老耿笑了笑,看着小王,“所以,坏味道无处不在,有的坏味道比较好察觉,有的坏味道需要经过特殊训练,才能识别。”

老耿又指了指投影仪大屏幕:“所以,我们今天是一期特殊训练课,教会你们识别 24 种常见的坏味道及重构手法,这门课至少可以让你们成为一个有着一些特别好的习惯的还不错的程序员。”

“咳咳,你们俩赚大了”大宝补充道:“老耿这级别的大牛,在外边上课可是要收费的哟~”

“等等,我去拿笔记本。”小李举手示意,然后打开门走出去,小王见状也跟着小李出去拿笔记本了。

24 种常见的坏味道及重构手法

老耿见大家已经都做好了学习的准备,站起身走到屏幕旁边,对着大家说道:“那我们开始吧!”

”坏味道我们刚才已经说过了,我再讲个小故事吧。我这么多年以来,看过很多很多代码,它们所属的项目有大获成功的,也有奄奄一息的。观察这些代码时,我和我的老搭档学会了从中找出某些特定结构,这些结构指出了 重构 的可能性,这些结构也就是我刚才提到的 坏味道。”

“噢对,我刚才还提到了一个词 —— 重构。这听着像是个很可怕的词,但是我可以和你们说,我口中的 重构 并不是你们想的那种推翻重来,有本书给了这个词一个全新的定义 —— 对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。,你们也可以理解为是在 使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

“如果有人说他们的代码在重构过程中有一两天时间不可用,基本上可以确定,他们在做的事不是重构。”老耿开了个玩笑:“他们可能在对代码施展某种治疗魔法,这种魔法带来的副作用就是会让软件短暂性休克。”

“在这里,还有一点值得一提,那就是如何做到 不改变软件可观察行为,这需要一些外力的协助。这里我不建议你们再把加班的人群延伸到测试人员,我给出的方案是准备一套完备的、运行速度很快的测试套件。在绝大多数情况下,如果想要重构,就得先有一套可以自测试的代码。”

“接下来,我会先审查你们商城系统的代码,发现代码中存在的坏味道,然后添加单元测试,进行重构后,最后通过测试完成重构。”

“我会在这个过程中,给你们演示并讲解 24 种常见的坏味道及重构手法。”

“我们开始吧!”老耿重新回到座位。

(一)神秘命名(Mysterious Name)

function countOrder(order) {
  const basePrice = order.quantity * order.itemPrice;
  const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
  const shipping = Math.min(basePrice * 0.1, 100);
  return basePrice - quantityDiscount + shipping;
}

const orderPrice = countOrder(order);
复制代码

“上面的这段 countOrder 函数是做什么的?看到函数名的第一感觉不太清晰,统计订单?订单商品数量吗?还是统计什么?但是,看到函数内部实现后,我明白了这是个统计订单总价格的函数。这就是其中一个坏味道 —— 神秘命名,当代码中这样的坏味道多了之后,花在猜谜上的时间就会越来越多了。”

“我们现在来对这段代码进行重构,我们需要先添加单元测试代码。这里我只做演示,写两个测试用例。”

老耿很快针对这段代码,使用著名的 jest 框架写出了下面两个测试用例。

describe('test price', () => {
  test('countOrder should return normal price when input correct order quantity < 500', () => {
    const input = {
      quantity: 20,
      itemPrice: 10
    };

    const result = countOrder(input);

    expect(result).toBe(220);
  });

  test('countOrder should return discount price when input correct order quantity > 500', () => {
    const input = {
      quantity: 1000,
      itemPrice: 10
    };

    const result = countOrder(input);

    expect(result).toBe(9850);
  });
});
复制代码

老耿 运行了一下测试用例,显示测试通过后,说:“我们有了单元测试后,就可以开始准备重构工作了。”

“我们先把 countOrder 内部的实现提炼成新函数,命名为 getPrice,这个名字不一定是最合适的名字,但是会比之前的要好。”老耿使用 Ide 很容易就把这一步完成了。

function getPrice(order) {
    const basePrice = order.quantity * order.itemPrice;
    const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
    const shipping = Math.min(basePrice * 0.1, 100);
    return basePrice - quantityDiscount + shipping;
}

function countOrder(order) {
    return getPrice(order);
}

const orderPrice = countOrder(order);
复制代码

“这一步看起来没什么问题,但是我们还是先 运行一下测试用例。”老耿按下了执行用例快捷键,用例跑了起来,并且很快就通过了测试。

“这一步说明我们的修改没有问题,下一步我们修改测试用例,将测试用例调用的 countOrder 方法都修改为调用 getPrice 方法,再次 运行修改后的测试用例。”

老耿指着修改后的测试用例:“再次运行后,getPrice 也通过了测试,那接下来,我们就可以把调用 countOrder 方法的地方,都修改为调用 getPrice 方法,就像这样。”

function getPrice(order) {
    const basePrice = order.quantity * order.itemPrice;
    const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
    const shipping = Math.min(basePrice * 0.1, 100);
    return basePrice - quantityDiscount + shipping;
}

function countOrder(order) {
    return getPrice(order);
}

const orderPrice = getPrice(order);
复制代码

“这时候我们可以看到,编辑器已经提示我们,原来的 countOrder 方法没有被使用到,我们可以借助 Ide 直接把这个函数删除掉。”

“删除后,我们的重构就完成了,新的代码看起来像这样。”

function getPrice(order) {
    const basePrice = order.quantity * order.itemPrice;
    const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
    const shipping = Math.min(basePrice * 0.1, 100);
    return basePrice - quantityDiscount + shipping;
}

const orderPrice = getPrice(order);
复制代码

“嗯,这个命名看起来更合适了,一眼看过去就可以知道这是个获取订单价格的函数。如果哪天又想到了更好的名字,用同样的步骤把它替换掉就好了,有单元测试就可以保证你在操作的过程中并不会出错,引发其他的问题。”

“对了,还有最重要的一步。”老耿加重了语气:“记得提交代码!”

老耿用快捷键提交了一个 Commit 记录,继续说道:“每一次重构完成都应该提交代码,这样就可以在下一次重构出现问题的时候,迅速回退到上一次正常工作时的状态,这一点很有用!“

大宝补充到:“这样的重构还有个好处,那就是可以保证代码随时都是可发布的状态,因为并没有影响到整体功能的运行。”

“不过想一个合适的好名字确实不容易,耿哥的意思是让我们要持续的小步的进行重构吧。”小王摸着下巴思考说道。

“阿宝和小王说的都对,在不改变软件可观察行为的前提下,持续小步的重构,保证软件随时都处于可发布的状态。这意味着我们随时都可以进行重构,最简单的重构,比如我刚才演示的那种用不了几分钟,而最长的重构也不该超过几小时。”

“我再补充一点。”大宝说道:“我们绝对不能忽视自动化测试,只有自动化测试才能保证在重构的过程中不改变软件可观察行为,这一点看似不起眼,却是最最重要的关键之处。”

“阿宝说的没错,我们至少要保证我们重构的地方有单元测试,且能通过单元测试,才能算作是重构完成。”

老耿稍作停顿后,等待大家理解自己刚才的那段话后,接着说:“看来大家都开始感受到了重构的魅力,我们最后看看这段代码重构前后的对比。”

“ok,那我们接着说剩下的坏味道吧。”

(二)重复代码(Repeat Code)

function renderPerson(person) {
  const result = [];
  result.push(`<p>${person.name}</p>`);
  result.push(`<p>title: ${person.photo.title}</p>`);
  result.push(emitPhotoData(person.photo));
  return result.join('\n');
}

function photoDiv(photo) {
  return ['<div>', `<p>title: ${photo.title}</p>`, emitPhotoData(photo), '</div>'].join('\n');
}

function emitPhotoData(aPhoto) {
  const result = [];
  result.push(`<p>location: ${aPhoto.location}</p>`);
  result.push(`<p>date: ${aPhoto.date}</p>`);
  return result.join('\n');
}
复制代码

“嗯,这段代乍一看是没有什么问题的,应该是用来渲染个人资料界面的。但是我们仔细看的话,会发现 renderPerson 方法和 photoDiv 中有一个同样的实现,那就是渲染 photo.title 的部分。这一部分的逻辑总是在执行 emitPhotoData 函数的前面,这是一段重复代码。”

“虽然这是一段看似无伤大雅的重复代码,但是要记住,一旦有重复代码存在,阅读这些重复的代码时你就必须加倍仔细,留意其间细微的差异。如果要修改重复代码,你必须找出所有的副本来修改,这一点让人在阅读和修改代码时都很容易出现纰漏。”

“所以,我们就挑这一段代码来进行重构。按照惯例,我先写两个单元测试用例。”老耿开始写用例。

describe('test render', () => {
  test('renderPerson should return correct struct when input correct struct', () => {
    const input = {
      name: 'jack',
      photo: {
        title: 'travel',
        location: 'tokyo',
        date: '2021-06-08'
      }
    };

    const result = renderPerson(input);

    expect(result).toBe(`<p>jack</p>\n<p>title: travel</p>\n<p>location: tokyo</p>\n<p>date: 2021-06-08</p>`);
  });

  test('photoDiv should return correct struct when input correct struct', () => {
    const input = {
      title: 'adventure',
      location: 'india',
      date: '2021-01-08'
    };

    const result = photoDiv(input);

    expect(result).toBe(`<div>\n<p>title: adventure</p>\n<p>location: india</p>\n<p>date: 2021-01-08</p>\n</div>`);
  });
});
复制代码

“我们先运行测试一下我们的测试用例是否能通过吧。“老耿按下了执行快捷键。

“ok,测试通过,记得提交一个 Commit,保存我们的测试代码。接下来,我们准备开始重构,这个函数比较简单,我们可以直接把那一行重复的代码移动到 emitPhotoData 函数中。但是这次我们还是要演示一下风险较低的一种重构手法,防止出错。“老耿说完,把 emitPhotoDataNew ctrl c + ctrl v,在复制的函数体内稍作修改,完成了组装。

function emitPhotoDataNew(aPhoto) {
  const result = [];
  result.push(`<p>title: ${aPhoto.title}</p>`);
  result.push(`<p>location: ${aPhoto.location}</p>`);
  result.push(`<p>date: ${aPhoto.date}</p>`);
  return result.join('\n');
}
复制代码

“然后,我们把 renderPersonphotoDiv 内部调用的方法,都换成 emitPhotoDataNew 新方法,如果再稳妥一点的话,最好是换一个函数执行一次测试用例。”

function renderPerson(person) {
  const result = [];
  result.push(`<p>${person.name}</p>`);
  result.push(emitPhotoDataNew(person.photo));
  return result.join('\n');
}

function photoDiv(photo) {
  return ['<div>', emitPhotoDataNew(photo), '</div>'].join('\n');
}

function emitPhotoData(aPhoto) {
  const result = [];
  result.push(`<p>location: ${aPhoto.location}</p>`);
  result.push(`<p>date: ${aPhoto.date}</p>`);
  return result.join('\n');
}

function emitPhotoDataNew(aPhoto) {
  const result = [];
  result.push(`<p>title: ${aPhoto.title}</p>`);
  result.push(`<p>location: ${aPhoto.location}</p>`);
  result.push(`<p>date: ${aPhoto.date}</p>`);
  return result.join('\n');
}
复制代码

“替换完成后,执行测试用例,看看效果。”

“ok,测试通过,说明重构并没有产生什么问题,接下来把原来的 emitPhotoData 安全删除,然后把 emitPhotoDataNew 重命名为 emitPhotoData,重构就完成了!”

function renderPerson(person) {
  const result = [];
  result.push(`<p>${person.name}</p>`);
  result.push(emitPhotoData(person.photo));
  return result.join('\n');
}

function photoDiv(photo) {
  return ['<div>', emitPhotoData(photo), '</div>'].join('\n');
}

function emitPhotoData(aPhoto) {
  const result = [];
  result.push(`<p>title: ${aPhoto.title}</p>`);
  result.push(`<p>location: ${aPhoto.location}</p>`);
  result.push(`<p>date: ${aPhoto.date}</p>`);
  return result.join('\n');
}
复制代码

“修改完后,别忘了运行测试用例。”老耿每次修改完成后运行测试用例的动作,似乎已经形成了肌肉记忆。

“ok,测试通过。这次重构完成了,提交一个 Commit,再看一下修改前后的对比。”

“我们继续看下一个坏味道。”

(三)过长函数(Long Function)

function printOwing(invoice) {
  let outstanding = 0;
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
  // calculate outstanding
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }
  // record due date
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
  //print details
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
复制代码

“嗯,这个函数看起来是用来打印用户白条信息的。这个函数实现细节和命名方面倒是没有太多问题,但是这里有一个坏味道,那就是 —— 过长的函数。”

“函数越长,就越难理解。而更好的阐释力、更易于分享、更多的选择——都是由小函数来支持的。”

“对于识别这种坏味道,有一个技巧。那就是,如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。”

“像这个函数就是那种需要花一定时间浏览才能弄清楚在做什么的,不过好在这个函数还有些注释。”

“还是老方法,我们先写两个测试用例。”老耿开始敲代码。

describe('test printOwing', () => {
  let collections = [];
  console.log = message => {
    collections.push(message);
  };

  afterEach(() => {
    collections = [];
  });

  test('printOwing should return correct struct when input correct struct', () => {
    const input = {
      customer: 'jack',
      orders: [{ amount: 102 }, { amount: 82 }, { amount: 87 }, { amount: 128 }]
    };

    printOwing(input);

    const today = new Date(Date.now());
    const dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30).toLocaleDateString();
    expect(collections).toStrictEqual([
      '***********************',
      '**** Customer Owes ****',
      '***********************',
      'name: jack',
      'amount: 399',
      'due: ' + dueDate
    ]);
  });

  test('printOwing should return correct struct when input correct struct 2', () => {
    const input = {
      customer: 'dove',
      orders: [{ amount: 63 }, { amount: 234 }, { amount: 12 }, { amount: 1351 }]
    };

    printOwing(input);
    
    const today = new Date(Date.now());
    const dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30).toLocaleDateString();
    expect(collections).toStrictEqual([
      '***********************',
      '**** Customer Owes ****',
      '***********************',
      'name: dove',
      'amount: 1660',
      'due: ' + dueDate
    ]);
  });
});
复制代码

“测试用例写完以后运行一下。“

“接下来的提取步骤就很简单了,因为代码本身是有注释的,我们只需要参考注释的节奏来进行提取就好了,依旧是小步慢跑,首先调整函数执行的顺序,将函数分层。”

function printOwing(invoice) {
  // print banner
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');

  // calculate outstanding
  let outstanding = 0;
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }

  // record due date
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

  // print details
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
复制代码

“进行函数分层后,先运行一遍测试用例,防止调整顺序的过程中,影响了函数功能... ok,测试通过了。”

“被调整顺序后的函数就变得非常简单了,接下来我们分四步提取就可以了”

“第一步,提炼 printBanner 函数,然后运行测试用例。”

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function printOwing(invoice) {
  printBanner();

  // calculate outstanding
  let outstanding = 0;
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }

  // record due date
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

  // print details
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
复制代码

“我们在提取的过程中,把注释也去掉了,因为确实不需要了,函数名和注释的内容一样。”

“第二步,提炼 calOutstanding 函数 ,然后运行测试用例。”

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function calOutstanding(invoice) {
  let outstanding = 0;
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }
  return outstanding;
}

function printOwing(invoice) {
  printBanner();

  let outstanding = calOutstanding(invoice);

  // record due date
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

  // print details
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
复制代码

“第三步,提炼 recordDueDate 函数,然后运行测试用例。”

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function calOutstanding(invoice) {
  let outstanding = 0;
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }
  return outstanding;
}

function recordDueDate(invoice) {
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
}

function printOwing(invoice) {
  printBanner();

  let outstanding = calOutstanding(invoice);

  recordDueDate(invoice);

  // print details
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
复制代码

“第四步,提炼 printDetails 函数,然后运行测试用例。”

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function calOutstanding(invoice) {
  let outstanding = 0;
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }
  return outstanding;
}

function recordDueDate(invoice) {
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
}

function printDetails(invoice, outstanding) {
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}

function printOwing(invoice) {
  printBanner();
  let outstanding = calOutstanding(invoice);
  recordDueDate(invoice);
  printDetails(invoice, outstanding);
}
复制代码

“测试用例通过后,别忘了提交代码。”

“然后我们再来审视一下这个重构后的 printOwing 函数,简单的四行代码,清晰的描述了函数所做的事情,这就是小函数的魅力!”

“说到底,让小函数易于理解的关键还是在于良好的命名。如果你能给函数起个好名字,阅读代码的人就可以通过名字了解函数的作用,根本不必去看其中写了些什么。这可以节约大量的时间,也能减少你们结对编程的时间。”老耿面带微笑看着小李和小王。

小李小王相视一笑,觉得有点不好意思。

“我们来看看重构前后的对比。”

“我们继续。”老耿马不停蹄。

(四)过长参数列表(Long Parameter List)

// range.js
function priceRange(products, min, max, isOutSide) {
  if (isOutSide) {
    return products
      .filter(r => r.price < min || r.price > max);
  } else {
    return products
      .filter(r => r.price > min && r.price < max);
  }
}

// a.js
const range = { min: 1, max: 10 }
const outSidePriceProducts = priceRange(
  [ /* ... */ ],
  range.min,
  range.max,
  true
)

// b.js
const range = { min: 5, max: 8 }
const insidePriceProducts = priceRange(
  [ /* ... */ ],
  range.min,
  range.max,
  false
)
复制代码

“第一眼看过去,priceRange 是过滤商品的函数,仔细看的话会发现,主要比对的是 product.price 字段和传入的参数 minmax 之间的大小对比关系。如果 isOutSidetrue 的话,则过滤出价格区间之外的商品,否则过滤出价格区间之内的商品。”

“第一眼看过去,这个函数的参数实在是太多了,这会让客户端调用方感到很疑惑。还好参数 isOutSide 的命名还算不错,不然这个函数会看起来更深奥。”

小李忍不住插了句:“我每次调用 priceRange 函数的时候都要去看一眼这个函数的实现,我老是忘记最后一个参数的规则。”

“我和小李的看法是一样的。”老耿点了点头:“我也不喜欢标记参数,因为它们让人难以理解到底有哪些函数可以调用、应该怎么调用。使用这样的函数,我还得弄清标记参数有哪些可用的值。”

“既然小李也已经发现这个问题了,那我们就从这个 isOutSide 参数下手,进行优化。老规矩,我们先针对现有的代码写两个测试用例。” 老耿开始写代码。

describe('test priceRange', () => {
  test('priceRange should return correct result when input correct outside conditional', () => {
    const products = [
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 },
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ];
    const range = { min: 1, max: 10 };
    const isOutSide = true;

    const result = priceRange(products, range.min, range.max, isOutSide);

    expect(result).toStrictEqual([
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ]);
  });

  test('priceRange should return correct result when input correct inside conditional', () => {
    const products = [
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 },
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ];
    const range = { min: 5, max: 8 };
    const isOutSide = false;

    const result = priceRange(products, range.min, range.max, isOutSide);

    expect(result).toStrictEqual([
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 }
    ]);
  });
});
复制代码

“运行一下单元测试...嗯,是可以通过的。那接下来就可以进行参数精简了,我们先把刚才小李提的那个问题解决,就是标记参数,我们针对 priceRange 再提炼两个函数。”

“我们先修改我们的单元测试代码,按我们期望调用的方式修改。”

const priceRange = require('./long_parameter_list');

describe('test priceRange', () => {
  test('priceOutSideRange should return correct result when input correct outside conditional', () => {
    const products = [
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 },
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ];
    const range = { min: 1, max: 10 };

    const result = priceOutSideRange(products, range.min, range.max);

    expect(result).toStrictEqual([
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ]);
  });

  test('priceInsideRange should return correct result when input correct inside conditional', () => {
    const products = [
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 },
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ];
    const range = { min: 5, max: 8 };

    const result = priceInsideRange(products, range.min, range.max);

    expect(result).toStrictEqual([
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 }
    ]);
  });
});
复制代码

“我把 priceRangeisOutSide 标记参数移除了,并且使用 priceOutsideRangepriceInsideRange 两个方法来实现原有的功能。这时候还不能运行测试用例,因为我们的代码还没改呢。同样的,把代码调整成符合用例调用的方式。”

function priceRange(products, min, max, isOutSide) {
  if (isOutSide) {
    return products.filter(r => r.price < min || r.price > max);
  } else {
    return products.filter(r => r.price > min && r.price < max);
  }
}

function priceOutSideRange(products, min, max) {
  return priceRange(products, min, max, true);
}

function priceInsideRange(products, min, max) {
  return priceRange(products, min, max, false);
}
复制代码

“代码调整完成后,我们来运行一下测试用例。好的,通过了!”

“嗯,我想到这里以后,可以更进一步,把 priceRange 的函数进一步抽离,就像这样。”

function priceRange(products, min, max, isOutSide) {
  if (isOutSide) {
    return products.filter(r => r.price < min || r.price > max);
  } else {
    return products.filter(r => r.price > min && r.price < max);
  }
}

function priceOutSideRange(products, min, max) {
  return products.filter(r => r.price < min || r.price > max);
}

function priceInsideRange(products, min, max) {
  return products.filter(r => r.price > min && r.price < max);
}
复制代码

“拆解完成后,记得运行一下测试用例... ok,通过了”

“在测试用例通过后,就可以开始准备迁移工作了。把原来调用 priceRange 的地方替换成新的调用,然后再把 priceRange 函数安全删除,就像这样。”

// range.js
function priceOutSideRange(products, min, max) {
  return products.filter(r => r.price < min || r.price > max);
}

function priceInsideRange(products, min, max) {
  return products.filter(r => r.price > min && r.price < max);
}

// a.js
const range = { min: 1, max: 10 }
const outSidePriceProducts = priceOutSideRange(
  [ /* ... */ ],
  range.min,
  range.max
)

// b.js
const range = { min: 5, max: 8 }
const insidePriceProducts = priceInsideRange(
  [ /* ... */ ],
  range.min,
  range.max
)
复制代码

“这么做以后,原来让人疑惑的标记参数就被移除了,取而代之的是两个语义更加清晰的函数。”

“接下来,我们要继续做一件有价值的重构,那就是将数据组织成结构,因为这样让数据项之间的关系变得明晰。比如 rangeminmax 总是在调用中被一起使用,那这两个参数就可以组织成结构。我先修改我的测试用例以适应最新的改动,就像这样。”

//...
const range = { min: 1, max: 10 };

const result = priceOutSideRange(products, range);

expect(result).toStrictEqual([
  { name: 'orange', price: 15 },
  { name: 'cookie', price: 0.5 }
]);

//...
const range = { min: 5, max: 8 };

const result = priceInsideRange(products, range);

expect(result).toStrictEqual([
  { name: 'apple', price: 6 },
  { name: 'banana', price: 7 }
]);
复制代码

“测试用例修改完成后,来修改一下我们的函数。”

// range.js
function priceOutSideRange(products, range) {
  return products.filter(r => r.price < range.min || r.price > range.max);
}

function priceInsideRange(products, range) {
  return products.filter(r => r.price > range.min && r.price < range.max);
}

// a.js
const range = { min: 1, max: 10 }
const outSidePriceProducts = priceOutSideRange(
  [ /* ... */ ],
  range
)

// b.js
const range = { min: 5, max: 8 }
const insidePriceProducts = priceInsideRange(
  [ /* ... */ ],
  range
)
复制代码

“修改完成后,运行我们的测试用例,顺利通过,别忘了提交代码。”说完,老耿打了个 Commit

“这一步重构又精简了一个参数,这是这项重构最直接的价值。而这项重构真正的意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,我就可以重组程序的行为来使用这些结构。这句话实际应用起来是什么意思呢?我还是拿这个案例来举例。”

“我们会发现 priceOutSideRangepriceInsideRange 的函数命名已经足够清晰,但是内部对 range 范围的判定还是需要花费一定时间理解,而 range 作为我们刚识别出来的一种结构,可以继续进行重构,就像这样。”

// range.js
class Range {
  constructor(min, max) {
    this._min = min;
    this._max = max;
  }

  outside(num) {
    return num < this._min || num > this._max;
  }

  inside(num) {
    return num > this._min && num < this._max;
  }
}

function priceOutSideRange(products, range) {
  return products.filter(r => range.outside(r.price));
}

function priceInsideRange(products, range) {
  return products.filter(r => range.inside(r.price));
}

// a.js
const outSidePriceProducts = priceOutSideRange(
  [ /* ... */ ],
  new Range(1, 10)
)

// b.js
const insidePriceProducts = priceInsideRange(
  [ /* ... */ ],
  new Range(5, 8)
)
复制代码

“修改测试用例也传入 Range 对象,然后运行测试用例...ok,通过了。测试通过后再提交代码。”

“这样一来,让 priceOutSideRangepriceInsideRange 函数内部也更加清晰了。同时,range 被组织成了一种新的数据结构,这种结构可以在任何计算区间的地方使用。”

“我们来看看重构前后的对比。”

“我们继续。”

(五)全局数据(Global Data)

// global.js
// ...
let userAuthInfo = {
  platform: 'pc',
  token: ''
}

export {
  userAuthInfo
};

// main.js
userAuthInfo.token = localStorage.token;

// request.js
const reply = await login();
userAuthInfo.token = reply.data.token;

// business.js
await request({ authInfo: userAuthInfo });
复制代码

“这个 global.js 似乎是用来提供全局数据的,这是最刺鼻的坏味道之一了。”

“这个 platform 被全局都使用到了,我可以把它修改为别的值吗?会引发什么问题吗?”老耿问道

小李连忙说:“这个 platform 不能改,后端要靠这个字段来选择识别 token 的方式,改了就会出问题。”

“但是我现在可以在代码库的任何一个角落都可以修改 platformtoken,而且没有任何机制可以探测出到底哪段代码做出了修改,这就是全局数据的问题。”

“每当我们看到可能被各处的代码污染的数据,我们还是需要全局数据用一个函数包装起来,至少你就能看见修改它的地方,并开始控制对它的访问,这里我做个简单的封装,然后再写两个测试用例。”

let userAuthInfo = {
  platform: 'pc',
  token: ''
};

function getUserAuthInfo() {
  return { ...userAuthInfo };
}

function setToken(token) {
  userAuthInfo.token = token;
}

export {
  getUserAuthInfo,
  setToken
}

// main.js
setToken(localStorage.token);

// request.js
const reply = await login();
setToken(reply.data.token);

// business.js
await request({ authInfo: getUserAuthInfo() });
复制代码

“接下来运行一下测试用例。”

describe("test global data", () => {
    test('getUserAuthInfo.platform should return pc when modify reference', () => {
        const userAuthInfo = getUserAuthInfo();
        userAuthInfo.platform = 'app';

        const result = getUserAuthInfo().platform;

        expect(result).toBe('pc');
    });

    test('getUserInfo.token should return test-token when setToken test-token', () => {
       setToken('test-token');

       const result = getUserAuthInfo().token;

       expect(result).toBe('test-token');
    });
});
复制代码

“这样一来,通过对象引用就无法修改源对象了,并且我这里控制了对 platform 属性的修改,只开放对 token 修改的接口。即便如此,我们还是要尽可能的避免全局数据,因为全局数据是最刺鼻的坏味道之一!”老耿语气加重。

小李小王疯狂点头。

“我们来看一下重构前后的对比。”

“那我们继续。”

(六)可变数据(Mutable Data)

function merge(target, source) {
  for (const key in source) {
    target[key] = source[key];
  }
  return target;
}
复制代码

“这个函数好像有点古老。”老耿有些疑惑。

“这个是我从之前的仓库 copy 过来的一个工具函数,用来合成对象的,一直没改过。”小王补充道。

“嗯,这个函数的问题是对 merge 对象的源对象 target 进行了修改,对数据的修改经常导致出乎意料的结果和难以发现的 bug。现在来看程序并没有因为这个函数出现问题,但如果故障只在很罕见的情况下发生,要找出故障原因就会更加困难。”

“先写两个测试用例来进行验证吧。”老耿开始写代码。

describe('test merge', () => {
  test('test merge should return correct struct when merge', () => {
    const baseConfig = {
      url: 'https://api.com',
      code: 'mall'
    };

    const testSpecialConfig = {
      url: 'https://test-api.com',
      code: 'test-mall'
    };

    const result = merge(baseConfig, testSpecialConfig);

    expect(result).toStrictEqual({
      url: 'https://test-api.com',
      code: 'test-mall'
    });
  });

  test('test merge should return original struct when merge', () => {
    const baseConfig = {
      url: 'https://api.com',
      code: 'mall'
    };

    const testSpecialConfig = {
      url: 'https://test-api.com',
      code: 'test-mall'
    };

    merge(baseConfig, testSpecialConfig);

    expect(baseConfig).toStrictEqual({
      url: 'https://api.com',
      code: 'mall'
    });
  });
});
复制代码

“运行一下... 第二个用例报错了。”

“报错的原因就是因为对源对象进行了修改调整,从而影响了 baseConfig 的值。接下来我们调整一下 merge 函数就行了,现在 javascript 有很简单的方法可以修改这个函数。”

function merge(target, source) {
  return {
    ...target,
    ...source
  }
}
复制代码

“修改完成后,再次运行用例,就可以看到用例运行通过了。”

“我刚才的重构手法其实有一整个软件开发流派 —— 函数式编程,就是完全建立在“数据永不改变”的概念基础上:如果要更新一个数据结构,就返回一份新的数据副本,旧的数据仍保持不变,这样可以避免很多因数据变化而引发的问题。”

“在刚才介绍全局数据时用到的封装变量的方法,也是对可变数据这种坏味道常见的一种解决方案。还有,如果可变数据的值能在其他地方计算出来,这就是一个特别刺鼻的坏味道。它不仅会造成困扰、bug和加班,而且毫无必要。”

“这里我就不做展开了,如果你们俩感兴趣的话,可以去看看《重构:改善既有代码的设计(第2版)》这本书,我刚才提到的坏味道,书里面都有。”

小李小王奋笔疾书,把书名记了下来。

“我们来看一下重构前后的对比。”

“那我们继续。”

(七)发散式变化(Divergent Change)

function getPrice(order) {
  const basePrice = order.quantity * order.itemPrice;
  const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
  const shipping = Math.min(basePrice * 0.1, 100);
  return basePrice - quantityDiscount + shipping;
}

const orderPrice = getPrice(order);
复制代码

“这个函数是我们最早重构的函数,它的职责就是计算基础价格 - 数量折扣 + 运费。我们再来看看这个函数,如果基础价格计算规则改变,需要修改这个函数,如果折扣规则发生改变也需要修改这个函数,同理,运费计算规则也会引发它的改变。”

“如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。”

“测试用例已经有了,所以我们可以直接对函数进行重构。”老耿开始写代码。

function calBasePrice(order) {
    return order.quantity * order.itemPrice;
}

function calDiscount(order) {
    return Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
}

function calShipping(basePrice) {
    return Math.min(basePrice * 0.1, 100);
}

function getPrice(order) {
    return calBasePrice(order) - calDiscount(order) + calShipping(calBasePrice(order));
}

const orderPrice = getPrice(order);
复制代码

“修改完成后,我们运行之前写的测试用例... 测试通过了。”

“修改之后的三个函数只需要关心自己职责方向的变化就可以了,而不是一个函数关注多个方向的变化。并且,单元测试粒度还可以写的更细一点,这样对排查问题的效率也有很大的提升。”

大宝适时补充了一句:“其实这就是面向对象设计原则中的 单一职责原则。”

“阿宝说的没错,keep simple,每次只关心一个上下文 这一点一直很重要。”

“我们来看一下重构前后的对比。”

“我们继续。”

(八)霰弹式修改(Shotgun Surgery)

// File Reading.js
const reading = {customer: "ivan", quantity: 10, month: 5, year: 2017};
function acquireReading() { return reading };
function baseRate(month, year) {
    /* */
}

// File 1
const aReading = acquireReading();
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity;

// File 2
const aReading = acquireReading();
const base = (baseRate(aReading.month, aReading.year) * aReading.quantity);
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));
function taxThreshold(year) { /* */ }

// File 3
const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);
function calculateBaseCharge(aReading) {
  return baseRate(aReading.month, aReading.year) * aReading.quantity;
}
复制代码

“接下来要说的就是 发散式变化 的反例 —— 霰弹式修改。这个要找一个软件里的案例需要花点时间,这里我直接拿《重构》原书的一个例子来作讲解。”

“其实这个问题和重复代码有点像,重复代码经常会引起霰弹式修改的问题。”

“像上面的演示代码,如果 reading 的部分逻辑发生了改变,对这部分逻辑的修改需要跨越好几个文件调整。”

“如果每遇到某种变化,你都必须在许多不同的类或文件内做出许多小修改,你所面临的坏味道就是霰弹式修改。如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。”

“这里我来对这段代码进行重构,由于源代码也不是很完整,这里我只把修改的思路提一下,就不写测试代码了。”老耿开始写代码。

// File Reading.js
class Reading {
  constructor(data) {
    this._customer = data.customer;
    this._quantity = data.quantity;
    this._month = data.month;
    this._year = data.year;
  }

  get customer() {
    return this._customer;
  }

  get quantity() {
    return this._quantity;
  }

  get month() {
    return this._month;
  }

  get year() {
    return this._year;
  }

  get baseRate() {
    /* ... */
  }

  get baseCharge() {
    return baseRate(this.month, this.year) * this.quantity;
  }

  get taxableCharge() {
    return Math.max(0, base - taxThreshold());
  }

  get taxThreshold() {
    /* ... */
  }
}

const reading = new Reading({ customer: 'ivan', quantity: 10, month: 5, year: 2017 });
复制代码

“在修改完成后,所有和 reading 相关的逻辑都放在一起管理了,并且我把它组合成一个类以后还有一个好处。那就是类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。”

“如果你们在编码过程中,有遇到我刚才提到的那些问题,那就是一种坏味道。下次就可以用类似的重构手法进行重构了,当然,别忘了写测试用例。”老耿对着小李二人说道。

小李小王疯狂点头。

“我们来看一下重构前后的对比。”

“那我们继续。”

(九)依恋情节(Feature Envy)

class Account {
  constructor(data) {
    this._name = data.name;
    this._type = data.type;
  }

  get loanAmount() {
    if (this._type.type === 'vip') {
      return 20000;
    } else {
      return 10000;
    }
  }
}

class AccountType {
  constructor(type) {
    this._type = type;
  }

  get type() {
    return this._type;
  }
}
复制代码

“这段代码是账户 Account 和账户类型 AccountType,如果账户的类型是 vip,贷款额度 loanAmount 就有 20000,否则就只有 10000。”

“在获取贷款额度时,Account 内部的 loanAmount 方法和另一个类 AccountType 的内部数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。”

“我们先写两个测试用例吧。”老耿开始写代码。

describe('test Account', () => {
  test('Account should return 20000 when input vip type', () => {
    const input = {
      name: 'jack',
      type: new AccountType('vip')
    };

    const result = new Account(input).loanAmount;

    expect(result).toBe(20000);
  });

  test('Account should return 20000 when input normal type', () => {
    const input = {
      name: 'dove',
      type: new AccountType('normal')
    };

    const result = new Account(input).loanAmount;

    expect(result).toBe(10000);
  });
});
复制代码

“测试用例可以直接运行... ok,通过了。”

“接下来,我们把 loanAmount 搬移到真正属于它的地方。”

class Account {
  constructor(data) {
    this._name = data.name;
    this._type = data.type;
  }

  get loanAmount() {
    return this._type.loanAmount;
  }
}

class AccountType {
  constructor(type) {
    this._type = type;
  }

  get type() {
    return this._type;
  }

  get loanAmount() {
    if (this.type === 'vip') {
      return 20000;
    } else {
      return 10000;
    }
  }
}
复制代码

“在搬移完成后,loanAmount 访问的都是自身模块的数据,不再依恋其他模块。我们运行一下测试用例。”

“ok,测试通过了,别忘了提交代码。”老耿提交了一个 commit。

“我们来看一下重构前后的对比。”

“我们继续下一个。”

(十)数据泥团(Data Clumps)

class Person {
  constructor(name) {
    this._name = name;
  }

  get name() {
    return this._name;
  }

  set name(arg) {
    this._name = arg;
  }

  get telephoneNumber() {
    return `(${this.officeAreaCode}) ${this.officeNumber}`;
  }

  get officeAreaCode() {
    return this._officeAreaCode;
  }

  set officeAreaCode(arg) {
    this._officeAreaCode = arg;
  }

  get officeNumber() {
    return this._officeNumber;
  }

  set officeNumber(arg) {
    this._officeNumber = arg;
  }
}

const person = new Person('jack');
person.officeAreaCode = '+86';
person.officeNumber = 18726182811;
console.log(`person's name is ${person.name}, telephoneNumber is ${person.telephoneNumber}`);
// person's name is jack, telephoneNumber is (+86) 18726182811
复制代码

“这个 Person 类记录了用户的名字(name),电话区号(officeAreaCode)和电话号码(officeNumber),这里有一个不是很刺鼻的坏味道。”

“如果我把 officeNumber 字段删除,那 officeAreaCode 就失去了意义。这说明这两个字段总是一起出现的,除了 Person 类,其他用到电话号码的地方也是会出现这两个字段的组合。”

“这个坏味道叫做数据泥团,主要体现在数据项喜欢成群结队地待在一块儿。你常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数,这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。”

“老规矩,我们先写两个测试用例。”老耿开始写代码

describe('test Person', () => {
  test('person.telephoneNumber should return (+86) 18726182811 when input correct struct', () => {
    const person = new Person('jack');
    person.officeAreaCode = '+86';
    person.officeNumber = 18726182811;

    const result = person.telephoneNumber;

    expect(person.officeAreaCode).toBe('+86');
    expect(person.officeNumber).toBe(18726182811);
    expect(result).toBe('(+86) 18726182811');
  });

  test('person.telephoneNumber should return (+51) 15471727172 when input correct struct', () => {
    const person = new Person('jack');
    person.officeAreaCode = '+51';
    person.officeNumber = 15471727172;

    const result = person.telephoneNumber;

    expect(person.officeAreaCode).toBe('+51');
    expect(person.officeNumber).toBe(15471727172);
    expect(result).toBe('(+51) 15471727172');
  });
});
复制代码

“运行一下测试用例... ok,测试通过了,准备开始重构了。”

“我们先新建一个 TelephoneNumber 类,用于分解 Person 类所承担的责任。”

class TelephoneNumber {
  constructor(areaCode, number) {
    this._areaCode = areaCode;
    this._number = number;
  }

  get areaCode() {
    return this._areaCode;
  }

  get number() {
    return this._number;
  }

  toString() {
    return `(${this._areaCode}) ${this._number}`;
  }
}
复制代码

“这时候,我们再调整一下我们的 Person 类,使用新的数据结构。”

class Person {
  constructor(name) {
    this._name = name;
    this._telephoneNumber = new TelephoneNumber();
  }

  get name() {
    return this._name;
  }

  set name(arg) {
    this._name = arg;
  }

  get telephoneNumber() {
    return this._telephoneNumber.toString();
  }

  get officeAreaCode() {
    return this._telephoneNumber.areaCode;
  }

  set officeAreaCode(arg) {
    this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber);
  }

  get officeNumber() {
    return this._telephoneNumber.number;
  }

  set officeNumber(arg) {
    this._telephoneNumber = new TelephoneNumber(this.officeAreaCode, arg);
  }
}
复制代码

“重构完成,我们运行测试代码。”

“测试用例运行通过了,别忘了提交代码。”

“在这里我选择新建一个类,而不是简单的记录结构,是因为一旦拥有新的类,你就有机会让程序散发出一种芳香。得到新的类以后,你就可以着手寻找其他坏味道,例如“依恋情节”,这可以帮你指出能够移至新类中的种种行为。这是一种强大的动力:有用的类被创建出来,大量的重复被消除,后续开发得以加速,原来的数据泥团终于在它们的小社会中充分发挥价值。”

“比如这里,TelephoneNumber 类被提炼出来后,就可以去消灭那些使用到 telephoneNumber 的重复代码,并且根据使用情况进一步优化,我就不做展开了。”

“我们来看一下重构前后的对比。”

“我们继续讲下一个坏味道。”

(十一)基本类型偏执(Primitive Obsession)

class Product {
  constructor(data) {
    this._name = data.name;
    this._price = data.price;
    /* ... */
  }

  get name() {
    return this.name;
  }

  /* ... */

  get price() {
    return `${this.priceCount} ${this.priceSuffix}`;
  }

  get priceCount() {
    return parseFloat(this._price.slice(1));
  }

  get priceUnit() {
    switch (this._price.slice(0, 1)) {
      case '¥':
        return 'cny';
      case '$':
        return 'usd';
      case 'k':
        return 'hkd';
      default:
        throw new Error('un support unit');
    }
  }

  get priceCnyCount() {
    switch (this.priceUnit) {
      case 'cny':
        return this.priceCount;
      case 'usd':
        return this.priceCount * 7;
      case 'hkd':
        return this.priceCount * 0.8;
      default:
        throw new Error('un support unit');
    }
  }

  get priceSuffix() {
    switch (this.priceUnit) {
      case 'cny':
        return '元';
      case 'usd':
        return '美元';
      case 'hkd':
        return '港币';
      default:
        throw new Error('un support unit');
    }
  }
}
复制代码

“我们来看看这个 Product(产品)类,大家应该也看出来了这个类的一些坏味道,price 字段作为一个基本类型,在 Product 类中被各种转换计算,然后输出不同的格式,Product 类需要关心 price 的每一个细节。”

“在这里,price 非常值得我们为它创建一个属于它自己的基本类型 - Price。”

“在重构之前,先把测试用例覆盖完整。”老耿开始写代码。

describe('test Product price', () => {
  const products = [
    { name: 'apple', price: '$6' },
    { name: 'banana', price: '¥7' },
    { name: 'orange', price: 'k15' },
    { name: 'cookie', price: '$0.5' }
  ];

  test('Product.price should return correct price when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).price);

    expect(result).toStrictEqual(['6 美元', '7 元', '15 港币', '0.5 美元']);
  });

  test('Product.price should return correct priceCount when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).priceCount);

    expect(result).toStrictEqual([6, 7, 15, 0.5]);
  });

  test('Product.price should return correct priceUnit when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).priceUnit);

    expect(result).toStrictEqual(['usd', 'cny', 'hkd', 'usd']);
  });

  test('Product.price should return correct priceCnyCount when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).priceCnyCount);

    expect(result).toStrictEqual([42, 7, 12, 3.5]);
  });

  test('Product.price should return correct priceSuffix when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).priceSuffix);

    expect(result).toStrictEqual(['美元', '元', '港币', '美元']);
  });
});
复制代码

“测试用例写完以后运行一下,看看效果。”

“这个重构手法也比较简单,先新建一个 Price 类,先把 price 和相关的行为搬移到 Price 类中,然后再委托给 Product 类即可。我们先来实现 Price 类。”

class Price {
  constructor(value) {
    this._value = value;
  }

  toString() {
    return `${this.count} ${this.suffix}`;
  }

  get count() {
    return parseFloat(this._value.slice(1));
  }

  get unit() {
    switch (this._value.slice(0, 1)) {
      case '¥':
        return 'cny';
      case '$':
        return 'usd';
      case 'k':
        return 'hkd';
      default:
        throw new Error('un support unit');
    }
  }

  get cnyCount() {
    switch (this.unit) {
      case 'cny':
        return this.count;
      case 'usd':
        return this.count * 7;
      case 'hkd':
        return this.count * 0.8;
      default:
        throw new Error('un support unit');
    }
  }

  get suffix() {
    switch (this.unit) {
      case 'cny':
        return '元';
      case 'usd':
        return '美元';
      case 'hkd':
        return '港币';
      default:
        throw new Error('un support unit');
    }
  }
}
复制代码

“此时,Product 类我还没有修改,但是如果你觉得你搬移函数的过程中容易手抖不放心的话,可以运行一下测试用例。”

“接下来是重构 Product 类,将原有跟 price 相关的逻辑,使用中间人委托来调用。”

class Product {
  constructor(data) {
    this._name = data.name;
    this._price = new Price(data.price);
    /* ... */
  }

  get name() {
    return this.name;
  }

  /* ... */

  get price() {
    return this._price.toString();
  }

  get priceCount() {
    return this._price.count;
  }

  get priceUnit() {
    return this._price.unit;
  }

  get priceCnyCount() {
    return this._price.cnyCount;
  }

  get priceSuffix() {
    return this._price.suffix;
  }
}
复制代码

“重构完成后,运行测试用例。” 老耿按下运行键。

作者:零壹技术栈
原文链接:https://juejin.cn/post/7186555425574584381

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值