《重构2》一书中对重构进行了定义:

所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减小整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。

重构和性能优化有相同点,也有不同点。

相同的地方是它们都在不改变程序功能的情况下修改代码;不同的地方是重构为了让代码变得更加容易理解、易于修改,性能优化则是为了让程序运行得更快。这里还得重点提一句,由于侧重点不同,重构可能使程序运行得更快,也可能使程序运行得更慢

重构可以一边写代码一边重构,也可以在程序写完后,拿出一段时间专门去做重构。没有说哪个方式更好,视个人情况而定。如果你专门拿一段时间来做重构,则建议在重构一段代码后,立即进行测试。这样可以避免修改代码太多,在出错时找不到错误点。

重构的原则

  1. 事不过三,三则重构。即不能重复写同样的代码,在这种情况下要去重构。
  2. 如果一段代码让人很难看懂,那就该考虑重构了。
  3. 如果已经理解了代码,但是非常繁琐或者不够好,也可以重构。
  4. 过长的函数,需要重构。
  5. 一个函数最好对应一个功能,如果一个函数被塞入多个功能,那就要对它进行重构了。(4 和 5 不冲突)
  6. 重构的关键在于运用大量微小且保持软件行为的步骤,一步步达成大规模的修改。每个单独的重构要么很小,要么由若干小步骤组合而成。

重构的手法

《重构2》这本书中,介绍了多达上百种重构手法。但我觉得以下八种是比较常用的:

  1. 提取重复代码,封装成函数
  2. 拆分功能太多的函数
  3. 变量/函数改名
  4. 替换算法
  5. 以函数调用取代内联代码
  6. 移动语句
  7. 折分嵌套条件表达式
  8. 将查询函数和修改函数分离

提取重复代码,封装成函数

假设有一个查询数据的接口 /getUserData?age=17&city=beijing。现在需要做的是把用户数据:{ age: 17, city: 'beijing' } 转成 URL 参数的形式:

  1. let result = ''
  2. const keys = Object.keys(data) // { age: 17, city: 'beijing' }
  3. keys.forEach(key => {
  4. result += '&' + key + '=' + data[key]
  5. })
  6. result.substr(1) // age=17&city=beijing

如果只有这一个接口需要转换,不封装成函数是没问题的。但如果有多个接口都有这种需求,那就得把它封装成函数了:

  1. function JSON2Params(data) {
  2. let result = ''
  3. const keys = Object.keys(data)
  4. keys.forEach(key => {
  5. result += '&' + key + '=' + data[key]
  6. })
  7. return result.substr(1)
  8. }

拆分功能太多的函数

下面是一个打印账单的程序:

  1. function printBill(data = []) {
  2. // 汇总数据
  3. const total = {}
  4. data.forEach(item => {
  5. if (total[item.department] === undefined) {
  6. total[item.department] = 0
  7. }
  8. total[item.department] += item.value
  9. })
  10. // 打印汇总后的数据
  11. const keys = Object.keys(total)
  12. keys.forEach(key => {
  13. console.log(`${key} 部门:${total[key]}`)
  14. })
  15. }
  16. printBill([
  17. {
  18. department: '销售部',
  19. value: 89,
  20. },
  21. {
  22. department: '后勤部',
  23. value: 132,
  24. },
  25. {
  26. department: '财务部',
  27. value: 78,
  28. },
  29. {
  30. department: '总经办',
  31. value: 90,
  32. },
  33. {
  34. department: '后勤部',
  35. value: 56,
  36. },
  37. {
  38. department: '总经办',
  39. value: 120,
  40. },
  41. ])

可以看到这个 printBill() 函数实际上包含有两个功能:汇总和打印。我们可以把汇总数据的代码提取出来,封装成一个函数。这样 printBill() 函数就只需要关注打印功能了。

  1. function printBill(data = []) {
  2. const total = calculateBillData(data)
  3. const keys = Object.keys(total)
  4. keys.forEach(key => {
  5. console.log(`${key} 部门:${total[key]}`)
  6. })
  7. }
  8. function calculateBillData(data) {
  9. const total = {}
  10. data.forEach(item => {
  11. if (total[item.department] === undefined) {
  12. total[item.department] = 0
  13. }
  14. total[item.department] += item.value
  15. })
  16. return total
  17. }

变量/函数改名

无论是变量命名,还是函数命名,都要尽量让别人明白你这个变量/函数是干什么的。变量命名的规则着重于描述“是什么”,函数命名的规则着重于描述“做什么”。

变量

  1. const a = width * height

上面这个变量就不太好,a 很难让人看出来它是什么。

  1. const area = width * height

改成这样就很好理解了,原来这个变量是表示面积。

函数

  1. function cache(data) {
  2. const result = []
  3. data.forEach(item => {
  4. if (item.isCache) {
  5. result.push(item)
  6. }
  7. })
  8. return result
  9. }

这个函数名称会让人很疑惑,cache 代表什么?是设置缓存还是删除缓存?再一细看代码,噢,原来是获取缓存数据。所以这个函数名称改成 getCache() 更加合适。

替换算法

  1. function foundPersonData(person) {
  2. if (person == 'Tom') {
  3. return {
  4. name: 'Tom',
  5. age: 18,
  6. id: 21,
  7. }
  8. }
  9. if (person == 'Jim') {
  10. return {
  11. name: 'Jim',
  12. age: 20,
  13. id: 111,
  14. }
  15. }
  16. if (person == 'Lin') {
  17. return {
  18. name: 'Lin',
  19. age: 19,
  20. id: 10,
  21. }
  22. }
  23. return null
  24. }

上面这个函数的功能是根据用户姓名查找用户的详细信息,可以看到这个函数做了三次 if 判断,如果没找到数据就返回 null。这个函数不利于扩展,每多一个用户就得多写一个 if 语句,我们可以用更方便的“查找表”来替换它。

  1. function foundPersonData(person) {
  2. const data = {
  3. 'Tom': {
  4. name: 'Tom',
  5. age: 18,
  6. id: 21,
  7. },
  8. 'Jim': {
  9. name: 'Jim',
  10. age: 20,
  11. id: 111,
  12. },
  13. 'Lin': {
  14. name: 'Lin',
  15. age: 19,
  16. id: 10,
  17. },
  18. }
  19. return data[person] || null
  20. }

修改后代码结构看起来更加清晰,也方便未来做扩展。

以函数调用取代内联代码

如果一些代码所做的事情和已有函数的功能重复,那就最好用函数调用来取代这些代码。

  1. let hasApple = false
  2. for (const fruit of fruits) {
  3. if (fruit == 'apple') {
  4. hasApple = true
  5. break
  6. }
  7. }

例如上面的代码,可以用数组的 includes() 方法代替:

  1. const hasApple = fruits.includes('apple')

修改后代码更加简洁。

移动语句

让存在关联的东西一起出现,可以使代码更容易理解。如果有一些代码都是作用在一个地方,那么最好是把它们放在一起,而不是夹杂在其他的代码中间。最简单的情况下,只需使用移动语句就可以让它们聚集起来。就像下面的示例一样:

  1. const name = getName()
  2. const age = getAge()
  3. let revenue
  4. const address = getAddress()
  5. // ...
  1. const name = getName()
  2. const age = getAge()
  3. const address = getAddress()
  4. let revenue
  5. // ...

由于两块数据区域的功能是不同的,所以除了移动语句外,我还在它们之间空了一行,这样让人更容易区分它们之间的不同。

折分嵌套条件表达式

当很多的条件表达式嵌套在一起时,会让代码变得很难阅读:

  1. function getPayAmount() {
  2. if (isDead) {
  3. return deadAmount()
  4. } else {
  5. if (isSeparated) {
  6. return separatedAmount()
  7. } else if (isRetired) {
  8. return retireAmount()
  9. } else {
  10. return normalAmount()
  11. }
  12. }
  13. }
  1. function getPayAmount() {
  2. if (isDead) return deadAmount()
  3. if (isSeparated) return separatedAmount()
  4. if (isRetired) return retireAmount()
  5. return normalAmount()
  6. }

将条件表达式拆分后,代码的可阅读性大大增强了。

将查询函数和修改函数分离

一般的查询函数都是用于取值的,例如 getUserData()getAget()getName() 等等。有时候,我们可能为了方便,在查询函数上附加其他功能。例如下面的函数:

  1. function getValue() {
  2. let result = 0
  3. this.data.forEach(val => result += val)
  4. // 这里插入了一个奇怪的操作
  5. sendBill()
  6. return result
  7. }

千万不要这样做,函数很重要的功能是职责分离。所以我们要将它们分开:

  1. function getValue() {
  2. let result = 0
  3. this.data.forEach(val => result += val)
  4. return result
  5. }
  6. function sendBill() {
  7. // ...
  8. }

这样函数的功能就很清晰了。

小结

古人云:尽信书,不如无书。《重构2》也不例外,在看这本书的时候一定要带着批判性的目光去阅读它。

里面介绍的重构手法有很多,多达上百种,但这些手法不一定适用所有人。所以一定要有取舍,将里面有用的手法摘抄下来,时不时的看几遍。这样在写代码时,重构才能像呼吸一样自然,即使用了你也不知道。

参考资料